openapi_kit 0.1.0.pre.1
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/LICENSE.txt +21 -0
- data/README.md +385 -0
- data/lib/openapi_kit/body.rb +71 -0
- data/lib/openapi_kit/codec/boolean.rb +34 -0
- data/lib/openapi_kit/codec/byte.rb +31 -0
- data/lib/openapi_kit/codec/contract.rb +22 -0
- data/lib/openapi_kit/codec/date.rb +32 -0
- data/lib/openapi_kit/codec/date_time.rb +33 -0
- data/lib/openapi_kit/codec/decimal.rb +36 -0
- data/lib/openapi_kit/codec/float.rb +34 -0
- data/lib/openapi_kit/codec/integer.rb +27 -0
- data/lib/openapi_kit/codec/string.rb +26 -0
- data/lib/openapi_kit/codec/uuid.rb +28 -0
- data/lib/openapi_kit/codec.rb +13 -0
- data/lib/openapi_kit/decode.rb +171 -0
- data/lib/openapi_kit/errors.rb +34 -0
- data/lib/openapi_kit/form.rb +25 -0
- data/lib/openapi_kit/optional.rb +67 -0
- data/lib/openapi_kit/rendering.rb +37 -0
- data/lib/openapi_kit/response.rb +21 -0
- data/lib/openapi_kit/runtime.rb +16 -0
- data/lib/openapi_kit/security.rb +122 -0
- data/lib/openapi_kit/version.rb +6 -0
- data/lib/openapi_kit/wire.rb +14 -0
- data/lib/openapi_kit.rb +4 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6065eebfc91e1494e62d6ee4bd5986e4cd65793dff51552bf237e6834233e663
|
|
4
|
+
data.tar.gz: 9f08ae0a11555959f1a7bb35641c6b9a6e97992bc04d8cd38744699cf161bf0b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a165502bb0a88de4442110f3813026f8f4af796213830f2b9ee77ec83fa7294e0ce299139651ab41ec2fa8a81fb039bc69b3acb88723638bda3ce2bdb4f3be0f
|
|
7
|
+
data.tar.gz: a31c6a518a580629fc9a772c786bc22aaf6738c05ef12c34156368decf0d4ef23202dea77f4b2dfb2fd96677c56bedbabf52fe85c2e76c992a996c10fd07a7c3
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nexus Mods
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# openapi_kit
|
|
2
|
+
|
|
3
|
+
Generates Sorbet-typed Rails server stubs from an OpenAPI 3 document. Handlers are strict
|
|
4
|
+
interfaces bound through a generated registry, responses are sealed, and authentication is
|
|
5
|
+
enforced where the document says it should be. Change the document and the build tells you
|
|
6
|
+
what no longer compiles.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
gem "openapi_kit" # what generated code calls
|
|
10
|
+
|
|
11
|
+
group :development do
|
|
12
|
+
gem "openapi_kit-codegen" # the generator
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Contents
|
|
17
|
+
|
|
18
|
+
- [Generating](#generating)
|
|
19
|
+
- [Integrating with Rails](#integrating-with-rails)
|
|
20
|
+
- [Security](#security)
|
|
21
|
+
- [Custom types](#custom-types)
|
|
22
|
+
- [Files and binary responses](#files-and-binary-responses)
|
|
23
|
+
- [Not supported yet](#not-supported-yet)
|
|
24
|
+
- [Development](#development)
|
|
25
|
+
|
|
26
|
+
## Generating
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
# openapi_kit.yml
|
|
30
|
+
spec: openapi/petstore.yaml
|
|
31
|
+
output: app/api
|
|
32
|
+
modules: [Petstore, V1]
|
|
33
|
+
controller_base: Api::BaseController
|
|
34
|
+
principal: "::Petstore::Principal"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Option | Meaning |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `spec` | root OpenAPI document, resolved relative to this file |
|
|
40
|
+
| `output` | directory the tree is written to, which openapi_kit owns |
|
|
41
|
+
| `modules` | namespace for every generated constant, so `Petstore::V1::Types::Pet` |
|
|
42
|
+
| `controller_base` | class the generated controllers inherit from |
|
|
43
|
+
| `principal` | the class a successful authentication produces |
|
|
44
|
+
| `type_mappings` | your Ruby type for a `type:format` pair |
|
|
45
|
+
| `name_overrides` | a different Ruby name for a schema |
|
|
46
|
+
|
|
47
|
+
The first four are required.
|
|
48
|
+
|
|
49
|
+
```console
|
|
50
|
+
$ bundle exec openapi_kit generate -c openapi_kit.yml
|
|
51
|
+
app/api/petstore/v1/types/pet.rb
|
|
52
|
+
app/api/petstore/v1/operations/list_pets.rb
|
|
53
|
+
app/api/petstore/v1/handlers/pets.rb
|
|
54
|
+
app/api/petstore/v1/controllers/pets_controller.rb
|
|
55
|
+
app/api/petstore/v1/security.rb
|
|
56
|
+
app/api/petstore/v1/registry.rb
|
|
57
|
+
app/api/petstore/v1/routes.rb
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
One constant per file at the path that constant implies, so Rails autoloads it. Each run
|
|
61
|
+
wipes `output`, but only after checking openapi_kit generated every `.rb` in it.
|
|
62
|
+
|
|
63
|
+
## Integrating with Rails
|
|
64
|
+
|
|
65
|
+
**Autoload the output**, if it is not already under `app/`. An acronym in `modules`
|
|
66
|
+
becomes a directory, so it needs an inflection like any other acronym constant.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# config/application.rb
|
|
70
|
+
config.autoload_paths << Rails.root.join("app/api").to_s
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Draw the routes.** They carry no prefix of their own, so mount them where you like.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# config/routes.rb
|
|
77
|
+
Rails.application.routes.draw do
|
|
78
|
+
scope "/v1" do
|
|
79
|
+
Petstore::V1::Routes.draw(self)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Implement one handler per tag.** Miss an operation and Sorbet names the abstract
|
|
85
|
+
method. Return an undeclared response variant and it will not compile.
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class PetsHandler
|
|
89
|
+
extend T::Sig
|
|
90
|
+
include Petstore::V1::Handlers::Pets
|
|
91
|
+
|
|
92
|
+
sig do
|
|
93
|
+
override.params(request: Petstore::V1::Operations::ListPets::Request)
|
|
94
|
+
.returns(Petstore::V1::Operations::ListPets::Response)
|
|
95
|
+
end
|
|
96
|
+
def list_pets(request:)
|
|
97
|
+
pets = Pet.where(store: request.path.store_id).page(request.query.page)
|
|
98
|
+
|
|
99
|
+
Petstore::V1::Operations::ListPets::Ok.new(body: pets.map { |pet| present(pet) })
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Give the controllers a base class.** They inherit whatever you put there and need
|
|
105
|
+
nothing from it, so this is where your own concerns and error mapping live. A request
|
|
106
|
+
openapi_kit cannot decode raises `OpenAPIKit::DecodeError`, carrying `detail` and a `json_pointer`
|
|
107
|
+
naming the field, and openapi_kit takes no view on the wire format.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
# app/controllers/api/base_controller.rb
|
|
111
|
+
module Api
|
|
112
|
+
class BaseController < ApplicationController
|
|
113
|
+
rescue_from OpenAPIKit::DecodeError, with: :unprocessable
|
|
114
|
+
rescue_from OpenAPIKit::SecurityError, with: :unauthorized
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def unauthorized = head(:unauthorized)
|
|
119
|
+
|
|
120
|
+
def unprocessable(error)
|
|
121
|
+
render json: { detail: error.detail, pointer: error.json_pointer },
|
|
122
|
+
status: 422
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Assign the registry.** openapi_kit generates a `Registry` struct with one slot per handler and
|
|
129
|
+
authenticator, and controllers read it from `Petstore::V1.registry`. Rails instantiates
|
|
130
|
+
controllers itself, so they cannot be handed one. Omit a slot, or pass something that does
|
|
131
|
+
not implement its interface, and it does not compile.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# config/initializers/openapi_kit.rb
|
|
135
|
+
Rails.application.config.to_prepare do
|
|
136
|
+
Petstore::V1.registry = Petstore::V1::Registry.new(
|
|
137
|
+
pets: PetsHandler.new(repo: PetRepo.new),
|
|
138
|
+
system: SystemHandler.new,
|
|
139
|
+
bearer_auth: BearerAuthenticator.new(decoder: TokenDecoder.new)
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The registry is openapi_kit's boundary and nothing more. What a handler needs behind it is
|
|
145
|
+
yours, and unlike controllers *you* construct handlers, so they take whatever they need.
|
|
146
|
+
|
|
147
|
+
Building the registry in `to_prepare` constructs every handler at boot, along with
|
|
148
|
+
whatever their constructors resolve, and reassigns them on a code reload.
|
|
149
|
+
|
|
150
|
+
## Security
|
|
151
|
+
|
|
152
|
+
Where the document declares `security`, the generated action authenticates before decoding
|
|
153
|
+
anything, and your handler receives the principal. Nothing else can reach the handler.
|
|
154
|
+
|
|
155
|
+
```yaml
|
|
156
|
+
security: [{ bearerAuth: [] }] # the document's default
|
|
157
|
+
paths:
|
|
158
|
+
/stores/{storeId}/pets:
|
|
159
|
+
post:
|
|
160
|
+
security: # this operation overrides it
|
|
161
|
+
- bearerAuth: [pets:write]
|
|
162
|
+
- apiKeyAuth: []
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Name what authentication produces, and seal it if your schemes produce different shapes:
|
|
166
|
+
that is what makes a handler's `case` exhaustive.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
module Petstore::Principal
|
|
170
|
+
extend T::Helpers
|
|
171
|
+
sealed!
|
|
172
|
+
|
|
173
|
+
class Token < T::Struct # a JWT carries its permissions
|
|
174
|
+
include Petstore::Principal
|
|
175
|
+
const :user_id, Integer
|
|
176
|
+
const :permissions, T::Array[String]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
class Key < T::Struct # an API key does not
|
|
180
|
+
include Petstore::Principal
|
|
181
|
+
const :client, String
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Then write one authenticator per scheme. Return `nil` to say this alternative was not
|
|
187
|
+
satisfied, so openapi_kit tries the next one. Where the credential lives is what the document
|
|
188
|
+
declares, so `credential` is implemented for you.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
class BearerAuthenticator
|
|
192
|
+
extend T::Sig
|
|
193
|
+
include Petstore::V1::Security::BearerAuth
|
|
194
|
+
|
|
195
|
+
sig { params(decoder: TokenDecoder).void }
|
|
196
|
+
def initialize(decoder:)
|
|
197
|
+
@decoder = decoder
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
sig do
|
|
201
|
+
override.params(request: ActionDispatch::Request, scopes: T::Array[String])
|
|
202
|
+
.returns(T.nilable(Petstore::Principal::Token))
|
|
203
|
+
end
|
|
204
|
+
def authenticate(request:, scopes:)
|
|
205
|
+
token = credential(request) or return nil
|
|
206
|
+
claims = @decoder.decode(token) or return nil
|
|
207
|
+
return nil unless scopes.all? { |scope| claims.scopes.include?(scope) }
|
|
208
|
+
|
|
209
|
+
Petstore::Principal::Token.new(user_id: claims.sub, permissions: claims.scopes)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`credential` reads the `Authorization` header and strips the declared scheme for `http`,
|
|
215
|
+
`oauth2` and `openIdConnect`, and reads the named header, query parameter or cookie for
|
|
216
|
+
`apiKey`. A `basic` scheme also gets `basic_credential`, returning the decoded
|
|
217
|
+
`[user, password]`.
|
|
218
|
+
|
|
219
|
+
Your handler then reads `request.principal`:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
def create_pet(request:)
|
|
223
|
+
owner = case request.principal
|
|
224
|
+
when Petstore::Principal::Token then "user-#{request.principal.user_id}"
|
|
225
|
+
when Petstore::Principal::Key then request.principal.client
|
|
226
|
+
else T.absurd(request.principal)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Alternatives are tried in document order and the first to produce a principal wins. If
|
|
232
|
+
none do, openapi_kit raises `OpenAPIKit::SecurityError`. An operation offering anonymous
|
|
233
|
+
access (`security: [..., {}]`) makes the context `T.nilable` and raises nothing.
|
|
234
|
+
|
|
235
|
+
### What each scheme type gives you
|
|
236
|
+
|
|
237
|
+
`SCHEMES` is the document's `securitySchemes` as typed values, so an authenticator reads
|
|
238
|
+
its own configuration rather than restating the document.
|
|
239
|
+
|
|
240
|
+
| `type` | The scheme carries |
|
|
241
|
+
| --- | --- |
|
|
242
|
+
| `http` | `scheme`, `bearer_format` |
|
|
243
|
+
| `apiKey` | `location`, `parameter_name` |
|
|
244
|
+
| `oauth2` | the declared `scopes` catalogue |
|
|
245
|
+
| `openIdConnect` | `url`, the discovery document |
|
|
246
|
+
|
|
247
|
+
Plus any `x-` keys, on all four. OpenAPI 3.0 fixes these four types, so a bespoke scheme
|
|
248
|
+
is an `http` one with your own name, and anything the type cannot express goes in `x-`:
|
|
249
|
+
|
|
250
|
+
```yaml
|
|
251
|
+
securitySchemes:
|
|
252
|
+
hmacAuth:
|
|
253
|
+
type: http
|
|
254
|
+
scheme: HMAC-SHA256
|
|
255
|
+
x-signing-key: SIGNING_KEY
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
That reaches the scheme as `extensions`, which is also where an `oauth2` scheme's issuer
|
|
259
|
+
and JWKS URI belong, since OpenAPI has no field for them. An `openIdConnect` scheme needs
|
|
260
|
+
neither, because `url` discovers both.
|
|
261
|
+
|
|
262
|
+
## Custom types
|
|
263
|
+
|
|
264
|
+
A `type:format` pair with no built-in mapping is an error naming the config to add:
|
|
265
|
+
|
|
266
|
+
```yaml
|
|
267
|
+
type_mappings:
|
|
268
|
+
"string:money":
|
|
269
|
+
type: "::Money"
|
|
270
|
+
codec: "MyApp::MoneyCodec"
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`type` appears in signatures. `codec` converts it, and `Value` ties the halves together so
|
|
274
|
+
a codec cannot decode one type and encode another:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
module MyApp::MoneyCodec
|
|
278
|
+
extend T::Sig
|
|
279
|
+
extend T::Generic
|
|
280
|
+
extend OpenAPIKit::Codec::Contract
|
|
281
|
+
|
|
282
|
+
Value = type_template { { fixed: ::Money } }
|
|
283
|
+
|
|
284
|
+
sig { override.params(value: OpenAPIKit::Wire).returns(::Money) }
|
|
285
|
+
def self.from_wire(value) = ::Money.parse(OpenAPIKit::Codec::String.from_wire(value))
|
|
286
|
+
|
|
287
|
+
sig { override.params(value: ::Money).returns(OpenAPIKit::Wire) }
|
|
288
|
+
def self.to_wire(value) = value.format
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`codec` need only name a constant answering `from_wire` and `to_wire`, so a codec that
|
|
293
|
+
needs configuration can `include` the contract instead and you name the instance you
|
|
294
|
+
built. The same override works inline, for one property rather than every occurrence of a
|
|
295
|
+
format:
|
|
296
|
+
|
|
297
|
+
```yaml
|
|
298
|
+
price:
|
|
299
|
+
type: string
|
|
300
|
+
x-ruby-type: "::Money"
|
|
301
|
+
x-ruby-codec: "MyApp::MoneyCodec"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Files and binary responses
|
|
305
|
+
|
|
306
|
+
A file is the one thing outside `OpenAPIKit::Wire`, the value model every media type shares,
|
|
307
|
+
so it never goes through a codec. `format: binary` is handled in two places instead, and
|
|
308
|
+
refused everywhere else.
|
|
309
|
+
|
|
310
|
+
**An upload is a property of a `multipart/form-data` request body**, decoded through a
|
|
311
|
+
`Form` where other types carry a `Codec`:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
class UploadPetPhotoBody < T::Struct
|
|
315
|
+
const :photo, ::ActionDispatch::Http::UploadedFile
|
|
316
|
+
const :description, T.nilable(::String)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def upload_pet_photo(request:)
|
|
320
|
+
Blob.store!(io: request.body.photo.tempfile)
|
|
321
|
+
|
|
322
|
+
Petstore::V1::Operations::UploadPetPhoto::NoContent.new
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**A binary response is the whole body**, taking a file on disk or a block that writes bytes:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
body: OpenAPIKit::Body::File.new(path: Rails.root.join("photos", name))
|
|
330
|
+
|
|
331
|
+
body: OpenAPIKit::Body::Stream.new(
|
|
332
|
+
body: ->(sink) { Archive.open(pet) { |zip| IO.copy_stream(zip, sink) } }
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
body: OpenAPIKit::Body::Stream.new(body: ->(sink) { sink << header << row })
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
A path names the file and nothing more, so the server sends it however it likes: `sendfile`
|
|
339
|
+
under Puma, an `X-Accel-Redirect` or `X-Sendfile` under nginx and Apache through
|
|
340
|
+
`Rack::Sendfile`, and `Content-Length` comes off the file. A stream sends no
|
|
341
|
+
`Content-Length`, and whatever its block opens it also closes. Neither supports `Range`.
|
|
342
|
+
|
|
343
|
+
Every response variant answers `to_body`, a sealed `OpenAPIKit::Body` of `Empty`, `Json`,
|
|
344
|
+
`Stream` or `File`. `string:binary` takes no [`type_mappings`](#custom-types) entry, since a
|
|
345
|
+
file has nothing for a codec to convert.
|
|
346
|
+
|
|
347
|
+
## Not supported yet
|
|
348
|
+
|
|
349
|
+
Refused at generation time, rather than mis-generated:
|
|
350
|
+
|
|
351
|
+
- Parameter styles other than `simple` for path and `form` for query.
|
|
352
|
+
- One content type per request body, and it must be `application/json`, a `+json` type,
|
|
353
|
+
`application/x-www-form-urlencoded` or `multipart/form-data`. A response body whose
|
|
354
|
+
schema is `format: binary` may declare any content type at all.
|
|
355
|
+
- Two security schemes required together in one alternative (`{a: [], b: []}`). One scheme
|
|
356
|
+
per alternative.
|
|
357
|
+
- A path template Rails cannot route, such as `{pet-id}`.
|
|
358
|
+
|
|
359
|
+
Documented behaviour to know about:
|
|
360
|
+
|
|
361
|
+
- Array and object query parameters follow Rails' conventions, not OpenAPI's: send
|
|
362
|
+
`?tags[]=a&tags[]=b` and `?filter[lat]=1`, not `?tags=a&tags=b` or an exploded `?lat=1`.
|
|
363
|
+
- Schema keyword validation (`minLength`, `pattern`, `minimum`) is not enforced. Types and
|
|
364
|
+
formats only.
|
|
365
|
+
- Codecs coerce strings, since path, query and header values arrive as strings. That
|
|
366
|
+
leniency also applies to bodies, so `{"count": "42"}` satisfies `type: integer`.
|
|
367
|
+
- `format: binary` is only valid as a top-level property of a `multipart/form-data`
|
|
368
|
+
request body, or as the whole schema of a response body. Anywhere else is refused: a
|
|
369
|
+
file is bytes rather than a parsed value, so no codec can convert it. Use `format: byte`
|
|
370
|
+
to carry bytes inside a value.
|
|
371
|
+
- An OAuth2 flow's `authorizationUrl`, `tokenUrl` and `refreshUrl` are not carried, since
|
|
372
|
+
they tell a client where to obtain a token and a resource server never calls them.
|
|
373
|
+
|
|
374
|
+
## Development
|
|
375
|
+
|
|
376
|
+
```console
|
|
377
|
+
$ bundle exec rake golden # regenerate the output the specs compare against
|
|
378
|
+
$ bundle exec rake # rspec, srb tc, rubocop
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`srb tc` covers `spec/golden` as well as the generator, so output that does not typecheck
|
|
382
|
+
fails the build. `spec/dummy` is a Rails application whose `app/api` is generated the same
|
|
383
|
+
way, and `spec/generated/rails_request_spec.rb` issues real requests against it.
|
|
384
|
+
|
|
385
|
+
[ARCHITECTURE.md](ARCHITECTURE.md) covers the pipeline and where to change what.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
require "openapi_kit/wire"
|
|
7
|
+
|
|
8
|
+
module OpenAPIKit
|
|
9
|
+
class Sink
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(block: T.proc.params(bytes: ::String).void).void }
|
|
13
|
+
def initialize(block)
|
|
14
|
+
@block = block
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { params(bytes: ::String).returns(::Integer) }
|
|
18
|
+
def write(bytes)
|
|
19
|
+
@block.call(bytes.b)
|
|
20
|
+
bytes.bytesize
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(bytes: ::String).returns(T.self_type) }
|
|
24
|
+
def <<(bytes)
|
|
25
|
+
write(bytes)
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns(T.self_type) }
|
|
30
|
+
def flush = self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module Body
|
|
34
|
+
extend T::Sig
|
|
35
|
+
extend T::Helpers
|
|
36
|
+
abstract!
|
|
37
|
+
sealed!
|
|
38
|
+
|
|
39
|
+
class Empty < T::Struct
|
|
40
|
+
include Body
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Json < T::Struct
|
|
44
|
+
include Body
|
|
45
|
+
|
|
46
|
+
const :wire, OpenAPIKit::Wire
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Stream < T::Struct
|
|
50
|
+
extend T::Sig
|
|
51
|
+
include Body
|
|
52
|
+
|
|
53
|
+
const :body, T.proc.params(sink: OpenAPIKit::Sink).void
|
|
54
|
+
|
|
55
|
+
sig { params(block: T.proc.params(bytes: ::String).void).void }
|
|
56
|
+
def each(&block) = body.call(Sink.new(block))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class File < T::Struct
|
|
60
|
+
extend T::Sig
|
|
61
|
+
include Body
|
|
62
|
+
|
|
63
|
+
const :path, ::Pathname
|
|
64
|
+
|
|
65
|
+
sig { returns(::Integer) }
|
|
66
|
+
def size = path.size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Binary = T.type_alias { T.any(OpenAPIKit::Body::Stream, OpenAPIKit::Body::File) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "openapi_kit/codec/contract"
|
|
5
|
+
|
|
6
|
+
module OpenAPIKit
|
|
7
|
+
module Codec
|
|
8
|
+
module Boolean
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend T::Generic
|
|
11
|
+
extend Contract
|
|
12
|
+
|
|
13
|
+
Value = type_template { { fixed: T::Boolean } }
|
|
14
|
+
|
|
15
|
+
TRUTHY = T.let(%w[true 1].freeze, T::Array[::String])
|
|
16
|
+
FALSEY = T.let(%w[false 0].freeze, T::Array[::String])
|
|
17
|
+
|
|
18
|
+
sig { override.params(value: OpenAPIKit::Wire).returns(T::Boolean) }
|
|
19
|
+
def self.from_wire(value)
|
|
20
|
+
return value if value.is_a?(::TrueClass) || value.is_a?(::FalseClass)
|
|
21
|
+
|
|
22
|
+
if value.is_a?(::String)
|
|
23
|
+
return true if TRUTHY.include?(value.downcase)
|
|
24
|
+
return false if FALSEY.include?(value.downcase)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
raise DecodeError.new("expected a boolean, got #{value.inspect}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { override.params(value: T::Boolean).returns(OpenAPIKit::Wire) }
|
|
31
|
+
def self.to_wire(value) = value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "openapi_kit/codec/contract"
|
|
5
|
+
|
|
6
|
+
module OpenAPIKit
|
|
7
|
+
module Codec
|
|
8
|
+
module Byte
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend T::Generic
|
|
11
|
+
extend Contract
|
|
12
|
+
|
|
13
|
+
Value = type_template { { fixed: ::String } }
|
|
14
|
+
|
|
15
|
+
sig { override.params(value: OpenAPIKit::Wire).returns(::String) }
|
|
16
|
+
def self.from_wire(value)
|
|
17
|
+
raise DecodeError.new("expected base64, got #{value.inspect}") unless value.is_a?(::String)
|
|
18
|
+
|
|
19
|
+
decoded = value.unpack1("m0")
|
|
20
|
+
raise DecodeError.new("expected base64, got #{value.inspect}") if decoded.nil?
|
|
21
|
+
|
|
22
|
+
decoded
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
raise DecodeError.new("expected base64, got #{value.inspect}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { override.params(value: ::String).returns(OpenAPIKit::Wire) }
|
|
28
|
+
def self.to_wire(value) = [value].pack("m0")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "openapi_kit/wire"
|
|
5
|
+
|
|
6
|
+
module OpenAPIKit
|
|
7
|
+
module Codec
|
|
8
|
+
module Contract
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend T::Generic
|
|
11
|
+
interface!
|
|
12
|
+
|
|
13
|
+
Value = type_member
|
|
14
|
+
|
|
15
|
+
sig { abstract.params(value: OpenAPIKit::Wire).returns(Value) }
|
|
16
|
+
def from_wire(value); end
|
|
17
|
+
|
|
18
|
+
sig { abstract.params(value: Value).returns(OpenAPIKit::Wire) }
|
|
19
|
+
def to_wire(value); end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
require "openapi_kit/codec/contract"
|
|
7
|
+
|
|
8
|
+
module OpenAPIKit
|
|
9
|
+
module Codec
|
|
10
|
+
module Date
|
|
11
|
+
extend T::Sig
|
|
12
|
+
extend T::Generic
|
|
13
|
+
extend Contract
|
|
14
|
+
|
|
15
|
+
Value = type_template { { fixed: ::Date } }
|
|
16
|
+
|
|
17
|
+
sig { override.params(value: OpenAPIKit::Wire).returns(::Date) }
|
|
18
|
+
def self.from_wire(value)
|
|
19
|
+
raise DecodeError.new("expected an ISO 8601 date, got #{value.inspect}") unless value.is_a?(::String)
|
|
20
|
+
|
|
21
|
+
begin
|
|
22
|
+
::Date.iso8601(value)
|
|
23
|
+
rescue ::Date::Error
|
|
24
|
+
raise DecodeError.new("expected an ISO 8601 date, got #{value.inspect}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { override.params(value: ::Date).returns(OpenAPIKit::Wire) }
|
|
29
|
+
def self.to_wire(value) = value.iso8601
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require "openapi_kit/codec/contract"
|
|
7
|
+
|
|
8
|
+
module OpenAPIKit
|
|
9
|
+
module Codec
|
|
10
|
+
module DateTime
|
|
11
|
+
extend T::Sig
|
|
12
|
+
extend T::Generic
|
|
13
|
+
extend Contract
|
|
14
|
+
|
|
15
|
+
Value = type_template { { fixed: ::Time } }
|
|
16
|
+
|
|
17
|
+
sig { override.params(value: OpenAPIKit::Wire).returns(::Time) }
|
|
18
|
+
def self.from_wire(value)
|
|
19
|
+
raise DecodeError.new("expected an RFC 3339 date-time, got #{value.inspect}") unless
|
|
20
|
+
value.is_a?(::String)
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
::Time.iso8601(value)
|
|
24
|
+
rescue ArgumentError
|
|
25
|
+
raise DecodeError.new("expected an RFC 3339 date-time, got #{value.inspect}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { override.params(value: ::Time).returns(OpenAPIKit::Wire) }
|
|
30
|
+
def self.to_wire(value) = value.utc.iso8601
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
|
|
6
|
+
require "openapi_kit/codec/contract"
|
|
7
|
+
|
|
8
|
+
module OpenAPIKit
|
|
9
|
+
module Codec
|
|
10
|
+
module Decimal
|
|
11
|
+
extend T::Sig
|
|
12
|
+
extend T::Generic
|
|
13
|
+
extend Contract
|
|
14
|
+
|
|
15
|
+
Value = type_template { { fixed: ::BigDecimal } }
|
|
16
|
+
|
|
17
|
+
sig { override.params(value: OpenAPIKit::Wire).returns(::BigDecimal) }
|
|
18
|
+
def self.from_wire(value)
|
|
19
|
+
case value
|
|
20
|
+
when ::Integer then Kernel.BigDecimal(value)
|
|
21
|
+
when ::Float then Kernel.BigDecimal(value, ::Float::DIG)
|
|
22
|
+
when ::String
|
|
23
|
+
begin
|
|
24
|
+
Kernel.BigDecimal(value)
|
|
25
|
+
rescue ArgumentError, TypeError
|
|
26
|
+
raise DecodeError.new("expected a decimal, got #{value.inspect}")
|
|
27
|
+
end
|
|
28
|
+
else raise DecodeError.new("expected a decimal, got #{value.inspect}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { override.params(value: ::BigDecimal).returns(OpenAPIKit::Wire) }
|
|
33
|
+
def self.to_wire(value) = value.to_s("F")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|