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.
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comatype
4
+ # The request a server handler receives.
5
+ Request = Struct.new(:params, :search, :headers, :body, :ctx, :raw, keyword_init: true)
6
+
7
+ # Endpoint descriptor (held by generated code). Request / response conversion is done here according to the description.
8
+ class Endpoint
9
+ attr_reader :method, :path, :module_name, :base_url, :wraps, :cookie, :cookie_def, :cache, :cache_headers, :params, :search, :headers, :body, :responses
10
+
11
+ # params / search / headers: Model subclasses (nil if absent), body: type description (nil if absent)
12
+ # responses: array of Response subclasses
13
+ def initialize(method:, path:, module_name:, base_url: nil, wraps: [], cookie: false, cookie_def: nil, cache: nil, cache_headers: [],
14
+ params: nil, search: nil, headers: nil, body: nil, responses: [])
15
+ @method = method
16
+ @path = path
17
+ @module_name = module_name
18
+ @base_url = base_url
19
+ @wraps = wraps.map(&:to_s)
20
+ @cookie = cookie
21
+ @cookie_def = cookie_def
22
+ @cache = cache # CacheSpec (GET / QUERY only; nil means no caching)
23
+ @cache_headers = cache_headers # names of declared request headers included in the client cache key
24
+ @params = params
25
+ @search = search
26
+ @headers = headers
27
+ @body = body
28
+ @responses = responses.to_h { |r| [r.status, r] }
29
+ end
30
+
31
+ # ---- client: arguments (params: search: headers: body:) -> RawRequest ----
32
+
33
+ def build_request(args)
34
+ unknown = args.keys - %i[params search headers body]
35
+ raise ArgumentError, "#{module_name}: 知らない引数 #{unknown.join(', ')}" unless unknown.empty?
36
+
37
+ raw = RawRequest.new(method, path)
38
+ if @params
39
+ p = @params.coerce(args[:params]) || @params.new
40
+ p.validate
41
+ segs = raw.path.split("/", -1).map do |s|
42
+ next s unless s.start_with?(":")
43
+
44
+ name = s[1..]
45
+ f = @params.fields.find { |x| x.json == name }
46
+ v = f && Codec.to_wire_list(f.type, p.public_send(f.attr)).first
47
+ raise ValidationError.new(name, "必須です") if v.nil? || v.empty?
48
+
49
+ raw.params[name] = v
50
+ Cookies.encode(v)
51
+ end
52
+ raw.path = segs.join("/")
53
+ end
54
+ if @search
55
+ s = @search.coerce(args[:search]) || @search.new
56
+ s.validate
57
+ @search.fields.each { |f| Codec.to_wire_list(f.type, s.public_send(f.attr)).each { |v| raw.query << [f.json, v] } }
58
+ end
59
+ if @headers
60
+ h = @headers.coerce(args[:headers]) || @headers.new
61
+ h.validate
62
+ write_headers(raw.headers, @headers, h)
63
+ end
64
+ if @body
65
+ raise ValidationError.new("body", "必須です") if args[:body].nil?
66
+
67
+ b = args[:body]
68
+ b = b.is_a?(Model) ? b : coerce_body(@body, b)
69
+ Codec.validate(@body, b, "")
70
+ raw.body = JSON.generate(Codec.encode(@body, b)).b
71
+ raw.headers["Content-Type"] = "application/json"
72
+ end
73
+ raw
74
+ end
75
+
76
+ # ---- client: RawResponse -> Response ----
77
+
78
+ def parse_response(raw)
79
+ cls = @responses[raw.status] or raise UnexpectedStatusError.new(raw.status, raw.body)
80
+ body = nil
81
+ if cls.body_type
82
+ raise ValidationError.new("body", "必須です") if raw.body.empty?
83
+
84
+ body = Codec.decode(cls.body_type, Wire.parse_json(raw.body, "body"), "body")
85
+ end
86
+ headers = nil
87
+ if cls.headers_class
88
+ headers = cls.headers_class.from_wire(->(n) { raw.headers.get_all(n) }, split: true)
89
+ headers.validate
90
+ end
91
+ cls.new(body, headers: headers)
92
+ end
93
+
94
+ # ---- server: RawRequest -> Request ----
95
+
96
+ def parse_request(raw)
97
+ req = Request.new(ctx: raw.ctx, raw: raw)
98
+ req.params = @params&.from_wire(->(n) { raw.params.key?(n) ? [raw.params[n]] : [] })&.tap(&:validate)
99
+ req.search = @search&.from_wire(->(n) { raw.query_all(n) })&.tap(&:validate)
100
+ req.headers = @headers&.from_wire(->(n) { raw.headers.get_all(n) }, split: true)&.tap(&:validate)
101
+ if @body
102
+ raise ValidationError.new("body", "必須です") if raw.body.empty?
103
+
104
+ req.body = Codec.decode(@body, Wire.parse_json(raw.body, "body"), "body")
105
+ end
106
+ req
107
+ end
108
+
109
+ # ---- server: Response -> RawResponse ----
110
+
111
+ def build_response(res)
112
+ cls = @responses[res.status]
113
+ raise ConfigError, "s#{res.status} は #{module_name} に宣言されていません" unless cls && res.is_a?(cls)
114
+
115
+ out = RawResponse.new(res.status)
116
+ if cls.body_type
117
+ Codec.validate(cls.body_type, res.body, "")
118
+ out.body = JSON.generate(Codec.encode(cls.body_type, res.body)).b
119
+ out.headers["Content-Type"] = "application/json"
120
+ end
121
+ if cls.headers_class
122
+ res.headers.validate
123
+ write_headers(out.headers, cls.headers_class, res.headers)
124
+ end
125
+ out.set_cookies << Cookies.serialize(@cookie_def, res.cookie) if res.cookie
126
+ out
127
+ end
128
+
129
+ private
130
+
131
+ def write_headers(headers, klass, model)
132
+ klass.fields.each do |f|
133
+ vs = Codec.to_wire_list(f.type, model.public_send(f.attr))
134
+ headers[f.json] = vs.join(", ") unless vs.empty?
135
+ end
136
+ end
137
+
138
+ # Hash / array values other than the body go straight through the type description (Model references are built from hashes)
139
+ def coerce_body(t, v)
140
+ kind = Codec.kind_of(t)
141
+ case kind
142
+ when :ref
143
+ k = Codec.resolve(t[1])
144
+ k.respond_to?(:coerce) ? k.coerce(v) : v
145
+ when :array then v.map { |x| coerce_body(t[1], x) }
146
+ when :nullable then v.nil? ? nil : coerce_body(t[1], v)
147
+ else v
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comatype
4
+ class Error < StandardError; end
5
+
6
+ # An input or output does not match the .comat type.
7
+ class ValidationError < Error
8
+ attr_reader :path, :reason
9
+
10
+ def initialize(path, reason)
11
+ @path = path
12
+ @reason = reason
13
+ super(path.empty? ? reason : "#{path}: #{reason}")
14
+ end
15
+
16
+ def self.join(path, name)
17
+ path.empty? ? name.to_s : "#{path}.#{name}"
18
+ end
19
+ end
20
+
21
+ # A status not declared in .comat was returned.
22
+ class UnexpectedStatusError < Error
23
+ attr_reader :status, :body
24
+
25
+ def initialize(status, body)
26
+ @status = status
27
+ @body = body
28
+ super("想定していないステータス #{status} が返りました")
29
+ end
30
+ end
31
+
32
+ # Configuration error (a wrapper was not passed, no base url, etc.).
33
+ class ConfigError < Error; end
34
+
35
+ # The request failed.
36
+ class TransportError < Error; end
37
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comatype
4
+ # A named string enum. Declare values with `values "admin", "member"` (constants are uppercase: Role::ADMIN).
5
+ class Enum
6
+ class << self
7
+ def values(*vals)
8
+ return @values if vals.empty?
9
+
10
+ @values = vals.freeze
11
+ vals.each { |v| const_set(v.to_s.upcase.gsub(/[^A-Z0-9]/, "_").sub(/\A(\d)/, 'V_\1'), v) }
12
+ end
13
+
14
+ def decode(j, path = "")
15
+ raise ValidationError.new(path, "文字列にしてください(#{Codec.describe(j)} でした)") unless j.is_a?(String)
16
+
17
+ Check.one_of(path, j, values)
18
+ j
19
+ end
20
+
21
+ def validate_value(v, path)
22
+ Check.one_of(path, v, values)
23
+ end
24
+
25
+ def encode_value(v)
26
+ v
27
+ end
28
+
29
+ def from_string(s, name)
30
+ Check.one_of(name, s, values)
31
+ s
32
+ end
33
+
34
+ def to_string(v)
35
+ v.to_s
36
+ end
37
+ end
38
+ end
39
+
40
+ # Object type. Subclasses declare `field :attr, "jsonName", type, optional: true`.
41
+ # Parent of generated types (named types, params / search / headers, body).
42
+ class Model
43
+ Field = Struct.new(:attr, :json, :type, :optional)
44
+
45
+ class << self
46
+ def fields
47
+ @fields ||= []
48
+ end
49
+
50
+ def inherited(sub)
51
+ super
52
+ sub.instance_variable_set(:@fields, fields.dup)
53
+ end
54
+
55
+ def field(attr, json, type, optional: false)
56
+ fields << Field.new(attr, json, type, optional)
57
+ attr_accessor attr
58
+ end
59
+
60
+ def nullable?(t)
61
+ t.is_a?(Array) && t[0] == :nullable
62
+ end
63
+
64
+ # JSON tree -> instance
65
+ def decode(j, path = "")
66
+ raise ValidationError.new(path, "オブジェクトにしてください(#{Codec.describe(j)} でした)") unless j.is_a?(Hash)
67
+
68
+ kw = {}
69
+ fields.each do |f|
70
+ p = ValidationError.join(path, f.json)
71
+ if !j.key?(f.json) || (j[f.json].nil? && f.optional)
72
+ raise ValidationError.new(p, "必須です") unless f.optional || nullable?(f.type)
73
+
74
+ kw[f.attr] = nil
75
+ next
76
+ end
77
+ kw[f.attr] = Codec.decode(f.type, j[f.json], p)
78
+ end
79
+ new(**kw)
80
+ end
81
+
82
+ # Build from path / query / header values (a call that returns name -> list of strings)
83
+ def from_wire(lookup, split: false)
84
+ kw = {}
85
+ fields.each do |f|
86
+ vals = lookup.call(f.json)
87
+ if vals.empty?
88
+ raise ValidationError.new(f.json, "必須です") unless f.optional || nullable?(f.type)
89
+
90
+ kw[f.attr] = nil
91
+ else
92
+ kw[f.attr] = Codec.from_wire(f.type, vals, f.json, split)
93
+ end
94
+ end
95
+ new(**kw)
96
+ end
97
+
98
+ # Entry point for validating nested values
99
+ def validate_value(v, path)
100
+ raise ValidationError.new(path, "#{name} にしてください") unless v.is_a?(self)
101
+
102
+ v.validate(path)
103
+ end
104
+
105
+ def encode_value(v)
106
+ v.encode
107
+ end
108
+
109
+ # Turn a hash (keyed by attr) or an existing instance into an instance of this class
110
+ def coerce(v)
111
+ return v if v.is_a?(self) || v.nil?
112
+ return new(**v.transform_keys(&:to_sym)) if v.is_a?(Hash)
113
+
114
+ raise ArgumentError, "#{name} かハッシュを渡してください(#{v.class})"
115
+ end
116
+ end
117
+
118
+ def initialize(**kw)
119
+ unknown = kw.keys - self.class.fields.map(&:attr)
120
+ raise ArgumentError, "#{self.class.name} に無いキー: #{unknown.join(', ')}" unless unknown.empty?
121
+
122
+ self.class.fields.each { |f| instance_variable_set("@#{f.attr}", kw[f.attr]) }
123
+ end
124
+
125
+ # Check that it matches the .comat type (including constraints)
126
+ def validate(path = "")
127
+ self.class.fields.each do |f|
128
+ v = instance_variable_get("@#{f.attr}")
129
+ p = ValidationError.join(path, f.json)
130
+ if v.nil?
131
+ raise ValidationError.new(p, "必須です") unless f.optional || self.class.nullable?(f.type)
132
+
133
+ next
134
+ end
135
+ Codec.validate(f.type, v, p)
136
+ end
137
+ true
138
+ end
139
+
140
+ # JSON tree (Hash with string keys). Validated before it is built
141
+ def encode
142
+ validate
143
+ self.class.fields.each_with_object({}) do |f, h|
144
+ v = instance_variable_get("@#{f.attr}")
145
+ next if v.nil? && f.optional
146
+
147
+ h[f.json] = Codec.encode(f.type, v)
148
+ end
149
+ end
150
+
151
+ def to_h
152
+ self.class.fields.to_h { |f| [f.attr, instance_variable_get("@#{f.attr}")] }
153
+ end
154
+
155
+ def ==(other)
156
+ other.class == self.class && other.to_h == to_h
157
+ end
158
+
159
+ def to_s
160
+ "#<#{self.class.name} #{to_h.inspect}>"
161
+ end
162
+ alias inspect to_s
163
+ end
164
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comatype
4
+ # Case-insensitive headers (the same name may appear several times).
5
+ class Headers
6
+ include Enumerable
7
+
8
+ def initialize
9
+ @items = []
10
+ end
11
+
12
+ def [](name)
13
+ kv = @items.find { |k, _| k.casecmp?(name) }
14
+ kv&.last
15
+ end
16
+
17
+ def get_all(name)
18
+ @items.select { |k, _| k.casecmp?(name) }.map(&:last)
19
+ end
20
+
21
+ # Replace entries with the same name
22
+ def []=(name, value)
23
+ @items.reject! { |k, _| k.casecmp?(name) }
24
+ @items << [name, value.to_s]
25
+ end
26
+
27
+ # Add, keeping entries with the same name
28
+ def add(name, value)
29
+ @items << [name, value.to_s]
30
+ end
31
+
32
+ def key?(name)
33
+ !self[name].nil?
34
+ end
35
+
36
+ def each(&block)
37
+ @items.each(&block)
38
+ end
39
+ end
40
+
41
+ # The raw request a wrapper sees (the on-the-wire form, with no per-endpoint types).
42
+ class RawRequest
43
+ # method: lowercase, path: with params filled in (client) / the path that arrived (server)
44
+ attr_accessor :method, :path, :headers, :query, :params, :body, :cookies, :ctx
45
+
46
+ def initialize(method, path)
47
+ @method = method
48
+ @path = path
49
+ @headers = Headers.new
50
+ @query = []
51
+ @params = {}
52
+ @body = "".b
53
+ @cookies = {}
54
+ @ctx = {} # values added by server wrappers (wrapper name -> value)
55
+ end
56
+
57
+ def cookie(name)
58
+ @cookies[name.to_s]
59
+ end
60
+
61
+ def query_all(name)
62
+ @query.select { |k, _| k == name }.map(&:last)
63
+ end
64
+ end
65
+
66
+ # The raw response a wrapper sees (the on-the-wire form).
67
+ class RawResponse
68
+ # set_cookies: Set-Cookie lines to send (server) / lines received (client), err: a cause not included in the response
69
+ attr_accessor :status, :headers, :body, :set_cookies, :err
70
+
71
+ def initialize(status)
72
+ @status = status
73
+ @headers = Headers.new
74
+ @body = "".b
75
+ @set_cookies = []
76
+ @err = nil
77
+ end
78
+
79
+ def self.json(status, tree)
80
+ r = new(status)
81
+ unless tree.nil?
82
+ r.body = JSON.generate(tree).b
83
+ r.headers["Content-Type"] = "application/json"
84
+ end
85
+ r
86
+ end
87
+
88
+ # Turn the server's own error into a 500. Details are not shown to the client.
89
+ def self.error(cause)
90
+ r = new(500)
91
+ r.body = '{"code":"internal_error","message":"Internal Server Error"}'.b
92
+ r.headers["Content-Type"] = "application/json"
93
+ r.err = cause.to_s
94
+ r
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comatype
4
+ # Parent of the per-status responses. Generated code declares `class S200 < Comatype::Response`.
5
+ # status 200 / body_type <type description> / headers_class <Model subclass>
6
+ # The server returns `S200.new(body, headers: {...})`; the client receives an instance of this class.
7
+ class Response
8
+ class << self
9
+ def status(n = nil)
10
+ n.nil? ? @status : (@status = n)
11
+ end
12
+
13
+ def body_type(t = nil)
14
+ t.nil? ? @body_type : (@body_type = t)
15
+ end
16
+
17
+ def headers_class(k = nil)
18
+ k.nil? ? @headers_class : (@headers_class = k)
19
+ end
20
+
21
+ # Build the RawResponse a wrapper returns (no headers / cookies). Used by the generated `Init.s401(body)`
22
+ def raw(body = nil)
23
+ out = RawResponse.new(status)
24
+ if body_type
25
+ Codec.validate(body_type, body, "")
26
+ out.body = JSON.generate(Codec.encode(body_type, body)).b
27
+ out.headers["Content-Type"] = "application/json"
28
+ end
29
+ out
30
+ end
31
+ end
32
+
33
+ attr_reader :body, :headers, :cookie
34
+
35
+ # A status without a body is `S204.new`; headers only is `S200.new(headers: {...})`
36
+ def initialize(body = nil, headers: nil)
37
+ if body.nil? && self.class.body_type && !(self.class.body_type.is_a?(Array) && self.class.body_type[0] == :nullable)
38
+ raise ArgumentError, "#{self.class.name} にはボディが要ります"
39
+ end
40
+ raise ArgumentError, "#{self.class.name} にはボディを渡せません" if !body.nil? && self.class.body_type.nil?
41
+
42
+ @body = body
43
+ hc = self.class.headers_class
44
+ raise ArgumentError, "#{self.class.name} にはヘッダを渡せません" if headers && hc.nil?
45
+
46
+ @headers = hc ? (hc.coerce(headers) || hc.new) : nil
47
+ @cookie = nil
48
+ end
49
+
50
+ def status
51
+ self.class.status
52
+ end
53
+
54
+ # Issue a cookie from init.comat (server)
55
+ def with_cookie(value, max_age: nil, path: nil, domain: nil)
56
+ @cookie = CookieValue.new(value: value, delete: false, max_age: max_age, path: path, domain: domain)
57
+ self
58
+ end
59
+
60
+ # Delete a cookie (server)
61
+ def clear_cookie
62
+ @cookie = CookieValue.new(value: "", delete: true)
63
+ self
64
+ end
65
+
66
+ def to_s
67
+ "#<#{self.class.name} body=#{@body.inspect} headers=#{@headers.inspect}>"
68
+ end
69
+ alias inspect to_s
70
+ end
71
+ end