rails_contract_sync 0.2.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +157 -0
- data/lib/rails_contract_sync/builder.rb +90 -0
- data/lib/rails_contract_sync/configuration.rb +25 -0
- data/lib/rails_contract_sync/merger.rb +42 -0
- data/lib/rails_contract_sync/openapi_document.rb +62 -0
- data/lib/rails_contract_sync/railtie.rb +19 -0
- data/lib/rails_contract_sync/runtime/middleware.rb +75 -0
- data/lib/rails_contract_sync/runtime/observation_store.rb +27 -0
- data/lib/rails_contract_sync/runtime/route_resolver.rb +21 -0
- data/lib/rails_contract_sync/schema_inferrer.rb +64 -0
- data/lib/rails_contract_sync/static/params_extractor.rb +78 -0
- data/lib/rails_contract_sync/static/route_extractor.rb +29 -0
- data/lib/rails_contract_sync/version.rb +3 -0
- data/lib/rails_contract_sync.rb +15 -0
- data/lib/tasks/rails_contract_sync.rake +44 -0
- metadata +146 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 751fea8841b43d6858240cdfdda225ac84208f87c189983bf5cdefbda6f8e8c1
|
|
4
|
+
data.tar.gz: '088b08bbfc5150f9831121f9009bfe8bd07858ca399c73eefc84768790d93a71'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e247f9f52088a834beb06a5f9b934ac42c5d9bc3fbc9ae7086a0b8e85c2141ad9813d066fc45e7a71397aa62a31fb314a134e7eff4cc258d89beb4768bd01bf9
|
|
7
|
+
data.tar.gz: 6d5aa091d2897944443ee56f301cdd72d482230634dbe87856dca1ebe76abe35429871df16f24f8e067458c363fe9886f0bfee0c616faa2d83f56d94af681151
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dani
|
|
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,157 @@
|
|
|
1
|
+
# RailsSync
|
|
2
|
+
|
|
3
|
+
**Keep an OpenAPI 3.1 contract for your Rails JSON API in sync — automatically.**
|
|
4
|
+
|
|
5
|
+
RailsSync produces and maintains a single committed `openapi.yml` for your Rails API by combining two sources of truth:
|
|
6
|
+
|
|
7
|
+
- **Static introspection** — reads your routes and `params.require/permit` declarations (via [Prism](https://github.com/ruby/prism)) to lay down the endpoint + request-parameter skeleton, with zero test runs.
|
|
8
|
+
- **Runtime observation** — a lightweight Rack middleware records the *actual* JSON responses your app returns (in your test suite, or while you click around in development) and fills in real response schemas.
|
|
9
|
+
|
|
10
|
+
The two are merged into one committed file that is:
|
|
11
|
+
|
|
12
|
+
- **Idempotent** — re-run it anytime; the output is byte-stable, so diffs show only real API changes.
|
|
13
|
+
- **Prose-preserving** — your hand-written `summary`/`description`/`tags` are never clobbered by a regeneration.
|
|
14
|
+
- **Honest** — endpoints you haven't exercised yet are flagged, not faked.
|
|
15
|
+
|
|
16
|
+
Because the runtime layer reads the response *bytes*, RailsSync is **serializer-agnostic** — it doesn't care whether you use ActiveModel::Serializers, Jbuilder, Blueprinter, Alba, or plain `render json:`.
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
You changed an endpoint. Now your OpenAPI doc is a lie — until someone remembers to hand-edit it. Hand-written API specs rot; fully manual DSLs are tedious; and pure static analysis can't see what your serializers actually emit at runtime. RailsSync splits the difference: static analysis gives you an instant, zero-setup skeleton, and your existing tests (or a few minutes of clicking) supply the real response shapes.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Add it to your Gemfile — typically in the development and test groups, since that's where the contract is generated:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
group :development, :test do
|
|
28
|
+
gem "rails_sync"
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bundle install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
Three steps.
|
|
41
|
+
|
|
42
|
+
### 1. Generate the static skeleton
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bin/rails rails_sync:generate
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Reads your routes and strong-params and writes `openapi.yml` with paths, HTTP verbs, and request-body parameters. No response schemas yet — that's the next step.
|
|
49
|
+
|
|
50
|
+
### 2. Capture real responses
|
|
51
|
+
|
|
52
|
+
Run your app with `RAILS_SYNC=1` so the capture middleware is active:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
RAILS_SYNC=1 bundle exec rspec # capture from your request/system specs
|
|
56
|
+
# or
|
|
57
|
+
RAILS_SYNC=1 bin/rails server # then exercise the app by hand
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every JSON response is recorded to `tmp/rails_sync/observations.jsonl`. The middleware only mounts when `RAILS_SYNC` is set, so it never runs in production by accident.
|
|
61
|
+
|
|
62
|
+
### 3. Build the full contract
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bin/rails rails_sync:build
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Infers response schemas from the captured traffic, merges them with the static skeleton **and** with any descriptions you've added to `openapi.yml` by hand, and writes the result back. Commit `openapi.yml`.
|
|
69
|
+
|
|
70
|
+
Re-run `rails_sync:build` whenever your API changes. Stale endpoints (present in the file but no longer in your routes) are tagged `x-rails-sync-stale: true` rather than silently deleted.
|
|
71
|
+
|
|
72
|
+
## What the output looks like
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
openapi: 3.1.0
|
|
76
|
+
info:
|
|
77
|
+
title: API
|
|
78
|
+
version: 1.0.0
|
|
79
|
+
paths:
|
|
80
|
+
"/users":
|
|
81
|
+
post:
|
|
82
|
+
requestBody:
|
|
83
|
+
content:
|
|
84
|
+
application/json:
|
|
85
|
+
schema:
|
|
86
|
+
type: object
|
|
87
|
+
properties:
|
|
88
|
+
user:
|
|
89
|
+
type: object
|
|
90
|
+
properties:
|
|
91
|
+
name:
|
|
92
|
+
type: string
|
|
93
|
+
responses:
|
|
94
|
+
"201":
|
|
95
|
+
description: "" # add your own prose here — it survives rebuilds
|
|
96
|
+
content:
|
|
97
|
+
application/json:
|
|
98
|
+
schema:
|
|
99
|
+
type: object
|
|
100
|
+
properties:
|
|
101
|
+
id: { type: integer }
|
|
102
|
+
name: { type: string }
|
|
103
|
+
required: [id, name]
|
|
104
|
+
"/users/{id}":
|
|
105
|
+
get:
|
|
106
|
+
responses:
|
|
107
|
+
"200":
|
|
108
|
+
description: ""
|
|
109
|
+
content:
|
|
110
|
+
application/json:
|
|
111
|
+
schema:
|
|
112
|
+
type: object
|
|
113
|
+
properties:
|
|
114
|
+
id: { type: integer }
|
|
115
|
+
name: { type: string }
|
|
116
|
+
required: [id, name]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Point Swagger UI, `openapi-typescript`, Postman, or any OpenAPI 3.1 tool at this file.
|
|
120
|
+
|
|
121
|
+
## How it works
|
|
122
|
+
|
|
123
|
+
| Layer | What it does |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `Static::RouteExtractor` | Maps `Rails.application.routes` to OpenAPI paths (`/users/:id` → `/users/{id}`). |
|
|
126
|
+
| `Static::ParamsExtractor` | Parses controllers with Prism to read `params.require(...).permit(...)` (best-effort). |
|
|
127
|
+
| `Runtime::Middleware` | Env-gated Rack middleware; records real request params + response bodies. |
|
|
128
|
+
| `SchemaInferrer` | Turns observed JSON into JSON Schema, widening types across observations. |
|
|
129
|
+
| `Merger` | Reconciles static + observed + your existing file; preserves prose; idempotent. |
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
RailsSync.configuration.output_path # default: "openapi.yml"
|
|
135
|
+
RailsSync.configuration.observations_path # default: "tmp/rails_sync/observations.jsonl"
|
|
136
|
+
RailsSync.configuration.enabled? # true when ENV["RAILS_SYNC"] is truthy
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Scope & limitations (v1)
|
|
140
|
+
|
|
141
|
+
RailsSync is deliberately focused. It does **not** try to do everything:
|
|
142
|
+
|
|
143
|
+
- **JSON REST controllers only** (`ActionController` / `ActionController::API`). No GraphQL or Grape.
|
|
144
|
+
- **Static strong-params reading is best-effort.** It handles literal `permit` arguments; conditional or metaprogrammed params are simply filled in by the runtime layer the first time a request hits that endpoint.
|
|
145
|
+
- **Response schemas reflect the traffic you capture.** Coverage equals what your tests or manual usage exercise — an endpoint you never call won't get a response schema.
|
|
146
|
+
- **Not in scope (yet):** breaking-change / contract diffing in CI, and over-the-air bundle delivery. The committed `openapi.yml` is designed to be the seed for the former.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
bundle install
|
|
152
|
+
bundle exec rspec
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module RailsContractSync
|
|
4
|
+
class Builder
|
|
5
|
+
def initialize(route_set:, controller_sources: {}, observations: [])
|
|
6
|
+
@route_set = route_set
|
|
7
|
+
@controller_sources = controller_sources
|
|
8
|
+
@observations = observations
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build_fresh
|
|
12
|
+
doc = OpenAPIDocument.new
|
|
13
|
+
routes = Static::RouteExtractor.new(@route_set).extract
|
|
14
|
+
params_by_controller = extract_params
|
|
15
|
+
|
|
16
|
+
routes.each do |route|
|
|
17
|
+
op = { "responses" => {} }
|
|
18
|
+
add_request_body(op, route, params_by_controller)
|
|
19
|
+
add_observed(op, route)
|
|
20
|
+
op["responses"]["default"] = { "description" => "" } if op["responses"].empty?
|
|
21
|
+
doc.set_operation(route[:path], route[:verb], op)
|
|
22
|
+
end
|
|
23
|
+
doc
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def extract_params
|
|
29
|
+
@controller_sources.transform_values { |src| Static::ParamsExtractor.extract(src) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add_request_body(op, route, params_by_controller)
|
|
33
|
+
tree = params_by_controller.dig(route[:controller], route[:action])
|
|
34
|
+
static_schema = tree ? tree_to_schema(tree) : nil
|
|
35
|
+
runtime_schema = observed_request_schema(route)
|
|
36
|
+
schema = [static_schema, runtime_schema].compact.reduce(nil) { |a, s| a ? SchemaInferrer.merge(a, s) : s }
|
|
37
|
+
return if schema.nil?
|
|
38
|
+
|
|
39
|
+
op["requestBody"] = { "content" => { "application/json" => { "schema" => schema } } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def observed_request_schema(route)
|
|
43
|
+
bodies = matching(route).map { |o| o.dig("request", "params") }.compact
|
|
44
|
+
bodies.empty? ? nil : SchemaInferrer.infer_all(bodies)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_observed(op, route)
|
|
48
|
+
matching(route).group_by { |o| o.dig("response", "status") }.each do |status, group|
|
|
49
|
+
bodies = group.map { |o| o.dig("response", "body") }
|
|
50
|
+
op["responses"][status.to_s] = {
|
|
51
|
+
"description" => "",
|
|
52
|
+
"content" => { "application/json" => { "schema" => SchemaInferrer.infer_all(bodies) } }
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def matching(route)
|
|
58
|
+
@observations.select { |o| o["verb"] == route[:verb] && o["path_template"] == route[:path] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tree_to_schema(tree)
|
|
62
|
+
case tree
|
|
63
|
+
when nil then {}
|
|
64
|
+
when Array then { "type" => "array", "items" => tree_to_schema(tree.first) }
|
|
65
|
+
when Hash
|
|
66
|
+
props = tree.transform_values { |v| tree_to_schema(v) }
|
|
67
|
+
{ "type" => "object", "properties" => props }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
def generate(route_set:, controller_sources:, output_path:, prune: false)
|
|
75
|
+
write_merged(route_set: route_set, controller_sources: controller_sources, observations: [], output_path: output_path, prune: prune)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build(route_set:, controller_sources:, observation_store:, output_path:, prune: false)
|
|
79
|
+
write_merged(route_set: route_set, controller_sources: controller_sources, observations: observation_store.all, output_path: output_path, prune: prune)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def write_merged(route_set:, controller_sources:, observations:, output_path:, prune:)
|
|
83
|
+
fresh = Builder.new(route_set: route_set, controller_sources: controller_sources, observations: observations).build_fresh
|
|
84
|
+
existing = File.exist?(output_path) ? OpenAPIDocument.load_file(output_path) : nil
|
|
85
|
+
merged = Merger.merge(existing, fresh, prune: prune)
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(File.expand_path(output_path)))
|
|
87
|
+
merged.write(output_path)
|
|
88
|
+
merged
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module RailsContractSync
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :output_path, :observations_path
|
|
4
|
+
|
|
5
|
+
def initialize
|
|
6
|
+
@output_path = "openapi.yml"
|
|
7
|
+
@observations_path = "tmp/rails_contract_sync/observations.jsonl"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def enabled?
|
|
11
|
+
v = ENV["RAILS_CONTRACT_SYNC"]
|
|
12
|
+
!v.nil? && !v.empty? && v != "0" && v.downcase != "false"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def observation_store
|
|
16
|
+
Runtime::ObservationStore.new(observations_path)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def configuration
|
|
23
|
+
@configuration ||= Configuration.new
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module RailsContractSync
|
|
2
|
+
module Merger
|
|
3
|
+
HUMAN_OP_KEYS = %w[summary description tags].freeze
|
|
4
|
+
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def merge(existing, fresh, prune: false)
|
|
8
|
+
result = fresh.to_h
|
|
9
|
+
result_paths = result["paths"] ||= {}
|
|
10
|
+
return OpenAPIDocument.new(result) if existing.nil?
|
|
11
|
+
|
|
12
|
+
existing_h = existing.to_h
|
|
13
|
+
result["info"] = existing_h["info"] if existing_h["info"]
|
|
14
|
+
|
|
15
|
+
(existing_h["paths"] || {}).each do |path, ops|
|
|
16
|
+
ops.each do |verb, existing_op|
|
|
17
|
+
target = result_paths.dig(path, verb)
|
|
18
|
+
if target
|
|
19
|
+
HUMAN_OP_KEYS.each { |k| target[k] = existing_op[k] if existing_op.key?(k) }
|
|
20
|
+
preserve_descriptions(existing_op["responses"], target["responses"])
|
|
21
|
+
elsif !prune
|
|
22
|
+
(result_paths[path] ||= {})[verb] = existing_op.merge("x-rails-contract-sync-stale" => true)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
OpenAPIDocument.new(result)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Recursively copy "description" from old schema nodes onto matching new ones.
|
|
31
|
+
def preserve_descriptions(old_node, new_node)
|
|
32
|
+
return unless old_node.is_a?(Hash) && new_node.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
new_node["description"] = old_node["description"] if old_node.key?("description")
|
|
35
|
+
old_node.each do |key, old_child|
|
|
36
|
+
next if key == "description"
|
|
37
|
+
|
|
38
|
+
preserve_descriptions(old_child, new_node[key])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module RailsContractSync
|
|
4
|
+
class OpenAPIDocument
|
|
5
|
+
def self.load_file(path)
|
|
6
|
+
return new unless File.exist?(path)
|
|
7
|
+
|
|
8
|
+
new(YAML.safe_load_file(path))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(hash = nil)
|
|
12
|
+
@doc = deep_dup(hash) || skeleton
|
|
13
|
+
@doc["paths"] ||= {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def paths
|
|
17
|
+
@doc["paths"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def operation(path, verb)
|
|
21
|
+
paths.dig(path, verb.to_s.downcase)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def set_operation(path, verb, op_hash)
|
|
25
|
+
(paths[path] ||= {})[verb.to_s.downcase] = op_hash
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
deep_sort(deep_dup(@doc))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_yaml
|
|
33
|
+
to_h.to_yaml
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write(path)
|
|
37
|
+
File.write(path, to_yaml)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def skeleton
|
|
43
|
+
{ "openapi" => "3.1.0", "info" => { "title" => "API", "version" => "1.0.0" }, "paths" => {} }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def deep_dup(obj)
|
|
47
|
+
case obj
|
|
48
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
49
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
50
|
+
else obj
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def deep_sort(obj)
|
|
55
|
+
case obj
|
|
56
|
+
when Hash then obj.keys.sort.each_with_object({}) { |k, h| h[k] = deep_sort(obj[k]) }
|
|
57
|
+
when Array then obj.map { |v| deep_sort(v) }
|
|
58
|
+
else obj
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module RailsContractSync
|
|
2
|
+
class Railtie < Rails::Railtie
|
|
3
|
+
initializer "rails_contract_sync.middleware" do |app|
|
|
4
|
+
if RailsContractSync.configuration.enabled?
|
|
5
|
+
resolver = Runtime::RouteResolver.new(app.routes)
|
|
6
|
+
app.middleware.use(
|
|
7
|
+
Runtime::Middleware,
|
|
8
|
+
store: RailsContractSync.configuration.observation_store,
|
|
9
|
+
route_resolver: resolver,
|
|
10
|
+
enabled: true
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
rake_tasks do
|
|
16
|
+
load File.expand_path("../tasks/rails_contract_sync.rake", __dir__)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module RailsContractSync
|
|
4
|
+
module Runtime
|
|
5
|
+
class Middleware
|
|
6
|
+
def initialize(app, store:, route_resolver:, enabled: true)
|
|
7
|
+
@app = app
|
|
8
|
+
@store = store
|
|
9
|
+
@route_resolver = route_resolver
|
|
10
|
+
@enabled = enabled
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
status, headers, response = @app.call(env)
|
|
15
|
+
return [status, headers, response] unless enabled?
|
|
16
|
+
|
|
17
|
+
content_type = headers["Content-Type"] || headers["content-type"]
|
|
18
|
+
return [status, headers, response] unless content_type&.include?("application/json")
|
|
19
|
+
|
|
20
|
+
# Buffer the body so both the recorder and the downstream server can read it.
|
|
21
|
+
parts = []
|
|
22
|
+
response.each { |part| parts << part }
|
|
23
|
+
response.close if response.respond_to?(:close)
|
|
24
|
+
|
|
25
|
+
record(env, status, headers, content_type, parts)
|
|
26
|
+
[status, headers, parts]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def enabled?
|
|
32
|
+
@enabled
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def record(env, status, headers, content_type, parts)
|
|
36
|
+
template = @route_resolver.call(env)
|
|
37
|
+
return if template.nil?
|
|
38
|
+
|
|
39
|
+
@store.append(
|
|
40
|
+
"verb" => env["REQUEST_METHOD"],
|
|
41
|
+
"path_template" => template,
|
|
42
|
+
"request" => {
|
|
43
|
+
"content_type" => env["CONTENT_TYPE"],
|
|
44
|
+
"params" => request_params(env)
|
|
45
|
+
},
|
|
46
|
+
"response" => {
|
|
47
|
+
"status" => status,
|
|
48
|
+
"content_type" => content_type,
|
|
49
|
+
"body" => safe_parse(parts.join)
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def request_params(env)
|
|
57
|
+
input = env["rack.input"]
|
|
58
|
+
return {} unless input
|
|
59
|
+
|
|
60
|
+
raw = input.read
|
|
61
|
+
input.rewind if input.respond_to?(:rewind)
|
|
62
|
+
return {} if raw.nil? || raw.empty?
|
|
63
|
+
|
|
64
|
+
parsed = safe_parse(raw)
|
|
65
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def safe_parse(raw)
|
|
69
|
+
JSON.parse(raw)
|
|
70
|
+
rescue JSON::ParserError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module RailsContractSync
|
|
5
|
+
module Runtime
|
|
6
|
+
class ObservationStore
|
|
7
|
+
def initialize(path)
|
|
8
|
+
@path = path
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def append(hash)
|
|
12
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
13
|
+
File.open(@path, "a") { |f| f.puts(JSON.generate(hash)) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def all
|
|
17
|
+
return [] unless File.exist?(@path)
|
|
18
|
+
|
|
19
|
+
File.readlines(@path, chomp: true).reject(&:empty?).map { |line| JSON.parse(line) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def clear
|
|
23
|
+
File.delete(@path) if File.exist?(@path)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module RailsContractSync
|
|
2
|
+
module Runtime
|
|
3
|
+
class RouteResolver
|
|
4
|
+
def initialize(route_set)
|
|
5
|
+
@routes = Static::RouteExtractor.new(route_set).extract
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
params = env["action_dispatch.request.path_parameters"]
|
|
10
|
+
return nil unless params
|
|
11
|
+
|
|
12
|
+
controller = params[:controller]
|
|
13
|
+
action = params[:action]
|
|
14
|
+
match = @routes.find do |r|
|
|
15
|
+
r[:controller] == controller && r[:action] == action && r[:verb] == env["REQUEST_METHOD"]
|
|
16
|
+
end
|
|
17
|
+
match&.fetch(:path)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module RailsContractSync
|
|
2
|
+
module SchemaInferrer
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def infer(value)
|
|
6
|
+
case value
|
|
7
|
+
when nil then { "type" => "null" }
|
|
8
|
+
when true, false then { "type" => "boolean" }
|
|
9
|
+
when Integer then { "type" => "integer" }
|
|
10
|
+
when Float then { "type" => "number" }
|
|
11
|
+
when String then { "type" => "string" }
|
|
12
|
+
when Array then infer_array(value)
|
|
13
|
+
when Hash then infer_object(value)
|
|
14
|
+
else { "type" => "string" }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def infer_array(array)
|
|
19
|
+
{ "type" => "array", "items" => infer_all(array) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def infer_object(hash)
|
|
23
|
+
props = {}
|
|
24
|
+
hash.each { |k, v| props[k.to_s] = infer(v) }
|
|
25
|
+
{ "type" => "object", "properties" => props, "required" => hash.keys.map(&:to_s).sort }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def infer_all(values)
|
|
29
|
+
values.map { |v| infer(v) }.reduce(nil) { |acc, s| acc ? merge(acc, s) : s } || {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def merge(a, b)
|
|
33
|
+
a ||= {}
|
|
34
|
+
b ||= {}
|
|
35
|
+
return b if a.empty?
|
|
36
|
+
return a if b.empty?
|
|
37
|
+
|
|
38
|
+
types = (Array(a["type"]) | Array(b["type"])).sort
|
|
39
|
+
# Widen integer + number to just number
|
|
40
|
+
if types == ["integer", "number"]
|
|
41
|
+
types = ["number"]
|
|
42
|
+
end
|
|
43
|
+
result = { "type" => types.length == 1 ? types.first : types }
|
|
44
|
+
result.merge!(merge_object(a, b)) if types.include?("object")
|
|
45
|
+
result["items"] = merge(a["items"] || {}, b["items"] || {}) if types.include?("array")
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def merge_object(a, b)
|
|
50
|
+
props_a = a["properties"] || {}
|
|
51
|
+
props_b = b["properties"] || {}
|
|
52
|
+
merged = {}
|
|
53
|
+
(props_a.keys | props_b.keys).each do |k|
|
|
54
|
+
merged[k] = if props_a[k] && props_b[k]
|
|
55
|
+
merge(props_a[k], props_b[k])
|
|
56
|
+
else
|
|
57
|
+
props_a[k] || props_b[k]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
required = ((a["required"] || []) & (b["required"] || [])).sort
|
|
61
|
+
{ "properties" => merged, "required" => required }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module RailsContractSync
|
|
4
|
+
module Static
|
|
5
|
+
module ParamsExtractor
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def extract(source)
|
|
9
|
+
program = Prism.parse(source).value
|
|
10
|
+
actions = {}
|
|
11
|
+
each_def(program) do |def_node|
|
|
12
|
+
tree = first_permit_tree(def_node.body)
|
|
13
|
+
actions[def_node.name.to_s] = tree if tree
|
|
14
|
+
end
|
|
15
|
+
actions
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each_def(node, &block)
|
|
19
|
+
return unless node
|
|
20
|
+
|
|
21
|
+
yield node if node.is_a?(Prism::DefNode)
|
|
22
|
+
node.compact_child_nodes.each { |child| each_def(child, &block) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Depth-first: return the tree for the first `permit` call found.
|
|
26
|
+
def first_permit_tree(node)
|
|
27
|
+
return nil unless node
|
|
28
|
+
|
|
29
|
+
if node.is_a?(Prism::CallNode) && node.name == :permit
|
|
30
|
+
tree = permit_args_to_tree(node.arguments)
|
|
31
|
+
key = require_key(node.receiver)
|
|
32
|
+
return key ? { key => tree } : tree
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
node.compact_child_nodes.each do |child|
|
|
36
|
+
found = first_permit_tree(child)
|
|
37
|
+
return found if found
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def require_key(receiver)
|
|
43
|
+
return nil unless receiver.is_a?(Prism::CallNode) && receiver.name == :require
|
|
44
|
+
|
|
45
|
+
arg = receiver.arguments&.arguments&.first
|
|
46
|
+
arg.is_a?(Prism::SymbolNode) ? arg.unescaped : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def permit_args_to_tree(arguments_node)
|
|
50
|
+
tree = {}
|
|
51
|
+
(arguments_node&.arguments || []).each do |arg|
|
|
52
|
+
case arg
|
|
53
|
+
when Prism::SymbolNode
|
|
54
|
+
tree[arg.unescaped] = nil
|
|
55
|
+
when Prism::KeywordHashNode, Prism::HashNode
|
|
56
|
+
arg.elements.each do |assoc|
|
|
57
|
+
next unless assoc.is_a?(Prism::AssocNode) && assoc.key.is_a?(Prism::SymbolNode)
|
|
58
|
+
|
|
59
|
+
tree[assoc.key.unescaped] = value_to_tree(assoc.value)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
tree
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def value_to_tree(value)
|
|
67
|
+
return nil unless value.is_a?(Prism::ArrayNode)
|
|
68
|
+
return [nil] if value.elements.empty?
|
|
69
|
+
|
|
70
|
+
nested = {}
|
|
71
|
+
value.elements.each do |el|
|
|
72
|
+
nested[el.unescaped] = nil if el.is_a?(Prism::SymbolNode)
|
|
73
|
+
end
|
|
74
|
+
nested.empty? ? nil : nested
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module RailsContractSync
|
|
2
|
+
module Static
|
|
3
|
+
class RouteExtractor
|
|
4
|
+
VERBS = %w[GET POST PUT PATCH DELETE].freeze
|
|
5
|
+
|
|
6
|
+
def initialize(route_set)
|
|
7
|
+
@route_set = route_set
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def extract
|
|
11
|
+
@route_set.routes.filter_map do |route|
|
|
12
|
+
controller = route.defaults[:controller]
|
|
13
|
+
action = route.defaults[:action]
|
|
14
|
+
next if controller.nil? || action.nil?
|
|
15
|
+
|
|
16
|
+
verb = VERBS.find { |m| route.verb.to_s.include?(m) }
|
|
17
|
+
next if verb.nil?
|
|
18
|
+
|
|
19
|
+
spec = route.path.spec.to_s.sub(/\(\.:format\)\z/, "")
|
|
20
|
+
{ verb: verb,
|
|
21
|
+
path: spec.gsub(/:([a-z_]+)/) { "{#{Regexp.last_match(1)}}" },
|
|
22
|
+
controller: controller,
|
|
23
|
+
action: action,
|
|
24
|
+
path_params: spec.scan(/:([a-z_]+)/).flatten - ["format"] }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require_relative "rails_contract_sync/version"
|
|
2
|
+
require_relative "rails_contract_sync/schema_inferrer"
|
|
3
|
+
require_relative "rails_contract_sync/openapi_document"
|
|
4
|
+
require_relative "rails_contract_sync/static/route_extractor"
|
|
5
|
+
require_relative "rails_contract_sync/static/params_extractor"
|
|
6
|
+
require_relative "rails_contract_sync/runtime/observation_store"
|
|
7
|
+
require_relative "rails_contract_sync/runtime/middleware"
|
|
8
|
+
require_relative "rails_contract_sync/merger"
|
|
9
|
+
require_relative "rails_contract_sync/builder"
|
|
10
|
+
require_relative "rails_contract_sync/configuration"
|
|
11
|
+
require_relative "rails_contract_sync/runtime/route_resolver"
|
|
12
|
+
require_relative "rails_contract_sync/railtie" if defined?(Rails::Railtie)
|
|
13
|
+
|
|
14
|
+
module RailsContractSync
|
|
15
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
namespace :rails_contract_sync do
|
|
2
|
+
def rails_contract_sync_controller_sources
|
|
3
|
+
Dir[Rails.root.join("app/controllers/**/*.rb").to_s].each_with_object({}) do |path, h|
|
|
4
|
+
name = path.sub("#{Rails.root.join('app/controllers')}/", "").sub(/_controller\.rb\z/, "")
|
|
5
|
+
h[name] = File.read(path)
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
desc "Generate the static OpenAPI skeleton"
|
|
10
|
+
task generate: :environment do
|
|
11
|
+
sources = rails_contract_sync_controller_sources
|
|
12
|
+
output = RailsContractSync.configuration.output_path
|
|
13
|
+
|
|
14
|
+
result = RailsContractSync.generate(
|
|
15
|
+
route_set: Rails.application.routes,
|
|
16
|
+
controller_sources: sources,
|
|
17
|
+
output_path: output
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
paths = result.paths.keys
|
|
21
|
+
puts "rails_contract_sync: wrote #{output} (#{paths.size} paths from #{sources.size} controllers)"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc "Build the contract from static analysis + captured observations"
|
|
25
|
+
task build: :environment do
|
|
26
|
+
sources = rails_contract_sync_controller_sources
|
|
27
|
+
store = RailsContractSync.configuration.observation_store
|
|
28
|
+
output = RailsContractSync.configuration.output_path
|
|
29
|
+
observations = store.all
|
|
30
|
+
|
|
31
|
+
result = RailsContractSync.build(
|
|
32
|
+
route_set: Rails.application.routes,
|
|
33
|
+
controller_sources: sources,
|
|
34
|
+
observation_store: store,
|
|
35
|
+
output_path: output
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
paths = result.paths.keys
|
|
39
|
+
puts "rails_contract_sync: wrote #{output} (#{paths.size} paths, #{observations.size} observations, #{sources.size} controllers)"
|
|
40
|
+
if observations.empty?
|
|
41
|
+
puts "rails_contract_sync: hint — run your test suite with RAILS_CONTRACT_SYNC=1 to capture response observations"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails_contract_sync
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- dani
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: prism
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.24'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.24'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rack
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.2'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rails
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '7.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '7.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rspec
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.13'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.13'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rack-test
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '2.1'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '2.1'
|
|
96
|
+
description: |-
|
|
97
|
+
RailsContractSync produces and maintains a single committed openapi.yml for a Rails
|
|
98
|
+
JSON API by combining static route/strong-params introspection with runtime
|
|
99
|
+
observation of real responses via a Rack middleware. The result is idempotent,
|
|
100
|
+
preserves hand-written documentation, and is serializer-agnostic.
|
|
101
|
+
email:
|
|
102
|
+
- danielsilas23@yahoo.com
|
|
103
|
+
executables: []
|
|
104
|
+
extensions: []
|
|
105
|
+
extra_rdoc_files: []
|
|
106
|
+
files:
|
|
107
|
+
- LICENSE
|
|
108
|
+
- README.md
|
|
109
|
+
- lib/rails_contract_sync.rb
|
|
110
|
+
- lib/rails_contract_sync/builder.rb
|
|
111
|
+
- lib/rails_contract_sync/configuration.rb
|
|
112
|
+
- lib/rails_contract_sync/merger.rb
|
|
113
|
+
- lib/rails_contract_sync/openapi_document.rb
|
|
114
|
+
- lib/rails_contract_sync/railtie.rb
|
|
115
|
+
- lib/rails_contract_sync/runtime/middleware.rb
|
|
116
|
+
- lib/rails_contract_sync/runtime/observation_store.rb
|
|
117
|
+
- lib/rails_contract_sync/runtime/route_resolver.rb
|
|
118
|
+
- lib/rails_contract_sync/schema_inferrer.rb
|
|
119
|
+
- lib/rails_contract_sync/static/params_extractor.rb
|
|
120
|
+
- lib/rails_contract_sync/static/route_extractor.rb
|
|
121
|
+
- lib/rails_contract_sync/version.rb
|
|
122
|
+
- lib/tasks/rails_contract_sync.rake
|
|
123
|
+
homepage: https://github.com/yv-soft/rails-sync
|
|
124
|
+
licenses:
|
|
125
|
+
- MIT
|
|
126
|
+
metadata:
|
|
127
|
+
source_code_uri: https://github.com/yv-soft/rails-sync
|
|
128
|
+
rubygems_mfa_required: 'true'
|
|
129
|
+
rdoc_options: []
|
|
130
|
+
require_paths:
|
|
131
|
+
- lib
|
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: 3.2.0
|
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
138
|
+
requirements:
|
|
139
|
+
- - ">="
|
|
140
|
+
- !ruby/object:Gem::Version
|
|
141
|
+
version: '0'
|
|
142
|
+
requirements: []
|
|
143
|
+
rubygems_version: 4.0.5
|
|
144
|
+
specification_version: 4
|
|
145
|
+
summary: Generate and maintain an OpenAPI 3.1 contract for a Rails JSON API.
|
|
146
|
+
test_files: []
|