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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab38285db3764dd95c87bf9a255de6d822aa5aff0101d6554031fb5aa7f48e85
4
- data.tar.gz: 75d1069a73a87d4cf7eeb821c67bcdd2a357b2c542109d7eb95fd3d084589044
3
+ metadata.gz: e562f633d044fd756c25fc089ee2416511162bf2de89f67659ca17c23e167040
4
+ data.tar.gz: a51fa14ea291598cd2d05e4abc6ade1ce09d0d4f764953873285a04171cda797
5
5
  SHA512:
6
- metadata.gz: c4c160d99d6cfe4e353e3f7e5c917f1b4548b323285d79cc156c6fcaed207594cfd2234a58a2e96d02420b7cf764b985b76df1e5c07a64d09d4bea97fe15db0f
7
- data.tar.gz: 1b0d8d8abefa4135f39e7101478e7b0ebe916cb8c938a8f079cb50f58ce264a690a23f4fac19f34ddc236b0222adb5c6873b6fe366ee8e60f61012bc5d8c314a
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
- # GET /v3/documents/{key} — full Relaton JSON by any indexed id
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
- KEY_PATTERN = %r{\A/v3/documents/(?<key>[^/]+)\z}.freeze
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
- KEY_PATTERN.match?(path)
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 = URI.decode_www_form_component(KEY_PATTERN.match(env["PATH_INFO"])[:key])
21
- params = ::URI.decode_www_form(env["QUERY_STRING"].to_s).to_h
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
- 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))]]
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
- private
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) || {}
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Relaton
4
4
  module Api
5
- VERSION = "3.0.1"
5
+ VERSION = "3.0.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.