loading
Generated 2025-09-25T09:41:08+00:00

All Files ( 88.24% covered at 2.8 hits/line )

7 files in total.
187 relevant lines, 165 lines covered and 22 lines missed. ( 88.24% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
config.rb 100.00 % 3 2 2 0 1.00
config.ru 100.00 % 18 7 7 0 1.00
spec/metartaf_spec.rb 100.00 % 47 30 30 0 1.00
src/metartaf_api.rb 100.00 % 34 21 21 0 1.33
src/parser.rb 85.71 % 85 56 48 8 3.66
src/server.rb 88.24 % 31 17 15 2 1.00
src/sources.rb 77.78 % 114 54 42 12 4.35

config.rb

100.0% lines covered

2 relevant lines. 2 lines covered and 0 lines missed.
    
  1. 1 SWAGGER_UI = true
  2. 1 require 'rbase/base_config'

config.ru

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative 'config.rb'
  3. 1 require 'rbase/logging'
  4. 1 require_relative 'src/server'
  5. 1 require 'rbase/rack_helpers'
  6. 1 rack_run do
  7. # use SwaggerUI, swagger: '/swagger.json' if SWAGGER_UI
  8. 1 use ServiceInfo
  9. 1 run ServerApi.new
  10. end
  11. # PATCH: /home/user/.rbenv/versions/3.1.2/bin/bundle
  12. # Dir.chdir File.dirname(ARGV.last) if ARGV.last =~ /\.ru/
  13. # RACK_ENV=production bundle exec rackup -o 0.0.0.0 -p 7000 -s falcon

spec/metartaf_spec.rb

100.0% lines covered

30 relevant lines. 30 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # rubocop:disable Rspec/ExampleLength, Style/MixinUsage, Rspec/DescribeClass
  3. 1 describe 'Service REST API', timeout: 1000 do
  4. 1 it 'has swagger description containing methods [...]' do
  5. 1 endpoints = %i[/api/metartaf]
  6. 1 swagger = get_j '/swagger.json'
  7. 1 expect(swagger[:paths].keys).to eq(endpoints)
  8. end
  9. 1 it 'return "invalid_parameters" in error response' do
  10. 1 resp = get '/api/metartaf', {code: 'AA' }.to_json, 'CONTENT_TYPE' => 'application/json'
  11. 1 expect(resp.headers['content-type']).to eq 'application/json'
  12. 1 expect(resp.status.to_i).to eq(400), resp
  13. 1 body = JSON.parse(resp.body, symbolize_names: true)
  14. 1 expect(body.keys).to include(:error, :invalid_parameters)
  15. 1 expect(body[:invalid_parameters].class).to eq(Hash)
  16. 1 expect(body[:invalid_parameters].values.size).to eq(1)
  17. end
  18. 1 it 'Error calculation handle' do
  19. 1 resp = get '/api/metartaf?code=XXXX', 'CONTENT_TYPE' => 'application/json'
  20. 1 expect(resp.headers['content-type']).to eq 'application/json'
  21. 1 expect(resp.status.to_i).to eq(404), resp
  22. 1 body = JSON.parse(resp.body, symbolize_names: true)
  23. 1 expect(body.keys).to include(:error)
  24. 1 expect(body[:error]).to match(/Can't find metar for: XXXX/)
  25. end
  26. 1 it "get metartaf UUEE" do
  27. 1 get_j '/api/metartaf?code=UUEE' do |result|
  28. 1 expect(result.keys).to include(:code, :name, :metar_msg, :taf_msg)
  29. 1 expect(result[:code]).to eq('UUEE')
  30. 1 expect(result[:metar][:datetime]).to match(/00Z/)
  31. end
  32. end
  33. 1 it "get metartaf UUBW" do
  34. 1 get_j '/api/metartaf?code=UUBW' do |result|
  35. 1 expect(result.keys).to include(:code, :name, :metar_msg, :taf_msg)
  36. 1 expect(result[:code]).to eq('UUBW')
  37. 1 expect(result[:metar][:datetime]).to match(/00Z/)
  38. end
  39. end
  40. end

src/metartaf_api.rb

100.0% lines covered

21 relevant lines. 21 lines covered and 0 lines missed.
    
  1. 1 require 'rbase/utils'
  2. 1 require 'rbase/rest_client_faraday'
  3. 1 require_relative 'sources'
  4. 1 require_relative 'parser'
  5. 1 class MetarTafApi < Grape::API
  6. 1 class MetarTafResponse < Grape::Entity
  7. 1 expose :code, documentation: { desc: 'ICAO code', example: 'UAAA' }
  8. 1 expose :name, documentation: { desc: 'Airport name', example: '' }
  9. 1 expose :metar_msg, documentation: { desc: 'METAR BODY', example: '2023-09-12 13:00:00\nUUEE 121300Z 12003MPS 070V180 CAVOK 19/07 Q1021 R06R/CLRD62 R06C/CLRD62 NOSIG' }
  10. 1 expose :metar, documentation: { desc: 'Parsed METAR', }
  11. 1 expose :metar_summary, documentation: { desc: 'Parsed METAR summary', }
  12. 1 expose :taf_msg, documentation: { desc: 'TAF BODY', example: '2023-09-12 10:50:00\nTAF UUEE 121050Z 1212/1312 13003MPS 9999 SCT030 TX20/1212Z TN06/1303Z BECMG 1217/1218 4000 BR TEMPO 1218/1306 0300 FG BECMG 1306/1307 9999 NSW' }
  13. 1 expose :taf, documentation: { desc: 'Parsed TAF', }
  14. end
  15. 1 desc 'Calculate Takeoff', success: MetarTafResponse
  16. 1 params do
  17. 2 requires :code, desc: 'Airport ICAO code', type: String, regexp: /[a-z]{4}/i,
  18. documentation: { param_type: 'query', example: 'UAAA' }
  19. end
  20. 1 get '/metartaf' do
  21. 3 metartaf = Sources.first code: params[:code]
  22. 3 error!({error: "Can't find metar for: #{params[:code]}"}, 404) unless metartaf
  23. 2 parser = Parser.new Time.now.utc.iso8601, metartaf[:metar].gsub(/^(METAR|SPECI)\s+/,'')
  24. 2 MetarTafResponse.represent code: params[:code],
  25. metar_msg: metartaf[:metar], taf_msg: metartaf[:taf],
  26. metar: parser.metar, metar_summary: parser.summary
  27. # taf_date: metartaf[:taf].lines[0],
  28. # taf: Parser.taf(metartaf[:taf].lines[1])
  29. end
  30. end

src/parser.rb

85.71% lines covered

56 relevant lines. 48 lines covered and 8 lines missed.
    
  1. 1 require 'metar'
  2. 1 require 'time'
  3. 1 TEST_METAR1 = "2023-09-12 23:30:00\nKJFK 131751Z 22015KT 10SM FEW040 SCT100 23/16 A2992 RMK AO2 SLP132 T02330156 10233 20194 58005"
  4. 1 TEST_METAR = "2023-09-12 23:30:00\nUUEE 122330Z 08003MPS 9000 NSC 08/08 Q1022 R06C/CLRD62 NOSIG"
  5. 1 class String
  6. 1 def underscore
  7. 5 self.gsub(/::/, '/').
  8. gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
  9. gsub(/([a-z\d])([A-Z])/,'\1_\2').
  10. tr("-", "_").
  11. downcase
  12. end
  13. end
  14. 5 class Array; def represent = map{ _1.represent } end
  15. 5 class Array; def to_s = map{ _1.to_s }.join ', ' end
  16. 2 class String; def represent = to_s end
  17. 2 class Symbol; def represent = to_s end
  18. 2 class String; def raw = to_s end
  19. 2 class Struct; def represent
  20. { self.class.to_s[/([^:]+)$/,1].underscore.to_sym => to_h.transform_values { _1.respond_to?(:represent) ? _1.represent : _1 } }
  21. end
  22. end
  23. 1 module Metar::Data
  24. 2 class Temperature; def represent = { degrees: to_degrees, kelvin: to_kelvin, fahrenheit: to_fahrenheit, to_s: } end
  25. 2 class Wind; def represent = { direction: direction&.represent, speed: speed&.represent, gusts: gusts&.represent, to_s: } end
  26. 2 class VariableWind; def represent = { direction1: , direction2:, to_s: } end
  27. 2 class Direction; def represent = { degrees: to_degrees, compass: to_compass, to_s: } end
  28. 2 class Speed; def represent = { meters: to_meters_per_second, knots: to_knots, to_s: } end
  29. 2 class SkyCondition; def represent = { summary: to_summary, height: height&.represent, type:, quantity:, to_s: } end
  30. 2 class SkyCondition; def to_s = to_summary end
  31. 2 class SkyConditions; def to_s = map(&:to_s).join ' ' end
  32. 2 class Pressure; def represent = pressure.represent end
  33. 2 class Pressure; def to_s = pressure.to_s end
  34. 2 class M9t::Pressure; def represent = { inches_of_mercury: to_inches_of_mercury, bar: to_bar, pascals: to_pascals, hectopascals: to_hectopascals, kilopascals: to_kilopascals, to_s: } end
  35. 2 class M9t::Distance; def represent = { meters: to_meters, kilometers: to_kilometers, feet: to_feet, miles: to_miles, to_s: } end
  36. 2 class M9t::Direction; def represent = { degrees: to_degrees, compass: to_compass, to_s: } end
  37. 2 class WeatherPhenomenon; def represent = to_s end
  38. 2 class Observer; def represent = { value: } end
  39. 2 class Observer; def to_s = value.to_s end
  40. 2 class Visibility; def represent = { distance: distance.represent, direction: direction&.represent, comparator: comparator, to_s:} end
  41. 2 class Distance; def represent = { meters: to_meters, kilometers: to_kilometers, feet: to_feet, miles: to_miles, to_s: } end
  42. 2 class RunwayVisibleRange; def represent = to_s end
  43. end
  44. 1 class Parser
  45. 1 def initialize(date, msg)
  46. 2 raw = Metar::Raw::Data.new(msg, DateTime.parse(date))
  47. 2 @parser = Metar::Parser.new(raw)
  48. end
  49. 1 def summary
  50. 2 to_s = self.metar :to_s
  51. 50 to_s.reject{ _2.nil? || _2.to_s.empty? }.map{ "#{_1}: #{_2}"}.join ', '
  52. end
  53. 1 def metar(method = :represent)
  54. 4 @parser.instance_eval do
  55. 4 attr = { datetime: time } # station_code: station_code, #metar: metar,
  56. 4 %i( minimum_visibility observer sea_level_pressure temperature dew_point visibility variable_wind vertical_visibility wind
  57. present_weather recent_weather runway_visible_range sky_conditions remarks)
  58. .each do |key|
  59. 56 attr[key] = self.send(key)&.send(method)
  60. end
  61. 4 attr[:cavok] = 'CAVOK' if cavok?
  62. 4 attr
  63. end
  64. end
  65. end
  66. # SELF TEST ============================================================================================================
  67. 1 if File.expand_path($0) == File.expand_path(__FILE__)
  68. require 'rbase/rest_client_faraday'
  69. # Metar::Parse.compliance = :strict
  70. parser = Parser.new *TEST_METAR1.lines
  71. p parser.metar
  72. require_relative 'sources'
  73. metartaf = Sources.first code: 'UUBW'
  74. parser = Parser.new Time.now.utc.iso8601, metartaf[:metar]
  75. p parser.summary
  76. end

src/server.rb

88.24% lines covered

17 relevant lines. 15 lines covered and 2 lines missed.
    
  1. #!/bin/env ruby
  2. 1 require 'grape'
  3. 1 require 'grape-swagger'
  4. 1 require 'grape-swagger-entity'
  5. 1 require_relative 'metartaf_api'
  6. 1 require 'grape_logging'
  7. 1 class ServerApi < Grape::API
  8. 1 content_type :json, 'application/json'
  9. 1 format :json
  10. 1 rescue_from :all do |e|
  11. 1 $stderr.puts e.message, e.backtrace.join("\n")
  12. case
  13. 3 when e.respond_to?(:errors); error!({ error: e.message, invalid_parameters: e.errors.transform_keys{_1[0]} }, e.status)
  14. when e.respond_to?(:status); default_rescue_handler(e)
  15. else error!(e.message, 500)
  16. end
  17. end
  18. 1 namespace :api do
  19. 1 mount ::MetarTafApi
  20. end
  21. 1 add_swagger_documentation \
  22. info: { title: 'Server API' }, hide_documentation_path: true,
  23. mount_path: '/swagger.json', markdown: false, doc_version: '0.1.0'
  24. end
  25. 1 ServerApi.compile!

src/sources.rb

77.78% lines covered

54 relevant lines. 42 lines covered and 12 lines missed.
    
  1. 1 require 'thwait'
  2. 1 require 'faraday'
  3. 1 require 'rbase/utils'
  4. 1 unless ENV['RACK_ENV'] == 'production'
  5. profile_class '#<Class:Sources>'
  6. profile_class 'Parser'
  7. def profile(*a, &b)
  8. time = Time.now
  9. yield *a[1..], b
  10. ensure
  11. puts "#{a[0]}: #{Time.now - time} sec"
  12. end
  13. else
  14. 1 def profile(*a, &b) = yield *a[1..], b
  15. end
  16. 1 if defined? OpenTelemetry
  17. 1 def with_otel(ctx, &block) = OpenTelemetry::Context.with_current(ctx, &block)
  18. else
  19. def with_otel(ctx) = yield
  20. end
  21. 1 class Hash
  22. 1 def gsub_s(r, s) = gsub_s_(self, r, s)
  23. 1 def gsub_s_(h, r, s)
  24. 15 (h.is_a?(Hash) ? h.transform_values : h.map).each do |v|
  25. case
  26. 45 when v.is_a?(String); v.gsub(r,s)
  27. 12 when v.is_a?(Hash); gsub_s_(v, r, s)
  28. 15 else v
  29. end
  30. end
  31. end
  32. end
  33. 1 class Sources
  34. SOURCES = [
  35. 1 {post: 'https://e6bx.com/secure?api=adds',
  36. params: { params: { time: Time.now.to_i,
  37. stations: { stationString: 'UUUU' },
  38. metars: { stationString: 'UUUU', hoursBeforeNow: 3, mostRecentForEachStation: true },
  39. tafs: {stationString: 'UUUU', hoursBeforeNow: 12, mostRecentForEachStation: true }
  40. }},
  41. json: ->(b){
  42. 2 b[:metars].size > 0 ? { taf: b[:tafs][0][:raw_text], metar: b[:metars][0][:raw_text] } : nil
  43. }
  44. },
  45. {
  46. get: 'https://metartaf.ru/UUUU.json',
  47. 1 json: ->(body){ body.tap { |h|
  48. 1 h[:metar] = h[:metar].lines.last
  49. 1 h[:taf] = h[:taf].lines.last
  50. }}
  51. },
  52. { get: 'https://metar-taf.com/UUUU',
  53. text: ->(body){ { metar: body[/og:description" content="([^.]+)/, 1].gsub(/^METAR\s+/,'') } }
  54. },
  55. { get: 'https://beta.aviationweather.gov/cgi-bin/data/metar.php?ids=UUUU&hours=0&format=raw',
  56. text: ->(body){ body.rstrip.size > 0 ? { metar: body.rstrip } : nil }
  57. },
  58. ]
  59. 1 class << self
  60. 1 def first(test = 'fake', code:)
  61. 3 ctx_ = OpenTelemetry::Context.current if defined? OpenTelemetry
  62. 3 responses = []
  63. 3 threads = SOURCES.map do |src|
  64. 12 Thread.new do
  65. 12 with_otel(ctx_) do
  66. begin
  67. 12 response = profile( src[:get] || src[:post] ) do
  68. case
  69. 21 when src[:get]; Faraday.new(src[:get].gsub('UUUU', code), ssl: { verify: false } ).get
  70. 6 when src[:post]; Faraday.new(src[:post], ssl: { verify: false }){_1.request :json}.post '', src[:params].gsub_s('UUUU', code)
  71. else nil
  72. end
  73. end
  74. 8 if response.status == 200
  75. 3 responses << case
  76. 3 when src[:json]; src[:json].call JSON.parse response.body, symbolize_names: true
  77. when src[:text]; src[:text].call response.body
  78. end
  79. else
  80. 5 puts response.status
  81. end
  82. rescue => e
  83. end
  84. end
  85. end
  86. end
  87. 3 waiter = ThreadsWait.new *threads
  88. 3 while thread = waiter.next_wait rescue nil
  89. # p responses.last if responses.size > 0
  90. 11 responses.compact!
  91. 11 break if responses.size > 0
  92. end
  93. 3 profile 'Stop all' do
  94. 3 threads.each.each(&:kill).each(&:join)
  95. end
  96. 3 responses.first
  97. end
  98. end
  99. end
  100. # SELF TEST ============================================================================================================
  101. 1 if File.expand_path($0) == File.expand_path(__FILE__)
  102. p Sources.first code: 'UUBW'
  103. end