relaton-api 3.0.0 → 3.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b26aa3ff29566c41bcb2cbfd20e4290604ce35176a6402288b19b1fad9998e69
4
- data.tar.gz: c6d8efe6cb305e5e330b4cc9e436265b2ebd69b9ea2005bbb298514997994ca7
3
+ metadata.gz: ab38285db3764dd95c87bf9a255de6d822aa5aff0101d6554031fb5aa7f48e85
4
+ data.tar.gz: 75d1069a73a87d4cf7eeb821c67bcdd2a357b2c542109d7eb95fd3d084589044
5
5
  SHA512:
6
- metadata.gz: ef68420607897802cec12438e794d3a8eb53311076d6795f5bf607cf53f5a49df017dd38b283e42cc695832c3ec1895cee8cae1ce75685c136f3402097b3e397
7
- data.tar.gz: 92ebf8cf778149bc4beb6bce5a756c62726b249f89fbd17a43f724d1cceddc7fea6cdd70b559581c0a8aaa6951d7c7ac1555973980dde43c14032a2ec991e096
6
+ metadata.gz: c4c160d99d6cfe4e353e3f7e5c917f1b4548b323285d79cc156c6fcaed207594cfd2234a58a2e96d02420b7cf764b985b76df1e5c07a64d09d4bea97fe15db0f
7
+ data.tar.gz: 1b0d8d8abefa4135f39e7101478e7b0ebe916cb8c938a8f079cb50f58ce264a690a23f4fac19f34ddc236b0222adb5c6873b6fe366ee8e60f61012bc5d8c314a
@@ -7,7 +7,10 @@ module Relaton
7
7
  # Rack application. Routes are a registry — adding one is a new
8
8
  # autoloaded class plus one entry here (open/closed).
9
9
  class App
10
- ROUTES = [Routes::Version, Routes::Flavors, Routes::Documents].freeze
10
+ ROUTES = [
11
+ Routes::ApiV1::Document, Routes::ApiV1::Search, Routes::ApiV1::Version,
12
+ Routes::Version, Routes::Flavors, Routes::Documents,
13
+ ].freeze
11
14
 
12
15
  attr_reader :config, :store
13
16
 
@@ -10,6 +10,19 @@ module Relaton
10
10
  @name = name
11
11
  @data_dir = data_dir
12
12
  end
13
+
14
+ # The release banner's release half: the API's own version.
15
+ def release
16
+ VERSION
17
+ end
18
+
19
+ # The release banner's model half: the Relaton model line the
20
+ # stored documents serialize against (lazy — only the version
21
+ # file, never the full registry).
22
+ def model_version
23
+ require "relaton/version"
24
+ ::Relaton::VERSION
25
+ end
13
26
  end
14
27
  end
