relaton-api 3.0.1 → 3.0.2
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/routes/documents.rb +113 -19
- data/lib/relaton/api/store/file_system.rb +38 -0
- data/lib/relaton/api/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e562f633d044fd756c25fc089ee2416511162bf2de89f67659ca17c23e167040
|
|
4
|
+
data.tar.gz: a51fa14ea291598cd2d05e4abc6ade1ce09d0d4f764953873285a04171cda797
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85e1eac5109e2f9a4234e49d65ef81711eec2abb99ee3edac952c256f1ff38ad82ef03858707b96c5d02d7ef5051ec7e3e5bc232af66b6416c8e6ae39205348d
|
|
7
|
+
data.tar.gz: 52be47941620eb5072e68e6b533db99d24a5081ef0232d0a8bfe45320f2279077a4a3aa79ef3e05303637a0064bb05befc57baf56eb937e9b86d3c0073370f6b
|
|
@@ -1,49 +1,143 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
3
5
|
require "json"
|
|
4
6
|
require "uri"
|
|
5
7
|
|
|
6
8
|
module Relaton
|
|
7
9
|
module Api
|
|
8
10
|
module Routes
|
|
9
|
-
#
|
|
11
|
+
# The v3 surface (same shape as the Workers stack's /v3/documents):
|
|
12
|
+
# GET /v3/documents searches with cursor paging and Link headers,
|
|
13
|
+
# GET /v3/documents/{key} serves one document with Accept negotiation
|
|
14
|
+
# (application/json default, application/relaton+xml, application/yaml),
|
|
15
|
+
# strong ETags with immutable caching, If-None-Match 304s and HEAD.
|
|
16
|
+
# Errors are RFC 9457 problem+json.
|
|
10
17
|
class Documents
|
|
11
|
-
|
|
18
|
+
PATH_PATTERN = %r{\A/v3/documents(?:/(?<key>[^/]+))?\z}.freeze
|
|
19
|
+
|
|
20
|
+
XML_TYPE = "application/relaton+xml"
|
|
21
|
+
YAML_TYPE = "application/yaml"
|
|
22
|
+
JSON_TYPE = "application/json"
|
|
23
|
+
IMMUTABLE = "public, max-age=31536000, immutable"
|
|
24
|
+
|
|
25
|
+
QUERY_KEYS = %w[cursor limit format q flavor doctype year status all_parts].freeze
|
|
12
26
|
|
|
13
27
|
def matches?(path)
|
|
14
|
-
|
|
28
|
+
PATH_PATTERN.match?(path)
|
|
15
29
|
end
|
|
16
30
|
|
|
17
|
-
QUERY_KEYS = %w[format year all_parts keep_year].freeze
|
|
18
|
-
|
|
19
31
|
def call(env, app)
|
|
20
|
-
key =
|
|
21
|
-
params =
|
|
32
|
+
key = PATH_PATTERN.match(env["PATH_INFO"])[:key]
|
|
33
|
+
params = query_of(env)
|
|
34
|
+
return collection(env, app, params) unless key
|
|
35
|
+
|
|
36
|
+
document(env, app, decode_key(key), params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def query_of(env)
|
|
42
|
+
pairs = ::URI.decode_www_form(env["QUERY_STRING"].to_s)
|
|
43
|
+
pairs.select { |k, _| QUERY_KEYS.include?(k) }.to_h
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def decode_key(raw)
|
|
47
|
+
::URI.decode_www_form_component(raw)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def document(env, app, key, params)
|
|
22
51
|
entry = app.store.resolve(
|
|
23
52
|
key, year: params["year"]&.to_i,
|
|
24
53
|
all_parts: params["all_parts"] == "true",
|
|
25
54
|
)
|
|
26
55
|
return not_found(key) unless entry
|
|
27
56
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
57
|
+
type = negotiate(params["format"], env["HTTP_ACCEPT"])
|
|
58
|
+
body = body_of(app, entry, type)
|
|
59
|
+
etag = %("#{Digest::SHA256.hexdigest(body)}")
|
|
60
|
+
return not_modified(etag) if if_none_match?(env, etag)
|
|
61
|
+
|
|
62
|
+
headers = { "content-type" => type, "etag" => etag, "cache-control" => IMMUTABLE }
|
|
63
|
+
return [200, headers, []] if env["REQUEST_METHOD"] == "HEAD"
|
|
64
|
+
|
|
65
|
+
[200, headers, [body]]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def body_of(app, entry, type)
|
|
69
|
+
case type
|
|
70
|
+
when JSON_TYPE then JSON.generate(app.store.document_hash(entry))
|
|
71
|
+
when YAML_TYPE then app.store.document_yaml(entry)
|
|
72
|
+
else app.store.document_xml(entry)
|
|
38
73
|
end
|
|
39
74
|
end
|
|
40
75
|
|
|
41
|
-
|
|
76
|
+
# Explicit format wins; otherwise the first matching Accept token;
|
|
77
|
+
# JSON when nothing names a relaton representation.
|
|
78
|
+
def negotiate(format, accept)
|
|
79
|
+
case format
|
|
80
|
+
when "xml" then XML_TYPE
|
|
81
|
+
when "yaml" then YAML_TYPE
|
|
82
|
+
when "json" then JSON_TYPE
|
|
83
|
+
end || begin
|
|
84
|
+
offered = accept.to_s.split(",").map { |t| t.split(";").first.to_s.strip.downcase }
|
|
85
|
+
offered.each do |type|
|
|
86
|
+
return XML_TYPE if type == XML_TYPE || type == "text/xml"
|
|
87
|
+
return YAML_TYPE if type == YAML_TYPE || type == "text/yaml"
|
|
88
|
+
end
|
|
89
|
+
JSON_TYPE
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def if_none_match?(env, etag)
|
|
94
|
+
env["HTTP_IF_NONE_MATCH"].to_s.split(",").map(&:strip).include?(etag)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def not_modified(etag)
|
|
98
|
+
[304, { "etag" => etag, "cache-control" => IMMUTABLE }, []]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Docid-ordered cursor paging: the cursor is the url-safe base64 of
|
|
102
|
+
# the last docid served; the Link header carries it when more pages
|
|
103
|
+
# remain.
|
|
104
|
+
def collection(env, app, params)
|
|
105
|
+
cursor = params["cursor"] && begin
|
|
106
|
+
Base64.urlsafe_decode64(params["cursor"])
|
|
107
|
+
rescue ArgumentError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
return unprocessable("cursor is not a valid token") if params["cursor"] && cursor.nil?
|
|
111
|
+
|
|
112
|
+
result = app.store.search_cursor(
|
|
113
|
+
cursor: cursor, limit: params["limit"]&.to_i,
|
|
114
|
+
q: params["q"], flavor: params["flavor"],
|
|
115
|
+
doctype: params["doctype"], year: params["year"]&.to_i,
|
|
116
|
+
status: params["status"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# The cursor is opaque on the wire: the url-safe base64 of the
|
|
120
|
+
# last docid, the same encoding the Workers stack uses.
|
|
121
|
+
token = result[:next_cursor] && Base64.urlsafe_encode64(result[:next_cursor])
|
|
122
|
+
headers = {}
|
|
123
|
+
if token
|
|
124
|
+
query = { cursor: token, limit: params["limit"] }.compact
|
|
125
|
+
headers["link"] = %(</v3/documents?#{::URI.encode_www_form(query)}>; rel="next")
|
|
126
|
+
end
|
|
127
|
+
[200, { "content-type" => JSON_TYPE }.merge(headers),
|
|
128
|
+
[JSON.generate(items: result[:items], next_cursor: token)]]
|
|
129
|
+
end
|
|
42
130
|
|
|
43
131
|
def not_found(key)
|
|
44
132
|
[404, { "content-type" => "application/problem+json" },
|
|
45
133
|
[{ type: "about:blank", title: "Not Found",
|
|
46
|
-
detail: "unknown key #{key}" }.to_json]]
|
|
134
|
+
status: 404, detail: "unknown key #{key}" }.to_json]]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unprocessable(detail)
|
|
138
|
+
[422, { "content-type" => "application/problem+json" },
|
|
139
|
+
[{ type: "about:blank", title: "Unprocessable Content",
|
|
140
|
+
status: 422, detail: detail }.to_json]]
|
|
47
141
|
end
|
|
48
142
|
end
|
|
49
143
|
end
|
|
@@ -79,6 +79,30 @@ module Relaton
|
|
|
79
79
|
"items" => items, "facets" => facets }
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# v3 collection page: the same filters as #search, docid-ordered,
|
|
83
|
+
# paged by cursor (the docid the previous page ended on) instead of
|
|
84
|
+
# page numbers. -> { items:, next_cursor: }
|
|
85
|
+
def search_cursor(cursor: nil, limit: DEFAULT_SIZE, q: nil, flavor: nil,
|
|
86
|
+
doctype: nil, year: nil, status: nil)
|
|
87
|
+
limit = DEFAULT_SIZE if limit.nil? || limit < 1
|
|
88
|
+
limit = MAX_SIZE if limit > MAX_SIZE
|
|
89
|
+
|
|
90
|
+
filtered = @docs.select do |d|
|
|
91
|
+
(flavor.nil? || d.stream == flavor) &&
|
|
92
|
+
(doctype.nil? || d.doctype == doctype) &&
|
|
93
|
+
(year.nil? || d.year == year.to_i) &&
|
|
94
|
+
(status.nil? || d.status == status) &&
|
|
95
|
+
(q.nil? || matches_query?(d, q))
|
|
96
|
+
end.sort_by { |d| d.docid.to_s }
|
|
97
|
+
filtered = filtered.drop_while { |d| d.docid.to_s <= cursor.to_s } if cursor
|
|
98
|
+
|
|
99
|
+
page = filtered.first(limit + 1)
|
|
100
|
+
has_more = page.size > limit
|
|
101
|
+
served = page.first(limit)
|
|
102
|
+
{ items: served.map { |d| collection_item(d) },
|
|
103
|
+
next_cursor: has_more ? served.last.docid.to_s : nil }
|
|
104
|
+
end
|
|
105
|
+
|
|
82
106
|
# [{ stream:, count: }] per top-level stream directory
|
|
83
107
|
def streams
|
|
84
108
|
@docs.group_by(&:stream)
|
|
@@ -126,6 +150,20 @@ module Relaton
|
|
|
126
150
|
}
|
|
127
151
|
end
|
|
128
152
|
|
|
153
|
+
def collection_item(doc)
|
|
154
|
+
{
|
|
155
|
+
"key" => doc.docid,
|
|
156
|
+
"flavor" => doc.stream,
|
|
157
|
+
"docid" => doc.docid,
|
|
158
|
+
"title" => doc.title,
|
|
159
|
+
"year" => doc.year,
|
|
160
|
+
"doctype" => doc.doctype,
|
|
161
|
+
"status" => doc.status,
|
|
162
|
+
# encodeURIComponent's form (spaces as %20, not +).
|
|
163
|
+
"api" => "/v3/documents/#{::URI.encode_www_form_component(doc.docid.to_s).gsub('+', '%20')}",
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
129
167
|
def build_index
|
|
130
168
|
Dir[File.join(@data_dir, "**", "*.yaml")].sort.each do |path|
|
|
131
169
|
doc = YAML.safe_load(File.read(path), aliases: true) || {}
|
data/lib/relaton/api/version.rb
CHANGED