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.
data/lib/comatype/s.rb ADDED
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comatype
4
+ # Server. Register handlers on an `S` instance and run it with `call(env)` (Rack compatible), `handle` (for any framework),
5
+ # or `serve` (a simple server using only the standard library).
6
+ #
7
+ # s = Comatype::S.new
8
+ # s.init(Init.wrappers(auth: auth))
9
+ # s.put(UserPutId::ENDPOINT) { |req| UserPutId::S200.new(user) }
10
+ # run s # config.ru
11
+ class S
12
+ Route = Struct.new(:method, :module_name, :segs, :statics, :wraps, :cache, :handle)
13
+
14
+ def initialize
15
+ @routes = []
16
+ @wrappers = {}
17
+ @on_error = ->(msg) { warn("[comatype] #{msg}") }
18
+ @lock = Mutex.new
19
+ # responses of `cache <ttl>` (memory) endpoints. Hits on an exact request match
20
+ @cache = ResponseCache.new
21
+ end
22
+
23
+ # Call once at startup. A wrapper is `->(req, nxt) { ...; nxt.call(req) }` (req is a RawRequest; ctx is req.ctx[:name]).
24
+ # Requests to an endpoint that uses a missing wrapper get a 500.
25
+ def init(wrappers = {})
26
+ @wrappers = wrappers.transform_keys(&:to_s)
27
+ self
28
+ end
29
+
30
+ # Receiver of handler exceptions and causes of responses that do not match .comat. Defaults to stderr
31
+ def on_error(&block)
32
+ @on_error = block
33
+ self
34
+ end
35
+
36
+ %w[get post put patch delete options query head].each do |m|
37
+ define_method(m) { |ep, &h| register(m, ep, h) }
38
+ end
39
+
40
+ # Handle one request. target is `/path?query`; headers is Comatype::Headers. Returns a RawResponse.
41
+ def handle(method, target, headers, body)
42
+ method = method.downcase
43
+ path, _, query = target.partition("?")
44
+ segs = []
45
+ path.split("/").reject(&:empty?).each do |s|
46
+ d = Wire.percent_decode(s)
47
+ return json_error(400, "bad_request", "path が不正です") if d.nil?
48
+
49
+ segs << d
50
+ end
51
+ route, params = find(method, segs)
52
+ if route.nil?
53
+ allow = @routes.map(&:method).uniq.select { |m| find(m, segs).first }.map(&:upcase).sort
54
+ unless allow.empty?
55
+ r = json_error(405, "method_not_allowed", "使えるメソッド: #{allow.join(', ')}")
56
+ r.headers["Allow"] = allow.join(", ")
57
+ return r
58
+ end
59
+ return json_error(404, "not_found", path)
60
+ end
61
+
62
+ raw = RawRequest.new(method, path)
63
+ raw.query = Wire.parse_query(query)
64
+ raw.params = params
65
+ raw.body = body.b
66
+ headers.get_all("Cookie").each do |c|
67
+ c.split(";").each do |pair|
68
+ k, v = pair.strip.split("=", 2)
69
+ raw.cookies[k] ||= Cookies.decode(v) if k && !k.empty? && v
70
+ end
71
+ end
72
+ raw.headers = headers
73
+
74
+ # Cache (GET / QUERY, memory only; cookie caching is client-only). Looked up before the wrappers, so the key includes all headers (auth too)
75
+ cache_key = nil
76
+ if route.cache&.memory?
77
+ cache_key = ResponseCache.server_key(method, target, headers, body)
78
+ hit = @cache.get(cache_key)
79
+ return hit if hit
80
+ end
81
+
82
+ chain = route.wraps.map do |name|
83
+ @wrappers[name] or begin
84
+ @on_error.call("ラッパー #{name} が init に渡されていません(#{route.module_name})")
85
+ return RawResponse.error("wrapper missing")
86
+ end
87
+ end
88
+ resp =
89
+ begin
90
+ nxt = chain.reverse.reduce(route.handle) { |inner, w| ->(r) { w.call(r, inner) } }
91
+ nxt.call(raw)
92
+ rescue StandardError => e
93
+ RawResponse.error("#{route.module_name}: #{e.class}: #{e.message}")
94
+ end
95
+ if resp.err
96
+ @on_error.call("#{route.module_name}: #{resp.err}")
97
+ elsif cache_key && ResponseCache.cacheable?(resp)
98
+ @cache.set(cache_key, resp, route.cache.ttl_ms)
99
+ end
100
+ resp
101
+ end
102
+
103
+ # Rack compatible
104
+ def call(env)
105
+ headers = Headers.new
106
+ env.each do |k, v|
107
+ if k.start_with?("HTTP_")
108
+ headers.add(k[5..].split("_").map(&:capitalize).join("-"), v)
109
+ elsif %w[CONTENT_TYPE CONTENT_LENGTH].include?(k)
110
+ headers.add(k.split("_").map(&:capitalize).join("-"), v)
111
+ end
112
+ end
113
+ body = env["rack.input"] ? env["rack.input"].read.to_s : ""
114
+ target = env["PATH_INFO"].to_s + (env["QUERY_STRING"].to_s.empty? ? "" : "?#{env['QUERY_STRING']}")
115
+ r = handle(env["REQUEST_METHOD"], target, headers, body)
116
+ rack_headers = {}
117
+ r.headers.each { |k, v| rack_headers[k] = rack_headers.key?(k) ? "#{rack_headers[k]}\n#{v}" : v }
118
+ rack_headers["set-cookie"] = r.set_cookies.join("\n") unless r.set_cookies.empty?
119
+ no_body = [204, 205, 304].include?(r.status) || env["REQUEST_METHOD"].to_s.casecmp?("HEAD")
120
+ [r.status, rack_headers, no_body ? [] : [r.body]]
121
+ end
122
+
123
+ # Simple server using only the standard library (a thread per connection). Port 0 picks a free port. Returns {TCPServer, Thread}.
124
+ def serve(host: "127.0.0.1", port: 0)
125
+ server = TCPServer.new(host, port)
126
+ thread = Thread.new do
127
+ loop do
128
+ sock = server.accept
129
+ Thread.new(sock) { |s| connection(s) }
130
+ rescue IOError, SystemCallError
131
+ break
132
+ end
133
+ end
134
+ [server, thread]
135
+ end
136
+
137
+ private
138
+
139
+ def register(method, ep, handler)
140
+ raise ArgumentError, "S##{method} に #{ep.method} のエンドポイント(#{ep.module_name})は渡せません" unless ep.method == method
141
+ raise ArgumentError, "ハンドラ(ブロック)がありません" unless handler
142
+
143
+ segs = ep.path.split("/").reject(&:empty?)
144
+ route = Route.new(method, ep.module_name, segs, segs.count { |s| !s.start_with?(":") }, ep.wraps, ep.cache, nil)
145
+ route.handle = lambda do |raw|
146
+ req =
147
+ begin
148
+ ep.parse_request(raw)
149
+ rescue ValidationError => e
150
+ body = { "code" => "validation_error", "message" => e.message }
151
+ body["path"] = e.path unless e.path.empty?
152
+ return RawResponse.json(400, body)
153
+ end
154
+ res = handler.call(req)
155
+ ep.build_response(res)
156
+ rescue ConfigError, ValidationError, ArgumentError, NoMethodError, TypeError => e
157
+ RawResponse.error("#{e.class}: #{e.message}")
158
+ end
159
+ @lock.synchronize do
160
+ shape = ->(r) { "#{r.method} /" + r.segs.map { |s| s.start_with?(":") ? ":" : s }.join("/") }
161
+ if (o = @routes.find { |x| shape.call(x) == shape.call(route) })
162
+ raise ArgumentError, "#{shape.call(route)} が重複しています(#{o.module_name} と #{route.module_name})"
163
+ end
164
+
165
+ @routes << route
166
+ end
167
+ self
168
+ end
169
+
170
+ # Route matching the path (more static segments win)
171
+ def find(method, segs)
172
+ best = nil
173
+ best_params = nil
174
+ @routes.each do |r|
175
+ next unless r.method == method && r.segs.size == segs.size
176
+
177
+ params = {}
178
+ ok = r.segs.zip(segs).all? do |want, got|
179
+ if want.start_with?(":")
180
+ params[want[1..]] = got
181
+ true
182
+ else
183
+ want == got
184
+ end
185
+ end
186
+ if ok && (best.nil? || r.statics > best.statics)
187
+ best = r
188
+ best_params = params
189
+ end
190
+ end
191
+ [best, best_params]
192
+ end
193
+
194
+ def json_error(status, code, message)
195
+ RawResponse.json(status, { "code" => code, "message" => message })
196
+ end
197
+
198
+ REASONS = { 200 => "OK", 201 => "Created", 204 => "No Content", 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden",
199
+ 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict", 422 => "Unprocessable Entity", 500 => "Internal Server Error" }.freeze
200
+
201
+ def connection(sock)
202
+ line = sock.gets or return
203
+ method, target, = line.split(" ")
204
+ return unless method && target
205
+
206
+ headers = Headers.new
207
+ while (l = sock.gets) && !l.strip.empty?
208
+ k, v = l.split(":", 2)
209
+ headers.add(k.strip, v.to_s.strip) if v
210
+ end
211
+ body = +""
212
+ if headers["Transfer-Encoding"].to_s.casecmp?("chunked")
213
+ while (sz = sock.gets.to_s.strip.split(";").first.to_i(16)).positive?
214
+ body << sock.read(sz)
215
+ sock.gets
216
+ end
217
+ elsif (n = headers["Content-Length"].to_i).positive?
218
+ body = sock.read(n)
219
+ end
220
+ r = handle(method, target, headers, body)
221
+ no_body = [204, 205, 304].include?(r.status) || method.casecmp?("HEAD")
222
+ head = +"HTTP/1.1 #{r.status} #{REASONS.fetch(r.status, 'Status')}\r\n"
223
+ r.headers.each { |k, v| head << "#{k}: #{v}\r\n" unless k.casecmp?("content-length") }
224
+ r.set_cookies.each { |c| head << "Set-Cookie: #{c}\r\n" }
225
+ head << "Content-Length: #{no_body ? 0 : r.body.bytesize}\r\nConnection: close\r\n\r\n"
226
+ sock.write(head.b)
227
+ sock.write(r.body) unless no_body
228
+ rescue IOError, SystemCallError
229
+ nil
230
+ ensure
231
+ sock.close unless sock.closed?
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comatype
4
+ module Wire
5
+ module_function
6
+
7
+ def parse_json(bytes, path)
8
+ JSON.parse(bytes.dup.force_encoding("UTF-8"))
9
+ rescue JSON::ParserError => e
10
+ raise ValidationError.new(path, "JSON として読めません: #{e.message[0, 80]}")
11
+ end
12
+
13
+ def percent_decode(s)
14
+ URI.decode_www_form_component(s.gsub("+", "%2B"))
15
+ rescue ArgumentError
16
+ nil
17
+ end
18
+
19
+ def parse_query(q)
20
+ q.to_s.split("&").reject(&:empty?).map do |p|
21
+ k, v = p.split("=", 2)
22
+ [URI.decode_www_form_component(k), URI.decode_www_form_component(v.to_s)]
23
+ end
24
+ rescue ArgumentError
25
+ []
26
+ end
27
+ end
28
+ end
data/lib/comatype.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Comatype's Ruby runtime. Runs on the standard library only (json, net/http, bigdecimal, uri, date, time, base64, socket).
4
+ # Comatype::C client (C.get(ENDPOINT, params: {...}, body: ...))
5
+ # Comatype::S server (Rack compatible. S.new.get(ENDPOINT) { |req| ... })
6
+ require "json"
7
+ require "date"
8
+ require "time"
9
+ require "uri"
10
+ require "bigdecimal"
11
+ require "base64"
12
+ require "net/http"
13
+ require "socket"
14
+
15
+ require_relative "comatype/errors"
16
+ require_relative "comatype/raw"
17
+ require_relative "comatype/check"
18
+ require_relative "comatype/cache"
19
+ require_relative "comatype/codec"
20
+ require_relative "comatype/model"
21
+ require_relative "comatype/wire"
22
+ require_relative "comatype/cookie"
23
+ require_relative "comatype/response"
24
+ require_relative "comatype/endpoint"
25
+ require_relative "comatype/c"
26
+ require_relative "comatype/s"
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: comatype
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - oldsheeep3
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-09 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Runtime for Ruby code generated by Comatype: types, validation, an HTTP
14
+ client and a Rack-compatible server.'
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - lib/comatype.rb
22
+ - lib/comatype/c.rb
23
+ - lib/comatype/cache.rb
24
+ - lib/comatype/check.rb
25
+ - lib/comatype/codec.rb
26
+ - lib/comatype/cookie.rb
27
+ - lib/comatype/endpoint.rb
28
+ - lib/comatype/errors.rb
29
+ - lib/comatype/model.rb
30
+ - lib/comatype/raw.rb
31
+ - lib/comatype/response.rb
32
+ - lib/comatype/s.rb
33
+ - lib/comatype/wire.rb
34
+ homepage: https://github.com/oldsheeep3/comatype
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ source_code_uri: https://github.com/oldsheeep3/comatype/tree/main/packages/ruby
39
+ bug_tracker_uri: https://github.com/oldsheeep3/comatype/issues
40
+ rubygems_mfa_required: 'true'
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '3.1'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 3.5.22
57
+ signing_key:
58
+ specification_version: 4
59
+ summary: Comatype runtime for Ruby (client C and server S)
60
+ test_files: []