gemstack-contract 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 464059a0e986382b8df8437de79a21a93a1ec1bfe78bb7031d4f19d8d7d64096
4
- data.tar.gz: 6afde6c7d3331e6d0dfca5ad6b6cbb124a1adbf21b30fc0b98277d857d9bece0
3
+ metadata.gz: a9e7d41b09fe8059a601c23c23530ac5c506048e0f09d731c1f2ddf6b8a58c14
4
+ data.tar.gz: 9b92665afcbf394cb38966cd55c2813b52d653fb8d27d948ab2500bcd30c91b5
5
5
  SHA512:
6
- metadata.gz: c51286be801007a60b6b84d057c4d94156d2a266d10df8372053e0e04bf26ca354eea43304477d82d2b4f2afdeb415698b13433a47cb12495f5a10a7d1c517e2
7
- data.tar.gz: 6fe47b65dc2bbb4dded311fe36dfd733f434f175baa5c0e8a25541ac100cdfda5b182b343375ca71e8ea3983bdf59027074b21995962d8e94c2c4a8bdc405b8f
6
+ metadata.gz: e74e8bf84277d7aaa98432101bcdbfef6ca89ef7412435c58379a0d947a9ae288a8985f8e20a7d2dcada1ce5042a035626903c354b9fd55b7748731059f2a926
7
+ data.tar.gz: 157597e572caf21b5f55dec855c567e69e8d6949b8d624ad0cd33cf758327a2f55111fe85b9cebec9e380f2f4c3f553d63bca30f5a13cbb929df1827f67fe0e6
data/CHANGELOG.md CHANGED
@@ -1,29 +1,5 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.5
3
+ ## 0.3.0
4
4
 
5
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
-
7
- ## 0.2.4
8
-
9
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
10
-
11
- ## 0.2.3
12
-
13
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
14
-
15
- ## 0.2.2
16
-
17
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
18
-
19
- ## 0.2.1
20
-
21
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
22
-
23
- ## 0.2.0
24
-
25
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
26
-
27
- ## 0.1.0
28
-
29
- First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
5
+ Merged into the gemstack gem; this version is a transition shim. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/README.md CHANGED
@@ -1,23 +1,13 @@
1
1
  # gemstack-contract
2
2
 
3
- GemStack contract: TypeScript types, API clients and OpenAPI from the backend.
3
+ **Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
4
4
 
