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 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,3 @@
1
+ module RailsContractSync
2
+ VERSION = "0.2.0"
3
+ 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: []