openapi_blocks 0.2.1 → 0.3.0

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: c466c6845f12b5170db8082aab7e11146666fd06776e7026f29fb85ab2a9beb2
4
+ data.tar.gz: 99c38fe1681484456900b72ce75def577a248865d885abd21bf0d4b462ed27ad
5
5
  SHA512:
6
- metadata.gz: c8c0ddcfde5053794e761fde4ecec89f5cbdcc021b37bd5dde751f76fdc6a0d05749802c9504bc05104c0c9fb242c533f3b94251f29961bf9c077fc4788850fd
7
- data.tar.gz: 21a332227d4653fe2c233d666213087bc0e5ba91ef7210577f864d68a56987236d2023f2f7f9f5285dd5a9ea40b2eba97d1c1bf6d546ca7b0116fb897239f6c9
6
+ metadata.gz: 8f016873a16d2066461ae29891de560c5c4bd5867ec26a00fb99dbc9c4ca2c73887f9aefd64e272a5ed2c111f2a29b71756809ba19beba3fb8e3dde4c5bd7bb2
7
+ data.tar.gz: 70a4deb4c047856082593ada3c2c4476c5a5caf3aad08126d3505e35a233e8d12cc48ea49a95edfdc5ee47c70b870cfc0f87506f06a1e4dc31b1683e1f1df161
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-06-01
11
+
12
+ ### Added
13
+ - Scalar UI served at `/docs/scalar` alongside Swagger UI
14
+ - `SpecController#scalar` action serving Scalar with `displayRequestDuration` and Ruby `net_http` as default client
15
+ - `Resource` and `Controller` classes to support serializer-style resources and controller-scoped OpenAPI classes (`lib/openapi_blocks/resource.rb`, `lib/openapi_blocks/controller.rb`)
16
+
17
+ ### Changed
18
+ - `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
19
+ - Field classification (model / virtual / association) computed once via `classify_fields` and memoized — no runtime `respond_to?` or `Array#include?` per object
20
+ - Association metadata indexed by name in a `Hash` for O(1) lookup instead of `Array#find` per field per object
21
+ - Association serializer classes resolved at compile time inside `build_assoc_method` instead of per-object via `Object.const_get`
22
+ - 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
23
+ - `Builder#openapi_classes` expanded discovery to include classes ending with `Openapi` that inherit from `OpenapiBlocks::Base` or `OpenapiBlocks::Controller` (enables controller-scoped OpenAPI classes)
24
+ - `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.
25
+ - `Base#infer_model` updated to strip both `Openapi` and `Resource` suffixes when inferring the model class name.
26
+
10
27
  ## [0.2.1] - 2026-06-01
11
28
 
12
29
  ### 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,55 @@ 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
+
163
154
  tags "Users"
164
155
 
165
156
  operation :index do
166
157
  summary "List all users"
167
158
  description "Returns a paginated list of active users"
168
- tags "Users", "Admin" # overrides class-level tags for this operation
169
159
 
170
160
  parameter :page, in: :query, type: :integer, description: "Page number"
171
161
  parameter :per_page, in: :query, type: :integer, description: "Items per page"
@@ -177,34 +167,111 @@ class UserOpenapi < OpenapiBlocks::Base
177
167
  operation :show do
178
168
  summary "Get a user"
179
169
 
180
- response 200, description: "User found", schema: :User
170
+ response 200, description: "User found", schema: :User
181
171
  response 404, description: "User not found"
172
+
173
+ no_security!
182
174
  end
175
+ end
176
+ ```
183
177
 
184
- operation :create do
185
- summary "Create a user"
178
+ ```ruby
179
+ # app/controllers/users_controller.rb
180
+ def index
181
+ render json: UserResource.serialize(User.includes(:posts))
182
+ end
186
183
 
187
- response 201, description: "User created", schema: :User
188
- response 422, description: "Invalid data"
189
- end
184
+ def show
185
+ render json: UserResource.serialize(User.find(params[:id]))
186
+ end
187
+ ```
190
188
 
191
- operation :update do
192
- summary "Update a user"
189
+ ### Base (legacy, single class)
193
190
 
194
- response 200, description: "User updated", schema: :User
195
- response 404, description: "User not found"
196
- response 422, description: "Invalid data"
197
- end
191
+ ```ruby
192
+ # app/openapi/user_openapi.rb
193
+ class UserOpenapi < OpenapiBlocks::Base
194
+ tags "Users"
198
195
 
199
- operation :destroy do
200
- summary "Delete a user"
196
+ ignore :password_digest
201
197
 
202
- response 200, description: "User deleted"
203
- response 404, description: "User not found"
198
+ association :posts, type: :array, read_only: true
199
+
200
+ attribute :full_name, type: :string, read_only: true
201
+
202
+ operation :index do
203
+ summary "List all users"
204
+ response 200, description: "List of users", schema: { type: :array, items: :User }
204
205
  end
205
206
  end
