gemstack-contract 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cd1483984acde6e1833b4f5ff84b1e17f95f5aa89a9776faa4f1e55fa05f0512
4
+ data.tar.gz: 0da7d6e82afe251ffe35d34d4f003cab646c0fe51c75642961c15ca1dc777f31
5
+ SHA512:
6
+ metadata.gz: 61586112c4732b9cb92e14b99e194de7f3d53ad9f57cdcecf56eb5214e7540ec643e75ceef29f852780fa9365f66742f28b741e94bf544d7d2520e6342a5d858
7
+ data.tar.gz: 9136268c95709be9d483e7ee14b9181b0ca96f2221e33746fb0e06d486aef50c54111f50960ea2f86aff0497fba9ccef320eb0449b4924575e2fc7d46ba75c12
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoaib Malik
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,24 @@
1
+ # gemstack-contract
2
+
3
+ GemStack contract: TypeScript types, API clients and OpenAPI from the backend.
4
+
5
+ Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
+ applications. All GemStack gems are developed together in that repository and released with the same
7
+ version.
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`).
21
+
22
+ ## License
23
+
24
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,168 @@
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
@@ -0,0 +1,264 @@
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>
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Contract
5
+ # Development API docs (DECISIONS D-056):
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
@@ -0,0 +1,123 @@
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
@@ -0,0 +1,160 @@
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
+ "#{header}\n#{body.join("\n")}"
55
+ end
56
+
57
+ def paginated? = @contract[:resources].any? { |r| r[:endpoints].any? { |e| e[:paginated] } }
58
+
59
+ def object_type(fields, depth)
60
+ return "Record<string, never>" if fields.empty?
61
+
62
+ pad = " " * (depth + 1)
63
+ lines = fields.map do |field|
64
+ type = ts_type(field[:type], depth + 1)
65
+ type = "#{type} | null" if field[:nullable]
66
+ "#{pad}#{property(field[:name])}#{"?" if field[:optional]}: #{type};"
67
+ end
68
+ "{\n#{lines.join("\n")}\n#{" " * depth}}"
69
+ end
70
+
71
+ def ts_type(ref, depth = 0)
72
+ if ref[:scalar] then Types.fetch(ref[:scalar]).ts
73
+ elsif ref[:ref] then ref[:ref]
74
+ elsif ref[:array]
75
+ inner = ts_type(ref[:array], depth)
76
+ inner.match?(/\A[\w.]+\z/) ? "#{inner}[]" : "Array<#{inner}>"
77
+ elsif ref[:page] then "Paginated<#{ts_type(ref[:page], depth)}>"
78
+ elsif ref[:object] then object_type(ref[:object], depth)
79
+ else "unknown"
80
+ end
81
+ end
82
+
83
+ def property(name) = name.match?(/\A[A-Za-z_$][\w$]*\z/) ? name : name.inspect
84
+
85
+ def resource_file(resource)
86
+ refs = []
87
+ methods = resource[:endpoints].map { |endpoint| client_method(endpoint, refs) }
88
+ imports = refs.uniq.sort
89
+ lines = [header]
90
+ lines << %(import { api, type RequestOptions } from "#{@client_import}";)
91
+ lines << %(import type { #{imports.join(", ")} } from "./types";) unless imports.empty?
92
+ lines << ""
93
+ lines << "const segment = (value: string | number) => encodeURIComponent(String(value));"
94
+ lines << ""
95
+ lines << "export const #{export_name(resource)} = {"
96
+ lines.concat(methods)
97
+ lines << "};"
98
+ "#{lines.join("\n")}\n"
99
+ end
100
+
101
+ def client_method(endpoint, refs)
102
+ args = endpoint[:params].map { |param| "#{camel(param)}: string | number" }
103
+ collect_refs(endpoint[:body], refs)
104
+ collect_refs(endpoint[:query], refs)
105
+ collect_refs(endpoint[:response], refs)
106
+ args << "data: #{ts_type(endpoint[:body])}" if endpoint[:body]
107
+ args << query_arg(endpoint) if endpoint[:query] || endpoint[:paginated]
108
+ refs << "PaginationQuery" if endpoint[:paginated]
109
+ args << "options?: RequestOptions"
110
+ response = endpoint[:response] ? ts_type(endpoint[:response]) : "void"
111
+ call = "api.#{client_verb(endpoint[:verb])}<#{response}>(#{call_args(endpoint)})"
112
+ " /** #{endpoint[:verb]} #{@contract[:api_path]}#{endpoint[:path]} */\n " \
113
+ "#{endpoint[:name]}: (#{args.join(", ")}) => #{call},"
114
+ end
115
+
116
+ # Paginated lists take an optional { page, per_page } query, combined
117
+ # with the action's own query schema when there is one.
118
+ def query_arg(endpoint)
119
+ return "query: #{ts_type(endpoint[:query])}" unless endpoint[:paginated]
120
+ return "query?: PaginationQuery" unless endpoint[:query]
121
+
122
+ "query: #{ts_type(endpoint[:query])} & PaginationQuery"
123
+ end
124
+
125
+ def call_args(endpoint)
126
+ path = endpoint[:path].gsub(/[:*](\w+)/) { "${segment(#{camel(::Regexp.last_match(1))})}" }
127
+ path = path.include?("${") ? "`#{path}`" : path.inspect
128
+ options = endpoint[:query] || endpoint[:paginated] ? "{ ...options, query }" : "options"
129
+ case client_verb(endpoint[:verb])
130
+ when "get", "delete" then "#{path}, #{options}"
131
+ else "#{path}, #{endpoint[:body] ? "data" : "undefined"}, #{options}"
132
+ end
133
+ end
134
+
135
+ def client_verb(verb) = { "HEAD" => "get", "OPTIONS" => "get" }.fetch(verb, verb.downcase)
136
+
137
+ def collect_refs(ref, refs)
138
+ return unless ref
139
+
140
+ refs << ref[:ref] if ref[:ref]
141
+ refs << "Paginated" if ref[:page]
142
+ collect_refs(ref[:array], refs) if ref[:array]
143
+ collect_refs(ref[:page], refs) if ref[:page]
144
+ end
145
+
146
+ def index_file
147
+ exports = @contract[:resources].map { |r| %(export { #{export_name(r)} } from "./#{file_base(r)}";) }
148
+ "#{header}\nexport type * from \"./types\";\n#{exports.join("\n")}\n"
149
+ end
150
+
151
+ def file_base(resource) = resource[:name].tr("/", "_")
152
+ def export_name(resource) = camel(resource[:name].tr("/", "_"))
153
+
154
+ def camel(name)
155
+ camel = Inflector.camelize(name)
156
+ camel[0].downcase + camel[1..]
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,93 @@
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, DECISIONS D-023).
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
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemstack-contract
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shoaib Malik
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: gemstack-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: gemstack-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: gemstack-schema
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - '='
45
+ - !ruby/object:Gem::Version
46
+ version: 0.1.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: 0.1.0
54
+ email:
55
+ - gemstack26@gmail.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - CHANGELOG.md
61
+ - LICENSE.txt
62
+ - README.md
63
+ - lib/gemstack/contract.rb
64
+ - lib/gemstack/contract/builder.rb
65
+ - lib/gemstack/contract/docs.rb
66
+ - lib/gemstack/contract/docs/index.html
67
+ - lib/gemstack/contract/openapi.rb
68
+ - lib/gemstack/contract/typescript.rb
69
+ homepage: https://github.com/gemstack-rb/gemstack
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ rubygems_mfa_required: 'true'
74
+ source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-contract
75
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-contract/CHANGELOG.md
76
+ bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
77
+ documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '4.0'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 4.0.20
93
+ specification_version: 4
94
+ summary: 'GemStack contract: TypeScript types, API clients and OpenAPI from the backend'
95
+ test_files: []