openapi_minitest 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7e0b37db854964620741255ac8d97c19570c3425977a9e6cb507de3a4212bfca
4
+ data.tar.gz: 7e03765531c01ebefb133856ab647e1626527f828481ed4d682cd8aa7896e49f
5
+ SHA512:
6
+ metadata.gz: f666ddf8f5cb8e3986346d84b5920d7c07e7eb12a13c2f87324ebb7878d848a0bdd2f61dff0ca4611c713fad80cbd9e312e1a32019cc385058eaceaa2aa43d79
7
+ data.tar.gz: ac39441a39f4f8a8aa2828c7106f2c2ec34b101af98aca92d688a831ddbfd3d6404f75eff1234d3fb7832d66a8e8cda069c823c7d07d940621f1c020887ffd36
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.1.0"
3
+ }
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.1
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-02-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * add strict validation to catch undocumented fields ([767855b](https://github.com/k0va1/openapi_minitest/commit/767855b88d4d2c92fc633f366e6d71fddfd6232b))
9
+ * initial implementation of openapi_minitest gem ([b41acd7](https://github.com/k0va1/openapi_minitest/commit/b41acd7c2ab2cfdb17c68f2ecc99771856c34c33))
10
+ * output YAML format by default ([df930cf](https://github.com/k0va1/openapi_minitest/commit/df930cf925b73800dc628e3361019dc8bc453fc5))
11
+ * update to OpenAPI 3.1.0 specification ([778e5f3](https://github.com/k0va1/openapi_minitest/commit/778e5f3166066c50878b7b0cc1e6b00746643cdd))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * force initial release version to 0.1.0 ([a5b821d](https://github.com/k0va1/openapi_minitest/commit/a5b821decdea4a014cb12a2430ca33bb9ef4bc08))
17
+ * reset ResultCollector between tests to prevent test pollution ([ecc5bc2](https://github.com/k0va1/openapi_minitest/commit/ecc5bc268a6e8833eebdc6566441c0032b93f2ec))
18
+ * set manifest version to 0.0.0 for initial release as 0.1.0 ([7e340df](https://github.com/k0va1/openapi_minitest/commit/7e340dfdecc3bafdd1b3bf6147fc9e92e2e10f7e))
19
+ * use security schemes instead of Authorization header parameter ([eb092ef](https://github.com/k0va1/openapi_minitest/commit/eb092efaa34ae92745c890d88dea18b8b55ca7f8))
20
+
21
+ ## Changelog
data/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ # Project Instructions
2
+
3
+ - Use Conventional Commits format for all commit messages (e.g., `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`)
data/Makefile ADDED
@@ -0,0 +1,17 @@
1
+ .PHONY: test lint-fix console install
2
+
3
+ install:
4
+ bundle install
5
+
6
+ console:
7
+ bin/console
8
+
9
+
10
+ test:
11
+ bundle exec rake test
12
+
13
+
14
+
15
+ lint-fix:
16
+ bundle exec standardrb --fix
17
+
data/README.md ADDED
@@ -0,0 +1,356 @@
1
+ # OpenapiMinitest
2
+
3
+ Generate OpenAPI 3.1 documentation from your Minitest integration tests. No DSL, no magic - just one helper method.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "openapi_minitest"
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### 1. Configure (optional)
22
+
23
+ ```ruby
24
+ # config/initializers/openapi_minitest.rb (Rails)
25
+ # or test/support/openapi.rb
26
+
27
+ OpenapiMinitest.configure do |config|
28
+ config.title = "My API"
29
+ config.version = "1.0.0"
30
+ config.description = "API documentation"
31
+ config.output_path = "doc/openapi.yml" # YAML by default
32
+ config.servers = ["https://api.example.com"]
33
+ end
34
+ ```
35
+
36
+ ### 2. Define Schemas
37
+
38
+ ```ruby
39
+ OpenapiMinitest.define_schema :User, {
40
+ type: :object,
41
+ properties: {
42
+ id: { type: :integer },
43
+ email: { type: :string, format: :email },
44
+ name: { type: :string }
45
+ },
46
+ required: %w[id email]
47
+ }
48
+
49
+ OpenapiMinitest.define_schema :UserList, {
50
+ type: :object,
51
+ properties: {
52
+ data: { type: :array, items: { "$ref" => "#/components/schemas/User" } },
53
+ meta: {
54
+ type: :object,
55
+ properties: {
56
+ total: { type: :integer },
57
+ page: { type: :integer }
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ OpenapiMinitest.define_schema :Error, {
64
+ type: :object,
65
+ properties: {
66
+ error: { type: :string },
67
+ code: { type: :integer }
68
+ }
69
+ }
70
+ ```
71
+
72
+ ### 3. Write Tests
73
+
74
+ Write normal Minitest tests. Call `document_response` after each request you want documented:
75
+
76
+ ```ruby
77
+ class UsersApiTest < ActionDispatch::IntegrationTest
78
+ def test_returns_users
79
+ create(:user, name: "John")
80
+ create(:user, name: "Jane")
81
+
82
+ get "/api/users", headers: auth_headers
83
+
84
+ assert_response 200
85
+ document_response schema: :UserList, description: "Returns all users"
86
+
87
+ body = JSON.parse(response.body)
88
+ assert_equal 2, body["data"].size
89
+ end
90
+
91
+ def test_filters_users_by_name
92
+ create(:user, name: "John")
93
+ create(:user, name: "Jane")
94
+
95
+ get "/api/users", params: { q: "John" }, headers: auth_headers
96
+
97
+ assert_response 200
98
+ document_response schema: :UserList, description: "Filters users by name"
99
+
100
+ body = JSON.parse(response.body)
101
+ assert_equal 1, body["data"].size
102
+ end
103
+
104
+ def test_returns_single_user
105
+ user = create(:user, name: "John")
106
+
107
+ get "/api/users/#{user.id}", headers: auth_headers
108
+
109
+ assert_response 200
110
+ document_response schema: :User, description: "User found", tags: ["Users"]
111
+ end
112
+
113
+ def test_user_not_found
114
+ get "/api/users/999999", headers: auth_headers
115
+
116
+ assert_response 404
117
+ document_response schema: :Error, description: "User not found"
118
+ end
119
+
120
+ def test_creates_user
121
+ post "/api/users",
122
+ params: { user: { name: "New User", email: "new@example.com" } },
123
+ headers: auth_headers,
124
+ as: :json
125
+
126
+ assert_response 201
127
+ document_response schema: :User, description: "User created", tags: ["Users"]
128
+ end
129
+
130
+ def test_create_user_validation_error
131
+ post "/api/users",
132
+ params: { user: { name: "" } },
133
+ headers: auth_headers,
134
+ as: :json
135
+
136
+ assert_response 422
137
+ document_response schema: :Error, description: "Validation failed"
138
+ end
139
+
140
+ private
141
+
142
+ def auth_headers
143
+ { "Authorization" => "Bearer #{generate_token}" }
144
+ end
145
+ end
146
+ ```
147
+
148
+ ### 4. Generate Documentation
149
+
150
+ ```bash
151
+ # Run tests with documentation generation
152
+ OPENAPI_GENERATE=true rails test test/integration/
153
+
154
+ # Or use the rake task
155
+ rails openapi:generate
156
+ ```
157
+
158
+ ## API Reference
159
+
160
+ ### Configuration Options
161
+
162
+ ```ruby
163
+ OpenapiMinitest.configure do |config|
164
+ config.title = "My API" # API title
165
+ config.version = "1.0.0" # API version
166
+ config.description = "Description" # API description
167
+ config.output_path = "doc/openapi.yml" # Output file path (YAML by default)
168
+ config.servers = [ # Server URLs
169
+ "https://api.example.com",
170
+ { url: "https://staging.example.com", description: "Staging" }
171
+ ]
172
+ config.security_schemes = { # Security schemes
173
+ bearer: {
174
+ type: :http,
175
+ scheme: :bearer
176
+ }
177
+ }
178
+ config.validate_schema = true # Validate responses against schemas
179
+ config.strict_validation = false # Fail if response contains undocumented fields
180
+ end
181
+ ```
182
+
183
+ ### document_response
184
+
185
+ Call after making a request to record it for documentation:
186
+
187
+ ```ruby
188
+ document_response(
189
+ schema: :SchemaName, # Schema reference (Symbol) or inline schema (Hash)
190
+ summary: "Operation summary", # Defaults to test name
191
+ description: "Response desc", # Description of this response
192
+ tags: ["Tag1", "Tag2"], # Tags for grouping
193
+ operation_id: "getUsers", # Unique operation ID
194
+ deprecated: false, # Mark endpoint as deprecated
195
+ strict: nil # Override strict validation (true/false/nil for global config)
196
+ )
197
+ ```
198
+
199
+ ### Schema Definition
200
+
201
+ ```ruby
202
+ # Reference to another schema
203
+ OpenapiMinitest.define_schema :UserList, {
204
+ type: :object,
205
+ properties: {
206
+ data: { type: :array, items: { "$ref" => "#/components/schemas/User" } }
207
+ }
208
+ }
209
+
210
+ # Inline schema in tests
211
+ document_response schema: {
212
+ type: :object,
213
+ properties: {
214
+ status: { type: :string }
215
+ }
216
+ }
217
+
218
+ # Nullable fields (OpenAPI 3.1 syntax - use type array instead of nullable: true)
219
+ OpenapiMinitest.define_schema :Article, {
220
+ type: :object,
221
+ properties: {
222
+ id: { type: :integer },
223
+ title: { type: :string },
224
+ subtitle: { type: [:string, :null] }, # nullable string
225
+ published_at: { type: [:string, :null], format: :datetime }
226
+ }
227
+ }
228
+ ```
229
+
230
+ ## Features
231
+
232
+ - **No DSL** - Just one helper method, write normal Minitest tests
233
+ - **Schema validation** - Optionally validate responses against schemas during tests
234
+ - **Strict validation** - Fail tests when responses contain undocumented fields
235
+ - **Auto-detection** - Automatically extracts path parameters, query params
236
+ - **Security handling** - Authorization headers automatically use configured security schemes
237
+ - **Multiple examples** - Each test becomes an example in the docs
238
+ - **Request bodies** - Captures POST/PUT/PATCH request bodies as examples
239
+
240
+ ## Strict Validation
241
+
242
+ Enable strict validation to catch undocumented fields in API responses. This helps ensure your documentation stays in sync with your implementation.
243
+
244
+ When strict mode is enabled, the test will fail if the response contains any fields not defined in the schema:
245
+
246
+ ```
247
+ Response does not match schema:
248
+ The property '#/' contains additional properties ["unexpected_field"] outside of the schema when none are allowed
249
+ ```
250
+
251
+ ### Global Configuration
252
+
253
+ ```ruby
254
+ OpenapiMinitest.configure do |config|
255
+ config.strict_validation = true # All schemas strictly validated
256
+ end
257
+ ```
258
+
259
+ ### Per-Call Override
260
+
261
+ ```ruby
262
+ # Force strict validation for this endpoint
263
+ document_response schema: :User, strict: true
264
+
265
+ # Disable strict validation for this endpoint (e.g., third-party responses)
266
+ document_response schema: :ExternalData, strict: false
267
+
268
+ # Use global config setting (default behavior)
269
+ document_response schema: :User
270
+ ```
271
+
272
+ Strict validation works by automatically injecting `additionalProperties: false` into all object schemas during validation. This includes nested objects and objects within arrays. If a schema already defines `additionalProperties`, that value is preserved.
273
+
274
+ ## How It Works
275
+
276
+ 1. You write normal integration tests
277
+ 2. Call `document_response` after requests you want documented
278
+ 3. The gem captures:
279
+ - Request method, path, parameters, headers
280
+ - Response status, body
281
+ - Schema reference or definition
282
+ 4. After tests run, generates OpenAPI 3.1 YAML
283
+
284
+ ## Path Parameter Detection
285
+
286
+ The gem automatically detects numeric IDs in paths and converts them:
287
+
288
+ ```
289
+ /api/users/123 -> /api/users/{user_id}
290
+ /api/users/123/posts/456 -> /api/users/{user_id}/posts/{post_id}
291
+ ```
292
+
293
+ ## Security / Authentication
294
+
295
+ When you configure `security_schemes` and your tests include an `Authorization` header, the gem automatically adds the `security` property to those operations:
296
+
297
+ ```ruby
298
+ OpenapiMinitest.configure do |config|
299
+ config.security_schemes = {
300
+ bearer: {
301
+ type: :http,
302
+ scheme: :bearer
303
+ }
304
+ }
305
+ end
306
+ ```
307
+
308
+ Operations with Authorization headers will be generated with:
309
+
310
+ ```yaml
311
+ security:
312
+ - bearer: []
313
+ ```
314
+
315
+ ## Validator Compatibility
316
+
317
+ This gem generates OpenAPI 3.1.0 which supports type arrays for nullable fields:
318
+
319
+ ```yaml
320
+ type:
321
+ - string
322
+ - 'null'
323
+ ```
324
+
325
+ Some validators may not fully support OpenAPI 3.1.0 yet. If you encounter validation errors about type arrays, ensure your validator supports OpenAPI 3.1.0.
326
+
327
+ ## Non-Rails Usage
328
+
329
+ Include the DSL module manually:
330
+
331
+ ```ruby
332
+ class MyApiTest < Minitest::Test
333
+ include OpenapiMinitest::DSL
334
+
335
+ # ... your tests
336
+ end
337
+
338
+ # Generate documentation after tests
339
+ Minitest.after_run do
340
+ if ENV["OPENAPI_GENERATE"]
341
+ OpenapiMinitest::OpenAPI::Generator.new.write
342
+ end
343
+ end
344
+ ```
345
+
346
+ ## Development
347
+
348
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
349
+
350
+ ## Contributing
351
+
352
+ Bug reports and pull requests are welcome on GitHub at https://github.com/k0va1/openapi_minitest.
353
+
354
+ ## License
355
+
356
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiMinitest
4
+ class Configuration
5
+ attr_accessor :title,
6
+ :version,
7
+ :description,
8
+ :output_path,
9
+ :servers,
10
+ :security_schemes,
11
+ :tags,
12
+ :validate_schema,
13
+ :strict_validation
14
+
15
+ def initialize
16
+ @title = "API Documentation"
17
+ @version = "1.0.0"
18
+ @description = nil
19
+ @output_path = "doc/openapi.yml"
20
+ @servers = []
21
+ @security_schemes = {}
22
+ @tags = []
23
+ @validate_schema = true
24
+ @strict_validation = false
25
+ end
26
+ end
27
+
28
+ class << self
29
+ def configuration
30
+ @configuration ||= Configuration.new
31
+ end
32
+
33
+ def configure
34
+ yield(configuration)
35
+ end
36
+
37
+ def reset!
38
+ @configuration = Configuration.new
39
+ @schemas = {}
40
+ end
41
+
42
+ # Schema registry
43
+ def schemas
44
+ @schemas ||= {}
45
+ end
46
+
47
+ def define_schema(name, definition)
48
+ schemas[name.to_sym] = definition
49
+ end
50
+
51
+ def schema(name)
52
+ schemas[name.to_sym]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json-schema"
5
+
6
+ module OpenapiMinitest
7
+ module DSL
8
+ # Records the current request/response for OpenAPI documentation.
9
+ #
10
+ # Call this after making a request and asserting the response status.
11
+ # It captures all the details needed for OpenAPI generation.
12
+ #
13
+ # @param schema [Symbol, Hash, nil] Schema name (references defined schema) or inline schema hash
14
+ # @param summary [String, nil] Short summary of the operation (defaults to test name)
15
+ # @param description [String, nil] Description of this specific response
16
+ # @param tags [Array<String>] Tags for grouping endpoints
17
+ # @param operation_id [String, nil] Unique operation identifier
18
+ # @param deprecated [Boolean] Whether this endpoint is deprecated
19
+ # @param strict [Boolean, nil] Strict validation mode - fails if response contains undocumented fields.
20
+ # When nil (default), uses the global config.strict_validation setting.
21
+ #
22
+ # @example Basic usage
23
+ # def test_returns_users
24
+ # get "/api/users"
25
+ # assert_response 200
26
+ # document_response schema: :UserList, description: "Returns all users"
27
+ # end
28
+ #
29
+ # @example With tags and summary
30
+ # def test_creates_user
31
+ # post "/api/users", params: { name: "John" }
32
+ # assert_response 201
33
+ # document_response schema: :User, summary: "Create user", tags: ["Users"]
34
+ # end
35
+ #
36
+ # @example Inline schema
37
+ # def test_health_check
38
+ # get "/health"
39
+ # assert_response 200
40
+ # document_response schema: { type: :object, properties: { status: { type: :string } } }
41
+ # end
42
+ #
43
+ # @example Without schema (just documents the endpoint)
44
+ # def test_deletes_user
45
+ # delete "/api/users/1"
46
+ # assert_response 204
47
+ # document_response description: "User deleted"
48
+ # end
49
+ #
50
+ # @example Strict validation (fails if response has undocumented fields)
51
+ # def test_returns_user_strict
52
+ # get "/api/users/1"
53
+ # assert_response 200
54
+ # document_response schema: :User, strict: true
55
+ # end
56
+ #
57
+ def document_response(schema: nil, summary: nil, description: nil, tags: [], operation_id: nil, deprecated: false, strict: nil)
58
+ # Validate schema if provided and validation is enabled
59
+ if schema && OpenapiMinitest.configuration.validate_schema && response.body.present?
60
+ use_strict = strict.nil? ? OpenapiMinitest.configuration.strict_validation : strict
61
+ validate_response_schema!(schema, strict: use_strict)
62
+ end
63
+
64
+ # Record for OpenAPI generation
65
+ ResultCollector.instance.record(
66
+ request: request,
67
+ response: response,
68
+ schema: normalize_schema(schema),
69
+ summary: summary || generate_summary,
70
+ description: description,
71
+ tags: Array(tags),
72
+ operation_id: operation_id,
73
+ deprecated: deprecated,
74
+ test_name: name
75
+ )
76
+ end
77
+
78
+ private
79
+
80
+ def validate_response_schema!(schema, strict: false)
81
+ resolved = resolve_schema(schema)
82
+ resolved = apply_strict_validation(resolved) if strict
83
+ body = JSON.parse(response.body)
84
+
85
+ errors = JSON::Validator.fully_validate(
86
+ stringify_keys(resolved),
87
+ body,
88
+ strict: false,
89
+ clear_cache: true
90
+ )
91
+
92
+ return if errors.empty?
93
+
94
+ flunk "Response does not match schema:\n#{errors.join("\n")}\n\nBody: #{response.body}"
95
+ rescue JSON::ParserError => e
96
+ flunk "Response is not valid JSON: #{e.message}"
97
+ end
98
+
99
+ def apply_strict_validation(schema)
100
+ case schema
101
+ when Hash
102
+ result = schema.transform_values { |v| apply_strict_validation(v) }
103
+ # Add additionalProperties: false to object types
104
+ if object_type?(result)
105
+ result[:additionalProperties] = false unless result.key?(:additionalProperties) || result.key?("additionalProperties")
106
+ end
107
+ result
108
+ when Array
109
+ schema.map { |item| apply_strict_validation(item) }
110
+ else
111
+ schema
112
+ end
113
+ end
114
+
115
+ def object_type?(schema)
116
+ type = schema[:type] || schema["type"]
117
+ return false unless type
118
+
119
+ case type
120
+ when :object, "object"
121
+ true
122
+ when Array
123
+ type.any? { |t| t == :object || t == "object" }
124
+ else
125
+ false
126
+ end
127
+ end
128
+
129
+ def resolve_schema(schema)
130
+ case schema
131
+ when Symbol
132
+ referenced = OpenapiMinitest.schema(schema)
133
+ raise ArgumentError, "Unknown schema: #{schema}" unless referenced
134
+ deep_resolve_refs(deep_dup(referenced))
135
+ when Hash
136
+ deep_resolve_refs(deep_dup(schema))
137
+ else
138
+ schema
139
+ end
140
+ end
141
+
142
+ def deep_resolve_refs(obj)
143
+ case obj
144
+ when Hash
145
+ if obj[:$ref] || obj["$ref"]
146
+ ref = obj[:$ref] || obj["$ref"]
147
+ if ref.to_s.start_with?("#/components/schemas/")
148
+ schema_name = ref.to_s.split("/").last.to_sym
149
+ referenced = OpenapiMinitest.schema(schema_name)
150
+ if referenced
151
+ return deep_resolve_refs(deep_dup(referenced))
152
+ end
153
+ end
154
+ end
155
+ obj.transform_values { |v| deep_resolve_refs(v) }
156
+ when Array
157
+ obj.map { |item| deep_resolve_refs(item) }
158
+ else
159
+ obj
160
+ end
161
+ end
162
+
163
+ def deep_dup(obj)
164
+ case obj
165
+ when Hash
166
+ obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
167
+ when Array
168
+ obj.map { |item| deep_dup(item) }
169
+ else
170
+ obj
171
+ end
172
+ end
173
+
174
+ def stringify_keys(obj)
175
+ case obj
176
+ when Hash
177
+ obj.each_with_object({}) do |(key, value), result|
178
+ result[key.to_s] = stringify_keys(value)
179
+ end
180
+ when Array
181
+ obj.map { |item| stringify_keys(item) }
182
+ else
183
+ obj
184
+ end
185
+ end
186
+
187
+ def normalize_schema(schema)
188
+ case schema
189
+ when Symbol
190
+ {"$ref" => "#/components/schemas/#{schema}"}
191
+ when Hash
192
+ stringify_keys(schema)
193
+ end
194
+ end
195
+
196
+ def generate_summary
197
+ # Convert test_creates_user -> "Creates user"
198
+ name.to_s
199
+ .sub(/^test_/, "")
200
+ .tr("_", " ")
201
+ .capitalize
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module OpenapiMinitest
7
+ module OpenAPI
8
+ class Generator
9
+ def initialize
10
+ @config = OpenapiMinitest.configuration
11
+ @collector = ResultCollector.instance
12
+ end
13
+
14
+ def generate
15
+ {
16
+ "openapi" => "3.1.0",
17
+ "info" => build_info,
18
+ "servers" => build_servers,
19
+ "paths" => build_paths,
20
+ "components" => build_components
21
+ }.compact
22
+ end
23
+
24
+ def write
25
+ path = @config.output_path
26
+ FileUtils.mkdir_p(File.dirname(path))
27
+ File.write(path, generate.to_yaml)
28
+ path
29
+ end
30
+
31
+ private
32
+
33
+ def build_info
34
+ info = {
35
+ "title" => @config.title,
36
+ "version" => @config.version
37
+ }
38
+ info["description"] = @config.description if @config.description
39
+ info
40
+ end
41
+
42
+ def build_servers
43
+ return nil if @config.servers.empty?
44
+
45
+ @config.servers.map do |server|
46
+ case server
47
+ when String
48
+ {"url" => server}
49
+ when Hash
50
+ stringify_keys(server)
51
+ end
52
+ end
53
+ end
54
+
55
+ def build_paths
56
+ paths = {}
57
+
58
+ @collector.operations.each do |key, operation|
59
+ path = operation[:path]
60
+ method = operation[:method]
61
+
62
+ paths[path] ||= {}
63
+ paths[path][method] = build_operation(key, operation)
64
+ end
65
+
66
+ paths
67
+ end
68
+
69
+ def build_operation(key, operation)
70
+ op = {}
71
+
72
+ op["summary"] = operation[:summary] if operation[:summary]
73
+ op["tags"] = operation[:tags] if operation[:tags]&.any?
74
+ op["operationId"] = operation[:operation_id] if operation[:operation_id]
75
+ op["deprecated"] = true if operation[:deprecated]
76
+
77
+ # Security (if operation requires auth and security schemes are configured)
78
+ if operation[:requires_auth] && @config.security_schemes.any?
79
+ op["security"] = @config.security_schemes.keys.map { |scheme| {scheme.to_s => []} }
80
+ end
81
+
82
+ # Parameters
83
+ if operation[:parameters]&.any?
84
+ op["parameters"] = operation[:parameters].map { |p| stringify_keys(p) }
85
+ end
86
+
87
+ # Request body (for POST/PUT/PATCH)
88
+ request_body = build_request_body(key, operation[:method])
89
+ op["requestBody"] = request_body if request_body
90
+
91
+ # Responses
92
+ op["responses"] = build_responses(key)
93
+
94
+ op
95
+ end
96
+
97
+ def build_request_body(key, method)
98
+ return nil unless %w[post put patch].include?(method)
99
+
100
+ responses = @collector.responses[key]
101
+ return nil unless responses
102
+
103
+ # Find a request example from any response
104
+ example = nil
105
+ responses.each_value do |response_list|
106
+ response_list.each do |resp|
107
+ if resp[:request_example]
108
+ example = resp[:request_example]
109
+ break
110
+ end
111
+ end
112
+ break if example
113
+ end
114
+
115
+ return nil unless example
116
+
117
+ {
118
+ "required" => true,
119
+ "content" => {
120
+ "application/json" => {
121
+ "schema" => {"type" => "object"},
122
+ "example" => example
123
+ }
124
+ }
125
+ }
126
+ end
127
+
128
+ def build_responses(key)
129
+ responses_data = @collector.responses[key] || {}
130
+ responses = {}
131
+
132
+ responses_data.each do |status, response_list|
133
+ primary = response_list.first
134
+
135
+ response = {
136
+ "description" => primary[:description]
137
+ }
138
+
139
+ # Add content with schema and examples
140
+ if primary[:schema] || primary[:example]
141
+ content = {}
142
+
143
+ content["schema"] = primary[:schema] if primary[:schema]
144
+
145
+ # Build examples from all responses with this status
146
+ if response_list.any? { |r| r[:example] }
147
+ examples = {}
148
+ response_list.each do |resp|
149
+ next unless resp[:example]
150
+ example_name = resp[:test_name] || "example"
151
+ examples[example_name] = {
152
+ "value" => resp[:example]
153
+ }
154
+ end
155
+ content["examples"] = examples if examples.any?
156
+ end
157
+
158
+ response["content"] = {
159
+ "application/json" => content
160
+ }
161
+ end
162
+
163
+ responses[status] = response
164
+ end
165
+
166
+ # Ensure at least one response
167
+ responses["200"] = {"description" => "Successful response"} if responses.empty?
168
+
169
+ responses
170
+ end
171
+
172
+ def build_components
173
+ components = {}
174
+
175
+ # Add registered schemas
176
+ if OpenapiMinitest.schemas.any?
177
+ components["schemas"] = {}
178
+ OpenapiMinitest.schemas.each do |name, schema|
179
+ components["schemas"][name.to_s] = stringify_keys(schema)
180
+ end
181
+ end
182
+
183
+ # Add security schemes
184
+ if @config.security_schemes.any?
185
+ components["securitySchemes"] = stringify_keys(@config.security_schemes)
186
+ end
187
+
188
+ components.empty? ? nil : components
189
+ end
190
+
191
+ def stringify_keys(obj)
192
+ case obj
193
+ when Hash
194
+ obj.each_with_object({}) do |(key, value), result|
195
+ result[key.to_s] = stringify_keys(value)
196
+ end
197
+ when Array
198
+ obj.map { |item| stringify_keys(item) }
199
+ when Symbol
200
+ obj.to_s
201
+ else
202
+ obj
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiMinitest
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :openapi_minitest
6
+
7
+ rake_tasks do
8
+ namespace :openapi do
9
+ desc "Generate OpenAPI documentation from tests"
10
+ task generate: :environment do
11
+ ENV["OPENAPI_GENERATE"] = "true"
12
+
13
+ require "rails/test_unit/runner"
14
+ Rails::TestUnit::Runner.run(ARGV)
15
+ end
16
+ end
17
+ end
18
+
19
+ initializer "openapi_minitest.configure" do
20
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
21
+ include OpenapiMinitest::DSL
22
+ end
23
+ end
24
+
25
+ config.after_initialize do
26
+ if Rails.env.test?
27
+ Minitest.after_run do
28
+ if ENV["OPENAPI_GENERATE"] == "true" && !OpenapiMinitest::ResultCollector.instance.empty?
29
+ path = OpenapiMinitest::OpenAPI::Generator.new.write
30
+ puts "\nOpenAPI documentation generated: #{path}"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "json"
5
+
6
+ module OpenapiMinitest
7
+ class ResultCollector
8
+ include Singleton
9
+
10
+ def initialize
11
+ reset!
12
+ end
13
+
14
+ def reset!
15
+ @operations = {} # "METHOD /path" => operation data
16
+ @responses = {} # "METHOD /path" => { status => [response_data] }
17
+ end
18
+
19
+ def record(request:, response:, schema:, summary:, description:, tags:, operation_id:, deprecated:, test_name:)
20
+ method = request.request_method.downcase
21
+ path = normalize_path(request.path)
22
+ key = "#{method} #{path}"
23
+
24
+ # Store operation metadata (first one wins for summary, tags merge)
25
+ @operations[key] ||= {
26
+ method: method,
27
+ path: path,
28
+ summary: summary,
29
+ tags: [],
30
+ operation_id: operation_id,
31
+ deprecated: deprecated,
32
+ parameters: extract_parameters(request, path),
33
+ requires_auth: has_authorization_header?(request)
34
+ }
35
+
36
+ # Merge tags from all tests
37
+ @operations[key][:tags] = (@operations[key][:tags] + tags).uniq
38
+
39
+ # Store response data grouped by status
40
+ status = response.status.to_s
41
+ @responses[key] ||= {}
42
+ @responses[key][status] ||= []
43
+
44
+ @responses[key][status] << {
45
+ description: description || default_description(response.status),
46
+ schema: schema,
47
+ example: parse_body(response.body),
48
+ test_name: test_name,
49
+ request_example: extract_request_example(request)
50
+ }
51
+ end
52
+
53
+ attr_reader :operations
54
+
55
+ attr_reader :responses
56
+
57
+ def empty?
58
+ @operations.empty?
59
+ end
60
+
61
+ private
62
+
63
+ def normalize_path(path)
64
+ # Convert /api/users/123 to /api/users/{id}
65
+ # Convert /api/users/123/posts/456 to /api/users/{id}/posts/{post_id}
66
+ segments = path.split("/")
67
+ normalized = []
68
+ param_counts = Hash.new(0)
69
+
70
+ segments.each_with_index do |segment, index|
71
+ if segment.match?(/^\d+$/)
72
+ # This is an ID - figure out what to call it
73
+ prev_segment = segments[index - 1]
74
+ if prev_segment
75
+ # Singularize: users -> user_id, posts -> post_id
76
+ param_name = singularize(prev_segment) + "_id"
77
+ # Handle multiple params of same type
78
+ if param_counts[param_name] > 0
79
+ param_name = "#{param_name}_#{param_counts[param_name] + 1}"
80
+ end
81
+ param_counts[param_name] += 1
82
+ # First occurrence of a resource ID is just {id} for cleaner paths
83
+ param_name = "id" if param_counts[param_name] == 1 && normalized.size == segments.size - 2
84
+ normalized << "{#{param_name}}"
85
+ else
86
+ normalized << "{id}"
87
+ end
88
+ else
89
+ normalized << segment
90
+ end
91
+ end
92
+
93
+ normalized.join("/")
94
+ end
95
+
96
+ def singularize(word)
97
+ # Basic singularization - in Rails app, would use ActiveSupport
98
+ if word.end_with?("ies")
99
+ word[0..-4] + "y"
100
+ elsif word.end_with?("ses", "xes", "zes", "ches", "shes")
101
+ word[0..-3]
102
+ elsif word.end_with?("s") && !word.end_with?("ss")
103
+ word[0..-2]
104
+ else
105
+ word
106
+ end
107
+ end
108
+
109
+ def extract_parameters(request, normalized_path)
110
+ params = []
111
+
112
+ # Path parameters
113
+ normalized_path.scan(/\{(\w+)\}/).flatten.each do |param_name|
114
+ params << {
115
+ name: param_name,
116
+ in: "path",
117
+ required: true,
118
+ schema: {type: "string"}
119
+ }
120
+ end
121
+
122
+ # Query parameters
123
+ if request.query_parameters.any?
124
+ request.query_parameters.each do |name, value|
125
+ params << {
126
+ name: name.to_s,
127
+ in: "query",
128
+ required: false,
129
+ schema: infer_schema_type(value)
130
+ }
131
+ end
132
+ end
133
+
134
+ # Note: Authorization header is handled via security schemes, not parameters
135
+
136
+ params
137
+ end
138
+
139
+ def has_authorization_header?(request)
140
+ !!(request.headers["Authorization"] || request.headers["HTTP_AUTHORIZATION"])
141
+ end
142
+
143
+ def infer_schema_type(value)
144
+ case value
145
+ when Integer then {type: "integer"}
146
+ when Float then {type: "number"}
147
+ when TrueClass, FalseClass then {type: "boolean"}
148
+ when Array then {type: "array", items: {type: "string"}}
149
+ else {type: "string"}
150
+ end
151
+ end
152
+
153
+ def extract_request_example(request)
154
+ return nil if request.request_method == "GET"
155
+
156
+ body = request.body.read
157
+ request.body.rewind
158
+ return nil if body.empty?
159
+
160
+ JSON.parse(body)
161
+ rescue JSON::ParserError
162
+ nil
163
+ end
164
+
165
+ def parse_body(body)
166
+ return nil if body.nil? || body.empty?
167
+ JSON.parse(body)
168
+ rescue JSON::ParserError
169
+ nil
170
+ end
171
+
172
+ def default_description(status)
173
+ {
174
+ 200 => "Successful response",
175
+ 201 => "Created",
176
+ 204 => "No content",
177
+ 400 => "Bad request",
178
+ 401 => "Unauthorized",
179
+ 403 => "Forbidden",
180
+ 404 => "Not found",
181
+ 422 => "Unprocessable entity",
182
+ 500 => "Internal server error"
183
+ }[status] || "Response"
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiMinitest
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openapi_minitest/version"
4
+ require_relative "openapi_minitest/configuration"
5
+ require_relative "openapi_minitest/result_collector"
6
+ require_relative "openapi_minitest/dsl"
7
+ require_relative "openapi_minitest/openapi/generator"
8
+
9
+ require_relative "openapi_minitest/railtie" if defined?(Rails::Railtie)
10
+
11
+ module OpenapiMinitest
12
+ class Error < StandardError; end
13
+ class SchemaValidationError < Error; end
14
+ end
@@ -0,0 +1,17 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "package-name": "openapi_minitest",
5
+ "include-component-in-tag": false,
6
+ "changelog-path": "CHANGELOG.md",
7
+ "release-type": "ruby",
8
+ "bump-minor-pre-major": true,
9
+ "bump-patch-for-minor-pre-major": true,
10
+ "draft": false,
11
+ "prerelease": false,
12
+ "version-file": "lib/openapi_minitest/version.rb",
13
+ "release-as": "0.1.0"
14
+ }
15
+ },
16
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
17
+ }
@@ -0,0 +1,4 @@
1
+ module OpenapiMinitest
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: openapi_minitest
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alex Koval
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json-schema
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '4.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bigdecimal
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: A Ruby gem that generates OpenAPI 3.0 documentation from Minitest integration
55
+ tests in Rails applications. Uses serializers as the single source of truth for
56
+ response schemas.
57
+ email:
58
+ - al3xander.koval@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".release-please-manifest.json"
64
+ - ".standard.yml"
65
+ - CHANGELOG.md
66
+ - CLAUDE.md
67
+ - Makefile
68
+ - README.md
69
+ - Rakefile
70
+ - lib/openapi_minitest.rb
71
+ - lib/openapi_minitest/configuration.rb
72
+ - lib/openapi_minitest/dsl.rb
73
+ - lib/openapi_minitest/openapi/generator.rb
74
+ - lib/openapi_minitest/railtie.rb
75
+ - lib/openapi_minitest/result_collector.rb
76
+ - lib/openapi_minitest/version.rb
77
+ - release-please-config.json
78
+ - sig/openapi_minitest.rbs
79
+ homepage: https://github.com/k0va1/openapi_minitest
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ homepage_uri: https://github.com/k0va1/openapi_minitest
84
+ source_code_uri: https://github.com/k0va1/openapi_minitest
85
+ changelog_uri: https://github.com/k0va1/openapi_minitest/blob/master/CHANGELOG.md
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: 3.2.0
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.6.9
101
+ specification_version: 4
102
+ summary: Generate OpenAPI documentation from Minitest integration tests
103
+ test_files: []