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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +160 -0
- data/lib/gemstack/contract.rb +93 -0
- metadata +95 -0
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
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: []
|