api_binding_ui 3.2.0 → 3.3.2
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.
- package/README.md +3 -1
- package/dist/app/{index-xww2x0kd.js → index-bv4ej4bf.js} +9 -9
- package/dist/app/index.html +1 -1
- package/dist/cli.js +9 -9
- package/dist/{index-gfw08965.js → index-s44cgmck.js} +4 -2
- package/dist/{index-gfw08965.js.map → index-s44cgmck.js.map} +3 -3
- package/dist/index.js +11 -11
- package/dist/index.js.map +3 -3
- package/dist/studio/index.js +1 -1
- package/dist/vite/plugin.d.ts +6 -2
- package/dist/vite.js +33 -15
- package/package.json +1 -1
- package/spec/FRAMEWORK.md +178 -178
package/dist/vite/plugin.d.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Serves the studio under /__binding/ and injects a floating button into the app's HTML pages
|
|
9
9
|
* (SPA and server-rendered alike). Every save regenerates the screen hooks, so Vite reloads the
|
|
10
|
-
* screen with the new binding.
|
|
10
|
+
* screen with the new binding. On start it generates the API client (Orval, when the app has
|
|
11
|
+
* orval.config.ts) and the screen hooks, so a project needs no committed generated code.
|
|
12
|
+
* `vite build` never runs it (apply: "serve").
|
|
11
13
|
*/
|
|
12
14
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
15
|
export interface BindingStudioOptions {
|
|
@@ -39,11 +41,13 @@ export interface VitePluginLike {
|
|
|
39
41
|
watcher?: {
|
|
40
42
|
on: (event: "change", fn: (p: string) => void) => void;
|
|
41
43
|
};
|
|
42
|
-
}) => void
|
|
44
|
+
}) => Promise<void>;
|
|
43
45
|
}
|
|
44
46
|
/** Adds the overlay script before the last </body> of HTML chunks; leaves everything else as it is. */
|
|
45
47
|
export declare function injectOverlay(res: ServerResponse, tag: string): void;
|
|
46
48
|
/** Regenerates the hooks after a save; logs the outcome, since the file is already saved either way. */
|
|
47
49
|
export declare function regenerate(file: string, log?: (line: string) => void): Promise<void>;
|
|
50
|
+
/** Runs the app's Orval config, so the client the hooks import exists. Orval is the app's dependency, not this package's. */
|
|
51
|
+
export declare function generateClient(root: string, log?: (line: string) => void): Promise<void>;
|
|
48
52
|
export declare function bindingStudio(options?: BindingStudioOptions): VitePluginLike;
|
|
49
53
|
export {};
|
package/dist/vite.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/vite/plugin.ts
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import path4 from "node:path";
|
|
4
4
|
|
|
5
5
|
// src/server/handler.ts
|
|
@@ -744,10 +744,6 @@ function compileSteps(steps, cx, at, ops, imports) {
|
|
|
744
744
|
cx.use.add("finish");
|
|
745
745
|
for (const st of steps) {
|
|
746
746
|
const op = ops.get(st.operation);
|
|
747
|
-
if (!op) {
|
|
748
|
-
cx.issue("catalog.operation-missing", `${st.operation} is not in the API docs.`, at);
|
|
749
|
-
continue;
|
|
750
|
-
}
|
|
751
747
|
const scope = { item: true, steps: done };
|
|
752
748
|
const call = compileCall(op, st.request, cx, scope, at);
|
|
753
749
|
imports.add(call.fn);
|
|
@@ -818,6 +814,12 @@ function generate(file, catalog) {
|
|
|
818
814
|
issue: (code, message, at) => issues.push({ severity: "error", screen: screen.id, code, message, ...at }),
|
|
819
815
|
warn: (code, message, at) => issues.push({ severity: "warning", screen: screen.id, code, message, ...at })
|
|
820
816
|
};
|
|
817
|
+
const gone = (operations, at) => {
|
|
818
|
+
const missing = operations.filter((o) => !ops.has(o));
|
|
819
|
+
for (const o of missing)
|
|
820
|
+
cx.warn("catalog.operation-missing", `${o} is no longer in the API docs; generated unbound until it is rebound.`, at);
|
|
821
|
+
return missing.length > 0;
|
|
822
|
+
};
|
|
821
823
|
const bindings = new Map;
|
|
822
824
|
for (const b of index[screen.id] ?? []) {
|
|
823
825
|
if (!keys.has(b.component)) {
|
|
@@ -855,7 +857,7 @@ function generate(file, catalog) {
|
|
|
855
857
|
if (s.role === "trigger") {
|
|
856
858
|
types.add("Action");
|
|
857
859
|
outT.push(`${prop(s.id)}: Action`);
|
|
858
|
-
if (b?.type !== "steps") {
|
|
860
|
+
if (b?.type !== "steps" || gone(b.steps.map((st) => st.operation), at)) {
|
|
859
861
|
unbound(s.id, "UNBOUND_ACTION");
|
|
860
862
|
continue;
|
|
861
863
|
}
|
|
@@ -881,13 +883,11 @@ function generate(file, catalog) {
|
|
|
881
883
|
out.push(`${prop(s.id)}: { bound: true, data: ${compileExpr(b.value, cx, { item: false }, at)}, loading: false, error: undefined }`);
|
|
882
884
|
continue;
|
|
883
885
|
}
|
|
884
|
-
|
|
885
|
-
if (b?.type === "operation" && !op)
|
|
886
|
-
cx.issue("catalog.operation-missing", `${b.operation} is not in the API docs.`, at);
|
|
887
|
-
if (b?.type !== "operation" || !op) {
|
|
886
|
+
if (b?.type !== "operation" || gone([b.operation], at)) {
|
|
888
887
|
unbound(s.id, "UNBOUND_DATA");
|
|
889
888
|
continue;
|
|
890
889
|
}
|
|
890
|
+
const op = ops.get(b.operation);
|
|
891
891
|
const call = compileCall(op, b.request, cx, { item: false }, at);
|
|
892
892
|
imports.add(call.fn);
|
|
893
893
|
["useData", "ok"].forEach((h) => cx.use.add(h));
|
|
@@ -1045,8 +1045,7 @@ async function regenerate(file, log = console.log) {
|
|
|
1045
1045
|
try {
|
|
1046
1046
|
const r = await writeScreens(file);
|
|
1047
1047
|
for (const i of r.issues)
|
|
1048
|
-
|
|
1049
|
-
log(`[api-binding] ${describeIssue(i)}`);
|
|
1048
|
+
log(`[api-binding] ${describeIssue(i)}`);
|
|
1050
1049
|
if (r.issues.some((i) => i.severity === "error"))
|
|
1051
1050
|
log("[api-binding] Screen hooks not regenerated: fix the errors above.");
|
|
1052
1051
|
else if (r.changed.length || r.stale.length)
|
|
@@ -1055,10 +1054,23 @@ async function regenerate(file, log = console.log) {
|
|
|
1055
1054
|
log(`[api-binding] Screen hooks not regenerated: ${e.message}`);
|
|
1056
1055
|
}
|
|
1057
1056
|
}
|
|
1057
|
+
async function generateClient(root, log = console.log) {
|
|
1058
|
+
const config = path4.join(root, "orval.config.ts");
|
|
1059
|
+
if (!existsSync(config))
|
|
1060
|
+
return;
|
|
1061
|
+
try {
|
|
1062
|
+
const orval = "orval";
|
|
1063
|
+
const { generate } = await import(orval);
|
|
1064
|
+
await generate(config, root);
|
|
1065
|
+
log("[api-binding] API client generated.");
|
|
1066
|
+
} catch (e) {
|
|
1067
|
+
log(`[api-binding] API client not generated, so it may lack APIs the hooks call: ${e.message}`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1058
1070
|
function bindingStudio(options = {}) {
|
|
1059
1071
|
const base = (options.base ?? "/__binding").replace(/\/+$/, "");
|
|
1060
1072
|
const tag = `<script type="module" src="${base}/overlay.js"></script>`;
|
|
1061
|
-
let file = "";
|
|
1073
|
+
let file = "", root = "";
|
|
1062
1074
|
const norm = (s) => process.platform === "win32" ? path4.resolve(s).toLowerCase() : path4.resolve(s);
|
|
1063
1075
|
const isBindingsFile = (p) => {
|
|
1064
1076
|
const abs = norm(p), f = norm(file);
|
|
@@ -1076,11 +1088,16 @@ function bindingStudio(options = {}) {
|
|
|
1076
1088
|
name: "api-binding-studio",
|
|
1077
1089
|
apply: "serve",
|
|
1078
1090
|
configResolved(config) {
|
|
1079
|
-
|
|
1091
|
+
root = config.root;
|
|
1092
|
+
file = path4.resolve(root, options.file ?? "bindings.json");
|
|
1080
1093
|
},
|
|
1081
1094
|
transformIndexHtml: (html) => html.includes(tag) ? html : html.replace(/<\/body>/i, `${tag}</body>`),
|
|
1082
1095
|
config: () => ({ server: { watch: { ignored: [(p) => isBindingsFile(p)] } } }),
|
|
1083
|
-
configureServer(server) {
|
|
1096
|
+
async configureServer(server) {
|
|
1097
|
+
if (existsSync(file)) {
|
|
1098
|
+
await generateClient(root);
|
|
1099
|
+
await regenerate(file);
|
|
1100
|
+
}
|
|
1084
1101
|
const allowed = (req) => {
|
|
1085
1102
|
const origin = req.headers.origin;
|
|
1086
1103
|
return req.method === "GET" || !origin || new URL(origin).host === req.headers.host;
|
|
@@ -1108,6 +1125,7 @@ function bindingStudio(options = {}) {
|
|
|
1108
1125
|
}
|
|
1109
1126
|
export {
|
|
1110
1127
|
bindingStudio,
|
|
1128
|
+
generateClient,
|
|
1111
1129
|
injectOverlay,
|
|
1112
1130
|
regenerate
|
|
1113
1131
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api_binding_ui",
|
|
3
|
-
"version": "3.2
|
|
3
|
+
"version": "3.3.2",
|
|
4
4
|
"description": "Design-time API binding: register screen components, bind them to documented APIs in a studio, and generate plain React Query hooks over an Orval client. Nothing ships in the app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
package/spec/FRAMEWORK.md
CHANGED
|
@@ -1,178 +1,178 @@
|
|
|
1
|
-
# API Binding Framework — v3
|
|
2
|
-
|
|
3
|
-
Normative types: [`contract.ts`](../src/framework/contract.ts).
|
|
4
|
-
|
|
5
|
-
The framework connects **UI components** to **documented API operations** at design time and turns the result into **plain code**. Binding lives in two files: `registry.json` (what exists: kinds and the screen → component → part tree) and `bindings.json` (how it is wired to the API). After design time the app holds only ordinary React Query hooks over an Orval client; no framework code, runtime or manifest ships.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. Principles
|
|
10
|
-
|
|
11
|
-
1. **Nothing concrete in the framework.** Component kinds, products, operations and fields arrive as data: kinds and the registry in the bindings file, operations from the OpenAPI docs.
|
|
12
|
-
2. **The API docs are the only source of operations.** No local hooks, hardcoded option lists or free-text overrides.
|
|
13
|
-
3. **UI first.** The unit of work is a screen and its components; APIs attach to component slots.
|
|
14
|
-
4. **Two files.** `registry.json` says what exists (kinds, screens, components); `bindings.json` says how it is wired (output settings, bindings). The studio writes only `bindings.json`.
|
|
15
|
-
5. **No trace at run time.** The generator writes plain hooks; the app never imports this package.
|
|
16
|
-
6. **Deterministic.** The same file and docs always produce the same code. Generated files carry an `@generated` mark and are never edited by hand.
|
|
17
|
-
|
|
18
|
-
---
|
|
19
|
-
|
|
20
|
-
## 2. The pieces
|
|
21
|
-
|
|
22
|
-
```
|
|
23
|
-
OpenAPI docs ──► Orval ──────────────► src/api/generated (typed client)
|
|
24
|
-
│
|
|
25
|
-
└──► api-binding studio ◄──► bindings.json (+ registry.json) ──► api-binding generate ──► src/api/screens (one hook per screen)
|
|
26
|
-
│
|
|
27
|
-
app components ── use<Screen>Api(state) ┘
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
| Piece | What it is | Owned by |
|
|
31
|
-
|---|---|---|
|
|
32
|
-
| Kinds | A component kind's slots, with JSON Schema shapes | Design system, in `registry.json` → `kinds` |
|
|
33
|
-
| Screens and components | Every screen and component instance, by id | The app, in `registry.json` (a tree; nesting gives each part its parent) |
|
|
34
|
-
| Bindings | Which operation or value feeds each slot | Designers in the studio, reviewed by engineers |
|
|
35
|
-
| Orval client | Typed functions per operation | Generated by Orval from the same docs |
|
|
36
|
-
| Screen hooks | `use<Screen>Api(state)` per screen | Generated by `api-binding generate` |
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
## 3. Ids
|
|
41
|
-
|
|
42
|
-
One namespace for screens and components, used everywhere: in `componentId` props, in the file and in expressions.
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
<product>.<screen>[.<sub-screen>…].<component>[.<part>…]
|
|
46
|
-
collections.worklist screen
|
|
47
|
-
collections.worklist.add-notes sub-screen (a dialog over the worklist)
|
|
48
|
-
collections.worklist.grid component on collections.worklist
|
|
49
|
-
collections.worklist.grid.column.customer-name
|
|
50
|
-
collections.worklist.grid.row.add-notes
|
|
51
|
-
collections.worklist.add-notes.notes-type component on collections.worklist.add-notes
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
- Segments are lowercase kebab-case (`[a-z0-9-]`), joined by dots: `^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$`.
|
|
55
|
-
- A component belongs to the **longest** registered screen id that prefixes it. A screen's parent is the longest other screen id that prefixes it. Navigation is built from this; there is no `path` or `parent` to maintain.
|
|
56
|
-
- A component id may not equal a screen id (it would capture that screen's components).
|
|
57
|
-
- Ids are stable: renaming one orphans its bindings. They never depend on labels.
|
|
58
|
-
- In the app, each component gets `componentId="<id>"`; design-system components render it as `data-component-id`.
|
|
59
|
-
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## 4. Kinds and slots
|
|
63
|
-
|
|
64
|
-
A kind declares slots with one of three **roles**:
|
|
65
|
-
|
|
66
|
-
| Role | Meaning | Bound to |
|
|
67
|
-
|---|---|---|
|
|
68
|
-
| `consume` | The component needs data | An operation (request + response selection) or an expression |
|
|
69
|
-
| `produce` | The component exposes data | Nothing; a source others read |
|
|
70
|
-
| `trigger` | The component emits an event | Ordered API call steps, or "no API" |
|
|
71
|
-
|
|
72
|
-
- `scope.parentSlot`: the slot is evaluated per item of the parent's slot (a cell in a row, an action on a row); `$item` is that item.
|
|
73
|
-
- `feeds`: produce slots of the same component offered as request sources (paging, sorting).
|
|
74
|
-
- `"$ref": "#slot/<id>"`: the same shape as that slot after binding (a grid's selection has the shape of its rows).
|
|
75
|
-
|
|
76
|
-
---
|
|
77
|
-
|
|
78
|
-
## 5. The bindings file
|
|
79
|
-
|
|
80
|
-
```jsonc
|
|
81
|
-
{
|
|
82
|
-
"version": 3,
|
|
83
|
-
"openapi": "https://apidocs.example.com/index.yaml", // or a path next to this file
|
|
84
|
-
"output": { "dir": "src/api/screens", "client": "@/api/generated" },
|
|
85
|
-
"kinds": [ /* ComponentDescriptor */ ],
|
|
86
|
-
"screens": [ { "id": "collections.worklist.add-notes", "label": "Add Notes",
|
|
87
|
-
"context": [ { "id": "selection", "label": "Selected rows", "many": true,
|
|
88
|
-
"shape": { "from": { "screen": "collections.worklist", "component": "collections.worklist.grid", "slot": "selection" } } } ] } ],
|
|
89
|
-
"components": [ { "id": "collections.worklist.add-notes.notes-type", "kind": "hr-ds/select", "label": "Notes Type" } ],
|
|
90
|
-
"bindings": [ /* SlotBinding */ ]
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
| Slot role | Binding type | Contents |
|
|
95
|
-
|---|---|---|
|
|
96
|
-
| consume | `operation` | operation, request mapping (pointer → expression), response `select`, optional `map`, `load` |
|
|
97
|
-
| consume | `expression` | one expression |
|
|
98
|
-
| trigger | `steps` | ordered `call` steps; a step may run `forEach` over a list; `onError: stop \| continue` |
|
|
99
|
-
| trigger | `none` | reviewed: this event calls no API |
|
|
100
|
-
|
|
101
|
-
Only `confirmed` bindings are generated; drafts are reported as warnings. Bindings describe API calls only: opening, closing, navigation, toasts and reloads are app code.
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
## 6. Expressions
|
|
106
|
-
|
|
107
|
-
| Form | Resolves to |
|
|
108
|
-
|---|---|
|
|
109
|
-
| `<componentId>:<slot>[.path]` | A produce slot on the same screen, e.g. `collections.worklist.add-notes.subject:value` |
|
|
110
|
-
| `<expr>[*].<path>` | A property of every item, as a list |
|
|
111
|
-
| `$item[.path]` | The current item: a scoped slot's item, a `forEach` item, or the trigger's payload |
|
|
112
|
-
| `$context.<id>[.path]` | A value declared in the screen's `context` |
|
|
113
|
-
| `$steps.<stepId>.response[.path]` | The response of an earlier step |
|
|
114
|
-
|
|
115
|
-
`:` ends a component id, so ids can contain dots. Nothing outside this grammar is valid.
|
|
116
|
-
|
|
117
|
-
---
|
|
118
|
-
|
|
119
|
-
## 7. Generated code
|
|
120
|
-
|
|
121
|
-
`api-binding generate` writes into `output.dir`:
|
|
122
|
-
|
|
123
|
-
- `screen-helpers.ts`: the few helpers the hooks share (React Query only).
|
|
124
|
-
- `<screen id>.ts` per screen: `<Screen>State`, `<Screen>Api` and `use<Screen>Api(state)`.
|
|
125
|
-
- `index.ts`: re-exports.
|
|
126
|
-
|
|
127
|
-
**The signature comes from the registry, the body from the bindings.** `<Screen>State` has one optional entry per component that produces values; `<Screen>Api` has one entry per consume and trigger slot. Changing bindings never changes either type, so call sites stay put. Adding or removing components does, and `tsc` points at every call site to update.
|
|
128
|
-
|
|
129
|
-
| Slot | Returns | When unbound |
|
|
130
|
-
|---|---|---|
|
|
131
|
-
| consume | `Data<T>`: `{ bound, data, loading, error }` | `bound: false`; keep the UI's own value |
|
|
132
|
-
| consume, scoped | `Cell<T>`: `{ bound, read(item) }` | `read` returns `undefined` |
|
|
133
|
-
| trigger | `Action`: `{ bound, run(payload?), running, error }` | `run` resolves at once |
|
|
134
|
-
|
|
135
|
-
- Queries call the Orval function for the operation: path params in path order, then the body, then the query params object, then fetch options (headers). A query is enabled only when every required input has a value; `load: { everySeconds }` polls.
|
|
136
|
-
- Actions run steps in order. A failed call rejects `run` (`stop`) or is collected and rejected after the rest run (`continue`).
|
|
137
|
-
- Responses are unwrapped from Orval's `{ data, status, headers }`; a non-2xx status throws `ApiError`.
|
|
138
|
-
|
|
139
|
-
---
|
|
140
|
-
|
|
141
|
-
## 8. Suggestions
|
|
142
|
-
|
|
143
|
-
The studio ranks **all** operations for each slot and shows the best first; any operation can be chosen. Ranking uses metadata only: shape compatibility, intent, name similarity, glossary synonyms. Request fields are proposed from the values the screen has (produce slots, context, earlier steps, the current item), ranked by name and type. Proposals stay `origin: "suggested"` until a person confirms them.
|
|
144
|
-
|
|
145
|
-
---
|
|
146
|
-
|
|
147
|
-
## 9. Validation
|
|
148
|
-
|
|
149
|
-
`api-binding generate` fails, and writes nothing, on any error:
|
|
150
|
-
|
|
151
|
-
- an id that breaks the format, is duplicated, or clashes with a screen id; a component under no screen
|
|
152
|
-
- an unknown kind, or a parent that is not registered
|
|
153
|
-
- an expression reading a slot that is not produced on the screen, an undeclared context, a later step, or `$item` where there is none
|
|
154
|
-
- an operation not in the docs, a request input the operation does not have, an unconnected path parameter
|
|
155
|
-
- two components whose names become the same TypeScript key
|
|
156
|
-
|
|
157
|
-
Warnings: drafts, unconnected required body fields, and with `--src` every registered id missing from the source (and every `componentId` literal not registered). `--check` exits 1 when generated files are out of date.
|
|
158
|
-
|
|
159
|
-
---
|
|
160
|
-
|
|
161
|
-
## 10. Studio rules
|
|
162
|
-
|
|
163
|
-
- Runs from the Vite dev plugin (`api_binding_ui/vite`, serve only) or the CLI (`api-binding studio`). Either server reads `registry.json`, reads and writes `bindings.json`, and fetches the docs server-side, so the browser needs no CORS access.
|
|
164
|
-
- Under the Vite plugin every save regenerates the screen hooks, so a confirmed binding is live in the running app after the save. Vite does not watch `bindings.json`.
|
|
165
|
-
- The dev plugin's floating button opens the studio over the app on the screen shown: the screen most `data-component-id` values the user can see belong to; a component covered by any layer above it does not count. Alt+click opens it on one component. Nothing reaches a build.
|
|
166
|
-
- Navigation and component lists come from the ids. Icons come from kinds.
|
|
167
|
-
- Three slot editors, one per role, generated from slot schemas. A new kind never needs studio code.
|
|
168
|
-
- Shows only what validates.
|
|
169
|
-
|
|
170
|
-
---
|
|
171
|
-
|
|
172
|
-
## 11. Conformance rules for an app
|
|
173
|
-
|
|
174
|
-
1. Every bindable component has a `componentId` in the format above, registered in `registry.json`.
|
|
175
|
-
2. Each screen calls its generated `use<Screen>Api(state)` once, passes what its components produce and what it was opened with, and renders `bound ? data : <its own value>`.
|
|
176
|
-
3. No direct API calls in screens other than through generated hooks; no hardcoded data an API should provide.
|
|
177
|
-
4. Opening, closing, navigation, toasts and reloads stay in the app's UI code.
|
|
178
|
-
5. Orval output and screen hooks are regenerated, never edited by hand.
|
|
1
|
+
# API Binding Framework — v3
|
|
2
|
+
|
|
3
|
+
Normative types: [`contract.ts`](../src/framework/contract.ts).
|
|
4
|
+
|
|
5
|
+
The framework connects **UI components** to **documented API operations** at design time and turns the result into **plain code**. Binding lives in two files: `registry.json` (what exists: kinds and the screen → component → part tree) and `bindings.json` (how it is wired to the API). After design time the app holds only ordinary React Query hooks over an Orval client; no framework code, runtime or manifest ships.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Principles
|
|
10
|
+
|
|
11
|
+
1. **Nothing concrete in the framework.** Component kinds, products, operations and fields arrive as data: kinds and the registry in the bindings file, operations from the OpenAPI docs.
|
|
12
|
+
2. **The API docs are the only source of operations.** No local hooks, hardcoded option lists or free-text overrides.
|
|
13
|
+
3. **UI first.** The unit of work is a screen and its components; APIs attach to component slots.
|
|
14
|
+
4. **Two files.** `registry.json` says what exists (kinds, screens, components); `bindings.json` says how it is wired (output settings, bindings). The studio writes only `bindings.json`.
|
|
15
|
+
5. **No trace at run time.** The generator writes plain hooks; the app never imports this package.
|
|
16
|
+
6. **Deterministic.** The same file and docs always produce the same code. Generated files carry an `@generated` mark and are never edited by hand.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. The pieces
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
OpenAPI docs ──► Orval ──────────────► src/api/generated (typed client)
|
|
24
|
+
│
|
|
25
|
+
└──► api-binding studio ◄──► bindings.json (+ registry.json) ──► api-binding generate ──► src/api/screens (one hook per screen)
|
|
26
|
+
│
|
|
27
|
+
app components ── use<Screen>Api(state) ┘
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Piece | What it is | Owned by |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Kinds | A component kind's slots, with JSON Schema shapes | Design system, in `registry.json` → `kinds` |
|
|
33
|
+
| Screens and components | Every screen and component instance, by id | The app, in `registry.json` (a tree; nesting gives each part its parent) |
|
|
34
|
+
| Bindings | Which operation or value feeds each slot | Designers in the studio, reviewed by engineers |
|
|
35
|
+
| Orval client | Typed functions per operation | Generated by Orval from the same docs |
|
|
36
|
+
| Screen hooks | `use<Screen>Api(state)` per screen | Generated by `api-binding generate` |
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 3. Ids
|
|
41
|
+
|
|
42
|
+
One namespace for screens and components, used everywhere: in `componentId` props, in the file and in expressions.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
<product>.<screen>[.<sub-screen>…].<component>[.<part>…]
|
|
46
|
+
collections.worklist screen
|
|
47
|
+
collections.worklist.add-notes sub-screen (a dialog over the worklist)
|
|
48
|
+
collections.worklist.grid component on collections.worklist
|
|
49
|
+
collections.worklist.grid.column.customer-name
|
|
50
|
+
collections.worklist.grid.row.add-notes
|
|
51
|
+
collections.worklist.add-notes.notes-type component on collections.worklist.add-notes
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- Segments are lowercase kebab-case (`[a-z0-9-]`), joined by dots: `^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$`.
|
|
55
|
+
- A component belongs to the **longest** registered screen id that prefixes it. A screen's parent is the longest other screen id that prefixes it. Navigation is built from this; there is no `path` or `parent` to maintain.
|
|
56
|
+
- A component id may not equal a screen id (it would capture that screen's components).
|
|
57
|
+
- Ids are stable: renaming one orphans its bindings. They never depend on labels.
|
|
58
|
+
- In the app, each component gets `componentId="<id>"`; design-system components render it as `data-component-id`.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 4. Kinds and slots
|
|
63
|
+
|
|
64
|
+
A kind declares slots with one of three **roles**:
|
|
65
|
+
|
|
66
|
+
| Role | Meaning | Bound to |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `consume` | The component needs data | An operation (request + response selection) or an expression |
|
|
69
|
+
| `produce` | The component exposes data | Nothing; a source others read |
|
|
70
|
+
| `trigger` | The component emits an event | Ordered API call steps, or "no API" |
|
|
71
|
+
|
|
72
|
+
- `scope.parentSlot`: the slot is evaluated per item of the parent's slot (a cell in a row, an action on a row); `$item` is that item.
|
|
73
|
+
- `feeds`: produce slots of the same component offered as request sources (paging, sorting).
|
|
74
|
+
- `"$ref": "#slot/<id>"`: the same shape as that slot after binding (a grid's selection has the shape of its rows).
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 5. The bindings file
|
|
79
|
+
|
|
80
|
+
```jsonc
|
|
81
|
+
{
|
|
82
|
+
"version": 3,
|
|
83
|
+
"openapi": "https://apidocs.example.com/index.yaml", // or a path next to this file
|
|
84
|
+
"output": { "dir": "src/api/screens", "client": "@/api/generated" },
|
|
85
|
+
"kinds": [ /* ComponentDescriptor */ ],
|
|
86
|
+
"screens": [ { "id": "collections.worklist.add-notes", "label": "Add Notes",
|
|
87
|
+
"context": [ { "id": "selection", "label": "Selected rows", "many": true,
|
|
88
|
+
"shape": { "from": { "screen": "collections.worklist", "component": "collections.worklist.grid", "slot": "selection" } } } ] } ],
|
|
89
|
+
"components": [ { "id": "collections.worklist.add-notes.notes-type", "kind": "hr-ds/select", "label": "Notes Type" } ],
|
|
90
|
+
"bindings": [ /* SlotBinding */ ]
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Slot role | Binding type | Contents |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| consume | `operation` | operation, request mapping (pointer → expression), response `select`, optional `map`, `load` |
|
|
97
|
+
| consume | `expression` | one expression |
|
|
98
|
+
| trigger | `steps` | ordered `call` steps; a step may run `forEach` over a list; `onError: stop \| continue` |
|
|
99
|
+
| trigger | `none` | reviewed: this event calls no API |
|
|
100
|
+
|
|
101
|
+
Only `confirmed` bindings are generated; drafts are reported as warnings. Bindings describe API calls only: opening, closing, navigation, toasts and reloads are app code.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 6. Expressions
|
|
106
|
+
|
|
107
|
+
| Form | Resolves to |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `<componentId>:<slot>[.path]` | A produce slot on the same screen, e.g. `collections.worklist.add-notes.subject:value` |
|
|
110
|
+
| `<expr>[*].<path>` | A property of every item, as a list |
|
|
111
|
+
| `$item[.path]` | The current item: a scoped slot's item, a `forEach` item, or the trigger's payload |
|
|
112
|
+
| `$context.<id>[.path]` | A value declared in the screen's `context` |
|
|
113
|
+
| `$steps.<stepId>.response[.path]` | The response of an earlier step |
|
|
114
|
+
|
|
115
|
+
`:` ends a component id, so ids can contain dots. Nothing outside this grammar is valid.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 7. Generated code
|
|
120
|
+
|
|
121
|
+
`api-binding generate` writes into `output.dir`:
|
|
122
|
+
|
|
123
|
+
- `screen-helpers.ts`: the few helpers the hooks share (React Query only).
|
|
124
|
+
- `<screen id>.ts` per screen: `<Screen>State`, `<Screen>Api` and `use<Screen>Api(state)`.
|
|
125
|
+
- `index.ts`: re-exports.
|
|
126
|
+
|
|
127
|
+
**The signature comes from the registry, the body from the bindings.** `<Screen>State` has one optional entry per component that produces values; `<Screen>Api` has one entry per consume and trigger slot. Changing bindings never changes either type, so call sites stay put. Adding or removing components does, and `tsc` points at every call site to update.
|
|
128
|
+
|
|
129
|
+
| Slot | Returns | When unbound |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| consume | `Data<T>`: `{ bound, data, loading, error }` | `bound: false`; keep the UI's own value |
|
|
132
|
+
| consume, scoped | `Cell<T>`: `{ bound, read(item) }` | `read` returns `undefined` |
|
|
133
|
+
| trigger | `Action`: `{ bound, run(payload?), running, error }` | `run` resolves at once |
|
|
134
|
+
|
|
135
|
+
- Queries call the Orval function for the operation: path params in path order, then the body, then the query params object, then fetch options (headers). A query is enabled only when every required input has a value; `load: { everySeconds }` polls.
|
|
136
|
+
- Actions run steps in order. A failed call rejects `run` (`stop`) or is collected and rejected after the rest run (`continue`).
|
|
137
|
+
- Responses are unwrapped from Orval's `{ data, status, headers }`; a non-2xx status throws `ApiError`.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 8. Suggestions
|
|
142
|
+
|
|
143
|
+
The studio ranks **all** operations for each slot and shows the best first; any operation can be chosen. Ranking uses metadata only: shape compatibility, intent, name similarity, glossary synonyms. Request fields are proposed from the values the screen has (produce slots, context, earlier steps, the current item), ranked by name and type. Proposals stay `origin: "suggested"` until a person confirms them.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 9. Validation
|
|
148
|
+
|
|
149
|
+
`api-binding generate` fails, and writes nothing, on any error:
|
|
150
|
+
|
|
151
|
+
- an id that breaks the format, is duplicated, or clashes with a screen id; a component under no screen
|
|
152
|
+
- an unknown kind, or a parent that is not registered
|
|
153
|
+
- an expression reading a slot that is not produced on the screen, an undeclared context, a later step, or `$item` where there is none
|
|
154
|
+
- an operation not in the docs, a request input the operation does not have, an unconnected path parameter
|
|
155
|
+
- two components whose names become the same TypeScript key
|
|
156
|
+
|
|
157
|
+
Warnings: drafts, unconnected required body fields, and with `--src` every registered id missing from the source (and every `componentId` literal not registered). `--check` exits 1 when generated files are out of date.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 10. Studio rules
|
|
162
|
+
|
|
163
|
+
- Runs from the Vite dev plugin (`api_binding_ui/vite`, serve only) or the CLI (`api-binding studio`). Either server reads `registry.json`, reads and writes `bindings.json`, and fetches the docs server-side, so the browser needs no CORS access.
|
|
164
|
+
- Under the Vite plugin every save regenerates the screen hooks, so a confirmed binding is live in the running app after the save. Vite does not watch `bindings.json`. On start it also generates the API client (the app's `orval.config.ts`) and the hooks, so a project needs no committed generated code.
|
|
165
|
+
- The dev plugin's floating button opens the studio over the app on the screen shown: the screen most `data-component-id` values the user can see belong to; a component covered by any layer above it does not count. Alt+click opens it on one component. Nothing reaches a build.
|
|
166
|
+
- Navigation and component lists come from the ids. Icons come from kinds.
|
|
167
|
+
- Three slot editors, one per role, generated from slot schemas. A new kind never needs studio code.
|
|
168
|
+
- Shows only what validates.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 11. Conformance rules for an app
|
|
173
|
+
|
|
174
|
+
1. Every bindable component has a `componentId` in the format above, registered in `registry.json`.
|
|
175
|
+
2. Each screen calls its generated `use<Screen>Api(state)` once, passes what its components produce and what it was opened with, and renders `bound ? data : <its own value>`.
|
|
176
|
+
3. No direct API calls in screens other than through generated hooks; no hardcoded data an API should provide.
|
|
177
|
+
4. Opening, closing, navigation, toasts and reloads stay in the app's UI code.
|
|
178
|
+
5. Orval output and screen hooks are regenerated, never edited by hand.
|