206
207
  ```
207
208
 
209
+ ```ruby
210
+ # app/controllers/users_controller.rb
211
+ def index
212
+ render json: UserOpenapi.serialize(User.includes(:posts))
213
+ end
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Serializer
219
+
220
+ 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.
221
+
222
+ ### Performance (200 records, arm64, Ruby 4.0)
223
+
224
+ | | i/s | μs/i | vs serialize |
225
+ |---|---|---|---|
226
+ | serialize | 4 239 | 235 | — |
227
+ | to_json | 1 444 | 692 | 2.94× slower |
228
+ | as_json | 1 186 | 843 | 3.58× slower |
229
+ | oj+as_json | 1 126 | 888 | 3.77× slower |
230
+
231
+ Scaling is linear — the 3.6× advantage over `as_json` holds from 10 to 5000 records.
232
+
233
+ ### Virtual attributes and method resolution
234
+
235
+ | Declared with | Method in resource? | Calls |
236
+ |---|---|---|
237
+ | `attribute :full_name` | yes | `resource_instance.full_name` |
238
+ | `attribute :full_name` | no | `object.full_name` (delegated to model) |
239
+ | column in db | — | `object.full_name` (direct) |
240
+
241
+ ### Association serializer resolution
242
+
243
+ For each association, the serializer resolves the serializer class in this order:
244
+
245
+ 1. `PostResource` — has `serialize`, used directly.
246
+ 2. `PostOpenapi` — is a `Controller`, delegates to its `_resource`.
247
+ 3. Fallback — calls `as_json` on the association value.
248
+
249
+ ---
250
+
251
+ ## What is generated automatically
252
+
253
+ Given this model:
254
+
255
+ ```ruby
256
+ class User < ApplicationRecord
257
+ validates :name, presence: true, length: { minimum: 2, maximum: 100 }
258
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
259
+ validates :age, numericality: { greater_than: 0 }
260
+ validates :role, inclusion: { in: %w[admin user guest] }
261
+ end
262
+ ```
263
+
264
+ OpenapiBlocks generates:
265
+
266
+ - `User` schema from `db/schema.rb` columns and types
267
+ - `UserInput` schema for POST, PUT and PATCH request bodies (without `id`, `created_at`, `updated_at` and `read_only` fields)
268
+ - `required` fields from `presence: true` validations
269
+ - `minLength`, `maxLength` from `length` validations
270
+ - `minimum`, `maximum` from `numericality` validations
271
+ - `enum` from `inclusion` validations
272
+ - `format: "email"` from format validations
273
+ - All paths from `config/routes.rb`
274
+
208
275
  ---
209
276
 
210
277
  ## Security
@@ -235,9 +302,9 @@ end
235
302
  ## Associations
236
303
 
237
304
  ```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)
305
+ association :company # belongs_to — $ref to Company schema
306
+ association :posts, type: :array # has_many — array of $ref to Post schema
307
+ association :posts, type: :array, read_only: true # excluded from UserInput (response only)
241
308
  ```
242
309
 
243
310
  ---
@@ -246,10 +313,10 @@ association :posts, type: :array, read_only: true # excluded from UserInput (r
246
313
 
247
314
  Virtual attributes are fields that exist in the API response but not in the database.
248
315
 
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 |
316
+ | Option | Description | Appears in User | Appears in UserInput |
317
+ |---|---|:---:|:---:|
318
+ | `read_only: true` | Calculated or system-generated fields | YES | NO |
319
+ | `read_only: false` | Fields the client can send and receive | YES | YES |
253
320
 
254
321
  ```ruby
255
322
  attribute :full_name, type: :string, read_only: true # response only
@@ -261,19 +328,19 @@ attribute :nickname, type: :string # request and response
261
328
 
262
329
  ## Type Mapping
263
330
 
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 |
331
+ | ActiveRecord type | OpenAPI type |
332
+ |---|---|
333
+ | integer | integer / int32 |
334
+ | bigint | integer / int64 |
335
+ | float | number / float |
336
+ | decimal | number / double |
337
+ | string | string |
338
+ | text | string |
339
+ | boolean | boolean |
340
+ | date | string / date |
341
+ | datetime | string / date-time |
342
+ | uuid | string / uuid |
343
+ | json / jsonb | object |
277
344
 
278
345
  ---
279
346
 
@@ -288,7 +355,7 @@ config/routes.rb
288
355
  db/schema.rb
289
356
  ```
290
357
 
291
- The spec is automatically regenerated on the next request to /docs/openapi.json whenever any of these files change. No server restart needed.
358
+ The spec is automatically regenerated on the next request to `/docs/openapi.json` whenever any of these files change. No server restart needed.
292
359
 
293
360
  ---
294
361
 
@@ -301,4 +368,4 @@ The spec is automatically regenerated on the next request to /docs/openapi.json
301
368
 
302
369
  ## License
303
370
 
304
- MIT (LICENSE.txt)
371
+ MIT (LICENSE.txt)