openapi_blocks 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05d45b3316abff7109efe2f2438be8cb720300c67040c72a6c93245354b5129c
4
- data.tar.gz: f51d879560afec23a90423ba83b162cb3c799748a4447e1b56e62a41a601dc9a
3
+ metadata.gz: a45ac9389f6202430fb9c21ded74a327d221a9925e5b6a9a3622ea712a216290
4
+ data.tar.gz: bc02bd7c04f7ab2e2c6981b23ca317c500b070bfddb4120808d1b801d300c679
5
5
  SHA512:
6
- metadata.gz: c8c0ddcfde5053794e761fde4ecec89f5cbdcc021b37bd5dde751f76fdc6a0d05749802c9504bc05104c0c9fb242c533f3b94251f29961bf9c077fc4788850fd
7
- data.tar.gz: 21a332227d4653fe2c233d666213087bc0e5ba91ef7210577f864d68a56987236d2023f2f7f9f5285dd5a9ea40b2eba97d1c1bf6d546ca7b0116fb897239f6c9
6
+ metadata.gz: 28ab89af18ab105d20c37ea9882239925d2afba4a8f644fb038d1d93c444fc29b94f4f117429a36e0d6442116fe984c48bafbb37fbef0ebd4131a994e2d04141
7
+ data.tar.gz: a679b294432e62db66c3e3e774d042319aaf4d9371a3e788bcbcedecc3f71906a7b924b04090196c79a608a177a245fb12f545ad272307882e3d3ed459079f67
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-06-02
11
+
12
+ ### Changed
13
+ - `OpenapiBlocks::Controller` now uses `controller` to specifies exact controller
14
+
15
+ ## [0.3.0] - 2026-06-01
16
+
17
+ ### Added
18
+ - Scalar UI served at `/docs/scalar` alongside Swagger UI
19
+ - `SpecController#scalar` action serving Scalar with `displayRequestDuration` and Ruby `net_http` as default client
20
+ - `Resource` and `Controller` classes to support serializer-style resources and controller-scoped OpenAPI classes (`lib/openapi_blocks/resource.rb`, `lib/openapi_blocks/controller.rb`)
21
+
22
+ ### Changed
23
+ - `OpenapiBlocks::Serializer` now uses `class_eval` to compile a monolithic extractor method per serializer class at boot time, eliminating per-object branching and lambda indirection
24
+ - Field classification (model / virtual / association) computed once via `classify_fields` and memoized — no runtime `respond_to?` or `Array#include?` per object
25
+ - Association metadata indexed by name in a `Hash` for O(1) lookup instead of `Array#find` per field per object
26
+ - Association serializer classes resolved at compile time inside `build_assoc_method` instead of per-object via `Object.const_get`
27
+ - Serializer is now **1.86× faster** than the original implementation and **3.6× faster** than `as_json` across 10–5000 records with consistent linear scaling
28
+ - `Builder#openapi_classes` expanded discovery to include classes ending with `Openapi` that inherit from `OpenapiBlocks::Base` or `OpenapiBlocks::Controller` (enables controller-scoped OpenAPI classes)
29
+ - `Serializer` now includes virtual attributes and associations marked `read_only` in serialized output (they previously were omitted). This ensures `read_only: true` fields are present in responses/listings while still being excluded from `*Input` schemas.
30
+ - `Base#infer_model` updated to strip both `Openapi` and `Resource` suffixes when inferring the model class name.
31
+
10
32
  ## [0.2.1] - 2026-06-01
11
33
 
12
34
  ### Changed
data/README.md CHANGED
@@ -1,10 +1,21 @@
1
1
  # OpenapiBlocks
2
2
 
