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 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
@@ -0,0 +1,7 @@
1
+ # comatype
2
+
3
+ Runtime for Ruby code generated by [Comatype](https://github.com/oldsheeep3/comatype): the client `Comatype::C` and the server `Comatype::S`. It uses only the standard library.
4
+
5
+ ```ruby
6
+ require "comatype"
7
+ ```
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