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
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: []
|