iata 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'fileutils'
7
+
8
+ module Iata
9
+ module Data
10
+ # Downloads the IATA airport code list from Wikidata via SPARQL and
11
+ # writes it to `lib/iata/data/airports.json` as a single JSON object
12
+ # keyed by IATA code.
13
+ #
14
+ # Data source: Wikidata property P238 ("IATA airport code"). Each
15
+ # result is an airport with its English label, ISO 3166-1 alpha-2
16
+ # country code, and WGS-84 coordinates.
17
+ module Fetcher
18
+ SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql'
19
+ USER_AGENT = 'metanorma-iata-gem/0.1 (https://github.com/metanorma/iata)'
20
+ DATA_DIR = File.expand_path(__dir__)
21
+ OUTPUT_PATH = File.join(DATA_DIR, 'airports.json')
22
+
23
+ QUERY = <<~SPARQL
24
+ SELECT ?iata ?name ?country ?countryIso2 ?coord ?wdId WHERE {
25
+ ?airport wdt:P238 ?iata .
26
+ ?airport wdt:P17 ?country .
27
+ ?airport rdfs:label ?name . FILTER(LANG(?name) = "en")
28
+ OPTIONAL { ?country wdt:P297 ?countryIso2 . }
29
+ OPTIONAL { ?airport wdt:P625 ?coord . }
30
+ BIND(STRAFTER(STR(?airport), "/entity/") AS ?wdId)
31
+ }
32
+ ORDER BY ?iata
33
+ SPARQL
34
+
35
+ class << self
36
+ # @return [String] the path written
37
+ def call
38
+ results = query
39
+ data = transform(results)
40
+ write(data, fetched_at: Time.now.utc.iso8601, result_count: results.size)
41
+ warn "Fetched #{data.size} IATA airports from Wikidata (#{File.size(OUTPUT_PATH)} bytes)"
42
+ OUTPUT_PATH
43
+ end
44
+
45
+ # @return [Array<Hash>] raw SPARQL bindings, each as a hash of {key: {value:, type:}}
46
+ def query
47
+ uri = URI(SPARQL_ENDPOINT)
48
+ uri.query = URI.encode_www_form(
49
+ query: QUERY,
50
+ format: 'json'
51
+ )
52
+
53
+ http = Net::HTTP.new(uri.host, uri.port)
54
+ http.use_ssl = (uri.scheme == 'https')
55
+ http.open_timeout = 30
56
+ http.read_timeout = 180
57
+
58
+ request = Net::HTTP::Get.new(uri.request_uri)
59
+ request['User-Agent'] = USER_AGENT
60
+ request['Accept'] = 'application/sparql-results+json'
61
+
62
+ response = http.request(request)
63
+ unless response.is_a?(Net::HTTPSuccess)
64
+ raise "Wikidata SPARQL query failed: HTTP #{response.code} #{response.message}\n#{response.body[0..500]}"
65
+ end
66
+
67
+ JSON.parse(response.body, symbolize_names: true)[:results][:bindings]
68
+ end
69
+
70
+ # @param bindings [Array<Hash>] SPARQL result bindings
71
+ # @return [Hash<String, Hash>] keyed by IATA code
72
+ def transform(bindings)
73
+ data = {}
74
+ bindings.each do |row|
75
+ code = row[:iata]&.dig(:value)
76
+ next if code.nil?
77
+
78
+ data[code] = build_entry(row)
79
+ end
80
+ data
81
+ end
82
+
83
+ def write(data, fetched_at:, result_count:)
84
+ FileUtils.mkdir_p(DATA_DIR)
85
+ payload = {
86
+ '_meta' => {
87
+ 'fetched_at' => fetched_at,
88
+ 'source' => 'Wikidata (property P238)',
89
+ 'result_count' => result_count,
90
+ 'entry_count' => data.size
91
+ }
92
+ }.merge(data)
93
+ File.write(OUTPUT_PATH, JSON.pretty_generate(payload))
94
+ end
95
+
96
+ private
97
+
98
+ def build_entry(row)
99
+ {
100
+ 'code' => row[:iata]&.dig(:value),
101
+ 'name' => row[:name]&.dig(:value),
102
+ 'wikidata_id' => row[:wdId]&.dig(:value),
103
+ 'country_iso2' => row[:countryIso2]&.dig(:value),
104
+ 'country_name' => extract_country_label(row[:country]),
105
+ 'latitude' => extract_lat(row[:coord]),
106
+ 'longitude' => extract_lon(row[:coord])
107
+ }
108
+ end
109
+
110
+ def extract_country_label(country_binding)
111
+ return nil unless country_binding
112
+
113
+ uri = country_binding[:value].to_s
114
+ # Use the entity's last URL segment as a placeholder; the human
115
+ # name comes from the rdfs:label via SERVICE wikibase:label
116
+ # which we don't include in the query. The dedicated
117
+ # `unlocode-iso3166` gem is the proper way to resolve a full
118
+ # country name; this is just a hint.
119
+ uri.split('/').last
120
+ end
121
+
122
+ def extract_lat(coord_binding)
123
+ return nil unless coord_binding
124
+
125
+ point = coord_binding[:value].to_s
126
+ match = point.match(/Point\(([-\d.]+)\s+([-\d.]+)/)
127
+ match && match[2].to_f
128
+ end
129
+
130
+ def extract_lon(coord_binding)
131
+ return nil unless coord_binding
132
+
133
+ point = coord_binding[:value].to_s
134
+ match = point.match(/Point\(([-\d.]+)\s+([-\d.]+)/)
135
+ match && match[1].to_f
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
data/lib/iata/data.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iata
4
+ module Data
5
+ autoload :Fetcher, "#{__dir__}/data/fetcher"
6
+ end
7
+ end
data/lib/iata/entry.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lutaml/model'
4
+ require_relative 'coordinates'
5
+
6
+ module Iata
7
+ # A single IATA airport entry.
8
+ #
9
+ # Stores wire-level fields as `lutaml-model` attributes (so the bundled
10
+ # JSON can be parsed round-trip) and exposes typed helpers (#coordinates,
11
+ # #country_name) for ergonomic queries.
12
+ class Entry < Lutaml::Model::Serializable
13
+ attribute :code, :string
14
+ attribute :name, :string
15
+ attribute :wikidata_id, :string
16
+ attribute :country_iso2, :string
17
+ attribute :country_name, :string
18
+ attribute :latitude, :float
19
+ attribute :longitude, :float
20
+
21
+ def coordinates
22
+ return Coordinates.new(latitude: nil, longitude: nil) if latitude.nil? && longitude.nil?
23
+
24
+ Coordinates.new(latitude: latitude, longitude: longitude)
25
+ end
26
+
27
+ def ==(other)
28
+ other.is_a?(Entry) && code == other.code
29
+ end
30
+
31
+ def hash
32
+ code&.hash || super
33
+ end
34
+
35
+ def eql?(other)
36
+ self == other
37
+ end
38
+
39
+ def to_s
40
+ "#{code} #{name}".strip
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'entry'
5
+
6
+ module Iata
7
+ # Parses the bundled IATA airport JSON file into {Entry} instances.
8
+ #
9
+ # File format:
10
+ #
11
+ # {
12
+ # "_meta": {
13
+ # "fetched_at": "2026-07-02T12:00:00Z",
14
+ # "source": "Wikidata (P238)",
15
+ # "count": 12345
16
+ # },
17
+ # "PVG": {
18
+ # "code": "PVG",
19
+ # "name": "Shanghai Pudong International Airport",
20
+ # "wikidata_id": "Q86792",
21
+ # "country_iso2": "CN",
22
+ # "country_name": "China",
23
+ # "latitude": 31.1434,
24
+ # "longitude": 121.8052
25
+ # },
26
+ # ...
27
+ # }
28
+ class Loader
29
+ class << self
30
+ # @param path [String]
31
+ # @return [Array<Iata::Entry>]
32
+ def load_file(path)
33
+ load_json(File.read(path))
34
+ end
35
+
36
+ # @param json [String]
37
+ # @return [Array<Iata::Entry>]
38
+ def load_json(json)
39
+ parse(JSON.parse(json, symbolize_names: false))
40
+ end
41
+
42
+ # @param data [Hash]
43
+ # @return [Array<Iata::Entry>]
44
+ def parse(data)
45
+ entries = data.is_a?(Hash) ? data.except('_meta') : {}
46
+ entries.map { |_code, attrs| build_entry(attrs) }
47
+ end
48
+
49
+ private
50
+
51
+ def build_entry(attrs)
52
+ Entry.new(
53
+ code: attrs['code'],
54
+ name: attrs['name'],
55
+ wikidata_id: attrs['wikidata_id'],
56
+ country_iso2: attrs['country_iso2'],
57
+ country_name: attrs['country_name'],
58
+ latitude: attrs['latitude'],
59
+ longitude: attrs['longitude']
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require_relative 'loader'
5
+
6
+ module Iata
7
+ # In-memory, lazily-indexed registry over a set of {Entry} instances.
8
+ #
9
+ # The default registry is loaded from the vendored dataset bundled with the
10
+ # gem (see {.load_default}). Callers can also construct a registry from any
11
+ # other source via {.from_entries} or {.load_file}.
12
+ class Registry
13
+ include Enumerable
14
+ extend Forwardable
15
+
16
+ attr_reader :entries
17
+
18
+ def_delegators :@entries, :size, :count, :to_a, :empty?
19
+
20
+ # Map of `#where` filter keys to the Entry attribute they read.
21
+ # `name` is intentionally NOT in this map — it gets routed through
22
+ # filter_name so Regexp / substring matching works (see apply_filter).
23
+ SCALAR_FILTERS = {
24
+ code: :code,
25
+ country: :country_iso2,
26
+ country_iso2: :country_iso2,
27
+ wikidata_id: :wikidata_id
28
+ }.freeze
29
+
30
+ def initialize(entries = [])
31
+ @entries = entries.freeze
32
+ end
33
+
34
+ def each(&)
35
+ @entries.each(&)
36
+ end
37
+
38
+ class << self
39
+ # Load the bundled dataset shipped inside the gem.
40
+ # @return [Registry]
41
+ def load_default
42
+ from_entries(Loader.load_file(default_data_path))
43
+ end
44
+
45
+ # Load a specific JSON file from disk.
46
+ # @param path [String]
47
+ # @return [Registry]
48
+ def load_file(path)
49
+ from_entries(Loader.load_file(path))
50
+ end
51
+
52
+ # Build a registry from an existing list of entries.
53
+ # @param entries [Array<Iata::Entry>]
54
+ # @return [Registry]
55
+ def from_entries(entries)
56
+ new(entries)
57
+ end
58
+
59
+ private
60
+
61
+ def default_data_path
62
+ File.expand_path('data/airports.json', __dir__)
63
+ end
64
+ end
65
+
66
+ # Exact-code lookup.
67
+ # @param code [String] 3-letter IATA code (case-insensitive)
68
+ # @return [Iata::Entry, nil]
69
+ def find(code)
70
+ return nil if code.nil?
71
+
72
+ by_code[code.to_s.upcase]
73
+ end
74
+
75
+ alias [] find
76
+
77
+ # Filter entries by one or more predicates. Scalar filters accept either
78
+ # a single value or an array (any-of). `name` accepts a String
79
+ # (case-insensitive equality) or a Regexp.
80
+ #
81
+ # @example
82
+ # registry.where(country: 'CN')
83
+ # registry.where(country: %w[CN HK], name: /international/i)
84
+ #
85
+ # @return [Array<Iata::Entry>]
86
+ def where(filters)
87
+ filters.reduce(entries) { |scope, (key, value)| apply_filter(scope, key, value) }
88
+ end
89
+
90
+ # All distinct country codes present in the registry, sorted.
91
+ # @return [Array<String>]
92
+ def countries
93
+ entries.map(&:country_iso2).compact.uniq.sort
94
+ end
95
+
96
+ # Count of entries per country.
97
+ # @return [Hash{String=>Integer}]
98
+ def counts_by_country
99
+ entries.each_with_object(Hash.new(0)) { |e, h| h[e.country_iso2] += 1 if e.country_iso2 }
100
+ end
101
+
102
+ private
103
+
104
+ def by_code
105
+ @by_code ||= entries.each_with_object({}) do |e, h|
106
+ h[e.code.to_s.upcase] = e if e.code
107
+ end
108
+ end
109
+
110
+ def apply_filter(scope, key, value)
111
+ if SCALAR_FILTERS.key?(key)
112
+ filter_scalar(scope, SCALAR_FILTERS.fetch(key), value)
113
+ elsif key == :name
114
+ filter_name(scope, value)
115
+ else
116
+ raise ArgumentError, "unknown filter: #{key.inspect}"
117
+ end
118
+ end
119
+
120
+ def filter_scalar(scope, attr_name, value)
121
+ candidates = Array(value).map { |v| v.to_s.upcase }
122
+ scope.select do |e|
123
+ attr_val = e.public_send(attr_name)
124
+ attr_val && candidates.include?(attr_val.to_s.upcase)
125
+ end
126
+ end
127
+
128
+ def filter_name(scope, value)
129
+ scope.select { |e| e.name && name_matches?(e.name, value) }
130
+ end
131
+
132
+ def name_matches?(string, value)
133
+ value.is_a?(Regexp) ? string.match?(value) : string.casecmp?(value.to_s)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iata
4
+ VERSION = '0.1.0'
5
+ end
data/lib/iata.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'lutaml/model'
5
+ require 'json'
6
+
7
+ require_relative 'iata/version'
8
+
9
+ # Vendored IATA airport code list as a queryable Ruby registry.
10
+ #
11
+ # The data is sourced from Wikidata (property P238, "IATA airport code") and
12
+ # ships inside the gem as a small JSON file so the registry works offline.
13
+ # All entries are loaded lazily on first call to {.registry}.
14
+ module Iata
15
+ extend SingleForwardable
16
+
17
+ class << self
18
+ # @return [Iata::Registry] the process-wide registry, loaded lazily
19
+ def registry
20
+ @registry ||= Registry.load_default
21
+ end
22
+
23
+ # Reset the process-wide registry. Used by specs to swap fixtures.
24
+ def reset_registry!
25
+ @registry = nil
26
+ end
27
+
28
+ # The Wikidata query timestamp bundled with this gem version
29
+ # (UTC ISO8601). nil if the data file lacks this metadata.
30
+ # @return [String, nil]
31
+ def source_timestamp
32
+ @source_timestamp ||= registry.entries.first&.source_timestamp
33
+ end
34
+ end
35
+
36
+ def_delegators :registry, :find, :where, :each, :size, :count, :countries, :counts_by_country
37
+
38
+ autoload :Coordinates, 'iata/coordinates'
39
+ autoload :Entry, 'iata/entry'
40
+ autoload :Loader, 'iata/loader'
41
+ autoload :Registry, 'iata/registry'
42
+ autoload :Data, 'iata/data'
43
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: iata
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ribose Inc.
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: lutaml-model
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.8'
40
+ description: |
41
+ Vendored, offline access to the IATA (International Air Transport
42
+ Association) airport code list, sourced from Wikidata. Provides a
43
+ model-driven Ruby registry for looking up airports by IATA code,
44
+ country, or name.
45
+ email:
46
+ - open.source@ribose.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - LICENSE
52
+ - README.adoc
53
+ - lib/iata.rb
54
+ - lib/iata/coordinates.rb
55
+ - lib/iata/data.rb
56
+ - lib/iata/data/airports.json
57
+ - lib/iata/data/fetcher.rb
58
+ - lib/iata/entry.rb
59
+ - lib/iata/loader.rb
60
+ - lib/iata/registry.rb
61
+ - lib/iata/version.rb
62
+ homepage: https://github.com/metanorma/iata
63
+ licenses:
64
+ - BSD-2-Clause
65
+ metadata:
66
+ homepage_uri: https://github.com/metanorma/iata
67
+ source_code_uri: https://github.com/metanorma/iata
68
+ bug_tracker_uri: https://github.com/metanorma/iata/issues
69
+ rubygems_mfa_required: 'true'
70
+ rdoc_options: []
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: 3.1.0
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ requirements: []
84
+ rubygems_version: 3.6.9
85
+ specification_version: 4
86
+ summary: IATA airport codes as a queryable Ruby registry
87
+ test_files: []