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 +4 -4
- data/lib/relaton/api/app.rb +4 -1
- data/lib/relaton/api/config.rb +13 -0
- data/lib/relaton/api/routes/api_v1.rb +98 -0
- data/lib/relaton/api/routes/documents.rb +19 -3
- data/lib/relaton/api/routes.rb +7 -0
- data/lib/relaton/api/store/file_system.rb +145 -19
- data/lib/relaton/api/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab38285db3764dd95c87bf9a255de6d822aa5aff0101d6554031fb5aa7f48e85
|
|
4
|
+
data.tar.gz: 75d1069a73a87d4cf7eeb821c67bcdd2a357b2c542109d7eb95fd3d084589044
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c4c160d99d6cfe4e353e3f7e5c917f1b4548b323285d79cc156c6fcaed207594cfd2234a58a2e96d02420b7cf764b985b76df1e5c07a64d09d4bea97fe15db0f
|
|
7
|
+
data.tar.gz: 1b0d8d8abefa4135f39e7101478e7b0ebe916cb8c938a8f079cb50f58ce264a690a23f4fac19f34ddc236b0222adb5c6873b6fe366ee8e60f61012bc5d8c314a
|
data/lib/relaton/api/app.rb
CHANGED
|
@@ -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 = [
|
|
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
|
|
data/lib/relaton/api/config.rb
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
-
[
|
|
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
|
data/lib/relaton/api/routes.rb
CHANGED
|
@@ -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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
55
|
-
|
|
182
|
+
def undated(key)
|
|
183
|
+
key.sub(/:\d{4}\z/, "")
|
|
56
184
|
end
|
|
57
185
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
data/lib/relaton/api/version.rb
CHANGED
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.
|
|
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-
|
|
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
|