15
28
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Relaton
6
+ module Api
7
+ module Routes
8
+ # The /api/v1 contract surface — the same paths and shapes the
9
+ # Workers stack serves at api.relaton.org, so the shared contract
10
+ # fixtures pass against this stack unchanged.
11
+ module ApiV1
12
+ PROBLEM = { "content-type" => "application/problem+json" }.freeze
13
+
14
+ def self.parse_query(env)
15
+ query = env["QUERY_STRING"].to_s
16
+ ::URI.decode_www_form(query).to_h
17
+ end
18
+
19
+ # GET /api/v1/document?code=...&year=&all_parts=&keep_year=
20
+ # -> text/xml bibdata (the model round-trip of the stored YAML)
21
+ class Document
22
+ PATH = %r{\A/api/v1/document\z}.freeze
23
+
24
+ def matches?(path) = PATH.match?(path)
25
+
26
+ def call(env, app)
27
+ params = ApiV1.parse_query(env)
28
+ code = params["code"].to_s.strip
29
+ return bad_request("code parameter is required") if code.empty?
30
+
31
+ entry = app.store.resolve(
32
+ code,
33
+ year: params["year"]&.to_i,
34
+ all_parts: params["all_parts"] == "true",
35
+ )
36
+ return not_found unless entry
37
+
38
+ [200, { "content-type" => "text/xml" },
39
+ [app.store.document_xml(entry)]]
40
+ end
41
+
42
+ private
43
+
44
+ def bad_request(detail)
45
+ [400, PROBLEM,
46
+ [{ type: "about:blank", title: "Bad Request",
47
+ detail: detail }.to_json]]
48
+ end
49
+
50
+ def not_found
51
+ [404, { "content-type" => "text/plain" },
52
+ ["Resource doesn't exist."]]
53
+ end
54
+ end
55
+
56
+ # GET /api/v1/search?q=&flavor=&doctype=&year=&status=&page=&size=
57
+ # -> { total, page, size, items[], facets{} }
58
+ class Search
59
+ PATH = %r{\A/api/v1/search\z}.freeze
60
+
61
+ def matches?(path) = PATH.match?(path)
62
+
63
+ def call(env, app)
64
+ p = ApiV1.parse_query(env)
65
+ result = app.store.search(
66
+ q: presence(p["q"]), flavor: presence(p["flavor"]),
67
+ doctype: presence(p["doctype"]), year: p["year"]&.to_i,
68
+ status: presence(p["status"]), page: p["page"]&.to_i,
69
+ size: p["size"]&.to_i,
70
+ )
71
+ [200, { "content-type" => "application/json" },
72
+ [JSON.generate(result)]]
73
+ end
74
+
75
+ private
76
+
77
+ def presence(value)
78
+ v = value.to_s.strip
79
+ v.empty? ? nil : v
80
+ end
81
+ end
82
+
83
+ # GET /api/v1/version -> text/plain release banner
84
+ class Version
85
+ PATH = %r{\A/api/v1/version\z}.freeze
86
+
87
+ def matches?(path) = PATH.match?(path)
88
+
89
+ def call(_env, app)
90
+ [200, { "content-type" => "text/plain" },
91
+ ["Release: #{app.config.release}, " \
92
+ "Relaton version: #{app.config.model_version}"]]
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -14,12 +14,28 @@ module Relaton
14
14
  KEY_PATTERN.match?(path)
15
15
  end
16
16
 
17
+ QUERY_KEYS = %w[format year all_parts keep_year].freeze
18
+
17
19
  def call(env, app)
18
20
  key = URI.decode_www_form_component(KEY_PATTERN.match(env["PATH_INFO"])[:key])
19
- document = app.store.fetch(key)
20
- return not_found(key) unless document
21
+ params = ::URI.decode_www_form(env["QUERY_STRING"].to_s).to_h
22
+ entry = app.store.resolve(
23
+ key, year: params["year"]&.to_i,
24
+ all_parts: params["all_parts"] == "true",
25
+ )
26
+ return not_found(key) unless entry
21
27
 
22
- [200, { "content-type" => "application/json" }, [JSON.generate(document)]]
28
+ case params["format"]
29
+ when "xml"
30
+ [200, { "content-type" => "text/xml" },
31
+ [app.store.document_xml(entry)]]
32
+ when "yaml"
33
+ [200, { "content-type" => "text/yaml" },
34
+ [app.store.document_yaml(entry)]]
35
+ else
36
+ [200, { "content-type" => "application/json" },
37
+ [JSON.generate(app.store.document_hash(entry))]]
38
+ end
23
39
  end
24
40
 
25
41
  private
@@ -4,6 +4,13 @@ module Relaton
4
4
  module Api
5
5
  # Route namespace; children declared here.
6
6
  module Routes
7
+ # The /api/v1 contract surface (same paths as api.relaton.org).
8
+ module ApiV1
9
+ autoload :Document, "relaton/api/routes/api_v1"
10
+ autoload :Search, "relaton/api/routes/api_v1"
11
+ autoload :Version, "relaton/api/routes/api_v1"
12
+ end
13
+
7
14
  autoload :Version, "relaton/api/routes/version"
8
15
  autoload :Documents, "relaton/api/routes/documents"
9
16
  autoload :Flavors, "relaton/api/routes/flavors"
@@ -6,59 +6,185 @@ module Relaton
6
6
  module Api
7
7
  module Store
8
8
  # Reads documents from a dataset directory (Relaton YAML, any stream
9
- # layout). Builds a lookup map at boot from every docidentifier
10
- # variant; keys are uppercased, whitespace-stripped (the corpus
11
- # doctrine — datasets own richer variants via the #109 index).
9
+ # layout). Builds an in-memory index at boot: one DocEntry per file
10
+ # with the summary fields the API serves, plus a key map over every
11
+ # docidentifier variant (uppercased, whitespace-stripped — the corpus
12
+ # doctrine; datasets own richer variants via the #109 index).
12
13
  class FileSystem