5
- Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
- applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed together in that repository and released with the same
7
- version.
5
+ This version is a transition shim: it depends on `gemstack` and loads `gemstack/contract`, so
6
+ Gemfiles that still list `gemstack-contract` keep working. To finish upgrading, remove `gem "gemstack-contract"`
7
+ from your Gemfile (it's loaded by `require "gemstack"`).
8
8
 
9
- ## Installation
10
-
11
- Installed with the `gemstack` gem; you rarely need to add it yourself.
12
-
13
- ## Documentation
14
-
15
- - [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/typescript.md)
16
- - [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
17
- [Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
18
-
19
- Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
20
- (this gem lives in `gems/gemstack-contract`).
9
+ GemStack is a modular Ruby API framework for Next.js applications by
10
+ [Adware Technologies](https://www.adwaretech.com) — [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack).
21
11
 
22
12
  ## License
23
13
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # gemstack-contract is part of the gemstack gem since 0.3.0. This shim keeps Gemfiles
4
+ # that still list gemstack-contract working; remove the line from your Gemfile.
5
+ require "gemstack/contract"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstack-contract
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adware Technologies
@@ -11,47 +11,28 @@ cert_chain: []
11
11
  date: 1980-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: gemstack-core
14
+ name: gemstack
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - '='
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.2.5
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - '='
25
- - !ruby/object:Gem::Version
26
- version: 0.2.5
27
- - !ruby/object:Gem::Dependency
28
- name: gemstack-http
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - '='
19
+ version: 0.3.0
20
+ - - "<"
32
21
  - !ruby/object:Gem::Version
33
- version: 0.2.5
22
+ version: '1.0'
34
23
  type: :runtime
35
24
  prerelease: false
36
25
  version_requirements: !ruby/object:Gem::Requirement
37
26
  requirements:
38
- - - '='
27
+ - - ">="
39
28
  - !ruby/object:Gem::Version
40
- version: 0.2.5
41
- - !ruby/object:Gem::Dependency
42
- name: gemstack-schema
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - '='
46
- - !ruby/object:Gem::Version
47
- version: 0.2.5
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - '='
29
+ version: 0.3.0
30
+ - - "<"
53
31
  - !ruby/object:Gem::Version
54
- version: 0.2.5
32
+ version: '1.0'
33
+ description: Since GemStack 0.3.0, gemstack-contract is part of the gemstack gem.
34
+ This version only depends on gemstack and loads gemstack/contract, so Gemfiles that
35
+ still list it keep working.
55
36
  email:
56
37
  - gemstack26@gmail.com
57
38
  executables: []
@@ -61,21 +42,18 @@ files:
61
42
  - CHANGELOG.md
62
43
  - LICENSE.txt
63
44
  - README.md
64
- - lib/gemstack/contract.rb
65
- - lib/gemstack/contract/builder.rb
66
- - lib/gemstack/contract/docs.rb
67
- - lib/gemstack/contract/docs/index.html
68
- - lib/gemstack/contract/openapi.rb
69
- - lib/gemstack/contract/typescript.rb
45
+ - lib/gemstack-contract.rb
70
46
  homepage: https://github.com/gemstack-rb/gemstack
71
47
  licenses:
72
48
  - MIT
73
49
  metadata:
74
50
  rubygems_mfa_required: 'true'
75
- source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-contract
76
- changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-contract/CHANGELOG.md
51
+ source_code_uri: https://github.com/gemstack-rb/gemstack
52
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
77
53
  bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
78
54
  documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
55
+ post_install_message: gemstack-contract is now part of the gemstack gem. Remove `gem
56
+ "gemstack-contract"` from your Gemfile (it's loaded by `require "gemstack"`).
79
57
  rdoc_options: []
80
58
  require_paths:
81
59
  - lib
@@ -92,5 +70,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
70
  requirements: []
93
71
  rubygems_version: 4.0.20
94
72
  specification_version: 4
95
- summary: 'GemStack contract: TypeScript types, API clients and OpenAPI from the backend'
73
+ summary: Merged into the gemstack gem — remove gemstack-contract from your Gemfile
96
74
  test_files: []
@@ -1,168 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Contract
5
- # Turns the route table into a language-neutral contract (IR):
6
- #
7
- # {
8
- # api_path: "/api",
9
- # types: { "Product" => { fields: [{ name:, type:, nullable:, optional: }] }, ... },
10
- # resources: [{ name: "products", endpoints: [{ name: "list", verb: "GET", path: "/products",
11
- # params: [], body: nil, query: nil, response: { array: { ref: "Product" } } }] }],
12
- # warnings: [...]
13
- # }
14
- #
15
- # A type reference is { scalar: :string }, { ref: "Product" }, { array: ref },
16
- # { object: [fields] } (anonymous nested object) or { unknown: true }.
17
- #
18
- # Response conventions (overridable with `returns` in the controller):
19
- # index → [<Resource>Serializer], show/create/update → <Resource>Serializer,
20
- # destroy → no body, anything else → unknown (with a warning).
21
- class Builder
22
- # Names the generated TypeScript defines itself.
23
- RESERVED_TYPES = %w[Paginated PaginationMeta PaginationQuery RequestOptions].freeze
24
-
25
- METHOD_NAMES = { "index" => "list", "show" => "get", "create" => "create", "update" => "update",
26
- "destroy" => "delete" }.freeze
27
- VERB_PREFERENCE = %w[GET POST PATCH PUT DELETE].freeze
28
-
29
- def initialize(routes:, api_path:)
30
- @routes = routes
31
- @api_path = api_path.to_s
32
- @types = {}
33
- @warnings = []
34
- end
35
-
36
- def build
37
- result = build_contract
38
- clash = result[:types].keys & RESERVED_TYPES
39
- unless clash.empty?
40
- raise ConfigurationError, "API type name(s) #{clash.join(", ")} are reserved by the generated TypeScript; " \
41
- "rename the serializer/schema or set its type_name"
42
- end
43
- result
44
- end
45
-
46
- def build_contract
47
- resources = @routes.select(&:controller).group_by(&:controller).sort.filter_map do |controller_name, routes|
48
- controller = resolve(controller_name) or next
49
- endpoints = routes.group_by(&:action).map do |action, action_routes|
50
- endpoint(controller, action, action_routes)
51
- end
52
- { name: controller_name, endpoints: endpoints.compact.sort_by { |e| [e[:path], e[:name]] } }
53
- end
54
- { api_path: @api_path, types: @types.sort.to_h, resources: resources, warnings: @warnings }
55
- end
56
-
57
- private
58
-
59
- def resolve(controller_name)
60
- const = "#{Inflector.camelize(controller_name)}Controller"
61
- Object.const_get(const)
62
- rescue NameError
63
- @warnings << "#{const} is not defined; its routes are left out of the contract"
64
- nil
65
- end
66
-
67
- def endpoint(controller, action, routes)
68
- route = routes.min_by { |r| VERB_PREFERENCE.index(r.verb) || 99 }
69
- path = route.path.delete_prefix(@api_path)
70
- path = "/" if path.empty?
71
- schema = controller.input_schemas[action]
72
- input = schema && schema_ref(schema, "#{resource_name(controller)}#{Inflector.camelize(action)}Input")
73
- get = %w[GET HEAD].include?(route.verb)
74
- response = response_ref(controller, action)
75
- {
76
- name: METHOD_NAMES.fetch(action) { lower_camel(action) }, action: action, verb: route.verb, path: path,
77
- params: route.param_names.dup, body: get ? nil : input, query: get ? input : nil,
78
- response: response, paginated: response.is_a?(Hash) && response.key?(:page)
79
- }
80
- end
81
-
82
- def response_ref(controller, action)
83
- if controller.response_types.key?(action)
84
- type = controller.response_types[action]
85
- return type.nil? ? nil : type_ref(type)
86
- end
87
-
88
- serializer = conventional_serializer(controller)
89
- case action
90
- when "index" then serializer ? { array: serializer_ref(serializer) } : unknown(controller, action)
91
- when "show", "create", "update" then serializer ? serializer_ref(serializer) : unknown(controller, action)
92
- when "destroy" then nil
93
- else unknown(controller, action)
94
- end
95
- end
96
-
97
- def unknown(controller, action)
98
- @warnings << "#{controller.name}##{action}: response type unknown (add `returns :#{action}, SomeSerializer`)"
99
- { unknown: true }
100
- end
101
-
102
- # ProductsController → ProductSerializer (Admin::ProductsController tries Admin::ProductSerializer first).
103
- def conventional_serializer(controller)
104
- parts = controller.name.delete_suffix("Controller").split("::")
105
- singular = Inflector.singularize(parts.last)
106
- candidates = ["#{(parts[0...-1] + [singular]).join("::")}Serializer", "#{singular}Serializer"].uniq
107
- candidates.each do |name|
108
- klass = Object.const_get(name) if Object.const_defined?(name)
109
- return klass if klass.is_a?(Class) && klass <= Serializer
110
- rescue NameError
111
- next
112
- end
113
- nil
114
- end
115
-
116
- def resource_name(controller)
117
- Inflector.singularize(controller.name.delete_suffix("Controller").split("::").join)
118
- end
119
-
120
- def type_ref(type)
121
- case type
122
- when HTTP::Page::Type then { page: type_ref(type.item) }
123
- when Array then { array: type_ref(type.first) }
124
- when Class
125
- return serializer_ref(type) if type <= Serializer
126
- return schema_ref(type, type.name.to_s.split("::").join) if type <= Schema
127
-
128
- { scalar: Types.fetch(type).name }
129
- else { scalar: Types.fetch(type).name }
130
- end
131
- end
132
-
133
- def serializer_ref(serializer)
134
- name = serializer.type_name
135
- unless @types.key?(name)
136
- @types[name] = :pending # guards against recursive serializers
137
- fields = serializer.resolved_attributes.map do |attr|
138
- if attr[:type] == :json && !serializer.attributes_list[attr[:name]].type
139
- @warnings << "#{serializer.name}##{attr[:name]}: type unknown, emitted as unknown"
140
- end
141
- { name: attr[:name].to_s, type: type_ref(attr[:type]), nullable: attr[:nullable], optional: false }
142
- end
143
- @types[name] = { fields: fields }
144
- end
145
- { ref: name }
146
- end
147
-
148
- def schema_ref(schema, fallback_name)
149
- name = schema.type_name || fallback_name
150
- @types[name] ||= { fields: schema_fields(schema) }
151
- { ref: name }
152
- end
153
-
154
- def schema_fields(schema)
155
- schema.fields.values.map do |field|
156
- type = field.schema ? { object: schema_fields(field.schema) } : { scalar: field.type }
157
- type = { array: type } if field.array
158
- { name: field.name.to_s, type: type, nullable: field.nullable, optional: field.ts_optional? }
159
- end
160
- end
161
-
162
- def lower_camel(name)
163
- camel = Inflector.camelize(name)
164
- camel[0].downcase + camel[1..]
165
- end
166
- end
167
- end
168
- end
@@ -1,264 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>__TITLE__ API · GemStack</title>
7
- <style>
8
- :root { color-scheme: light dark; --bg: #fff; --fg: #16181d; --muted: #5b6170; --panel: #f4f5f8; --line: #e3e5ea;
9
- --get: #1f7a4d; --post: #2b59c3; --patch: #9a6700; --put: #7a3fb0; --delete: #c53030; --focus: #2b59c3; }
10
- @media (prefers-color-scheme: dark) { :root { --bg: #0f1115; --fg: #e6e8ee; --muted: #9aa0ad; --panel: #181c26;
11
- --line: #262b38; --get: #4cc38a; --post: #7ea2ff; --patch: #e0b44c; --put: #c49bf0; --delete: #ff7b7b; --focus: #7ea2ff; } }
12
- * { box-sizing: border-box; }
13
- body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
14
- header { position: sticky; top: 0; z-index: 2; display: flex; gap: 1rem; align-items: center; padding: .7rem 1rem;
15
- background: var(--bg); border-bottom: 1px solid var(--line); flex-wrap: wrap; }
16
- header h1 { font-size: 1.05rem; margin: 0; } header .muted { font-size: .85rem; }
17
- header input { flex: 1; min-width: 10rem; max-width: 24rem; }
18
- .layout { display: grid; grid-template-columns: 15rem minmax(0, 1fr); }
19
- nav { position: sticky; top: 3.3rem; align-self: start; max-height: calc(100vh - 3.3rem); overflow-y: auto; padding: 1rem;
20
- border-right: 1px solid var(--line); font-size: .9rem; }
21
- nav h2 { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 1rem 0 .3rem; }
22
- nav a { display: flex; gap: .4rem; padding: .15rem 0; color: inherit; text-decoration: none; overflow-wrap: anywhere; }
23
- nav a:hover { text-decoration: underline; }
24
- main { padding: 1rem 1.5rem 4rem; min-width: 0; }
25
- section.op { border: 1px solid var(--line); border-radius: 10px; margin: 0 0 1rem; overflow: hidden; }
26
- section.op > summary { list-style: none; } details > summary::-webkit-details-marker { display: none; }
27
- summary { display: flex; gap: .7rem; align-items: center; padding: .6rem .9rem; cursor: pointer; flex-wrap: wrap; }
28
- summary code { font-size: .95rem; overflow-wrap: anywhere; }
29
- .body { padding: .2rem 1rem 1rem; border-top: 1px solid var(--line); }
30
- .verb { font: 700 .72rem/1 ui-monospace, Menlo, monospace; padding: .3rem .45rem; border-radius: 5px; color: #fff; min-width: 3.9rem; text-align: center; }
31
- .GET { background: var(--get); } .POST { background: var(--post); } .PATCH { background: var(--patch); }
32
- .PUT { background: var(--put); } .DELETE { background: var(--delete); }
33
- nav .verb { min-width: 3.2rem; font-size: .62rem; padding: .22rem .3rem; }
34
- h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 1rem 0 .35rem; }
35
- pre, textarea, input, select, button { font: 13px/1.5 ui-monospace, Menlo, monospace; }
36
- pre { background: var(--panel); border-radius: 8px; padding: .7rem .9rem; margin: 0; overflow-x: auto; white-space: pre; }
37
- table { border-collapse: collapse; width: 100%; font-size: .9rem; } td, th { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; }
38
- th { color: var(--muted); font-weight: 600; }
39
- input, textarea, select { width: 100%; padding: .4rem .5rem; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
40
- input:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
41
- textarea { min-height: 7rem; resize: vertical; }
42
- button { padding: .45rem .9rem; border-radius: 6px; border: 1px solid var(--fg); background: var(--fg); color: var(--bg); cursor: pointer; }
43
- button:disabled { opacity: .6; cursor: default; }
44
- .row { display: flex; gap: .6rem; align-items: center; margin-top: .6rem; flex-wrap: wrap; }
45
- .muted { color: var(--muted); } .warn { border-left: 3px solid var(--patch); padding: .4rem .8rem; background: var(--panel); border-radius: 4px; margin: 0 0 1rem; font-size: .9rem; }
46
- a.ref { color: var(--focus); }
47
- .status { font-weight: 700; } .ok { color: var(--get); } .bad { color: var(--delete); }
48
- @media (max-width: 760px) { .layout { grid-template-columns: 1fr; } nav { position: static; max-height: none; border-right: 0; border-bottom: 1px solid var(--line); } main { padding: 1rem; } }
49
- </style>
50
- </head>
51
- <body>
52
- <header>
53
- <h1>__TITLE__ API</h1>
54
- <input id="filter" type="search" placeholder="Filter endpoints…" aria-label="Filter endpoints">
55
- <span class="muted">Development only · <a class="ref" href="__OPENAPI_URL__">openapi.json</a></span>
56
- </header>
57
- <div class="layout">
58
- <nav id="nav" aria-label="Endpoints"></nav>
59
- <main id="main"><p class="muted">Loading…</p></main>
60
- </div>
61
- <script>
62
- "use strict";
63
- const OPENAPI_URL = "__OPENAPI_URL__";
64
- const VERBS = ["get", "post", "put", "patch", "delete"];
65
- let doc;
66
-
67
- function el(tag, attrs, ...children) {
68
- const node = document.createElement(tag);
69
- for (const [key, value] of Object.entries(attrs || {})) {
70
- if (value === undefined || value === null || value === false) continue;
71
- if (key === "class") node.className = value;
72
- else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
73
- else node.setAttribute(key, value === true ? "" : value);
74
- }
75
- for (const child of children.flat(Infinity)) {
76
- if (child === null || child === undefined || child === false) continue;
77
- node.append(child instanceof Node ? child : String(child));
78
- }
79
- return node;
80
- }
81
-
82
- const refName = (ref) => ref.split("/").pop();
83
- const resolve = (schema) => (schema && schema.$ref ? doc.components.schemas[refName(schema.$ref)] : schema);
84
-
85
- // JSON Schema → a TypeScript-like description (matches the generated client types).
86
- function typeText(schema, indent = "") {
87
- if (!schema) return "unknown";
88
- if (schema.$ref) return refName(schema.$ref);
89
- const variants = schema.anyOf || schema.oneOf;
90
- if (variants) return variants.map((s) => typeText(s, indent)).join(" | ");
91
- if (schema.enum) return schema.enum.map((v) => JSON.stringify(v)).join(" | ");
92
- const types = Array.isArray(schema.type) ? schema.type : [schema.type];
93
- const nullable = types.includes("null");
94
- const type = types.find((t) => t !== "null");
95
- let text;
96
- if (type === "array") text = `${wrap(typeText(schema.items, indent))}[]`;
97
- else if (type === "object" && schema.properties) {
98
- const required = new Set(schema.required || []);
99
- const inner = indent + " ";
100
- const lines = Object.entries(schema.properties).map(
101
- ([name, prop]) => `${inner}${name}${required.has(name) ? "" : "?"}: ${typeText(prop, inner)};`,
102
- );
103
- text = `{\n${lines.join("\n")}\n${indent}}`;
104
- } else if (type === "object") text = schema.additionalProperties ? `Record<string, ${typeText(schema.additionalProperties, indent)}>` : "Record<string, unknown>";
105
- else if (type === "integer" || type === "number") text = "number";
106
- else if (type === "string") text = schema.format ? `string /* ${schema.format} */` : "string";
107
- else text = type || "unknown";
108
- return nullable ? `${text} | null` : text;
109
- }
110
- const wrap = (t) => (t.includes("|") ? `(${t})` : t);
111
-
112
- function example(schema, depth = 0) {
113
- schema = resolve(schema);
114
- if (!schema || depth > 4) return null;
115
- if (schema.example !== undefined) return schema.example;
116
- if (schema.enum) return schema.enum[0];
117
- if (schema.anyOf || schema.oneOf) return example((schema.anyOf || schema.oneOf)[0], depth + 1);
118
- const types = Array.isArray(schema.type) ? schema.type : [schema.type];
119
- const type = types.find((t) => t !== "null");
120
- if (type === "object") {
121
- const out = {};
122
- for (const [name, prop] of Object.entries(schema.properties || {})) out[name] = example(prop, depth + 1);
123
- return out;
124
- }
125
- if (type === "array") return [example(schema.items, depth + 1)];
126
- if (type === "integer" || type === "number") return 0;
127
- if (type === "boolean") return false;
128
- if (type === "string") return { "date-time": new Date().toISOString(), date: new Date().toISOString().slice(0, 10), uuid: crypto.randomUUID() }[schema.format] || "";
129
- return null;
130
- }
131
-
132
- // Types mentioned by a schema, linked below the signature.
133
- function refsIn(schema, found = new Set()) {
134
- if (!schema || typeof schema !== "object") return found;
135
- if (schema.$ref) {
136
- const name = refName(schema.$ref);
137
- if (!found.has(name)) { found.add(name); refsIn(doc.components.schemas[name], found); }
138
- return found;
139
- }
140
- for (const value of Object.values(schema)) if (typeof value === "object") refsIn(value, found);
141
- return found;
142
- }
143
-
144
- function schemaBlock(schema) {
145
- const refs = [...refsIn(schema)].filter((name) => name !== "Error");
146
- return [
147
- el("pre", {}, typeText(schema)),
148
- refs.length ? el("p", { class: "muted" }, "Types: ", refs.flatMap((name, i) => [i ? ", " : "", el("a", { class: "ref", href: `#type-${name}` }, name)])) : null,
149
- ];
150
- }
151
-
152
- function operationSection(path, verb, op) {
153
- const method = verb.toUpperCase();
154
- const id = `op-${op.operationId || method + path}`.replace(/[^\w.-]/g, "-");
155
- const params = op.parameters || [];
156
- const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
157
- const [status, response] = Object.entries(op.responses || {}).find(([code]) => code !== "default") || [];
158
- const responseSchema = response?.content?.["application/json"]?.schema;
159
-
160
- const inputs = {};
161
- const paramRows = params.map((p) => {
162
- inputs[p.name] = el("input", { name: p.name, placeholder: p.required ? "required" : "optional", "aria-label": p.name });
163
- return el("tr", {}, el("td", {}, el("code", {}, p.name)), el("td", {}, p.in), el("td", {}, typeText(p.schema)), el("td", {}, inputs[p.name]));
164
- });
165
- const bodyInput = bodySchema ? el("textarea", { "aria-label": "Request body (JSON)", spellcheck: "false" }, JSON.stringify(example(bodySchema), null, 2)) : null;
166
- const output = el("div");
167
- const send = el("button", { type: "button" }, "Send request");
168
- send.addEventListener("click", async () => {
169
- send.disabled = true;
170
- output.replaceChildren(el("p", { class: "muted" }, "Sending…"));
171
- try {
172
- let url = path;
173
- const query = new URLSearchParams();
174
- for (const p of params) {
175
- const value = inputs[p.name].value;
176
- if (p.in === "path") url = url.replace(`{${p.name}}`, encodeURIComponent(value));
177
- else if (p.in === "query" && value !== "") query.append(p.name, value);
178
- }
179
- if (query.toString()) url += `?${query}`;
180
- const init = { method, headers: { accept: "application/json" }, credentials: "same-origin" };
181
- if (bodyInput) {
182
- JSON.parse(bodyInput.value || "null"); // fail early on invalid JSON
183
- init.body = bodyInput.value;
184
- init.headers["content-type"] = "application/json";
185
- }
186
- const started = performance.now();
187
- const res = await fetch(url, init);
188
- const ms = Math.round(performance.now() - started);
189
- const text = await res.text();
190
- let shown = text;
191
- try { shown = JSON.stringify(JSON.parse(text), null, 2); } catch { /* not JSON */ }
192
- output.replaceChildren(
193
- el("p", {}, el("span", { class: `status ${res.ok ? "ok" : "bad"}` }, `${res.status} ${res.statusText}`), el("span", { class: "muted" }, ` · ${ms} ms · ${method} ${url}`)),
194
- text ? el("pre", {}, shown) : el("p", { class: "muted" }, "(no body)"),
195
- );
196
- } catch (error) {
197
- output.replaceChildren(el("p", { class: "bad" }, String(error.message || error)));
198
- } finally {
199
- send.disabled = false;
200
- }
201
- });
202
-
203
- return el("details", { class: "op", id, "data-search": `${method} ${path} ${op.operationId || ""}`.toLowerCase() },
204
- el("summary", {}, el("span", { class: `verb ${method}` }, method), el("code", {}, path), el("span", { class: "muted" }, op.operationId || "")),
205
- el("div", { class: "body" },
206
- params.length ? [el("h3", {}, "Parameters"), el("table", {}, el("tr", {}, el("th", {}, "Name"), el("th", {}, "In"), el("th", {}, "Type"), el("th", {}, "Value")), paramRows)] : null,
207
- bodySchema ? [el("h3", {}, "Request body"), schemaBlock(bodySchema)] : null,
208
- el("h3", {}, `Response ${status || ""}`), responseSchema ? schemaBlock(responseSchema) : el("p", { class: "muted" }, "No body"),
209
- el("h3", {}, "Try it"),
210
- el("p", { class: "muted" }, "Sent from this page with your session cookie (sign in through the app first for protected endpoints)."),
211
- bodyInput, el("div", { class: "row" }, send), output,
212
- ),
213
- );
214
- }
215
-
216
- function render() {
217
- const main = document.getElementById("main");
218
- const nav = document.getElementById("nav");
219
- const groups = new Map();
220
- for (const [path, item] of Object.entries(doc.paths || {})) {
221
- for (const verb of VERBS) {
222
- const op = item[verb];
223
- if (!op) continue;
224
- const tag = (op.tags && op.tags[0]) || "other";
225
- if (!groups.has(tag)) groups.set(tag, []);
226
- groups.get(tag).push({ path, verb, op });
227
- }
228
- }
229
- const content = [];
230
- const warnings = doc["x-gemstack-warnings"] || [];
231
- if (warnings.length) content.push(el("div", { class: "warn" }, el("strong", {}, "Contract warnings"), el("ul", {}, warnings.map((w) => el("li", {}, w)))));
232
- const navItems = [];
233
- for (const [tag, ops] of [...groups.entries()].sort()) {
234
- content.push(el("h2", { id: `tag-${tag}` }, tag));
235
- navItems.push(el("h2", {}, tag));
236
- for (const { path, verb, op } of ops) {
237
- const section = operationSection(path, verb, op);
238
- content.push(section);
239
- navItems.push(el("a", { href: `#${section.id}`, "data-search": section.dataset.search, onclick: () => (section.open = true) },
240
- el("span", { class: `verb ${verb.toUpperCase()}` }, verb.toUpperCase()), path));
241
- }
242
- }
243
- const schemas = Object.entries(doc.components?.schemas || {}).sort(([a], [b]) => a.localeCompare(b));
244
- content.push(el("h2", { id: "types" }, "Types"));
245
- for (const [name, schema] of schemas) content.push(el("div", { id: `type-${name}` }, el("h3", {}, name), el("pre", {}, typeText(schema))));
246
- if (!groups.size) content.unshift(el("p", { class: "muted" }, "No routes yet. Add some in config/routes.rb — this page rebuilds on every load."));
247
- main.replaceChildren(...content);
248
- nav.replaceChildren(...navItems, el("h2", {}, el("a", { href: "#types" }, "Types")));
249
- const hash = decodeURIComponent(location.hash.slice(1));
250
- if (hash) { const target = document.getElementById(hash); if (target) { if (target.tagName === "DETAILS") target.open = true; target.scrollIntoView(); } }
251
- }
252
-
253
- document.getElementById("filter").addEventListener("input", (event) => {
254
- const q = event.target.value.trim().toLowerCase();
255
- for (const node of document.querySelectorAll("[data-search]")) node.hidden = q !== "" && !node.dataset.search.includes(q);
256
- });
257
-
258
- fetch(OPENAPI_URL, { headers: { accept: "application/json" } })
259
- .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status} loading ${OPENAPI_URL}`))))
260
- .then((json) => { doc = json; render(); })
261
- .catch((error) => document.getElementById("main").replaceChildren(el("p", { class: "bad" }, String(error.message || error))));
262
- </script>
263
- </body>
264
- </html>
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Contract
5
- # Development API docs:
6
- # GET <api_path>/docs interactive page (self-contained, no CDN)
7
- # GET <api_path>/docs/openapi.json OpenAPI 3.1 built from the current routes
8
- # Rebuilt on every request, so it always matches the code after a reload.
9
- class Docs
10
- PAGE = File.read(File.join(__dir__, "docs", "index.html")).freeze
11
- CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; " \
12
- "img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
13
-
14
- def initialize(app, application)
15
- @app = app
16
- @application = application
17
- end
18
-
19
- def call(env)
20
- path = env[Rack::PATH_INFO]
21
- base = "#{@application.config.http.api_path}/docs"
22
- return @app.call(env) unless env[Rack::REQUEST_METHOD] == "GET" && [base, "#{base}/",
23
- "#{base}/openapi.json"].include?(path)
24
-
25
- path.end_with?(".json") ? openapi : page(base)
26
- end
27
-
28
- private
29
-
30
- def openapi
31
- contract = Builder.new(routes: @application.routes, api_path: @application.config.http.api_path).build
32
- body = JSON.generate(OpenAPI.new(contract).document.merge("x-gemstack-warnings" => contract[:warnings]))
33
- [200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [body]]
34
- end
35
-
36
- def page(base)
37
- title = Rack::Utils.escape_html(GemStack.config.name.to_s)
38
- html = PAGE.gsub("__OPENAPI_URL__", "#{base}/openapi.json").gsub("__TITLE__", title)
39
- headers = { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
40
- "content-security-policy" => CSP }
41
- [200, headers, [html]]
42
- end
43
- end
44
- end
45
- end
@@ -1,123 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Contract
5
- # OpenAPI 3.1 document from the contract IR.
6
- class OpenAPI
7
- ERROR_SCHEMA = {
8
- type: "object",
9
- required: ["error"],
10
- properties: {
11
- error: {
12
- type: "object", required: %w[code message],
13
- properties: { code: { type: "string" }, message: { type: "string" }, request_id: { type: "string" } }
14
- },
15
- errors: { type: "object", additionalProperties: { type: "array", items: { type: "string" } } }
16
- }
17
- }.freeze
18
-
19
- def initialize(contract, title: GemStack.config.name, version: GemStack::VERSION)
20
- @contract = contract
21
- @title = title
22
- @version = version
23
- end
24
-
25
- def document
26
- {
27
- openapi: "3.1.0",
28
- info: { title: @title, version: @version },
29
- paths: paths,
30
- components: { schemas: schemas.merge("Error" => ERROR_SCHEMA) }
31
- }
32
- end
33
-
34
- PAGE_PARAMETERS = %w[page per_page].map do |name|
35
- { name: name, in: "query", required: false, schema: { type: "integer", minimum: 1 } }
36
- end.freeze
37
-
38
- private
39
-
40
- def paths
41
- @contract[:resources].flat_map { |resource| resource[:endpoints].map { |e| [resource, e] } }
42
- .group_by { |_, e| openapi_path(e[:path]) }
43
- .transform_values do |pairs|
44
- pairs.to_h do |resource, e|
45
- [e[:verb].downcase, operation(resource, e)]
46
- end
47
- end
48
- end
49
-
50
- def operation(resource, endpoint)
51
- op = {
52
- operationId: "#{resource[:name].tr("/", "_")}.#{endpoint[:name]}",
53
- tags: [resource[:name]],
54
- parameters: endpoint[:params].map { |p| { name: p, in: "path", required: true, schema: { type: "string" } } },
55
- responses: responses(endpoint)
56
- }
57
- op[:parameters].concat(query_parameters(endpoint[:query])) if endpoint[:query]
58
- op[:parameters].concat(PAGE_PARAMETERS) if endpoint[:paginated]
59
- if endpoint[:body]
60
- op[:requestBody] =
61
- { required: true, content: { "application/json" => { schema: schema_for(endpoint[:body]) } } }
62
- end
63
- op
64
- end
65
-
66
- def responses(endpoint)
67
- success = endpoint[:verb] == "POST" ? "201" : "200"
68
- ok = if endpoint[:response]
69
- { success => { description: "OK",
70
- content: { "application/json" => { schema: schema_for(endpoint[:response]) } } } }
71
- else
72
- { "204" => { description: "No Content" } }
73
- end
74
- error = { description: "Error",
75
- content: { "application/json" => { schema: { "$ref": "#/components/schemas/Error" } } } }
76
- ok.merge("default" => error)
77
- end
78
-
79
- def query_parameters(ref)
80
- fields = ref[:ref] ? @contract[:types].dig(ref[:ref], :fields) : []
81
- Array(fields).map do |field|
82
- { name: field[:name], in: "query", required: !field[:optional], schema: schema_for(field[:type]) }
83
- end
84
- end
85
-
86
- def schemas
87
- @contract[:types].transform_values { |type| object_schema(type[:fields]) }
88
- end
89
-
90
- def object_schema(fields)
91
- properties = fields.to_h do |field|
92
- schema = schema_for(field[:type])
93
- schema = { oneOf: [schema, { type: "null" }] } if field[:nullable]
94
- [field[:name], schema]
95
- end
96
- { type: "object", properties: properties, required: fields.reject { |f| f[:optional] }.map { |f| f[:name] } }
97
- end
98
-
99
- def schema_for(ref)
100
- if ref[:scalar] then Types.fetch(ref[:scalar]).openapi.dup
101
- elsif ref[:ref] then { "$ref": "#/components/schemas/#{ref[:ref]}" }
102
- elsif ref[:array] then { type: "array", items: schema_for(ref[:array]) }
103
- elsif ref[:page] then page_schema(ref[:page])
104
- elsif ref[:object] then object_schema(ref[:object])
105
- else {}
106
- end
107
- end
108
-
109
- def page_schema(item)
110
- meta = %w[page per_page total total_pages].to_h { |key| [key, { type: "integer" }] }
111
- {
112
- type: "object", required: %w[data meta],
113
- properties: {
114
- data: { type: "array", items: schema_for(item) },
115
- meta: { type: "object", required: meta.keys, properties: meta }
116
- }
117
- }
118
- end
119
-
120
- def openapi_path(path) = "#{@contract[:api_path]}#{path.gsub(/[:*](\w+)/, '{\1}')}"
121
- end
122
- end
123
- end
@@ -1,162 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Contract
5
- # Emits TypeScript from the contract IR:
6
- # types.ts one `export type` per serializer / input schema
7
- # <resource>.ts a typed client object per controller
8
- # index.ts re-exports everything
9
- #
10
- # Shapes are type aliases (not interfaces) so they are assignable to the
11
- # client's Query record type.
12
- class TypeScript
13
- def initialize(contract, client_import: "@/lib/gemstack/client")
14
- @contract = contract
15
- @client_import = client_import
16
- end
17
-
18
- def files
19
- files = { "types.ts" => types_file }
20
- @contract[:resources].each { |resource| files["#{file_base(resource)}.ts"] = resource_file(resource) }
21
- files["index.ts"] = index_file
22
- files
23
- end
24
-
25
- PAGE_TYPES = <<~TS
26
- /** The pagination envelope returned by `paginate` in controllers. */
27
- export type PaginationMeta = {
28
- page: number;
29
- per_page: number;
30
- total: number;
31
- total_pages: number;
32
- };
33
-
34
- export type Paginated<T> = {
35
- data: T[];
36
- meta: PaginationMeta;
37
- };
38
-
39
- export type PaginationQuery = {
40
- page?: number;
41
- per_page?: number;
42
- };
43
- TS
44
-
45
- private
46
-
47
- def header = "// #{HEADER}\n"
48
-
49
- def types_file
50
- body = @contract[:types].map do |name, type|
51
- "export type #{name} = #{object_type(type[:fields], 0)};\n"
52
- end
53
- body.unshift(PAGE_TYPES) if paginated?
54
- # No types yet (a new app): still a module, or `export type * from "./types"` fails to compile.
55
- body << "export {};\n" if body.empty?
56
- "#{header}\n#{body.join("\n")}"
57
- end
58
-
59
- def paginated? = @contract[:resources].any? { |r| r[:endpoints].any? { |e| e[:paginated] } }
60
-
61
- def object_type(fields, depth)
62
- return "Record<string, never>" if fields.empty?
63
-
64
- pad = " " * (depth + 1)
65
- lines = fields.map do |field|
66
- type = ts_type(field[:type], depth + 1)
67
- type = "#{type} | null" if field[:nullable]
68
- "#{pad}#{property(field[:name])}#{"?" if field[:optional]}: #{type};"
69
- end
70
- "{\n#{lines.join("\n")}\n#{" " * depth}}"
71
- end
72
-
73
- def ts_type(ref, depth = 0)
74
- if ref[:scalar] then Types.fetch(ref[:scalar]).ts
75
- elsif ref[:ref] then ref[:ref]
76
- elsif ref[:array]
77
- inner = ts_type(ref[:array], depth)
78
- inner.match?(/\A[\w.]+\z/) ? "#{inner}[]" : "Array<#{inner}>"
79
- elsif ref[:page] then "Paginated<#{ts_type(ref[:page], depth)}>"
80
- elsif ref[:object] then object_type(ref[:object], depth)
81
- else "unknown"
82
- end
83
- end
84
-
85
- def property(name) = name.match?(/\A[A-Za-z_$][\w$]*\z/) ? name : name.inspect
86
-
87
- def resource_file(resource)
88
- refs = []
89
- methods = resource[:endpoints].map { |endpoint| client_method(endpoint, refs) }
90
- imports = refs.uniq.sort
91
- lines = [header]
92
- lines << %(import { api, type RequestOptions } from "#{@client_import}";)
93
- lines << %(import type { #{imports.join(", ")} } from "./types";) unless imports.empty?
94
- lines << ""
95
- lines << "const segment = (value: string | number) => encodeURIComponent(String(value));"
96
- lines << ""
97
- lines << "export const #{export_name(resource)} = {"
98
- lines.concat(methods)
99
- lines << "};"
100
- "#{lines.join("\n")}\n"
101
- end
102
-
103
- def client_method(endpoint, refs)
104
- args = endpoint[:params].map { |param| "#{camel(param)}: string | number" }
105
- collect_refs(endpoint[:body], refs)
106
- collect_refs(endpoint[:query], refs)
107
- collect_refs(endpoint[:response], refs)
108
- args << "data: #{ts_type(endpoint[:body])}" if endpoint[:body]
109
- args << query_arg(endpoint) if endpoint[:query] || endpoint[:paginated]
110
- refs << "PaginationQuery" if endpoint[:paginated]
111
- args << "options?: RequestOptions"
112
- response = endpoint[:response] ? ts_type(endpoint[:response]) : "void"
113
- call = "api.#{client_verb(endpoint[:verb])}<#{response}>(#{call_args(endpoint)})"
114
- " /** #{endpoint[:verb]} #{@contract[:api_path]}#{endpoint[:path]} */\n " \
115
- "#{endpoint[:name]}: (#{args.join(", ")}) => #{call},"
116
- end
117
-
118
- # Paginated lists take an optional { page, per_page } query, combined
119
- # with the action's own query schema when there is one.
120
- def query_arg(endpoint)
121
- return "query: #{ts_type(endpoint[:query])}" unless endpoint[:paginated]
122
- return "query?: PaginationQuery" unless endpoint[:query]
123
-
124
- "query: #{ts_type(endpoint[:query])} & PaginationQuery"
125
- end
126
-
127
- def call_args(endpoint)
128
- path = endpoint[:path].gsub(/[:*](\w+)/) { "${segment(#{camel(::Regexp.last_match(1))})}" }
129
- path = path.include?("${") ? "`#{path}`" : path.inspect
130
- options = endpoint[:query] || endpoint[:paginated] ? "{ ...options, query }" : "options"
131
- case client_verb(endpoint[:verb])
132
- when "get", "delete" then "#{path}, #{options}"
133
- else "#{path}, #{endpoint[:body] ? "data" : "undefined"}, #{options}"
134
- end
135
- end
136
-
137
- def client_verb(verb) = { "HEAD" => "get", "OPTIONS" => "get" }.fetch(verb, verb.downcase)
138
-
139
- def collect_refs(ref, refs)
140
- return unless ref
141
-
142
- refs << ref[:ref] if ref[:ref]
143
- refs << "Paginated" if ref[:page]
144
- collect_refs(ref[:array], refs) if ref[:array]
145
- collect_refs(ref[:page], refs) if ref[:page]
146
- end
147
-
148
- def index_file
149
- exports = @contract[:resources].map { |r| %(export { #{export_name(r)} } from "./#{file_base(r)}";) }
150
- "#{header}\nexport type * from \"./types\";\n#{exports.join("\n")}\n"
151
- end
152
-
153
- def file_base(resource) = resource[:name].tr("/", "_")
154
- def export_name(resource) = camel(resource[:name].tr("/", "_"))
155
-
156
- def camel(name)
157
- camel = Inflector.camelize(name)
158
- camel[0].downcase + camel[1..]
159
- end
160
- end
161
- end
162
- end
@@ -1,93 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "fileutils"
5
- require "gemstack/core"
6
- require "gemstack/schema"
7
- require "gemstack/http"
8
-
9
- module GemStack
10
- # The API contract: derived from the backend (routes + controller `accepts`
11
- # schemas + serializers) and emitted as TypeScript types, a typed client
12
- # per resource, and OpenAPI 3.1 (ARCHITECTURE §8).
13
- #
14
- # contract = GemStack::Contract.build(GemStack.boot!)
15
- # GemStack::Contract.write(contract, root: GemStack.root)
16
- module Contract
17
- class Config < Settings
18
- setting :output_dir, default: "frontend/lib/api/generated"
19
- setting :openapi_path, default: "openapi.json"
20
- # Import path of the client runtime from generated files.
21
- setting :client_import, default: "@/lib/gemstack/client"
22
- # Interactive API docs at <api_path>/docs, built from the live routes (development only by default).
23
- setting :docs, default: -> { GemStack.env.development? }
24
- end
25
-
26
- HEADER = "Generated by GemStack from the backend — do not edit. Run `gemstack contract` to regenerate."
27
-
28
- class << self
29
- def config = GemStack.config.contract
30
-
31
- # Builds the contract IR from a booted application (eager-loads code).
32
- def build(app)
33
- app.eager_load!
34
- Builder.new(routes: app.routes, api_path: app.config.http.api_path).build
35
- end
36
-
37
- # Writes TypeScript and OpenAPI files, touching only files whose content
38
- # changed (so Next.js doesn't hot-reload needlessly) and removing
39
- # generated files for resources that no longer exist. Returns
40
- # { written: [...], removed: [...], unchanged: [...] }. dry_run: true
41
- # reports what would change without touching anything (`gemstack doctor`).
42
- def write(contract, root:, output_dir: config.output_dir, openapi_path: config.openapi_path, typescript: true,
43
- dry_run: false)
44
- dir = File.expand_path(output_dir, root)
45
- files = typescript ? TypeScript.new(contract, client_import: config.client_import).files : {}
46
- report = { written: [], removed: [], unchanged: [] }
47
- files.each { |name, content| write_file(File.join(dir, name), content, report, dry_run: dry_run) }
48
- stale = typescript ? Dir.glob(File.join(dir, "*.ts")) : []
49
- stale = stale.reject { |path| files.key?(File.basename(path)) }
50
- stale.each do |path|
51
- next unless File.read(path).include?(HEADER)
52
-
53
- File.delete(path) unless dry_run
54
- report[:removed] << path
55
- end
56
- if openapi_path
57
- write_file(File.expand_path(openapi_path, root), "#{JSON.pretty_generate(OpenAPI.new(contract).document)}\n",
58
- report, dry_run: dry_run)
59
- end
60
- report
61
- end
62
-
63
- private
64
-
65
- def write_file(path, content, report, dry_run: false)
66
- if File.exist?(path) && File.read(path) == content
67
- report[:unchanged] << path
68
- return
69
- end
70
- return report[:written] << path if dry_run
71
-
72
- FileUtils.mkdir_p(File.dirname(path))
73
- File.write(path, content)
74
- report[:written] << path
75
- end
76
- end
77
- end
78
-
79
- Config.namespace(:contract, Contract::Config)
80
- end
81
-
82
- require_relative "contract/builder"
83
- require_relative "contract/typescript"
84
- require_relative "contract/openapi"
85
- require_relative "contract/docs"
86
-
87
- GemStack::Plugins.register(:contract_docs) do |app|
88
- next unless app.respond_to?(:routes) && app.config.contract.docs
89
-
90
- stack = app.config.http.middleware
91
- stack.insert_before(GemStack::HTTP::Middleware::HealthCheck, GemStack::Contract::Docs, app) unless
92
- stack.include?(GemStack::Contract::Docs)
93
- end