@nwire/koa 0.10.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.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/http-koa.d.ts +116 -0
- package/dist/http-koa.js +350 -0
- package/dist/inspect.d.ts +31 -0
- package/dist/inspect.js +142 -0
- package/dist/openapi.d.ts +28 -0
- package/dist/openapi.js +102 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Gefter / 200apps Ltd.
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @nwire/koa
|
|
2
|
+
|
|
3
|
+
> The canonical HTTP adopter — a Koa server that consumes Nwire wires.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @nwire/koa @nwire/app @nwire/endpoint @nwire/wires
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createApp } from "@nwire/app";
|
|
13
|
+
import { endpoint } from "@nwire/endpoint";
|
|
14
|
+
import { post } from "@nwire/wires/http";
|
|
15
|
+
import { httpKoa } from "@nwire/koa";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
const app = createApp({ appName: "api" });
|
|
19
|
+
|
|
20
|
+
app.wire(
|
|
21
|
+
post("/hello", { body: z.object({ name: z.string().min(1) }) }),
|
|
22
|
+
async (input) => ({ message: `Hello, ${input.name}!` }),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
await endpoint("api", { port: 3000 })
|
|
26
|
+
.use(httpKoa({ prefix: "/api" }))
|
|
27
|
+
.mount(app)
|
|
28
|
+
.run();
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Config
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
httpKoa({
|
|
35
|
+
port: 3000, // 0 = ephemeral; .port() returns the bound port after boot
|
|
36
|
+
host: "0.0.0.0",
|
|
37
|
+
prefix: "/api", // mounted on every wired route
|
|
38
|
+
helmet: true, // default-on; pass false to disable
|
|
39
|
+
cors: { origin: "*" }, // pass an options object or true; false to disable
|
|
40
|
+
bodyParser: { jsonLimit: "1mb" },
|
|
41
|
+
middleware: [requireUser], // adopter-wide Koa middleware run before every wire
|
|
42
|
+
logger: myLogger, // defaults to ConsoleLogger
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Per-route middleware
|
|
47
|
+
|
|
48
|
+
Wires can carry route-scoped middleware. `httpKoa` runs them after the
|
|
49
|
+
adopter-wide chain and before the handler.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { post } from "@nwire/wires/http";
|
|
53
|
+
|
|
54
|
+
app.wire(
|
|
55
|
+
post("/admin/users", {
|
|
56
|
+
body: UserBody,
|
|
57
|
+
middleware: [requireRole("admin")],
|
|
58
|
+
}),
|
|
59
|
+
createUser,
|
|
60
|
+
);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Handler shape
|
|
64
|
+
|
|
65
|
+
Two shapes are supported and route to the same dispatch:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// Foundation HTTP — plain (input, ctx)
|
|
69
|
+
app.wire(
|
|
70
|
+
post("/order", { body: OrderBody }),
|
|
71
|
+
async (input, ctx) => {
|
|
72
|
+
const repo = ctx.resolve<OrderRepo>("orders");
|
|
73
|
+
return repo.create(input);
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Forge HandlerDefinition — routed through runtime.execute
|
|
78
|
+
const placeOrder = defineHandler(placeOrderAction, async (input, ctx) => {
|
|
79
|
+
/* ... */
|
|
80
|
+
});
|
|
81
|
+
app.wire(post("/order", { body: OrderBody }), placeOrder);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The adopter detects forge handlers (`.run` + `runtime.execute` present)
|
|
85
|
+
and routes them through the runtime so `action.before/after` hooks,
|
|
86
|
+
retries, telemetry, and per-action middleware all fire. Plain handlers
|
|
87
|
+
take the direct path with a minimal ctx (`resolve`, `logger`, `koa`,
|
|
88
|
+
`envelope`).
|
|
89
|
+
|
|
90
|
+
## Response shaping
|
|
91
|
+
|
|
92
|
+
| Handler returns | HTTP response |
|
|
93
|
+
| --------------------------------------------------- | ------------------------------ |
|
|
94
|
+
| `{ $kind: "response-instance", status, body }` | `status`, JSON body |
|
|
95
|
+
| `{ $status, body }` (legacy envelope) | `$status`, JSON body |
|
|
96
|
+
| anything else | `200`, JSON serialized verbatim |
|
|
97
|
+
| validation throws | `400`, structured error JSON |
|
|
98
|
+
| handler throws `defineError`-shaped | `error.status`, structured JSON |
|
|
99
|
+
|
|
100
|
+
## Testing
|
|
101
|
+
|
|
102
|
+
`adapter.koa()` exposes the underlying Koa app for in-process testing
|
|
103
|
+
via `supertest` or `simulateRequest` from `@nwire/test-kit`:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const koa = httpKoa({ port: 0 });
|
|
107
|
+
const running = await endpoint("test", { ... }).use(koa).mount(app).run();
|
|
108
|
+
|
|
109
|
+
const res = await simulateRequest(koa.koa(), {
|
|
110
|
+
method: "POST",
|
|
111
|
+
path: "/hello",
|
|
112
|
+
body: { name: "Alice" },
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`adapter.port()` returns the bound port (useful when `port: 0`).
|
|
117
|
+
|
|
118
|
+
## Related
|
|
119
|
+
|
|
120
|
+
- [`@nwire/endpoint`](../core-endpoint) — the lifecycle host
|
|
121
|
+
- [`@nwire/wires`](../core-wires) — `get`/`post`/`put`/`patch`/`del`
|
|
122
|
+
binding factories
|
|
123
|
+
- [`@nwire/express`](../nwire-express) — the Express-flavored adopter
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nwire/koa` — Koa-backed HTTP adopter.
|
|
3
|
+
*
|
|
4
|
+
* import { httpKoa } from "@nwire/koa";
|
|
5
|
+
*
|
|
6
|
+
* await endpoint("api", { port: 3000 })
|
|
7
|
+
* .use(httpKoa({ helmet: true, cors: true }))
|
|
8
|
+
* .mount(app)
|
|
9
|
+
* .run();
|
|
10
|
+
*
|
|
11
|
+
* The adopter consumes wires whose `binding.$adapter === "http"`. For
|
|
12
|
+
* each wire it mounts a Koa route on the configured verb + path, parses
|
|
13
|
+
* params / body / query against the binding's zod schemas, builds a
|
|
14
|
+
* minimal handler ctx (input + resolve + logger), and invokes the
|
|
15
|
+
* wire's handler. Response shaping is intentionally loose at this stage
|
|
16
|
+
* — the handler returns a value that becomes ctx.body; status defaults
|
|
17
|
+
* to 200; { $status, body } objects unwrap.
|
|
18
|
+
*
|
|
19
|
+
* 0.10 first-pass scope:
|
|
20
|
+
* - helmet + cors + body parser layered when configured
|
|
21
|
+
* - per-wire handler dispatch via Koa router
|
|
22
|
+
* - per-request scoped container (parent = wire's source app)
|
|
23
|
+
* - dispatched as Adapter so endpoint().use(httpKoa()).mount(app).run()
|
|
24
|
+
* works end-to-end
|
|
25
|
+
*
|
|
26
|
+
* Deferred (lands in 0.10.x):
|
|
27
|
+
* - OpenAPI document + /docs serving
|
|
28
|
+
* - Scalar docs UI
|
|
29
|
+
* - Inspect endpoints under /_nwire/*
|
|
30
|
+
* - Per-route middleware (RouteBinding.middleware)
|
|
31
|
+
* - Per-request envelope scope (forge integration)
|
|
32
|
+
* - fromKoaApp / fromKoaMiddleware / fromKoa auto-wrapper
|
|
33
|
+
*/
|
|
34
|
+
import type { Adapter } from "@nwire/endpoint";
|
|
35
|
+
import { type Logger } from "@nwire/logger";
|
|
36
|
+
import Koa from "koa";
|
|
37
|
+
import cors from "@koa/cors";
|
|
38
|
+
export interface HttpKoaConfig {
|
|
39
|
+
/** Port to listen on. Falls through to endpoint's config.port if undefined. */
|
|
40
|
+
readonly port?: number;
|
|
41
|
+
readonly host?: string;
|
|
42
|
+
/** URL prefix mounted on the router (e.g. "/api"). */
|
|
43
|
+
readonly prefix?: string;
|
|
44
|
+
/** Enable koa-helmet. Default true. */
|
|
45
|
+
readonly helmet?: boolean;
|
|
46
|
+
/** Enable @koa/cors. Default false. Pass an object to forward CORS opts. */
|
|
47
|
+
readonly cors?: boolean | Parameters<typeof cors>[0];
|
|
48
|
+
/** Body parser options forwarded to koa-bodyparser. */
|
|
49
|
+
readonly bodyParser?: any;
|
|
50
|
+
/**
|
|
51
|
+
* Adopter-wide Koa middleware — runs before every wire's handler.
|
|
52
|
+
* Use for authn / authz / request-id / logging concerns shared across
|
|
53
|
+
* every route the adopter serves.
|
|
54
|
+
*/
|
|
55
|
+
readonly middleware?: readonly Koa.Middleware[];
|
|
56
|
+
readonly logger?: Logger;
|
|
57
|
+
/**
|
|
58
|
+
* OpenAPI 3.1 doc — three forms of input:
|
|
59
|
+
* - `auto: true` — generate the spec by walking the adopter's wires;
|
|
60
|
+
* needs `@asteasolutions/zod-to-openapi` installed as a peer dep
|
|
61
|
+
* - `spec` — pass a pre-built object (skip codegen entirely)
|
|
62
|
+
* - `generator` — lazy generation per request, e.g. for cache-busting
|
|
63
|
+
*
|
|
64
|
+
* The spec is served at `path` (default `/openapi.json`). `title`,
|
|
65
|
+
* `version`, `description`, `serverUrl`, `prefix` are forwarded to the
|
|
66
|
+
* auto-generator if used.
|
|
67
|
+
*/
|
|
68
|
+
readonly openapi?: {
|
|
69
|
+
readonly auto?: boolean;
|
|
70
|
+
readonly spec?: object;
|
|
71
|
+
readonly generator?: () => object | Promise<object>;
|
|
72
|
+
readonly path?: string;
|
|
73
|
+
readonly title?: string;
|
|
74
|
+
readonly version?: string;
|
|
75
|
+
readonly description?: string;
|
|
76
|
+
readonly serverUrl?: string;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Scalar UI for the OpenAPI spec. When `enabled`, mounts an HTML page
|
|
80
|
+
* at `path` (default `/docs`) that loads Scalar from the JSDelivr CDN
|
|
81
|
+
* and points at the configured `openapi.path`.
|
|
82
|
+
*/
|
|
83
|
+
readonly docs?: boolean | {
|
|
84
|
+
readonly path?: string;
|
|
85
|
+
readonly title?: string;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* `/_nwire/*` introspection surface (manifest, wires, runtime, actors,
|
|
89
|
+
* dispatch). `true` mounts with defaults; pass an object for prefix or
|
|
90
|
+
* adopter-wide guards (`middleware: [requireAdmin]`).
|
|
91
|
+
*/
|
|
92
|
+
readonly inspect?: boolean | import("./inspect.js").InspectConfig;
|
|
93
|
+
}
|
|
94
|
+
/** Minimal handler-ctx shape the adopter constructs per request. */
|
|
95
|
+
export interface HttpKoaHandlerCtx {
|
|
96
|
+
resolve<T = unknown>(name: string): T;
|
|
97
|
+
readonly logger: Logger;
|
|
98
|
+
/** The Koa context, for adapters that need raw request access. */
|
|
99
|
+
readonly koa: Koa.ParameterizedContext;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* `httpKoa(config)` — build the HTTP adopter. Returns an `Adapter` the
|
|
103
|
+
* endpoint installs via `.use(...)`.
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* The adopter handle. Extends the base `Adapter` contract with a
|
|
107
|
+
* `port()` accessor so tests + tooling can discover the bound port
|
|
108
|
+
* when `config.port === 0`.
|
|
109
|
+
*/
|
|
110
|
+
export interface HttpKoaAdapter extends Adapter {
|
|
111
|
+
port(): number | undefined;
|
|
112
|
+
/** Underlying Koa instance — present after boot. Tests can pass this to
|
|
113
|
+
* supertest(adapter.koa()!.callback()) for in-process request simulation. */
|
|
114
|
+
koa(): Koa | undefined;
|
|
115
|
+
}
|
|
116
|
+
export declare function httpKoa(config?: HttpKoaConfig): HttpKoaAdapter;
|
package/dist/http-koa.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nwire/koa` — Koa-backed HTTP adopter.
|
|
3
|
+
*
|
|
4
|
+
* import { httpKoa } from "@nwire/koa";
|
|
5
|
+
*
|
|
6
|
+
* await endpoint("api", { port: 3000 })
|
|
7
|
+
* .use(httpKoa({ helmet: true, cors: true }))
|
|
8
|
+
* .mount(app)
|
|
9
|
+
* .run();
|
|
10
|
+
*
|
|
11
|
+
* The adopter consumes wires whose `binding.$adapter === "http"`. For
|
|
12
|
+
* each wire it mounts a Koa route on the configured verb + path, parses
|
|
13
|
+
* params / body / query against the binding's zod schemas, builds a
|
|
14
|
+
* minimal handler ctx (input + resolve + logger), and invokes the
|
|
15
|
+
* wire's handler. Response shaping is intentionally loose at this stage
|
|
16
|
+
* — the handler returns a value that becomes ctx.body; status defaults
|
|
17
|
+
* to 200; { $status, body } objects unwrap.
|
|
18
|
+
*
|
|
19
|
+
* 0.10 first-pass scope:
|
|
20
|
+
* - helmet + cors + body parser layered when configured
|
|
21
|
+
* - per-wire handler dispatch via Koa router
|
|
22
|
+
* - per-request scoped container (parent = wire's source app)
|
|
23
|
+
* - dispatched as Adapter so endpoint().use(httpKoa()).mount(app).run()
|
|
24
|
+
* works end-to-end
|
|
25
|
+
*
|
|
26
|
+
* Deferred (lands in 0.10.x):
|
|
27
|
+
* - OpenAPI document + /docs serving
|
|
28
|
+
* - Scalar docs UI
|
|
29
|
+
* - Inspect endpoints under /_nwire/*
|
|
30
|
+
* - Per-route middleware (RouteBinding.middleware)
|
|
31
|
+
* - Per-request envelope scope (forge integration)
|
|
32
|
+
* - fromKoaApp / fromKoaMiddleware / fromKoa auto-wrapper
|
|
33
|
+
*/
|
|
34
|
+
import http from "node:http";
|
|
35
|
+
import { dummyContainer } from "@nwire/container";
|
|
36
|
+
import { ConsoleLogger } from "@nwire/logger";
|
|
37
|
+
import Koa from "koa";
|
|
38
|
+
import bodyParser from "koa-bodyparser";
|
|
39
|
+
import cors from "@koa/cors";
|
|
40
|
+
import helmet from "koa-helmet";
|
|
41
|
+
import KoaRouter from "@koa/router";
|
|
42
|
+
function isHttpBinding(b) {
|
|
43
|
+
return (typeof b === "object" &&
|
|
44
|
+
b !== null &&
|
|
45
|
+
b.$adapter === "http" &&
|
|
46
|
+
typeof b.verb === "string" &&
|
|
47
|
+
typeof b.path === "string");
|
|
48
|
+
}
|
|
49
|
+
export function httpKoa(config = {}) {
|
|
50
|
+
let server;
|
|
51
|
+
let koaInstance;
|
|
52
|
+
const logger = config.logger ?? new ConsoleLogger();
|
|
53
|
+
return {
|
|
54
|
+
$kind: "adapter",
|
|
55
|
+
kind: "http",
|
|
56
|
+
port() {
|
|
57
|
+
if (!server)
|
|
58
|
+
return undefined;
|
|
59
|
+
const addr = server.address();
|
|
60
|
+
return typeof addr === "object" && addr !== null ? addr.port : undefined;
|
|
61
|
+
},
|
|
62
|
+
koa() {
|
|
63
|
+
return koaInstance;
|
|
64
|
+
},
|
|
65
|
+
async boot(ctx) {
|
|
66
|
+
const koa = new Koa();
|
|
67
|
+
koaInstance = koa;
|
|
68
|
+
// Top-level error mapper — catches throws from middleware and
|
|
69
|
+
// unmatched routes. Renders defineError-shaped values with their
|
|
70
|
+
// {code, status, summary} envelope; otherwise opaque 500.
|
|
71
|
+
koa.use(async (kctx, next) => {
|
|
72
|
+
try {
|
|
73
|
+
await next();
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const e = err;
|
|
77
|
+
kctx.status = typeof e.status === "number" ? e.status : 500;
|
|
78
|
+
kctx.body = {
|
|
79
|
+
error: {
|
|
80
|
+
code: e.code ?? "internal_error",
|
|
81
|
+
summary: e.summary ?? e.message ?? "Internal error",
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
if (config.helmet !== false) {
|
|
87
|
+
koa.use(helmet());
|
|
88
|
+
}
|
|
89
|
+
if (config.cors) {
|
|
90
|
+
koa.use(cors(typeof config.cors === "object" ? config.cors : undefined));
|
|
91
|
+
}
|
|
92
|
+
koa.use(bodyParser(config.bodyParser));
|
|
93
|
+
// OpenAPI doc + Scalar UI (mounted before per-route middleware so
|
|
94
|
+
// they don't sit behind auth gates).
|
|
95
|
+
const openapiPath = config.openapi?.path ?? "/openapi.json";
|
|
96
|
+
const docsConfig = config.docs === true ? {} : config.docs === false ? null : (config.docs ?? null);
|
|
97
|
+
const docsPath = docsConfig?.path ?? "/docs";
|
|
98
|
+
const docsTitle = docsConfig?.title ?? "API docs";
|
|
99
|
+
if (config.openapi?.spec || config.openapi?.generator || config.openapi?.auto) {
|
|
100
|
+
// Auto-generation walks the adopter's wires at mount time and
|
|
101
|
+
// caches the resulting doc. `spec` and `generator` short-circuit
|
|
102
|
+
// the build; pass either if you want full control of the doc.
|
|
103
|
+
let cachedAutoSpec;
|
|
104
|
+
const buildAuto = async () => {
|
|
105
|
+
if (cachedAutoSpec)
|
|
106
|
+
return cachedAutoSpec;
|
|
107
|
+
const { generateOpenApi } = await import("./openapi.js");
|
|
108
|
+
cachedAutoSpec = await generateOpenApi(ctx.wires, {
|
|
109
|
+
title: config.openapi.title,
|
|
110
|
+
version: config.openapi.version,
|
|
111
|
+
description: config.openapi.description,
|
|
112
|
+
serverUrl: config.openapi.serverUrl,
|
|
113
|
+
prefix: config.prefix,
|
|
114
|
+
});
|
|
115
|
+
return cachedAutoSpec;
|
|
116
|
+
};
|
|
117
|
+
koa.use(async (kctx, next) => {
|
|
118
|
+
if (kctx.method !== "GET" || kctx.path !== openapiPath)
|
|
119
|
+
return next();
|
|
120
|
+
const spec = config.openapi.spec ??
|
|
121
|
+
(config.openapi.generator ? await config.openapi.generator() : await buildAuto());
|
|
122
|
+
kctx.type = "application/json";
|
|
123
|
+
kctx.body = JSON.stringify(spec);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (docsConfig !== null) {
|
|
127
|
+
koa.use(async (kctx, next) => {
|
|
128
|
+
if (kctx.method !== "GET" || kctx.path !== docsPath)
|
|
129
|
+
return next();
|
|
130
|
+
kctx.type = "text/html";
|
|
131
|
+
kctx.body = `<!doctype html>
|
|
132
|
+
<html>
|
|
133
|
+
<head>
|
|
134
|
+
<title>${docsTitle}</title>
|
|
135
|
+
<meta charset="utf-8" />
|
|
136
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
137
|
+
</head>
|
|
138
|
+
<body>
|
|
139
|
+
<script id="api-reference" data-url="${openapiPath}"></script>
|
|
140
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
141
|
+
</body>
|
|
142
|
+
</html>`;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// /_nwire/* introspection — mounted before adopter-wide auth so
|
|
146
|
+
// Studio can read manifest/wires/runtime without an admin token.
|
|
147
|
+
// Production consumers gate via inspect.middleware.
|
|
148
|
+
if (config.inspect) {
|
|
149
|
+
const inspectConfig = config.inspect === true ? {} : config.inspect;
|
|
150
|
+
const { mountInspect } = await import("./inspect.js");
|
|
151
|
+
// Find a representative source app — we mount inspect against the
|
|
152
|
+
// first wire's owning app; the wire collection is what we report.
|
|
153
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
154
|
+
const firstApp = ctx.wires[0]?.app;
|
|
155
|
+
mountInspect(koa, {
|
|
156
|
+
wires: ctx.wires,
|
|
157
|
+
app: firstApp,
|
|
158
|
+
adopterKind: "http",
|
|
159
|
+
config: inspectConfig,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Adopter-wide middleware runs before every wired handler.
|
|
163
|
+
for (const mw of config.middleware ?? []) {
|
|
164
|
+
koa.use(mw);
|
|
165
|
+
}
|
|
166
|
+
const router = config.prefix
|
|
167
|
+
? new KoaRouter({ prefix: config.prefix })
|
|
168
|
+
: new KoaRouter();
|
|
169
|
+
for (const wire of ctx.wires) {
|
|
170
|
+
if (!isHttpBinding(wire.binding))
|
|
171
|
+
continue;
|
|
172
|
+
const binding = wire.binding;
|
|
173
|
+
const verb = binding.verb;
|
|
174
|
+
const path = binding.path;
|
|
175
|
+
const handler = async (kctx) => {
|
|
176
|
+
// Parse + merge params / body / query into a flat input.
|
|
177
|
+
let input = {};
|
|
178
|
+
try {
|
|
179
|
+
if (binding.params) {
|
|
180
|
+
Object.assign(input, binding.params.parse(kctx.params));
|
|
181
|
+
}
|
|
182
|
+
if (binding.body) {
|
|
183
|
+
Object.assign(input, binding.body.parse(kctx.request.body));
|
|
184
|
+
}
|
|
185
|
+
if (binding.query) {
|
|
186
|
+
Object.assign(input, binding.query.parse(kctx.request.query));
|
|
187
|
+
}
|
|
188
|
+
if (!binding.params && !binding.body && !binding.query) {
|
|
189
|
+
// No schemas — pass merged raw values as input for hand-rolled
|
|
190
|
+
// resolvers. Adopters can still read kctx via handlerCtx.koa.
|
|
191
|
+
input = {
|
|
192
|
+
...kctx.params,
|
|
193
|
+
...kctx.request.body,
|
|
194
|
+
...kctx.request.query,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
kctx.status = 400;
|
|
200
|
+
kctx.body = {
|
|
201
|
+
error: {
|
|
202
|
+
code: "validation_failed",
|
|
203
|
+
summary: err.message,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Per-request scope: child container of the source app's container.
|
|
209
|
+
const parentContainer = ctx.containerOf(wire) ?? dummyContainer();
|
|
210
|
+
const reqContainer = parentContainer.createScope();
|
|
211
|
+
const handlerCtx = {
|
|
212
|
+
resolve: (name) => reqContainer.resolve(name),
|
|
213
|
+
logger,
|
|
214
|
+
koa: kctx,
|
|
215
|
+
};
|
|
216
|
+
// Per-request envelope derived from request headers — userId set
|
|
217
|
+
// by adopter middleware lands on it; tenant flows from a header
|
|
218
|
+
// for simple multi-tenancy; correlationId / causationId come
|
|
219
|
+
// from upstream callers so distributed traces stitch in.
|
|
220
|
+
const envelopePartial = {
|
|
221
|
+
tenant: kctx.request.headers["x-tenant"] ?? undefined,
|
|
222
|
+
userId: kctx.state.userId ?? undefined,
|
|
223
|
+
correlationId: kctx.request.headers["x-correlation-id"] ?? undefined,
|
|
224
|
+
causationId: kctx.request.headers["x-causation-id"] ?? undefined,
|
|
225
|
+
};
|
|
226
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
227
|
+
const wireApp = wire.app;
|
|
228
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
229
|
+
const runtimeExecute = wireApp?.runtime?.execute;
|
|
230
|
+
let result;
|
|
231
|
+
try {
|
|
232
|
+
if (runtimeExecute) {
|
|
233
|
+
// Unified dispatch — runtime.execute builds the canonical
|
|
234
|
+
// ctx (input + envelope + execute + emit + enqueue + resolve
|
|
235
|
+
// + scope) for both HandlerDefinition and plain `(input, ctx)`
|
|
236
|
+
// function shapes, threads transport extras (logger, koa)
|
|
237
|
+
// onto the same ctx, and runs the hook chain when present.
|
|
238
|
+
result = await runtimeExecute.call(wireApp.runtime, wire.handler, input, envelopePartial, { logger: handlerCtx.logger, koa: handlerCtx.koa });
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// No-runtime fallback — standalone interface without an App.
|
|
242
|
+
// Plain functions still get the unified ctx shape; lookups go
|
|
243
|
+
// through the per-request container directly.
|
|
244
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
245
|
+
const fn = wire.handler.run ?? wire.handler;
|
|
246
|
+
const fullCtx = { ...handlerCtx, envelope: envelopePartial };
|
|
247
|
+
result = await fn(input, fullCtx);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
// Generic error envelope; downstream phases hook in nwire-error
|
|
252
|
+
// mapping + sanitised production responses.
|
|
253
|
+
const e = err;
|
|
254
|
+
kctx.status = typeof e.status === "number" ? e.status : 500;
|
|
255
|
+
kctx.body = {
|
|
256
|
+
error: {
|
|
257
|
+
code: e.code ?? "internal_error",
|
|
258
|
+
summary: e.summary ?? e.message ?? "Internal error",
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Response shaping:
|
|
264
|
+
// - { $kind: "response-instance", status, body } — from defineResource
|
|
265
|
+
// - { $status, body } — legacy envelope
|
|
266
|
+
// - anything else — body verbatim, status 200
|
|
267
|
+
if (result &&
|
|
268
|
+
typeof result === "object" &&
|
|
269
|
+
result.$kind === "response-instance") {
|
|
270
|
+
const env = result;
|
|
271
|
+
// Koa rewrites status to 204 when body is set to null/undefined.
|
|
272
|
+
// For explicit 202/304/410-style "no body, but specific status"
|
|
273
|
+
// we set the empty body first then force the status afterward.
|
|
274
|
+
if (env.body === undefined || env.body === null) {
|
|
275
|
+
kctx.body = "";
|
|
276
|
+
kctx.status = env.status ?? 204;
|
|
277
|
+
kctx.length = 0;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
kctx.body = env.body;
|
|
281
|
+
kctx.status = env.status ?? 200;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else if (result &&
|
|
285
|
+
typeof result === "object" &&
|
|
286
|
+
"$status" in result) {
|
|
287
|
+
const env = result;
|
|
288
|
+
kctx.status = env.$status ?? 200;
|
|
289
|
+
kctx.body = env.body;
|
|
290
|
+
}
|
|
291
|
+
else if (result === undefined || result === null) {
|
|
292
|
+
kctx.status = 204;
|
|
293
|
+
kctx.body = undefined;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
kctx.status = kctx.status === 404 ? 200 : kctx.status;
|
|
297
|
+
kctx.body = result;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
// Per-route middleware lands as a Koa middleware chain before
|
|
301
|
+
// the dispatch handler. The binding's `middleware` field is
|
|
302
|
+
// declared `unknown` at the @nwire/wires layer; we narrow
|
|
303
|
+
// here to Koa.Middleware shape and append.
|
|
304
|
+
const routeMw = (binding.middleware ??
|
|
305
|
+
[]);
|
|
306
|
+
switch (verb) {
|
|
307
|
+
case "get":
|
|
308
|
+
router.get(path, ...routeMw, handler);
|
|
309
|
+
break;
|
|
310
|
+
case "post":
|
|
311
|
+
router.post(path, ...routeMw, handler);
|
|
312
|
+
break;
|
|
313
|
+
case "put":
|
|
314
|
+
router.put(path, ...routeMw, handler);
|
|
315
|
+
break;
|
|
316
|
+
case "patch":
|
|
317
|
+
router.patch(path, ...routeMw, handler);
|
|
318
|
+
break;
|
|
319
|
+
case "delete":
|
|
320
|
+
router.delete(path, ...routeMw, handler);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
koa.use(router.routes()).use(router.allowedMethods());
|
|
325
|
+
// Listen if a port is available: adopter-level config.port wins,
|
|
326
|
+
// otherwise fall back to the endpoint-level port hint from boot ctx.
|
|
327
|
+
const listenPort = config.port ?? ctx.port;
|
|
328
|
+
const listenHost = config.host ?? ctx.host ?? "0.0.0.0";
|
|
329
|
+
if (listenPort !== undefined) {
|
|
330
|
+
server = http.createServer(koa.callback());
|
|
331
|
+
await new Promise((resolve) => server.listen(listenPort, listenHost, resolve));
|
|
332
|
+
logger.info?.(`[http-koa] listening on ${listenHost}:${listenPort}`, undefined);
|
|
333
|
+
}
|
|
334
|
+
// Register a health check that surfaces "this adopter is alive."
|
|
335
|
+
ctx.addCheck({
|
|
336
|
+
name: "http-koa",
|
|
337
|
+
check: () => undefined,
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
async shutdown() {
|
|
341
|
+
if (server) {
|
|
342
|
+
await new Promise((resolve) => server.close(() => {
|
|
343
|
+
resolve();
|
|
344
|
+
}));
|
|
345
|
+
server = undefined;
|
|
346
|
+
}
|
|
347
|
+
koaInstance = undefined;
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/_nwire/*` — read + dispatch surface Studio (and MCP tooling) reads
|
|
3
|
+
* to render the live state of a running app.
|
|
4
|
+
*
|
|
5
|
+
* GET /_nwire/manifest — app name + adopter info + wire summary
|
|
6
|
+
* GET /_nwire/wires — every wire's binding kind + key fields
|
|
7
|
+
* GET /_nwire/runtime — telemetry tap + framework-event log shape
|
|
8
|
+
* GET /_nwire/actors/:t — actor instances of type `t` from the store
|
|
9
|
+
* POST /_nwire/dispatch — invoke a registered handler by name
|
|
10
|
+
*
|
|
11
|
+
* `inspect: true` (or `inspect: { ... }`) on `httpKoa({ ... })` mounts
|
|
12
|
+
* these before per-route middleware so they don't sit behind auth gates.
|
|
13
|
+
*
|
|
14
|
+
* Production deployments should add an admin guard via
|
|
15
|
+
* `inspect: { middleware: [requireAdmin] }`.
|
|
16
|
+
*/
|
|
17
|
+
import type Koa from "koa";
|
|
18
|
+
import type { Wire } from "@nwire/wires";
|
|
19
|
+
export interface InspectConfig {
|
|
20
|
+
readonly enabled?: boolean;
|
|
21
|
+
readonly prefix?: string;
|
|
22
|
+
/** Run before every /_nwire/* handler — for auth/audit. */
|
|
23
|
+
readonly middleware?: readonly Koa.Middleware[];
|
|
24
|
+
}
|
|
25
|
+
export interface MountInspectOptions {
|
|
26
|
+
readonly wires: ReadonlyArray<Wire>;
|
|
27
|
+
readonly app?: any;
|
|
28
|
+
readonly adopterKind: string;
|
|
29
|
+
readonly config: InspectConfig;
|
|
30
|
+
}
|
|
31
|
+
export declare function mountInspect(koa: Koa, opts: MountInspectOptions): void;
|
package/dist/inspect.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/_nwire/*` — read + dispatch surface Studio (and MCP tooling) reads
|
|
3
|
+
* to render the live state of a running app.
|
|
4
|
+
*
|
|
5
|
+
* GET /_nwire/manifest — app name + adopter info + wire summary
|
|
6
|
+
* GET /_nwire/wires — every wire's binding kind + key fields
|
|
7
|
+
* GET /_nwire/runtime — telemetry tap + framework-event log shape
|
|
8
|
+
* GET /_nwire/actors/:t — actor instances of type `t` from the store
|
|
9
|
+
* POST /_nwire/dispatch — invoke a registered handler by name
|
|
10
|
+
*
|
|
11
|
+
* `inspect: true` (or `inspect: { ... }`) on `httpKoa({ ... })` mounts
|
|
12
|
+
* these before per-route middleware so they don't sit behind auth gates.
|
|
13
|
+
*
|
|
14
|
+
* Production deployments should add an admin guard via
|
|
15
|
+
* `inspect: { middleware: [requireAdmin] }`.
|
|
16
|
+
*/
|
|
17
|
+
function summarizeWire(wire) {
|
|
18
|
+
const b = wire.binding;
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
const h = wire.handler;
|
|
21
|
+
return {
|
|
22
|
+
adapter: b.$adapter ?? "unknown",
|
|
23
|
+
kind: b.kind ?? "unknown",
|
|
24
|
+
verb: b.verb,
|
|
25
|
+
path: b.path,
|
|
26
|
+
tool: b.tool,
|
|
27
|
+
queue: b.queue,
|
|
28
|
+
cron: b.cron,
|
|
29
|
+
handlerName: h?.name ?? h?.action?.name,
|
|
30
|
+
source: b.source,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function mountInspect(koa, opts) {
|
|
34
|
+
const prefix = opts.config.prefix ?? "/_nwire";
|
|
35
|
+
const handle = async (kctx, next) => {
|
|
36
|
+
if (!kctx.path.startsWith(prefix + "/"))
|
|
37
|
+
return next();
|
|
38
|
+
// Adopter-wide middleware first (if any). We model these as a chain;
|
|
39
|
+
// Koa doesn't expose runtime chain composition for arbitrary inputs,
|
|
40
|
+
// so we fold them with a manual chain.
|
|
41
|
+
let i = 0;
|
|
42
|
+
const chain = opts.config.middleware ?? [];
|
|
43
|
+
const runNext = async () => {
|
|
44
|
+
const mw = chain[i++];
|
|
45
|
+
if (mw)
|
|
46
|
+
return mw(kctx, runNext);
|
|
47
|
+
await dispatchInspect(kctx, opts);
|
|
48
|
+
};
|
|
49
|
+
await runNext();
|
|
50
|
+
};
|
|
51
|
+
koa.use(handle);
|
|
52
|
+
}
|
|
53
|
+
async function dispatchInspect(kctx, opts) {
|
|
54
|
+
const prefix = opts.config.prefix ?? "/_nwire";
|
|
55
|
+
const sub = kctx.path.slice(prefix.length); // "/manifest", "/wires", …
|
|
56
|
+
const wires = opts.wires.map(summarizeWire);
|
|
57
|
+
if (kctx.method === "GET" && sub === "/manifest") {
|
|
58
|
+
kctx.type = "application/json";
|
|
59
|
+
kctx.body = JSON.stringify({
|
|
60
|
+
appName: opts.app?.appName ?? null,
|
|
61
|
+
adopter: opts.adopterKind,
|
|
62
|
+
wireCount: opts.wires.length,
|
|
63
|
+
generatedAt: new Date().toISOString(),
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (kctx.method === "GET" && sub === "/wires") {
|
|
68
|
+
kctx.type = "application/json";
|
|
69
|
+
kctx.body = JSON.stringify({ wires });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (kctx.method === "GET" && sub === "/runtime") {
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
+
const rt = opts.app?.runtime;
|
|
75
|
+
kctx.type = "application/json";
|
|
76
|
+
kctx.body = JSON.stringify({
|
|
77
|
+
appName: opts.app?.appName ?? null,
|
|
78
|
+
hasRuntime: typeof rt === "object" && rt !== null,
|
|
79
|
+
lifecycle: rt?.lifecycle ?? "unknown",
|
|
80
|
+
handlers: typeof rt?.listHandlers === "function"
|
|
81
|
+
? rt.listHandlers().map((h) => typeof h === "string" ? h : (h?.name ?? null)).filter(Boolean)
|
|
82
|
+
: [],
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (kctx.method === "GET" && sub.startsWith("/actors/")) {
|
|
87
|
+
// Per-type actor listing requires an ActorStore binding. We try to
|
|
88
|
+
// resolve it from the container; if absent, return 501.
|
|
89
|
+
const actorType = sub.slice("/actors/".length);
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
const container = opts.app?.container;
|
|
92
|
+
let store;
|
|
93
|
+
try {
|
|
94
|
+
store = container?.resolve("actor.store");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* not registered */
|
|
98
|
+
}
|
|
99
|
+
if (!store?.list) {
|
|
100
|
+
kctx.status = 501;
|
|
101
|
+
kctx.type = "application/json";
|
|
102
|
+
kctx.body = JSON.stringify({ error: "no actor.store on container" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const items = await store.list(actorType);
|
|
106
|
+
kctx.type = "application/json";
|
|
107
|
+
kctx.body = JSON.stringify({ actorType, items });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (kctx.method === "POST" && sub === "/dispatch") {
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
112
|
+
const body = kctx.request.body ?? {};
|
|
113
|
+
const handlerName = body.handler;
|
|
114
|
+
const input = body.input ?? {};
|
|
115
|
+
if (!handlerName) {
|
|
116
|
+
kctx.status = 400;
|
|
117
|
+
kctx.body = { error: { code: "missing_handler" } };
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
121
|
+
const rt = opts.app?.runtime;
|
|
122
|
+
if (!rt?.execute || typeof rt.getHandler !== "function") {
|
|
123
|
+
kctx.status = 501;
|
|
124
|
+
kctx.body = { error: { code: "no_runtime" } };
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const handler = rt.getHandler(handlerName);
|
|
129
|
+
const result = await rt.execute(handler, input);
|
|
130
|
+
kctx.type = "application/json";
|
|
131
|
+
kctx.body = JSON.stringify({ result });
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
const e = err;
|
|
135
|
+
kctx.status = typeof e.status === "number" ? e.status : 500;
|
|
136
|
+
kctx.body = { error: { code: e.code ?? "internal_error", summary: e.message ?? "" } };
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
kctx.status = 404;
|
|
141
|
+
kctx.body = { error: { code: "unknown_inspect_route", path: kctx.path } };
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate an OpenAPI 3.1 document from a Nwire App's wires.
|
|
3
|
+
*
|
|
4
|
+
* Walks `app.interface.wires`, picks the HTTP bindings, and emits a path
|
|
5
|
+
* entry per `(verb, path)` populated from the binding's `openapi`
|
|
6
|
+
* metadata + zod schemas via `@asteasolutions/zod-to-openapi`. The output
|
|
7
|
+
* is JSON-stringifiable and can be served at `/openapi.json` directly.
|
|
8
|
+
*
|
|
9
|
+
* Optional dependency: `@asteasolutions/zod-to-openapi` is a peer dep.
|
|
10
|
+
* Consumers that don't need OpenAPI don't pay the install cost; consumers
|
|
11
|
+
* who do install it once and call `httpKoa({ openapi: { auto: true } })`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Wire } from "@nwire/wires";
|
|
14
|
+
export interface GenerateOpenApiOptions {
|
|
15
|
+
readonly title?: string;
|
|
16
|
+
readonly version?: string;
|
|
17
|
+
readonly description?: string;
|
|
18
|
+
readonly serverUrl?: string;
|
|
19
|
+
/** Prefix the spec paths with this. Match the httpKoa prefix. */
|
|
20
|
+
readonly prefix?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build an OpenAPI 3.1 document from a wire collection.
|
|
24
|
+
*
|
|
25
|
+
* Requires `@asteasolutions/zod-to-openapi` to be installed. Throws a
|
|
26
|
+
* clear error otherwise; install it on the consumer side.
|
|
27
|
+
*/
|
|
28
|
+
export declare function generateOpenApi(wires: ReadonlyArray<Wire>, options?: GenerateOpenApiOptions): Promise<object>;
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate an OpenAPI 3.1 document from a Nwire App's wires.
|
|
3
|
+
*
|
|
4
|
+
* Walks `app.interface.wires`, picks the HTTP bindings, and emits a path
|
|
5
|
+
* entry per `(verb, path)` populated from the binding's `openapi`
|
|
6
|
+
* metadata + zod schemas via `@asteasolutions/zod-to-openapi`. The output
|
|
7
|
+
* is JSON-stringifiable and can be served at `/openapi.json` directly.
|
|
8
|
+
*
|
|
9
|
+
* Optional dependency: `@asteasolutions/zod-to-openapi` is a peer dep.
|
|
10
|
+
* Consumers that don't need OpenAPI don't pay the install cost; consumers
|
|
11
|
+
* who do install it once and call `httpKoa({ openapi: { auto: true } })`.
|
|
12
|
+
*/
|
|
13
|
+
function isOpenApiBinding(b) {
|
|
14
|
+
return (typeof b === "object" &&
|
|
15
|
+
b !== null &&
|
|
16
|
+
b.$adapter === "http" &&
|
|
17
|
+
typeof b.verb === "string" &&
|
|
18
|
+
typeof b.path === "string");
|
|
19
|
+
}
|
|
20
|
+
// Convert Koa-style `/users/:id` to OpenAPI `{id}` notation.
|
|
21
|
+
function toOpenApiPath(p) {
|
|
22
|
+
return p.replace(/:(\w+)/g, "{$1}");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build an OpenAPI 3.1 document from a wire collection.
|
|
26
|
+
*
|
|
27
|
+
* Requires `@asteasolutions/zod-to-openapi` to be installed. Throws a
|
|
28
|
+
* clear error otherwise; install it on the consumer side.
|
|
29
|
+
*/
|
|
30
|
+
export async function generateOpenApi(wires, options = {}) {
|
|
31
|
+
let zodToOpenapi;
|
|
32
|
+
try {
|
|
33
|
+
zodToOpenapi = await import("@asteasolutions/zod-to-openapi");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error("@nwire/koa: generateOpenApi() needs `@asteasolutions/zod-to-openapi` — `pnpm add @asteasolutions/zod-to-openapi` then retry.");
|
|
37
|
+
}
|
|
38
|
+
const { OpenAPIRegistry, OpenApiGeneratorV31, extendZodWithOpenApi } = zodToOpenapi;
|
|
39
|
+
const z = await import("zod");
|
|
40
|
+
extendZodWithOpenApi(z.z ?? z);
|
|
41
|
+
const registry = new OpenAPIRegistry();
|
|
42
|
+
const prefix = options.prefix ?? "";
|
|
43
|
+
for (const wire of wires) {
|
|
44
|
+
if (!isOpenApiBinding(wire.binding))
|
|
45
|
+
continue;
|
|
46
|
+
const b = wire.binding;
|
|
47
|
+
if (!b.openapi)
|
|
48
|
+
continue;
|
|
49
|
+
const path = prefix + toOpenApiPath(b.path);
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
const responses = {
|
|
52
|
+
"200": { description: "Success" },
|
|
53
|
+
};
|
|
54
|
+
// Returns from the binding override the default 200 envelope.
|
|
55
|
+
if (b.openapi.returns?.length) {
|
|
56
|
+
const first = b.openapi.returns[0];
|
|
57
|
+
const status = String(first?.status ?? 200);
|
|
58
|
+
const schema = first?.resource?.schema;
|
|
59
|
+
responses[status] = schema
|
|
60
|
+
? {
|
|
61
|
+
description: "Success",
|
|
62
|
+
content: { "application/json": { schema } },
|
|
63
|
+
}
|
|
64
|
+
: { description: "Success" };
|
|
65
|
+
}
|
|
66
|
+
if (b.openapi.errors?.length) {
|
|
67
|
+
for (const e of b.openapi.errors) {
|
|
68
|
+
if (typeof e.status === "number") {
|
|
69
|
+
responses[String(e.status)] = {
|
|
70
|
+
description: e.code ?? "Error",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
registry.registerPath({
|
|
76
|
+
method: b.verb,
|
|
77
|
+
path,
|
|
78
|
+
operationId: b.openapi.operation,
|
|
79
|
+
tags: b.openapi.tags ? [...b.openapi.tags] : undefined,
|
|
80
|
+
summary: b.openapi.summary,
|
|
81
|
+
description: b.openapi.description,
|
|
82
|
+
request: {
|
|
83
|
+
...(b.params ? { params: b.params } : {}),
|
|
84
|
+
...(b.query ? { query: b.query } : {}),
|
|
85
|
+
...(b.body
|
|
86
|
+
? { body: { content: { "application/json": { schema: b.body } } } }
|
|
87
|
+
: {}),
|
|
88
|
+
},
|
|
89
|
+
responses,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const generator = new OpenApiGeneratorV31(registry.definitions);
|
|
93
|
+
return generator.generateDocument({
|
|
94
|
+
openapi: "3.1.0",
|
|
95
|
+
info: {
|
|
96
|
+
title: options.title ?? "API",
|
|
97
|
+
version: options.version ?? "1.0.0",
|
|
98
|
+
description: options.description,
|
|
99
|
+
},
|
|
100
|
+
servers: options.serverUrl ? [{ url: options.serverUrl }] : undefined,
|
|
101
|
+
});
|
|
102
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nwire/koa",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Nwire — Koa-backed HTTP adopter. Consumes wires from @nwire/wires/http and serves them as Koa routes under endpoint lifecycle.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"adopter",
|
|
7
|
+
"http",
|
|
8
|
+
"koa",
|
|
9
|
+
"nwire"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./dist/http-koa.js",
|
|
19
|
+
"types": "./dist/http-koa.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": "./dist/http-koa.js",
|
|
23
|
+
"types": "./dist/http-koa.d.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@koa/cors": "^5.0.0",
|
|
31
|
+
"@koa/router": "^13.1.0",
|
|
32
|
+
"koa": "^2.16.1",
|
|
33
|
+
"koa-bodyparser": "^4.4.1",
|
|
34
|
+
"koa-helmet": "^8.0.1",
|
|
35
|
+
"zod": "^4.0.0",
|
|
36
|
+
"@nwire/endpoint": "0.10.0",
|
|
37
|
+
"@nwire/wires": "0.10.0",
|
|
38
|
+
"@nwire/logger": "0.10.0",
|
|
39
|
+
"@nwire/container": "0.10.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@asteasolutions/zod-to-openapi": "^8.0.0",
|
|
43
|
+
"@types/koa": "^2.15.0",
|
|
44
|
+
"@types/koa-bodyparser": "^4.3.13",
|
|
45
|
+
"@types/koa__cors": "^5.0.0",
|
|
46
|
+
"@types/koa__router": "^12.0.5",
|
|
47
|
+
"@types/node": "^22.19.9",
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "^4.0.18",
|
|
50
|
+
"@nwire/app": "0.10.0",
|
|
51
|
+
"@nwire/handler": "0.10.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@asteasolutions/zod-to-openapi": "^8.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@asteasolutions/zod-to-openapi": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsc && node ../../scripts/fix-dist-extensions.mjs dist",
|
|
63
|
+
"dev": "tsc --watch",
|
|
64
|
+
"typecheck": "tsc --noEmit"
|
|
65
|
+
}
|
|
66
|
+
}
|