comatype 0.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 +7 -0
- data/README.md +7 -0
- data/lib/comatype/c.rb +120 -0
- data/lib/comatype/cache.rb +119 -0
- data/lib/comatype/check.rb +46 -0
- data/lib/comatype/codec.rb +266 -0
- data/lib/comatype/cookie.rb +109 -0
- data/lib/comatype/endpoint.rb +151 -0
- data/lib/comatype/errors.rb +37 -0
- data/lib/comatype/model.rb +164 -0
- data/lib/comatype/raw.rb +97 -0
- data/lib/comatype/response.rb +71 -0
- data/lib/comatype/s.rb +234 -0
- data/lib/comatype/wire.rb +28 -0
- data/lib/comatype.rb +26 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 11378b390d51323b9426bd34ca59ae31c070eaa2fcaa044fa4e6c34fac481d58
|
|
4
|
+
data.tar.gz: 36c5c0659ce2dbb3047f6f5288cdc0879441dbefa1927b5ca228cd12a66df7d6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5564c686c421770a8f46c998d7b487a16c4ec6bac79fc93ded65f5340ef4bad2d774d86b2c8bb9322a0eca72438b3897b296575c0fc2e18379e6f5a855927242
|
|
7
|
+
data.tar.gz: 7f232ea4f98d2823afc0eea95c12f6ef2ab626f8301439b64e014692f56bf686b4113c13f3b3f8a05d9842e14a71899d7c8036c126a7ab383823564a95b0702d
|
data/README.md
ADDED
data/lib/comatype/c.rb
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Comatype
|
|
4
|
+
# Client. Called like `C.put(ENDPOINT, params: {id: id}, body: user)`.
|
|
5
|
+
# Argument keys are params: search: headers: body: (a Model instance or a hash keyed by attr).
|
|
6
|
+
module C
|
|
7
|
+
Config = Struct.new(:base_url, :wrappers, :cookie_jar, :headers, :timeout, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
@lock = Mutex.new
|
|
10
|
+
# responses of `cache <ttl>` (memory) endpoints
|
|
11
|
+
@cache = ResponseCache.new
|
|
12
|
+
# names of cookies carrying cached responses (for clear_cache)
|
|
13
|
+
@cache_cookies = []
|
|
14
|
+
@config = Config.new(wrappers: {}, headers: {}, timeout: 30)
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Call once at startup.
|
|
18
|
+
# base_url: precedence: this > env COMATYPE_BASE_URL > baseUrl in init.comat
|
|
19
|
+
# wrappers: e.g. the generated Init.wrappers(auth: ...). An endpoint that uses a missing wrapper raises an error
|
|
20
|
+
# cookies: true or a CookieJar. `cookie on` endpoints keep Set-Cookie and send it back
|
|
21
|
+
def init(wrappers: {}, base_url: nil, cookies: nil, headers: {}, timeout: 30)
|
|
22
|
+
jar = cookies == true ? CookieJar.new : cookies
|
|
23
|
+
@lock.synchronize do
|
|
24
|
+
@config = Config.new(base_url: base_url, wrappers: wrappers.transform_keys(&:to_s), cookie_jar: jar, headers: headers, timeout: timeout)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Drop the cache (memory and what is stored in the cookie jar), e.g. on logout.
|
|
29
|
+
def clear_cache
|
|
30
|
+
@cache.clear
|
|
31
|
+
jar = @lock.synchronize { @config.cookie_jar }
|
|
32
|
+
@cache_cookies.each { |name| jar&.store(["#{name}=; Path=/; Max-Age=0"]) }
|
|
33
|
+
@cache_cookies.clear
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
%w[get post put patch delete options query head].each do |m|
|
|
37
|
+
define_method(m) { |ep, **args| call(m, ep, args) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def call(method, ep, args)
|
|
43
|
+
raise ConfigError, "C.#{method} に #{ep.method} のエンドポイント(#{ep.module_name})は渡せません" unless ep.method == method
|
|
44
|
+
|
|
45
|
+
conf = @lock.synchronize { @config }
|
|
46
|
+
chain = ep.wraps.map do |name|
|
|
47
|
+
conf.wrappers[name] or raise ConfigError, "ラッパー #{name} が init に渡されていません(#{ep.module_name})"
|
|
48
|
+
end
|
|
49
|
+
raw = ep.build_request(args)
|
|
50
|
+
conf.headers.each { |k, v| raw.headers[k.to_s] = v unless raw.headers.key?(k.to_s) }
|
|
51
|
+
raw.headers["Accept"] = "application/json" unless raw.headers.key?("Accept")
|
|
52
|
+
if ep.cookie && conf.cookie_jar
|
|
53
|
+
# Cache cookies are not sent back to the server
|
|
54
|
+
conf.cookie_jar.get(raw.path).each { |k, v| raw.cookies[k] = v unless k.start_with?(ResponseCache::COOKIE_PREFIX) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
send_fn = ->(r) { transport(ep, conf, r) }
|
|
58
|
+
nxt = chain.reverse.reduce(send_fn) do |inner, w|
|
|
59
|
+
->(r) { w.call(r, inner) }
|
|
60
|
+
end
|
|
61
|
+
resp = nxt.call(raw)
|
|
62
|
+
raise ConfigError, "ラッパーが nil のレスポンスを返しました(#{ep.module_name})" unless resp.is_a?(RawResponse)
|
|
63
|
+
|
|
64
|
+
ep.parse_response(resp)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def transport(ep, conf, r)
|
|
68
|
+
base = conf.base_url || ENV["COMATYPE_BASE_URL"]
|
|
69
|
+
base = ep.base_url if base.nil? || base.empty?
|
|
70
|
+
if base.nil? || base.empty?
|
|
71
|
+
raise ConfigError, "base url がありません(init の base_url、環境変数 COMATYPE_BASE_URL、init.comat の baseUrl のどれかで指定してください)"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
url = base.sub(%r{/+\z}, "") + r.path
|
|
75
|
+
unless r.query.empty?
|
|
76
|
+
url += "?" + r.query.map { |k, v| "#{Cookies.encode(k)}=#{Cookies.encode(v)}" }.join("&")
|
|
77
|
+
end
|
|
78
|
+
uri = URI.parse(url)
|
|
79
|
+
# Cache (GET / QUERY): memory or cookie. The key is the method, URL, body and the declared request headers
|
|
80
|
+
spec = ep.cache
|
|
81
|
+
cache_key = nil
|
|
82
|
+
if spec && (spec.memory? || (spec.cookie? && conf.cookie_jar))
|
|
83
|
+
cache_key = ResponseCache.client_key(r.method, url, r.body, ep.cache_headers, r.headers)
|
|
84
|
+
hit =
|
|
85
|
+
if spec.memory?
|
|
86
|
+
@cache.get(cache_key)
|
|
87
|
+
elsif (v = conf.cookie_jar.get("/")[ResponseCache.cookie_name(cache_key)])
|
|
88
|
+
ResponseCache.decode_cookie(v)
|
|
89
|
+
end
|
|
90
|
+
return hit if hit
|
|
91
|
+
end
|
|
92
|
+
req = Net::HTTPGenericRequest.new(r.method.upcase, !r.body.empty?, true, uri.request_uri)
|
|
93
|
+
r.headers.each { |k, v| req.add_field(k, v) }
|
|
94
|
+
unless r.cookies.empty? || r.headers.key?("Cookie")
|
|
95
|
+
req["Cookie"] = r.cookies.sort.map { |k, v| "#{k}=#{Cookies.encode(v)}" }.join("; ")
|
|
96
|
+
end
|
|
97
|
+
req.body = r.body unless r.body.empty?
|
|
98
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: conf.timeout, read_timeout: conf.timeout) { |h| h.request(req) }
|
|
99
|
+
out = RawResponse.new(res.code.to_i)
|
|
100
|
+
res.each_header { |k, _| res.get_fields(k).each { |v| out.headers.add(k, v) } unless k.casecmp?("set-cookie") }
|
|
101
|
+
out.body = (res.body || "").b
|
|
102
|
+
out.set_cookies = res.get_fields("set-cookie") || []
|
|
103
|
+
conf.cookie_jar.store(out.set_cookies) if ep.cookie && conf.cookie_jar && !out.set_cookies.empty?
|
|
104
|
+
if cache_key && ResponseCache.cacheable?(out)
|
|
105
|
+
if spec.memory?
|
|
106
|
+
@cache.set(cache_key, out, spec.ttl_ms)
|
|
107
|
+
elsif (v = ResponseCache.encode_cookie(out))
|
|
108
|
+
name = ResponseCache.cookie_name(cache_key)
|
|
109
|
+
# cookie ttl is in seconds (rounded up)
|
|
110
|
+
conf.cookie_jar.store(["#{name}=#{v}; Path=/; Max-Age=#{[1, (spec.ttl_ms / 1000.0).ceil].max}"])
|
|
111
|
+
@cache_cookies << name
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
out
|
|
115
|
+
rescue SystemCallError, IOError, Timeout::Error, SocketError => e
|
|
116
|
+
raise TransportError, e.message
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Comatype
|
|
4
|
+
# The `cache` setting of `.comat` (GET / QUERY endpoints only).
|
|
5
|
+
# mode is :off (never cache) / :memory (in memory for the ttl) / :cookie (in a cookie for the ttl; client only)
|
|
6
|
+
CacheSpec = Struct.new(:mode, :ttl_ms) do
|
|
7
|
+
def memory?
|
|
8
|
+
mode == :memory && ttl_ms.to_i.positive?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def cookie?
|
|
12
|
+
mode == :cookie && ttl_ms.to_i.positive?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Cache: the in-memory TTL cache, and key / cookie encoding.
|
|
17
|
+
class ResponseCache
|
|
18
|
+
# Name prefix of cache cookies. The client does not send cookies with this prefix back to the server
|
|
19
|
+
COOKIE_PREFIX = "comatype_cache_"
|
|
20
|
+
# Maximum value length of one cookie. Larger responses are not cached in a cookie
|
|
21
|
+
COOKIE_MAX = 3800
|
|
22
|
+
|
|
23
|
+
def initialize(max = 1000, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
24
|
+
@max = max
|
|
25
|
+
@clock = clock
|
|
26
|
+
@map = {}
|
|
27
|
+
@lock = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# In-memory cache with ttl (expired entries are dropped on lookup; over the size limit, the oldest are dropped)
|
|
31
|
+
def get(key)
|
|
32
|
+
@lock.synchronize do
|
|
33
|
+
e = @map[key]
|
|
34
|
+
return nil unless e
|
|
35
|
+
|
|
36
|
+
if e[1] <= @clock.call
|
|
37
|
+
@map.delete(key)
|
|
38
|
+
return nil
|
|
39
|
+
end
|
|
40
|
+
self.class.copy(e[0])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def set(key, res, ttl_ms)
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
@map.delete(key)
|
|
47
|
+
@map[key] = [self.class.copy(res), @clock.call + (ttl_ms / 1000.0)]
|
|
48
|
+
@map.shift while @map.size > @max
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def clear
|
|
53
|
+
@lock.synchronize { @map.clear }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def size
|
|
57
|
+
@lock.synchronize { @map.size }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
def copy(r)
|
|
62
|
+
o = RawResponse.new(r.status)
|
|
63
|
+
r.headers.each { |k, v| o.headers.add(k, v) }
|
|
64
|
+
o.body = r.body.dup
|
|
65
|
+
o
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether a response may be cached (2xx, without Set-Cookie)
|
|
69
|
+
def cacheable?(r)
|
|
70
|
+
r.status.between?(200, 299) && r.set_cookies.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Client cache key: method, URL (path and query) and body, plus the request headers declared in `.comat`.
|
|
74
|
+
# Headers added by wrappers or init (Authorization etc.) are not included.
|
|
75
|
+
def client_key(method, url, body, declared, headers)
|
|
76
|
+
hs = declared.map(&:downcase).sort.map { |n| "#{n}:#{headers[n]}" }.join("\n")
|
|
77
|
+
"#{method}\n#{url}\n#{hs}\n\n#{body.dup.force_encoding('UTF-8')}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Server cache key: an exact match of the request (method, path, query, all headers, body)
|
|
81
|
+
def server_key(method, target, headers, body)
|
|
82
|
+
hs = headers.map { |k, v| "#{k.downcase}:#{v}" }.sort.join("\n")
|
|
83
|
+
"#{method}\n#{target}\n#{hs}\n\n#{body.dup.force_encoding('UTF-8')}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Derive a cookie name from the key (FNV-1a 64-bit)
|
|
87
|
+
def cookie_name(key)
|
|
88
|
+
h = 0xcbf29ce484222325
|
|
89
|
+
key.each_byte { |b| h = ((h ^ b) * 0x100000001b3) & 0xffffffffffffffff }
|
|
90
|
+
format("%s%016x", COOKIE_PREFIX, h)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Turn a response into a cookie value (base64url of `status\nname\tvalue\n...\n\nbody`). nil if too large
|
|
94
|
+
def encode_cookie(r)
|
|
95
|
+
head = +"#{r.status}\n"
|
|
96
|
+
r.headers.each { |k, v| head << "#{k}\t#{v}\n" }
|
|
97
|
+
v = Base64.urlsafe_encode64(head.b + "\n".b + r.body.b, padding: false)
|
|
98
|
+
v.length <= COOKIE_MAX ? v : nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def decode_cookie(value)
|
|
102
|
+
raw = Base64.urlsafe_decode64(value).b
|
|
103
|
+
head, sep, body = raw.partition("\n\n".b)
|
|
104
|
+
return nil if sep.empty?
|
|
105
|
+
|
|
106
|
+
lines = head.force_encoding("UTF-8").split("\n")
|
|
107
|
+
out = RawResponse.new(Integer(lines[0]))
|
|
108
|
+
lines[1..].each do |l|
|
|
109
|
+
k, tab, v = l.partition("\t")
|
|
110
|
+
out.headers.add(k, v) unless tab.empty?
|
|
111
|
+
end
|
|
112
|
+
out.body = body
|
|
113
|
+
out
|
|
114
|
+
rescue ArgumentError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Comatype
|
|
4
|
+
# Validation and constraints (min / max / pattern / oneof).
|
|
5
|
+
module Check
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Character-count range (`string(1..50)`)
|
|
9
|
+
def len(path, s, min, max)
|
|
10
|
+
n = s.to_s.length
|
|
11
|
+
raise ValidationError.new(path, "文字数は #{min} 以上にしてください") if min && n < min
|
|
12
|
+
raise ValidationError.new(path, "文字数は #{max} 以下にしてください") if max && n > max
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Value range (`int(0..150)`)
|
|
16
|
+
def range(path, n, min, max)
|
|
17
|
+
raise ValidationError.new(path, "値は #{min} 以上にしてください") if min && n < min
|
|
18
|
+
raise ValidationError.new(path, "値は #{max} 以下にしてください") if max && n > max
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def pattern(path, s, pattern)
|
|
22
|
+
re = begin
|
|
23
|
+
Regexp.new(pattern)
|
|
24
|
+
rescue RegexpError
|
|
25
|
+
raise ValidationError.new(path, "pattern #{pattern.inspect} が不正です")
|
|
26
|
+
end
|
|
27
|
+
raise ValidationError.new(path, "パターン #{pattern} に一致しません") unless re.match?(s.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def one_of(path, s, values)
|
|
31
|
+
raise ValidationError.new(path, "#{values.join(' | ')} のどれかにしてください") unless values.include?(s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
UUID_RE = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-([0-9a-fA-F])[0-9a-fA-F]{3}-([89abAB])[0-9a-fA-F]{3}-[0-9a-fA-F]{12}\z/.freeze
|
|
35
|
+
UUID_ANY = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/.freeze
|
|
36
|
+
|
|
37
|
+
def uuid(path, s, version)
|
|
38
|
+
label = version.zero? ? "uuid" : "uuidv#{version}"
|
|
39
|
+
raise ValidationError.new(path, "#{label} の形式が不正です") unless s.is_a?(String) && UUID_ANY.match?(s)
|
|
40
|
+
return if version.zero?
|
|
41
|
+
|
|
42
|
+
m = UUID_RE.match(s)
|
|
43
|
+
raise ValidationError.new(path, "#{label} の形式が不正です") unless m && m[1] == version.to_s
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Comatype
|
|
4
|
+
# Converts and validates between Ruby values and JSON trees / strings (path / query / headers) according to a type descriptor.
|
|
5
|
+
#
|
|
6
|
+
# :string :bool :int :int64 :email :phone :uuid :uuidv4 :uuidv7 :date :datetime :time :decimal :url :bytes :json
|
|
7
|
+
# [:string, {min: 1, max: 50}] scalar with constraints (min / max / pattern)
|
|
8
|
+
# [:array, t] [:nullable, t] [:literal, ["a", "b"]]
|
|
9
|
+
# [:ref, a Model/Enum class, or a Proc that returns it]
|
|
10
|
+
module Codec
|
|
11
|
+
SCALARS = %i[string bool int int64 email phone uuid uuidv4 uuidv7 date datetime time decimal url bytes json].freeze
|
|
12
|
+
STRINGY = %i[string email phone uuid uuidv4 uuidv7 date datetime time decimal url].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def fail!(path, reason)
|
|
17
|
+
raise ValidationError.new(path, reason)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def kind_of(t)
|
|
21
|
+
t.is_a?(Array) ? t[0] : t
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resolve(ref)
|
|
25
|
+
ref.is_a?(Proc) ? ref.call : ref
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def describe(j)
|
|
29
|
+
case j
|
|
30
|
+
when nil then "null"
|
|
31
|
+
when String then "文字列"
|
|
32
|
+
when true, false then "真偽値"
|
|
33
|
+
when Numeric then "数値"
|
|
34
|
+
when Array then "配列"
|
|
35
|
+
else "オブジェクト"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ---- string -> value (shared by JSON strings and path / query / header strings) ----
|
|
40
|
+
|
|
41
|
+
def parse_str(kind, s, path)
|
|
42
|
+
case kind
|
|
43
|
+
when :string then s
|
|
44
|
+
when :email
|
|
45
|
+
fail!(path, "email の形式が不正です") unless s.match?(/\A[^\s@]+@[^\s@]+\.[^\s@]+\z/)
|
|
46
|
+
s
|
|
47
|
+
when :phone
|
|
48
|
+
fail!(path, "phone は E.164 の形式にしてください") unless s.match?(/\A\+[1-9]\d{1,14}\z/)
|
|
49
|
+
s
|
|
50
|
+
when :uuid then Check.uuid(path, s, 0) || s.downcase
|
|
51
|
+
when :uuidv4 then Check.uuid(path, s, 4) || s.downcase
|
|
52
|
+
when :uuidv7 then Check.uuid(path, s, 7) || s.downcase
|
|
53
|
+
when :date
|
|
54
|
+
fail!(path, "date は YYYY-MM-DD の形式にしてください") unless s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
55
|
+
begin
|
|
56
|
+
Date.strptime(s, "%Y-%m-%d")
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
fail!(path, "date は YYYY-MM-DD の形式にしてください")
|
|
59
|
+
end
|
|
60
|
+
when :time
|
|
61
|
+
fail!(path, "time は HH:MM:SS の形式にしてください") unless s.match?(/\A([01]\d|2[0-3]):[0-5]\d:[0-5]\d\z/)
|
|
62
|
+
s
|
|
63
|
+
when :datetime
|
|
64
|
+
fail!(path, "datetime は RFC 3339 の形式にしてください") unless s.match?(/\A\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/)
|
|
65
|
+
begin
|
|
66
|
+
Time.iso8601(s.upcase)
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
fail!(path, "datetime は RFC 3339 の形式にしてください")
|
|
69
|
+
end
|
|
70
|
+
when :decimal
|
|
71
|
+
fail!(path, "decimal は数値の文字列にしてください") unless s.match?(/\A-?\d+(\.\d+)?\z/)
|
|
72
|
+
BigDecimal(s)
|
|
73
|
+
when :url
|
|
74
|
+
u = begin
|
|
75
|
+
URI.parse(s)
|
|
76
|
+
rescue URI::InvalidURIError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
fail!(path, "url は http(s)://host/... の形式にしてください") unless u && u.scheme && u.host
|
|
80
|
+
u
|
|
81
|
+
when :bool
|
|
82
|
+
return true if s == "true"
|
|
83
|
+
return false if s == "false"
|
|
84
|
+
|
|
85
|
+
fail!(path, "真偽値は true か false にしてください")
|
|
86
|
+
when :int, :int64
|
|
87
|
+
fail!(path, "整数ではありません") unless s.strip.match?(/\A-?\d+\z/)
|
|
88
|
+
s.strip.to_i
|
|
89
|
+
else
|
|
90
|
+
fail!(path, "#{kind} は path・クエリ・ヘッダに使えない型です")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# value -> string (path, query, headers)
|
|
95
|
+
def to_wire(kind, v)
|
|
96
|
+
case kind
|
|
97
|
+
when :date then v.strftime("%Y-%m-%d")
|
|
98
|
+
when :datetime then v.utc.iso8601(3)
|
|
99
|
+
when :decimal then v.to_s("F")
|
|
100
|
+
else v.to_s
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ---- JSON tree -> value ----
|
|
105
|
+
|
|
106
|
+
def decode(t, j, path = "")
|
|
107
|
+
kind = kind_of(t)
|
|
108
|
+
case kind
|
|
109
|
+
when :nullable then j.nil? ? nil : decode(t[1], j, path)
|
|
110
|
+
when :array
|
|
111
|
+
fail!(path, "配列にしてください(#{describe(j)} でした)") unless j.is_a?(Array)
|
|
112
|
+
j.each_with_index.map { |x, i| decode(t[1], x, "#{path}[#{i}]") }
|
|
113
|
+
when :literal
|
|
114
|
+
fail!(path, "文字列にしてください(#{describe(j)} でした)") unless j.is_a?(String)
|
|
115
|
+
Check.one_of(path, j, t[1])
|
|
116
|
+
j
|
|
117
|
+
when :ref then resolve(t[1]).decode(j, path)
|
|
118
|
+
else decode_scalar(kind, t.is_a?(Array) ? t[1] : nil, j, path)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def decode_scalar(kind, constraint, j, path)
|
|
123
|
+
v =
|
|
124
|
+
case kind
|
|
125
|
+
when :bool
|
|
126
|
+
fail!(path, "真偽値にしてください(#{describe(j)} でした)") unless j == true || j == false
|
|
127
|
+
j
|
|
128
|
+
when :int
|
|
129
|
+
ok = j.is_a?(Integer) || (j.is_a?(Float) && j == j.floor && j.abs < 9e15)
|
|
130
|
+
fail!(path, "整数にしてください(#{describe(j)} でした)") unless ok
|
|
131
|
+
j.to_i
|
|
132
|
+
when :int64
|
|
133
|
+
fail!(path, "int64 は整数の文字列で送ってください") unless j.is_a?(String) && j.match?(/\A-?\d+\z/)
|
|
134
|
+
j.to_i
|
|
135
|
+
when :bytes
|
|
136
|
+
fail!(path, "文字列にしてください(#{describe(j)} でした)") unless j.is_a?(String)
|
|
137
|
+
begin
|
|
138
|
+
Base64.strict_decode64(j)
|
|
139
|
+
rescue ArgumentError
|
|
140
|
+
fail!(path, "bytes は base64 の文字列にしてください")
|
|
141
|
+
end
|
|
142
|
+
when :json then j
|
|
143
|
+
else
|
|
144
|
+
fail!(path, "文字列にしてください(#{describe(j)} でした)") unless j.is_a?(String)
|
|
145
|
+
parse_str(kind, j, path)
|
|
146
|
+
end
|
|
147
|
+
check_constraint(kind, constraint, v, path)
|
|
148
|
+
v
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def check_constraint(kind, c, v, path)
|
|
152
|
+
return unless c
|
|
153
|
+
|
|
154
|
+
if STRINGY.include?(kind) && kind != :decimal
|
|
155
|
+
s = v.is_a?(String) ? v : to_wire(kind, v)
|
|
156
|
+
Check.len(path, s, c[:min], c[:max]) if c[:min] || c[:max]
|
|
157
|
+
Check.pattern(path, s, c[:pattern]) if c[:pattern]
|
|
158
|
+
elsif %i[int int64].include?(kind)
|
|
159
|
+
Check.range(path, v, c[:min], c[:max])
|
|
160
|
+
elsif kind == :decimal
|
|
161
|
+
Check.range(path, v, c[:min], c[:max])
|
|
162
|
+
Check.pattern(path, v.to_s("F"), c[:pattern]) if c[:pattern]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# ---- value validation (values built by the user; types and constraints) ----
|
|
167
|
+
|
|
168
|
+
def validate(t, v, path = "")
|
|
169
|
+
kind = kind_of(t)
|
|
170
|
+
case kind
|
|
171
|
+
when :nullable then v.nil? || validate(t[1], v, path)
|
|
172
|
+
when :array
|
|
173
|
+
fail!(path, "配列にしてください") unless v.is_a?(Array)
|
|
174
|
+
v.each_with_index { |x, i| validate(t[1], x, "#{path}[#{i}]") }
|
|
175
|
+
when :literal then Check.one_of(path, v, t[1])
|
|
176
|
+
when :ref then resolve(t[1]).validate_value(v, path)
|
|
177
|
+
else validate_scalar(kind, t.is_a?(Array) ? t[1] : nil, v, path)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate_scalar(kind, constraint, v, path)
|
|
182
|
+
case kind
|
|
183
|
+
when :string then fail!(path, "文字列にしてください") unless v.is_a?(String)
|
|
184
|
+
when :bool then fail!(path, "真偽値にしてください") unless v == true || v == false
|
|
185
|
+
when :int, :int64 then fail!(path, "整数にしてください") unless v.is_a?(Integer)
|
|
186
|
+
when :date then fail!(path, "date は Date にしてください") unless v.is_a?(Date)
|
|
187
|
+
when :datetime then fail!(path, "datetime は Time にしてください") unless v.is_a?(Time)
|
|
188
|
+
when :decimal then fail!(path, "decimal は BigDecimal にしてください") unless v.is_a?(BigDecimal)
|
|
189
|
+
when :url then fail!(path, "url は URI にしてください") unless v.is_a?(URI::Generic)
|
|
190
|
+
when :bytes then fail!(path, "bytes は文字列にしてください") unless v.is_a?(String)
|
|
191
|
+
when :json then nil
|
|
192
|
+
else
|
|
193
|
+
fail!(path, "文字列にしてください") unless v.is_a?(String)
|
|
194
|
+
parse_str(kind, v, path)
|
|
195
|
+
end
|
|
196
|
+
check_constraint(kind, constraint, v, path)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# ---- value -> JSON tree (assumed already validated) ----
|
|
200
|
+
|
|
201
|
+
def encode(t, v)
|
|
202
|
+
return nil if v.nil?
|
|
203
|
+
|
|
204
|
+
kind = kind_of(t)
|
|
205
|
+
case kind
|
|
206
|
+
when :nullable then encode(t[1], v)
|
|
207
|
+
when :array then v.map { |x| encode(t[1], x) }
|
|
208
|
+
when :literal then v
|
|
209
|
+
when :ref then resolve(t[1]).encode_value(v)
|
|
210
|
+
else encode_scalar(kind, v)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def encode_scalar(kind, v)
|
|
215
|
+
case kind
|
|
216
|
+
when :int64 then v.to_s
|
|
217
|
+
when :bytes then Base64.strict_encode64(v)
|
|
218
|
+
when :json, :bool, :int, :string then v
|
|
219
|
+
when :uuid, :uuidv4, :uuidv7 then v.downcase
|
|
220
|
+
else to_wire(kind, v)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ---- path, query, headers ----
|
|
225
|
+
|
|
226
|
+
# With `split` (headers), an array arrives as one ", "-separated value
|
|
227
|
+
def from_wire(t, vals, name, split)
|
|
228
|
+
kind = kind_of(t)
|
|
229
|
+
case kind
|
|
230
|
+
when :nullable then vals.empty? ? nil : from_wire(t[1], vals, name, split)
|
|
231
|
+
when :array
|
|
232
|
+
fail!(name, "必須です") if vals.empty?
|
|
233
|
+
items = vals
|
|
234
|
+
items = (vals[0].strip.empty? ? [] : vals[0].split(",", -1).map(&:strip)) if split && vals.size == 1
|
|
235
|
+
items.map { |s| from_wire(t[1], [s], name, false) }
|
|
236
|
+
else
|
|
237
|
+
fail!(name, "必須です") if vals.empty?
|
|
238
|
+
s = vals[0]
|
|
239
|
+
case kind
|
|
240
|
+
when :ref then resolve(t[1]).from_string(s, name)
|
|
241
|
+
when :literal
|
|
242
|
+
Check.one_of(name, s, t[1])
|
|
243
|
+
s
|
|
244
|
+
else
|
|
245
|
+
v = parse_str(kind, s, name)
|
|
246
|
+
check_constraint(kind, t.is_a?(Array) ? t[1] : nil, v, name)
|
|
247
|
+
v
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# value -> list of strings (nil is empty)
|
|
253
|
+
def to_wire_list(t, v)
|
|
254
|
+
return [] if v.nil?
|
|
255
|
+
|
|
256
|
+
kind = kind_of(t)
|
|
257
|
+
case kind
|
|
258
|
+
when :nullable then to_wire_list(t[1], v)
|
|
259
|
+
when :array then v.flat_map { |x| to_wire_list(t[1], x) }
|
|
260
|
+
when :ref then [resolve(t[1]).to_string(v)]
|
|
261
|
+
when :literal then [v.to_s]
|
|
262
|
+
else [to_wire(kind, v)]
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Comatype
|
|
4
|
+
# The `cookie <name> { ... }` of init.comat.
|
|
5
|
+
CookieDef = Struct.new(:name, :http_only, :secure, :same_site, :path, :domain, :max_age, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
# A cookie to issue.
|
|
8
|
+
CookieValue = Struct.new(:value, :delete, :max_age, :path, :domain, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
module Cookies
|
|
11
|
+
EPOCH = "Thu, 01 Jan 1970 00:00:00 GMT"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def encode(s)
|
|
16
|
+
URI.encode_www_form_component(s.to_s).gsub("+", "%20")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def decode(s)
|
|
20
|
+
URI.decode_www_form_component(s.gsub("+", "%2B"))
|
|
21
|
+
rescue ArgumentError
|
|
22
|
+
s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def attr(what, v)
|
|
26
|
+
raise ConfigError, "cookie の #{what} に使えない文字があります: #{v.inspect}" if v.to_s.match?(/[;\r\n]/)
|
|
27
|
+
|
|
28
|
+
v
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build a Set-Cookie value. Attributes (HttpOnly etc.) come from init.comat.
|
|
32
|
+
def serialize(defn, v)
|
|
33
|
+
raise ConfigError, "このエンドポイントは cookie を発行できません(cookie off、または init.comat に cookie の宣言がありません)" if defn.nil?
|
|
34
|
+
|
|
35
|
+
parts = ["#{defn.name}=#{encode(v.delete ? '' : v.value)}"]
|
|
36
|
+
parts << "Path=#{attr('path', v.path || defn.path || '/')}"
|
|
37
|
+
d = v.domain || defn.domain
|
|
38
|
+
parts << "Domain=#{attr('domain', d)}" if d
|
|
39
|
+
if v.delete
|
|
40
|
+
parts << "Max-Age=0" << "Expires=#{EPOCH}"
|
|
41
|
+
else
|
|
42
|
+
m = v.max_age || defn.max_age
|
|
43
|
+
if m
|
|
44
|
+
raise ConfigError, "cookie の maxAge が不正です: #{m}" if m.negative?
|
|
45
|
+
|
|
46
|
+
parts << "Max-Age=#{m}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
parts << "HttpOnly" if defn.http_only
|
|
50
|
+
parts << "Secure" if defn.secure
|
|
51
|
+
parts << "SameSite=#{defn.same_site.capitalize}" if defn.same_site
|
|
52
|
+
parts.join("; ")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A jar for HTTP clients that do not keep cookies themselves.
|
|
57
|
+
# Assuming the API is a single origin, Domain is ignored and only Path and expiry are checked.
|
|
58
|
+
class CookieJar
|
|
59
|
+
Stored = Struct.new(:value, :path, :expires)
|
|
60
|
+
|
|
61
|
+
def initialize
|
|
62
|
+
@m = {}
|
|
63
|
+
@lock = Mutex.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Cookies to send for this path (name -> value).
|
|
67
|
+
def get(path)
|
|
68
|
+
@lock.synchronize do
|
|
69
|
+
now = Time.now
|
|
70
|
+
@m.delete_if { |_, c| c.expires && c.expires <= now }
|
|
71
|
+
@m.each_with_object({}) do |(k, c), out|
|
|
72
|
+
prefix = c.path.end_with?("/") ? c.path : "#{c.path}/"
|
|
73
|
+
out[k] = c.value if c.path == "/" || path == c.path || path.start_with?(prefix)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Take in the response's Set-Cookie (Max-Age=0 deletes).
|
|
79
|
+
def store(lines)
|
|
80
|
+
@lock.synchronize do
|
|
81
|
+
lines.each do |line|
|
|
82
|
+
segs = line.split(";").map(&:strip)
|
|
83
|
+
name, value = segs.shift.to_s.split("=", 2)
|
|
84
|
+
next if name.nil? || name.empty? || value.nil?
|
|
85
|
+
|
|
86
|
+
c = Stored.new(Cookies.decode(value), "/", nil)
|
|
87
|
+
max_age = nil
|
|
88
|
+
segs.each do |a|
|
|
89
|
+
k, v = a.split("=", 2)
|
|
90
|
+
case k.downcase
|
|
91
|
+
when "path" then c.path = v if v&.start_with?("/")
|
|
92
|
+
when "max-age" then max_age = Integer(v, exception: false)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
if max_age && max_age <= 0
|
|
96
|
+
@m.delete(name)
|
|
97
|
+
else
|
|
98
|
+
c.expires = Time.now + max_age if max_age
|
|
99
|
+
@m[name] = c
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def clear
|
|
106
|
+
@lock.synchronize { @m.clear }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|