3
- OpenapiBlocks is a Rails gem that automatically generates OpenAPI 3.0/3.1 documentation from your ActiveRecord models, ActiveModel validations, and Rails routes — inspired by ActiveModel::Serializer (https://github.com/rails-api/active_model_serializers).
3
+ OpenapiBlocks is a Rails gem that automatically generates OpenAPI 3.0/3.1 documentation from your ActiveRecord models, ActiveModel validations, and Rails routes — inspired by [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers).
4
4
 
5
5
  Versão em português brasileiro: README.pt-BR.md
6
6
 
7
- No manual annotation. No DSL noise in your controllers. Just declare what to expose and the spec is generated automatically.
7
+ No manual annotation. No DSL noise in your controllers. Just declare what to expose and the spec is generated automatically. Includes a high-performance built-in serializer — ~3.6× faster than `as_json` with consistent linear scaling from 10 to 5000 records.
8
+
9
+ ## Key changes (recent)
10
+ - `OpenapiBlocks::Resource` and `OpenapiBlocks::Controller` introduced as a cleaner alternative to `OpenapiBlocks::Base` — separating serialization from documentation concerns.
11
+ - Default OpenAPI version is `3.1.0` (supported: `3.1.0`, `3.0.3`).
12
+ - Scalar UI is now served at `/docs/scalar` alongside Swagger UI at `/docs`.
13
+ - Swagger UI uses same-origin spec endpoints to avoid CORS issues.
14
+ - YAML output is normalized to use string keys so Swagger UI accepts the `openapi` version field.
15
+ - `association` DSL uses `read_only: true` to mark fields as response-only and exclude them from `*Input` schemas.
16
+ - `tags` are generated at the document root from paths and can be customized via the `tags` DSL on classes and operations.
17
+ - Schema references accept `Symbol` (e.g. `schema: :user`) and array items can be symbol references (e.g. `items: :user`).
18
+ - Serializer uses `class_eval` to compile a monolithic extractor method per class at boot — eliminating per-object branching, lambda indirection, and runtime `respond_to?` checks.
8
19
 
9
20
  ---
10
21
 
@@ -40,7 +51,8 @@ end
40
51
  This exposes:
41
52
 
42
53
  ```
43
- GET /docs -> Swagger UI
54
+ GET /docs -> Scalar UI
55
+ GET /docs/swagger -> Swagger UI
44
56
  GET /docs/openapi.json -> OpenAPI spec in JSON
45
57
  GET /docs/openapi.yaml -> OpenAPI spec in YAML
46
58
  ```
@@ -50,7 +62,7 @@ GET /docs/openapi.yaml -> OpenAPI spec in YAML
50
62
  ```ruby
51
63
  # config/initializers/openapi_blocks.rb
52
64
  OpenapiBlocks.configure do |config|
53
- config.openapi_version = "3.1" # "3.0" or "3.1"
65
+ config.openapi_version = "3.1.0" # "3.0.3" or "3.1.0"
54
66
 
55
67
  config.info do
56
68
  title "My API"
@@ -95,77 +107,56 @@ end
95
107
 
96
108
  ## Usage
97
109
 
98
- ### Creating an OpenAPI class
110
+ OpenapiBlocks provides two base classes with distinct responsibilities:
111
+
112
+ - `OpenapiBlocks::Resource` — defines the model, fields, associations, and serialization logic.
113
+ - `OpenapiBlocks::Controller` — defines the API operations, parameters, and responses for documentation.
114
+ - `OpenapiBlocks::Base` — legacy base class that combines both concerns. Still supported.
99
115
 
100
- Create a file in app/openapi/ following the same naming convention as ActiveModel::Serializer:
116
+ ### Resource + Controller (recommended)
101
117
 
102
118
  ```
103
119
  app/
104
120
  openapi/
105
- user_openapi.rb -> User model
106
- post_openapi.rb -> Post model
107
- order_openapi.rb -> Order model
121
+ user_resource.rb -> serialization + schema
122
+ user_openapi.rb -> API documentation
123
+ post_resource.rb
124
+ post_openapi.rb
108
125
  ```
109
126
 
110
127
  ```ruby
111
- # app/openapi/user_openapi.rb
112
- class UserOpenapi < OpenapiBlocks::Base
128
+ # app/openapi/user_resource.rb
129
+ class UserResource < OpenapiBlocks::Resource
113
130
  # model User is inferred automatically from the class name
114
131
 
115
- # custom tags (default: inferred from controller name)
116
- tags "Users"
117
-
118
- # opt-out sensitive or unnecessary fields
119
132
  ignore :password_digest, :reset_password_token
120
133
 
121
- # opt-in associations
122
- association :company
123
- association :posts, type: :array, read_only: true # excluded from UserInput
134
+ association :posts, type: :array, read_only: true
124
135
 
125
- # virtual attributes (not in the database)
126
- # read_only: true -> exposed in response (User), excluded from request body (UserInput)
127
- # read_only: false -> exposed in both User and UserInput
128
136
  attribute :full_name, type: :string, read_only: true
129
137
  attribute :access_token, type: :string, read_only: true
130
138
  attribute :nickname, type: :string
131
- end
132
- ```
133
139
 
134
- ### What is generated automatically
135
-
136
- Given this model:
140
+ # method defined here — called on the resource instance
141
+ def full_name
142
+ "#{object.name} (#{object.email})"
143
+ end
137
144
 
138
- ```ruby
139
- class User < ApplicationRecord
140
- validates :name, presence: true, length: { minimum: 2, maximum: 100 }
141
- validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
142
- validates :age, numericality: { greater_than: 0 }
143
- validates :role, inclusion: { in: %w[admin user guest] }
145
+ # or omit the method and it delegates to the model automatically
144
146
  end
145
147
  ```
146
148
 
147
- OpenapiBlocks generates:
148
-
149
- - User schema from db/schema.rb columns and types
150
- - UserInput schema for POST, PUT and PATCH request bodies (without id, created_at, updated_at and read_only virtual attributes)
151
- - required fields from presence: true validations
152
- - minLength, maxLength from length validations
153
- - minimum, maximum from numericality validations
154
- - enum from inclusion validations
155
- - format: "email" from format validations
156
- - All paths from config/routes.rb
157
-
158
- ### Customizing operations
159
-
160
149
  ```ruby
161
150
  # app/openapi/user_openapi.rb
162
- class UserOpenapi < OpenapiBlocks::Base
151
+ class UserOpenapi < OpenapiBlocks::Controller
152
+ resource UserResource
153
+ controller UsersController
154
+
163
155
  tags "Users"
164
156
 
165
157
  operation :index do
166
158
  summary "List all users"
167
159
  description "Returns a paginated list of active users"
168
- tags "Users", "Admin" # overrides class-level tags for this operation
169
160
 
170
161
  parameter :page, in: :query, type: :integer, description: "Page number"
171
162
  parameter :per_page, in: :query, type: :integer, description: "Items per page"
@@ -177,34 +168,111 @@ class UserOpenapi < OpenapiBlocks::Base
177
168
  operation :show do
178
169
  summary "Get a user"
179
170
 
180
- response 200, description: "User found", schema: :User
171
+ response 200, description: "User found", schema: :User
181
172
  response 404, description: "User not found"
173
+
174
+ no_security!
182
175
  end
176
+ end
177
+ ```
183
178
 
184
- operation :create do
185
- summary "Create a user"
179
+ ```ruby
180
+ # app/controllers/users_controller.rb
181
+ def index
182
+ render json: UserResource.serialize(User.includes(:posts))
183
+ end
186
184
 
187
- response 201, description: "User created", schema: :User
188
- response 422, description: "Invalid data"
189
- end
185
+ def show
186
+ render json: UserResource.serialize(User.find(params[:id]))
187
+ end
188
+ ```
190
189
 
191
- operation :update do
192
- summary "Update a user"
190
+ ### Base (legacy, single class)
193
191
 
194
- response 200, description: "User updated", schema: :User
195
- response 404, description: "User not found"
196
- response 422, description: "Invalid data"
197
- end
192
+ ```ruby
193
+ # app/openapi/user_openapi.rb
194
+ class UserOpenapi < OpenapiBlocks::Base
195
+ tags "Users"
198
196
 
199
- operation :destroy do
200
- summary "Delete a user"
197
+ ignore :password_digest
201
198
 
202
- response 200, description: "User deleted"
203
- response 404, description: "User not found"
199
+ association :posts, type: :array, read_only: true
200
+
201
+ attribute :full_name, type: :string, read_only: true
202
+
203
+ operation :index do
204
+ summary "List all users"
205
+ response 200, description: "List of users", schema: { type: :array, items: :User }
204
206
  end
205
207
  end
206
208
  ```
207
209
 
210
+ ```ruby
211
+ # app/controllers/users_controller.rb
212
+ def index
213
+ render json: UserOpenapi.serialize(User.includes(:posts))
214
+ end
215
+ ```
216
+
217
+ ---
218
+
219
+ ## Serializer
220
+
221
+ The built-in serializer compiles a monolithic extractor method per class at boot time using `class_eval`. There are no loops, no lambda indirection, and no runtime branching per object.
222
+
223
+ ### Performance (200 records, arm64, Ruby 4.0)
224
+
225
+ | | i/s | μs/i | vs serialize |
226
+ |---|---|---|---|
227
+ | serialize | 4 239 | 235 | — |
228
+ | to_json | 1 444 | 692 | 2.94× slower |
229
+ | as_json | 1 186 | 843 | 3.58× slower |
230
+ | oj+as_json | 1 126 | 888 | 3.77× slower |
231
+
232
+ Scaling is linear — the 3.6× advantage over `as_json` holds from 10 to 5000 records.
233
+
234
+ ### Virtual attributes and method resolution
235
+
236
+ | Declared with | Method in resource? | Calls |
237
+ |---|---|---|
238
+ | `attribute :full_name` | yes | `resource_instance.full_name` |
239
+ | `attribute :full_name` | no | `object.full_name` (delegated to model) |
240
+ | column in db | — | `object.full_name` (direct) |
241
+
242
+ ### Association serializer resolution
243
+
244
+ For each association, the serializer resolves the serializer class in this order:
245
+
246
+ 1. `PostResource` — has `serialize`, used directly.
247
+ 2. `PostOpenapi` — is a `Controller`, delegates to its `_resource`.
248
+ 3. Fallback — calls `as_json` on the association value.
249
+
250
+ ---
251
+
252
+ ## What is generated automatically
253
+
254
+ Given this model:
255
+
256
+ ```ruby
257
+ class User < ApplicationRecord
258
+ validates :name, presence: true, length: { minimum: 2, maximum: 100 }
259
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
260
+ validates :age, numericality: { greater_than: 0 }
261
+ validates :role, inclusion: { in: %w[admin user guest] }
262
+ end
263
+ ```
264
+
265
+ OpenapiBlocks generates:
266
+
267
+ - `User` schema from `db/schema.rb` columns and types
268
+ - `UserInput` schema for POST, PUT and PATCH request bodies (without `id`, `created_at`, `updated_at` and `read_only` fields)
269
+ - `required` fields from `presence: true` validations
270
+ - `minLength`, `maxLength` from `length` validations
271
+ - `minimum`, `maximum` from `numericality` validations
272
+ - `enum` from `inclusion` validations
273
+ - `format: "email"` from format validations
274
+ - All paths from `config/routes.rb`
275
+
208
276
  ---
209
277
 
210
278
  ## Security
@@ -235,9 +303,9 @@ end
235
303
  ## Associations
236
304
 
237
305
  ```ruby
238
- association :company # belongs_to — $ref to Company schema
239
- association :posts, type: :array # has_many — array of $ref to Post schema
240
- association :posts, type: :array, read_only: true # excluded from UserInput (response only)
306
+ association :company # belongs_to — $ref to Company schema
307
+ association :posts, type: :array # has_many — array of $ref to Post schema
308
+ association :posts, type: :array, read_only: true # excluded from UserInput (response only)
241
309
  ```
242
310
 
243
311
  ---
@@ -246,10 +314,10 @@ association :posts, type: :array, read_only: true # excluded from UserInput (r
246
314
 
247
315
  Virtual attributes are fields that exist in the API response but not in the database.
248
316
 
249
- | Option | Description | Appears in User | Appears in UserInput |
250
- | ---------------- | -------------------------------------- | :-------------: | :------------------: |
251
- | read_only: true | Calculated or system-generated fields | YES | NO |
252
- | read_only: false | Fields the client can send and receive | YES | YES |
317
+ | Option | Description | Appears in User | Appears in UserInput |
318
+ |---|---|:---:|:---:|
319
+ | `read_only: true` | Calculated or system-generated fields | YES | NO |
320
+ | `read_only: false` | Fields the client can send and receive | YES | YES |
253
321
 
254
322
  ```ruby
255
323
  attribute :full_name, type: :string, read_only: true # response only
@@ -261,19 +329,19 @@ attribute :nickname, type: :string # request and response
261
329
 
262
330
  ## Type Mapping
263
331
 
264
- | ActiveRecord type | OpenAPI type |
265
- | ----------------- | ------------------ |
266
- | integer | integer / int32 |
267
- | bigint | integer / int64 |
268
- | float | number / float |
269
- | decimal | number / double |
270
- | string | string |
271
- | text | string |
272
- | boolean | boolean |
273
- | date | string / date |
274
- | datetime | string / date-time |
275
- | uuid | string / uuid |
276
- | json / jsonb | object |
332
+ | ActiveRecord type | OpenAPI type |
333
+ |---|---|
334
+ | integer | integer / int32 |
335
+ | bigint | integer / int64 |
336
+ | float | number / float |
337
+ | decimal | number / double |
338
+ | string | string |
339
+ | text | string |
340
+ | boolean | boolean |
341
+ | date | string / date |
342
+ | datetime | string / date-time |
343
+ | uuid | string / uuid |
344
+ | json / jsonb | object |
277
345
 
278
346
  ---
279
347
 
@@ -288,7 +356,7 @@ config/routes.rb
288
356
  db/schema.rb
289
357
  ```
290
358
 
291
- The spec is automatically regenerated on the next request to /docs/openapi.json whenever any of these files change. No server restart needed.
359
+ The spec is automatically regenerated on the next request to `/docs/openapi.json` whenever any of these files change. No server restart needed.
292
360
 
293
361
  ---
294
362
 
@@ -301,4 +369,4 @@ The spec is automatically regenerated on the next request to /docs/openapi.json
301
369
 
302
370
  ## License
303
371
 
304
- MIT (LICENSE.txt)
372
+ MIT (LICENSE.txt)