13
- KeyEntry = Struct.new(:file, :docid)
14
+ DocEntry = Struct.new(:file, :stream, :docid, :title, :year,
15
+ :doctype, :status, :ids)
16
+
17
+ DEFAULT_SIZE = 25
18
+ MAX_SIZE = 100
19
+
20
+ attr_reader :data_dir
14
21
 
15
22
  def initialize(data_dir)
16
23
  @data_dir = data_dir
24
+ @docs = []
17
25
  @keys = {}
18
- @counts = Hash.new(0)
19
26
  build_index
20
27
  end
21
28
 
22
- def fetch(key)
23
- entry = @keys[key.to_s.upcase.delete(" ")]
24
- return nil unless entry
29
+ # Resolve a code to its DocEntry. Order: exact key; the undated
30
+ # form (trailing ":YYYY" dropped — latest edition wins, or the
31
+ # requested year); with all_parts the trailing "-<part>" also
32
+ # drops before that.
33
+ # -> DocEntry, or nil when nothing matches
34
+ def resolve(key, year: nil, all_parts: false)
35
+ key = key_of(key)
36
+ exact = @keys[key]
37
+ return exact if exact
38
+
39
+ base = undated(key)
40
+ base = base.sub(/-\d+\z/, "") if all_parts
41
+ matches = @docs.select do |d|
42
+ d.ids.any? { |i| (i == base) || i.start_with?("#{base}:") }
43
+ end
44
+ return nil if matches.empty?
45
+
46
+ best = matches.max_by { |d| d.year.to_i }
47
+ return best unless year
48
+
49
+ matches.find { |d| d.year == year.to_i } || best
50
+ end
51
+
52
+ # Full-contract search: substring over titles and identifiers,
53
+ # exact-value filters, one page of items in the Workers response
54
+ # shape, and facets computed over the whole filtered set.
55
+ def search(q: nil, flavor: nil, doctype: nil, year: nil,
56
+ status: nil, page: 0, size: DEFAULT_SIZE)
57
+ size = DEFAULT_SIZE if size.nil? || size < 1
58
+ size = MAX_SIZE if size > MAX_SIZE
59
+ page = 0 if page.nil? || page.negative?
60
+
61
+ filtered = @docs.select do |d|
62
+ (flavor.nil? || d.stream == flavor) &&
63
+ (doctype.nil? || d.doctype == doctype) &&
64
+ (year.nil? || d.year == year.to_i) &&
65
+ (status.nil? || d.status == status) &&
66
+ (q.nil? || matches_query?(d, q))
67
+ end
25
68
 
26
- document(entry)
69
+ facets = {
70
+ "flavor" => facet(filtered, :stream),
71
+ "doctype" => facet(filtered, :doctype),
72
+ "status" => facet(filtered, :status),
73
+ }
74
+ items = filtered.sort_by { |d| [d.title.to_s, d.docid.to_s] }
75
+ .drop(page * size)
76
+ .first(size)
77
+ .map { |d| item(d) }
78
+ { "total" => filtered.size, "page" => page, "size" => size,
79
+ "items" => items, "facets" => facets }
27
80
  end
28
81
 
29
82
  # [{ stream:, count: }] per top-level stream directory
30
83
  def streams
31
- @counts.sort.map { |stream, count| { stream: stream, count: count } }
84
+ @docs.group_by(&:stream)
85
+ .map { |stream, docs| { stream: stream, count: docs.size } }
86
+ .sort_by { |s| s[:stream] }
87
+ end
88
+
89
+ def document_hash(entry)
90
+ YAML.safe_load(File.read(entry.file), aliases: true)
91
+ end
92
+
93
+ def document_xml(entry)
94
+ require "relaton"
95
+ ::Relaton::Bib::Item.from_yaml(File.read(entry.file)).to_xml(bibdata: true)
96
+ end
97
+
98
+ def document_yaml(entry)
99
+ File.read(entry.file)
32
100
  end
33
101
 
34
102
  private
35
103
 
104
+ def matches_query?(doc, q)
105
+ needle = q.downcase
106
+ doc.title.to_s.downcase.include?(needle) ||
107
+ doc.ids.any? { |i| i.downcase.include?(needle) }
108
+ end
109
+
110
+ def facet(docs, attr)
111
+ docs.group_by { |d| d.send(attr) }
112
+ .reject { |value, _| value.nil? }
113
+ .map { |value, ds| { "value" => value, "count" => ds.size } }
114
+ .sort_by { |f| -f["count"] }
115
+ end
116
+
117
+ def item(doc)
118
+ {
119
+ "docid" => doc.docid,
120
+ "flavor" => doc.stream,
121
+ "year" => doc.year,
122
+ "doctype" => doc.doctype,
123
+ "status" => doc.status,
124
+ "title" => doc.title,
125
+ "api" => "/api/v1/document?code=#{::URI.encode_www_form_component(doc.docid.to_s)}",
126
+ }
127
+ end
128
+
36
129
  def build_index
37
130
  Dir[File.join(@data_dir, "**", "*.yaml")].sort.each do |path|
38
131
  doc = YAML.safe_load(File.read(path), aliases: true) || {}
39
132
  ids = (doc["docidentifier"] || doc["docid"] || [])
40
- .filter_map { |d| d["content"] || d["id"] }
133
+ .filter_map { |d| d.is_a?(Hash) ? (d["content"] || d["id"]) : d }
134
+ .reject { |i| i.to_s.empty? }
41
135
  next if ids.empty?
42
136
 
43
- @counts[stream_of(path)] += 1
44
- entry = KeyEntry.new(path, ids.first)
137
+ entry = DocEntry.new(
138
+ path, stream_of(path), ids.first.to_s, title_of(doc),
139
+ year_of(doc, ids.first), doctype_of(doc), status_of(doc),
140
+ ids.map { |i| key_of(i) },
141
+ )
142
+ @docs << entry
45
143
  ids.each { |id| @keys[key_of(id)] ||= entry }
46
144
  end
47
145
  end
48
146
 
147
+ def title_of(doc)
148
+ titles = Array(doc["title"])
149
+ main = titles.select { |t| t.is_a?(Hash) && t["type"] == "main" }
150
+ pool = main.empty? ? titles : main
151
+ chosen = pool.find { |t| t.is_a?(Hash) && t["language"] == "en" } ||
152
+ pool.first
153
+ chosen.is_a?(Hash) ? chosen["content"].to_s : chosen.to_s
154
+ end
155
+
156
+ def year_of(doc, primary_id)
157
+ dates = Array(doc["date"])
158
+ published = dates.find { |d| d.is_a?(Hash) && d["type"] == "published" }
159
+ value = published && (published["on"] || published["at"] ||
160
+ published["from"])
161
+ value.to_s[0, 4].to_i.nonzero? ||
162
+ primary_id.to_s[/:(\d{4})/, 1]&.to_i
163
+ end
164
+
165
+ def doctype_of(doc)
166
+ (doc.dig("ext", "doctype") || doc["doctype"] || doc["type"])&.to_s
167
+ end
168
+
169
+ def status_of(doc)
170
+ st = doc["status"]
171
+ return nil unless st.is_a?(Hash)
172
+
173
+ stage = st["stage"]
174
+ stage.is_a?(Hash) ? (stage["value"] || stage["content"]) : stage
175
+ end
176
+
49
177
  def stream_of(path)
50
178
  rel = path.delete_prefix("#{@data_dir}/")
51
179
  rel.include?("/") ? rel.split("/").first : "data"
52
180
  end
53
181
 
54
- def key_of(id)
55
- id.to_s.upcase.delete(" ")
182
+ def undated(key)
183
+ key.sub(/:\d{4}\z/, "")
56
184
  end
57
185
 
58
- # The dataset YAML is already the model serialization; serving it
59
- # as JSON is a passthrough, not a re-serialization.
60
- def document(entry)
61
- YAML.safe_load(File.read(entry.file), aliases: true)
186
+ def key_of(id)
187
+ id.to_s.upcase.delete(" ")
62
188
  end
63
189
  end
64
190
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Relaton
4
4
  module Api
5
- VERSION = "3.0.0"
5
+ VERSION = "3.0.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-31 00:00:00.000000000 Z
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -64,6 +64,7 @@ files:
64
64
  - lib/relaton/api/app.rb
65
65
  - lib/relaton/api/config.rb
66
66
  - lib/relaton/api/routes.rb
67
+ - lib/relaton/api/routes/api_v1.rb
67
68
  - lib/relaton/api/routes/documents.rb
68
69
  - lib/relaton/api/routes/flavors.rb
69
70
  - lib/relaton/api/routes/version.rb