@nanobpm/urban 0.40.2 → 0.42.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/dist/cli.js +7 -0
- package/dist/openapi/spec.d.ts +13 -0
- package/dist/openapi/spec.js +97 -5
- package/dist/runtime/core/modules/api.d.ts +27 -0
- package/dist/runtime/core/modules/api.js +67 -13
- package/dist/runtime/core/modules/pages.js +77 -10
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +3 -0
- package/dist/toolkit/gen.d.ts +3 -0
- package/dist/toolkit/gen.js +14 -5
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/openapi/spec.test.ts +94 -0
- package/src/openapi/spec.ts +95 -5
- package/src/runtime/core/logger.test.ts +11 -0
- package/src/runtime/core/modules/api.test.ts +129 -4
- package/src/runtime/core/modules/api.ts +72 -13
- package/src/runtime/core/modules/pages.test.ts +50 -6
- package/src/runtime/core/modules/pages.ts +83 -10
- package/src/runtime/index.ts +4 -2
- package/src/toolkit/gen.test.ts +32 -0
- package/src/toolkit/gen.ts +20 -6
package/src/openapi/spec.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
parseSpec,
|
|
13
13
|
resolveSchema,
|
|
14
14
|
responseSchemaForStatus,
|
|
15
|
+
sharedRequestBodySchemas,
|
|
15
16
|
toRouteMatcher,
|
|
16
17
|
undeclaredPathParams,
|
|
17
18
|
undeclaredSecuritySchemes,
|
|
@@ -333,6 +334,72 @@ test("validateValue enforces allOf (intersection), anyOf/oneOf (union) — match
|
|
|
333
334
|
assert.ok(validateValue(doc, one, "x").length > 0); // matches NEITHER
|
|
334
335
|
});
|
|
335
336
|
|
|
337
|
+
test("validateValue: a failed oneOf discriminant names the shapes and surfaces the closest variant", () => {
|
|
338
|
+
// The Camunda-style discriminated request body: exactly one of two mutually-exclusive variants
|
|
339
|
+
// (mutual exclusion enforced by additionalProperties:false + a distinct required discriminant).
|
|
340
|
+
const byPr: OpenApiSchema = {
|
|
341
|
+
type: "object",
|
|
342
|
+
additionalProperties: false,
|
|
343
|
+
required: ["pr"],
|
|
344
|
+
properties: { pr: { type: "string" }, maxRounds: { type: "integer" } },
|
|
345
|
+
};
|
|
346
|
+
const byUrl: OpenApiSchema = {
|
|
347
|
+
type: "object",
|
|
348
|
+
additionalProperties: false,
|
|
349
|
+
required: ["url"],
|
|
350
|
+
properties: { url: { type: "string" }, maxRounds: { type: "integer" } },
|
|
351
|
+
};
|
|
352
|
+
const start: OpenApiSchema = { oneOf: [byPr, byUrl] };
|
|
353
|
+
|
|
354
|
+
// Neither discriminant supplied → matched 0. The summary must NAME the allowed shapes, and the
|
|
355
|
+
// closest variant's own issue must be surfaced (not just the opaque "matched 0").
|
|
356
|
+
const none = validateValue(doc, start, { maxRounds: 3 }, "body");
|
|
357
|
+
assert.ok(none.length > 0, "an empty discriminant is rejected");
|
|
358
|
+
assert.ok(
|
|
359
|
+
none.some((i) => /allowed:.*\{pr\}.*\|.*\{url\}/.test(i.message)),
|
|
360
|
+
`summary names the allowed shapes: ${JSON.stringify(none)}`,
|
|
361
|
+
);
|
|
362
|
+
assert.ok(
|
|
363
|
+
none.some((i) => i.path === "body/pr" && /required/.test(i.message)),
|
|
364
|
+
`closest variant's field issue is surfaced: ${JSON.stringify(none)}`,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Both discriminants supplied → with additionalProperties:false, EACH variant rejects the other's
|
|
368
|
+
// field, so this is also "matches none" (the desirable strictness — a body can't smuggle both).
|
|
369
|
+
const both = validateValue(doc, start, { pr: "a/b#1", url: "http://x" }, "body");
|
|
370
|
+
assert.ok(
|
|
371
|
+
both.some((i) => /does not match any of the 2 allowed shapes/.test(i.message)),
|
|
372
|
+
`supplying both discriminants is rejected by the strict variants: ${JSON.stringify(both)}`,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Exactly one discriminant → valid.
|
|
376
|
+
assert.equal(validateValue(doc, start, { pr: "a/b#1" }, "body").length, 0);
|
|
377
|
+
|
|
378
|
+
// `anyOf` no-match reporting mirrors `oneOf`: name the allowed shapes AND surface the closest
|
|
379
|
+
// variant's issues, so the improved diagnostics are guarded for the union branch too. Here a value
|
|
380
|
+
// matches NEITHER discriminated variant, and `{pr}` is the closest (its own field is the issue).
|
|
381
|
+
const anyStart: OpenApiSchema = { anyOf: [byPr, byUrl] };
|
|
382
|
+
const anyNone = validateValue(doc, anyStart, { maxRounds: 3 }, "body");
|
|
383
|
+
assert.ok(anyNone.length > 0, "a value matching no anyOf variant is rejected");
|
|
384
|
+
assert.ok(
|
|
385
|
+
anyNone.some((i) => /allowed:.*\{pr\}.*\|.*\{url\}/.test(i.message)),
|
|
386
|
+
`anyOf summary names the allowed shapes: ${JSON.stringify(anyNone)}`,
|
|
387
|
+
);
|
|
388
|
+
assert.ok(
|
|
389
|
+
anyNone.some((i) => i.path === "body/pr" && /required/.test(i.message)),
|
|
390
|
+
`anyOf surfaces the closest variant's field issue: ${JSON.stringify(anyNone)}`,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// The "matched more than one" branch: genuinely overlapping (non-exclusive) variants. A value that
|
|
394
|
+
// satisfies two shapes is ambiguous and must be reported as such.
|
|
395
|
+
const overlapping: OpenApiSchema = { oneOf: [{ type: "integer" }, { type: "integer", minimum: 10 }] };
|
|
396
|
+
const ambiguous = validateValue(doc, overlapping, 20, "n");
|
|
397
|
+
assert.ok(
|
|
398
|
+
ambiguous.some((i) => /must match exactly one of the 2 allowed shapes, but matched 2/.test(i.message)),
|
|
399
|
+
`ambiguous (multi-match) is reported: ${JSON.stringify(ambiguous)}`,
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
336
403
|
test("undeclaredPathParams flags path-template params with no declared parameter", () => {
|
|
337
404
|
const drift: OpenApiDoc = {
|
|
338
405
|
openapi: "3.0.0",
|
|
@@ -608,3 +675,30 @@ test("responseSchemaForStatus: a status-range ('2XX') covers concrete statuses i
|
|
|
608
675
|
assert.equal(responseSchemaForStatus(entries, 200)?.type, "string"); // 2XX class → range schema
|
|
609
676
|
assert.equal(responseSchemaForStatus(entries, 404)?.type, "object"); // outside 2XX → default
|
|
610
677
|
});
|
|
678
|
+
|
|
679
|
+
test("sharedRequestBodySchemas: flags a requestBody $ref reused across operations, ignores single-use", () => {
|
|
680
|
+
const jsonRef = (ref: string) => ({ requestBody: { content: { "application/json": { schema: { $ref: ref } } } } });
|
|
681
|
+
const doc: OpenApiDoc = {
|
|
682
|
+
openapi: "3.1.0",
|
|
683
|
+
components: {
|
|
684
|
+
schemas: {
|
|
685
|
+
StartVariables: { type: "object" },
|
|
686
|
+
FeatureAnswer: { type: "object" },
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
paths: {
|
|
690
|
+
// Two operations share the SAME loose body schema — the defect fingerprint.
|
|
691
|
+
"/convergence": { post: { operationId: "startConvergenceLoop", ...jsonRef("#/components/schemas/StartVariables") } },
|
|
692
|
+
"/fanout": { post: { operationId: "startPlanFanout", ...jsonRef("#/components/schemas/StartVariables") } },
|
|
693
|
+
// A distinct, single-use body — must NOT be flagged.
|
|
694
|
+
"/feature": { post: { operationId: "answerFeature", ...jsonRef("#/components/schemas/FeatureAnswer") } },
|
|
695
|
+
// An inline (non-$ref) body — no shared identity to compare, must NOT be flagged.
|
|
696
|
+
"/inline": { post: { operationId: "inlineBody", requestBody: { content: { "application/json": { schema: { type: "object" } } } } } },
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const shared = sharedRequestBodySchemas(doc);
|
|
701
|
+
assert.equal(shared.length, 1, `only the reused ref is flagged: ${JSON.stringify(shared)}`);
|
|
702
|
+
assert.equal(shared[0].ref, "#/components/schemas/StartVariables");
|
|
703
|
+
assert.deepEqual(shared[0].operationIds, ["startConvergenceLoop", "startPlanFanout"]); // sorted
|
|
704
|
+
});
|
package/src/openapi/spec.ts
CHANGED
|
@@ -338,6 +338,32 @@ export function assertUniqueOperationIds(doc: OpenApiDoc): void {
|
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
|
|
342
|
+
* keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
|
|
343
|
+
* request-body schema reused verbatim by multiple operations is the structural fingerprint of the
|
|
344
|
+
* "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
|
|
345
|
+
* operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
|
|
346
|
+
* `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
|
|
347
|
+
* tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
|
|
348
|
+
* split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
|
|
349
|
+
* only (pure, total) — the message is built by the caller. */
|
|
350
|
+
export function sharedRequestBodySchemas(doc: OpenApiDoc): { ref: string; operationIds: string[] }[] {
|
|
351
|
+
const byRef = new Map<string, string[]>();
|
|
352
|
+
for (const op of collectOperations(doc)) {
|
|
353
|
+
const ref = op.requestBodySchema?.$ref;
|
|
354
|
+
if (typeof ref !== "string" || ref.length === 0) continue;
|
|
355
|
+
const ids = byRef.get(ref) ?? [];
|
|
356
|
+
ids.push(op.operationId);
|
|
357
|
+
byRef.set(ref, ids);
|
|
358
|
+
}
|
|
359
|
+
const out: { ref: string; operationIds: string[] }[] = [];
|
|
360
|
+
for (const [ref, ids] of byRef) {
|
|
361
|
+
if (ids.length > 1) out.push({ ref, operationIds: [...ids].sort() });
|
|
362
|
+
}
|
|
363
|
+
out.sort((a, b) => a.ref.localeCompare(b.ref));
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
341
367
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
342
368
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
343
369
|
export function operationsWithoutId(doc: OpenApiDoc): string[] {
|
|
@@ -697,6 +723,36 @@ function matchesAnyType(declared: string[], value: unknown): boolean {
|
|
|
697
723
|
return declared.some((t) => matchesType(t, value));
|
|
698
724
|
}
|
|
699
725
|
|
|
726
|
+
/** A short, human-readable label for one `anyOf`/`oneOf` variant, so a composition failure can name
|
|
727
|
+
* the allowed shapes instead of an opaque "matched 0". Prefers the variant's discriminant — its
|
|
728
|
+
* `required` fields (e.g. `{pr}` vs `{url}`) — since that is exactly what a caller must choose
|
|
729
|
+
* between; falls back to the declared type(s), else "object". */
|
|
730
|
+
function variantLabel(doc: OpenApiDoc, variant: OpenApiSchema): string {
|
|
731
|
+
const s = resolveSchema(doc, variant);
|
|
732
|
+
if (!s) return "?";
|
|
733
|
+
const req = Array.isArray(s.required) ? s.required : [];
|
|
734
|
+
if (req.length > 0) return `{${req.join(", ")}}`;
|
|
735
|
+
const types = schemaTypeList(s);
|
|
736
|
+
return types.length > 0 ? types.join("|") : "object";
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** The `(allowed: {pr} | {url})` suffix for a composition-failure summary message. */
|
|
740
|
+
function variantSummary(doc: OpenApiDoc, variants: OpenApiSchema[]): string {
|
|
741
|
+
return ` (allowed: ${variants.map((v) => variantLabel(doc, v)).join(" | ")})`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** When a `oneOf`/`anyOf` matched no variant, surface actionable detail: the issues of the variant
|
|
745
|
+
* the value was CLOSEST to (fewest problems). For the discriminant case this is the shape the
|
|
746
|
+
* caller meant, so its issues read like `pr: is required` / `url: is not an allowed property`
|
|
747
|
+
* instead of a bare "matched 0". Ties resolve to the earliest variant (declaration order). */
|
|
748
|
+
function closestVariantIssues(perVariant: ValidationIssue[][]): ValidationIssue[] {
|
|
749
|
+
let best: ValidationIssue[] | undefined;
|
|
750
|
+
for (const iss of perVariant) {
|
|
751
|
+
if (best === undefined || iss.length < best.length) best = iss;
|
|
752
|
+
}
|
|
753
|
+
return best ?? [];
|
|
754
|
+
}
|
|
755
|
+
|
|
700
756
|
/** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
|
|
701
757
|
* found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
|
|
702
758
|
export function validateValue(
|
|
@@ -743,13 +799,47 @@ export function validateValue(
|
|
|
743
799
|
if (schema.allOf) {
|
|
744
800
|
for (const sub of schema.allOf) issues.push(...validateValue(doc, sub, value, path));
|
|
745
801
|
}
|
|
746
|
-
if (schema.anyOf
|
|
747
|
-
|
|
802
|
+
if (schema.anyOf) {
|
|
803
|
+
// `anyOf` needs only ONE match, so short-circuit on the first clean variant — `validateValue`
|
|
804
|
+
// runs on every request (params/query/body/response) and evaluating every variant for a large
|
|
805
|
+
// schema is needless work once we have a hit. Only when NO variant matches do we build the full
|
|
806
|
+
// per-variant issue list to name the allowed shapes and surface the closest variant's issues.
|
|
807
|
+
let anyMatched = false;
|
|
808
|
+
const perVariant: ValidationIssue[][] = [];
|
|
809
|
+
for (const sub of schema.anyOf) {
|
|
810
|
+
const subIssues = validateValue(doc, sub, value, path);
|
|
811
|
+
if (subIssues.length === 0) {
|
|
812
|
+
anyMatched = true;
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
perVariant.push(subIssues);
|
|
816
|
+
}
|
|
817
|
+
if (!anyMatched) {
|
|
818
|
+
issues.push({
|
|
819
|
+
path: at,
|
|
820
|
+
message: `does not match any of the ${schema.anyOf.length} allowed shapes${variantSummary(doc, schema.anyOf)}`,
|
|
821
|
+
});
|
|
822
|
+
issues.push(...closestVariantIssues(perVariant));
|
|
823
|
+
}
|
|
748
824
|
}
|
|
749
825
|
if (schema.oneOf) {
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
826
|
+
const perVariant = schema.oneOf.map((sub) => validateValue(doc, sub, value, path));
|
|
827
|
+
const matched = perVariant.filter((v) => v.length === 0).length;
|
|
828
|
+
if (matched === 0) {
|
|
829
|
+
// No variant matched: name the allowed shapes AND surface the closest variant's issues, so a
|
|
830
|
+
// discriminated body (e.g. exactly one of pr|url) fails with actionable detail, not "matched 0".
|
|
831
|
+
issues.push({
|
|
832
|
+
path: at,
|
|
833
|
+
message: `does not match any of the ${schema.oneOf.length} allowed shapes${variantSummary(doc, schema.oneOf)}`,
|
|
834
|
+
});
|
|
835
|
+
issues.push(...closestVariantIssues(perVariant));
|
|
836
|
+
} else if (matched > 1) {
|
|
837
|
+
// Matched more than one: the shapes are not mutually exclusive for this value (usually a body
|
|
838
|
+
// that supplies two discriminants, or under-constrained variants). Report the ambiguity.
|
|
839
|
+
issues.push({
|
|
840
|
+
path: at,
|
|
841
|
+
message: `must match exactly one of the ${schema.oneOf.length} allowed shapes, but matched ${matched}${variantSummary(doc, schema.oneOf)}`,
|
|
842
|
+
});
|
|
753
843
|
}
|
|
754
844
|
}
|
|
755
845
|
|
|
@@ -219,3 +219,14 @@ test("child() never throws when the added bindings are hostile — degrades to t
|
|
|
219
219
|
assert.equal(records[0].msg, "shipped");
|
|
220
220
|
assert.deepEqual({ ...records[0].fields }, { orderId: "o1" });
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
test("createLogger is re-exported from the runtime public barrel", async () => {
|
|
224
|
+
// Consumers build a Logger via the package entry (`@nanobpm/urban/runtime`) — e.g. a no-op sink
|
|
225
|
+
// for test doubles of `app.log`, or a custom sink in app code — so the barrel must expose it.
|
|
226
|
+
const barrel = await import("../index.ts");
|
|
227
|
+
assert.equal(typeof barrel.createLogger, "function");
|
|
228
|
+
const { records, sink } = recorder();
|
|
229
|
+
barrel.createLogger(sink).info("via-barrel");
|
|
230
|
+
assert.equal(records.length, 1);
|
|
231
|
+
assert.equal(records[0].msg, "via-barrel");
|
|
232
|
+
});
|
|
@@ -6,7 +6,7 @@ import type { AppManifest } from "../manifest.ts";
|
|
|
6
6
|
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
7
7
|
import { makeRouter } from "../router.ts";
|
|
8
8
|
import { DataLayer } from "./datasource.ts";
|
|
9
|
-
import { mountApi, resolveOperationHandler, apiDocsPath, NotImplemented, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
9
|
+
import { mountApi, resolveOperationHandler, apiDocsPath, deriveSwaggerUrls, NotImplemented, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
10
10
|
|
|
11
11
|
function req(
|
|
12
12
|
method: string,
|
|
@@ -746,12 +746,137 @@ test("serves the Swagger UI page at {base}-docs by default", async () => {
|
|
|
746
746
|
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
747
747
|
const body = res.body ?? "";
|
|
748
748
|
assert.match(body, /swagger-ui/);
|
|
749
|
-
// The
|
|
750
|
-
//
|
|
751
|
-
|
|
749
|
+
// The page derives ABSOLUTE spec + server URLs from window.location (issue #151 + the trailing-
|
|
750
|
+
// slash 404): the embedded derivation is passed the mount-root docs base, operations base, and a
|
|
751
|
+
// document-relative fallback. It must never hard-code a root-absolute "/app/api-docs/openapi.json"
|
|
752
|
+
// that would escape the Nano console reverse-proxy prefix.
|
|
753
|
+
assert.match(body, /window\.location\.href/);
|
|
754
|
+
assert.match(body, /"\/app\/api-docs"/); // docsBase handed to the client derivation
|
|
755
|
+
assert.match(body, /"\/app\/api"/); // operations base handed to the client derivation
|
|
756
|
+
assert.match(body, /"api-docs\/openapi\.json"/); // document-relative fallback
|
|
752
757
|
assert.doesNotMatch(body, /url: "\/app\/api-docs\/openapi\.json"/);
|
|
753
758
|
});
|
|
754
759
|
|
|
760
|
+
test("the rendered docs page runs its derivation end-to-end and targets the app through the proxy", async () => {
|
|
761
|
+
const { router } = build({ spec: "openapi.json" }, {});
|
|
762
|
+
const res = await router(req("GET", "/app/api-docs"));
|
|
763
|
+
const body = res.body ?? "";
|
|
764
|
+
// Extract the inline bootstrap <script> (the one that is NOT the swagger-ui-bundle src include).
|
|
765
|
+
const scripts = [...body.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
|
766
|
+
assert.ok(scripts.length >= 1, "expected an inline bootstrap script");
|
|
767
|
+
const boot = scripts[scripts.length - 1];
|
|
768
|
+
// 1) It must be syntactically valid JS (guards the `${derive.toString()}` template interpolation).
|
|
769
|
+
const runBoot = new Function("window", "SwaggerUIBundle", "fetch", boot);
|
|
770
|
+
// 2) Execute it in a mocked browser at the EXACT reported failing URL (proxy prefix + trailing
|
|
771
|
+
// slash) and assert the servers override Swagger UI receives is the app's /app/api under the
|
|
772
|
+
// proxy — never the /app/api-docs/api that produced the 404.
|
|
773
|
+
const specDoc = { openapi: "3.0.3", paths: {}, servers: [{ url: "api" }] };
|
|
774
|
+
let received: unknown;
|
|
775
|
+
const swaggerStub = (opts: { spec?: { servers?: unknown } }): unknown => {
|
|
776
|
+
received = opts.spec?.servers;
|
|
777
|
+
return {};
|
|
778
|
+
};
|
|
779
|
+
const proxySlashUrl = "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/";
|
|
780
|
+
const win: { location: { href: string }; ui?: unknown } = { location: { href: proxySlashUrl } };
|
|
781
|
+
let fetched = "";
|
|
782
|
+
const fetchStub = (url: string): Promise<{ ok: boolean; json: () => Promise<unknown> }> => {
|
|
783
|
+
fetched = url;
|
|
784
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve(specDoc) });
|
|
785
|
+
};
|
|
786
|
+
runBoot(win, swaggerStub, fetchStub);
|
|
787
|
+
await new Promise((r) => setTimeout(r, 0)); // let the fetch().then microtasks settle
|
|
788
|
+
assert.equal(
|
|
789
|
+
fetched,
|
|
790
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
791
|
+
"spec is fetched at an absolute, proxy-prefixed URL (immune to the trailing slash)",
|
|
792
|
+
);
|
|
793
|
+
assert.deepEqual(received, [
|
|
794
|
+
{ url: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api" },
|
|
795
|
+
// ^ Try it out now targets the app's operations base under the proxy, not /app/api-docs/api.
|
|
796
|
+
]);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("deriveSwaggerUrls: absolute spec+server from the page URL, immune to trailing slash + proxy prefix", () => {
|
|
800
|
+
const docsBase = "/app/api-docs";
|
|
801
|
+
const apiBase = "/app/api";
|
|
802
|
+
const rel = "api-docs/openapi.json";
|
|
803
|
+
// Direct CLI (:3000), no trailing slash — the canonical case.
|
|
804
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs", docsBase, apiBase, rel), {
|
|
805
|
+
specUrl: "http://127.0.0.1:3000/app/api-docs/openapi.json",
|
|
806
|
+
server: "http://127.0.0.1:3000/app/api",
|
|
807
|
+
});
|
|
808
|
+
// Direct CLI WITH a trailing slash — a relative "servers: api" used to resolve to
|
|
809
|
+
// /app/api-docs/api here (the reported 404). The absolute derivation is unaffected.
|
|
810
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs/", docsBase, apiBase, rel), {
|
|
811
|
+
specUrl: "http://127.0.0.1:3000/app/api-docs/openapi.json",
|
|
812
|
+
server: "http://127.0.0.1:3000/app/api",
|
|
813
|
+
});
|
|
814
|
+
// Under the Nano console reverse proxy — the mount prefix is preserved in both URLs.
|
|
815
|
+
assert.deepEqual(
|
|
816
|
+
deriveSwaggerUrls(
|
|
817
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs",
|
|
818
|
+
docsBase,
|
|
819
|
+
apiBase,
|
|
820
|
+
rel,
|
|
821
|
+
),
|
|
822
|
+
{
|
|
823
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
824
|
+
server: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api",
|
|
825
|
+
},
|
|
826
|
+
);
|
|
827
|
+
// Proxy WITH a trailing slash — the exact failure the user reported. Now correct.
|
|
828
|
+
assert.deepEqual(
|
|
829
|
+
deriveSwaggerUrls(
|
|
830
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/",
|
|
831
|
+
docsBase,
|
|
832
|
+
apiBase,
|
|
833
|
+
rel,
|
|
834
|
+
),
|
|
835
|
+
{
|
|
836
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
837
|
+
server: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api",
|
|
838
|
+
},
|
|
839
|
+
);
|
|
840
|
+
// A query string / fragment on the docs URL is ignored (pathname only).
|
|
841
|
+
assert.equal(
|
|
842
|
+
deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs?x=1#y", docsBase, apiBase, rel).server,
|
|
843
|
+
"http://127.0.0.1:3000/app/api",
|
|
844
|
+
);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("deriveSwaggerUrls: a custom docs path keeps the proxy prefix too", () => {
|
|
848
|
+
// A string `docs` override lands the page at a root-level `/help`; the derivation still recovers
|
|
849
|
+
// the (proxy-prefixed) mount root by stripping that suffix and re-appending the operations base.
|
|
850
|
+
assert.deepEqual(
|
|
851
|
+
deriveSwaggerUrls(
|
|
852
|
+
"http://127.0.0.1:8080/console/app-view/App/help/",
|
|
853
|
+
"/help",
|
|
854
|
+
"/app/api",
|
|
855
|
+
"help/openapi.json",
|
|
856
|
+
),
|
|
857
|
+
{
|
|
858
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/App/help/openapi.json",
|
|
859
|
+
server: "http://127.0.0.1:8080/console/app-view/App/app/api",
|
|
860
|
+
},
|
|
861
|
+
);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("deriveSwaggerUrls: falls back to the document-relative spec when the path doesn't match or href is bad", () => {
|
|
865
|
+
const docsBase = "/app/api-docs";
|
|
866
|
+
const apiBase = "/app/api";
|
|
867
|
+
const rel = "api-docs/openapi.json";
|
|
868
|
+
// Page path doesn't end in docsBase (unexpected) → keep the relative fallback, no server override.
|
|
869
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/elsewhere", docsBase, apiBase, rel), {
|
|
870
|
+
specUrl: rel,
|
|
871
|
+
server: null,
|
|
872
|
+
});
|
|
873
|
+
// Malformed href → same safe fallback (no throw).
|
|
874
|
+
assert.deepEqual(deriveSwaggerUrls("not a url", docsBase, apiBase, rel), {
|
|
875
|
+
specUrl: rel,
|
|
876
|
+
server: null,
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
755
880
|
test("the trailing-slash docs variant 308-redirects to the canonical path", async () => {
|
|
756
881
|
const { router } = build({ spec: "openapi.json" }, {});
|
|
757
882
|
// The router exact-matches non-prefix routes, so `${base}-docs/` (common from proxies/browsers)
|
|
@@ -70,10 +70,54 @@ function escapeHtml(s: string): string {
|
|
|
70
70
|
);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Derive the ABSOLUTE spec + operations-server URLs the Swagger UI page should use, from the docs
|
|
75
|
+
* page's own address (`pageHref`), the mount-root-relative docs path (`docsBase`, e.g.
|
|
76
|
+
* `/app/api-docs`) and operations base (`apiBase`, e.g. `/app/api`).
|
|
77
|
+
*
|
|
78
|
+
* Why this can't be done server-side: the docs page can load at `<docsBase>` OR `<docsBase>/` (a
|
|
79
|
+
* trailing slash — reverse proxies and some webviews append one), and behind the Nano console
|
|
80
|
+
* proxy it carries a path prefix (`/console/app-view/<name>`). A document-relative spec URL or a
|
|
81
|
+
* relative `servers` entry resolves DIFFERENTLY depending on that trailing slash — which sent
|
|
82
|
+
* "Try it out" to `<mount>/app/api-docs/api/<op>` (a 404) instead of `<mount>/app/api/<op>`. The
|
|
83
|
+
* app never sees the proxy prefix, so `window.location` in the browser is the only place with the
|
|
84
|
+
* full mount path.
|
|
85
|
+
*
|
|
86
|
+
* We strip any trailing slash from the page path, then strip the known `docsBase` suffix to recover
|
|
87
|
+
* the (proxy-prefixed) mount root, and re-append `docsBase`/`apiBase` as ORIGIN-absolute URLs — so
|
|
88
|
+
* Swagger UI performs no further relative resolution and is immune to the trailing slash. When the
|
|
89
|
+
* page path doesn't end in `docsBase` (unexpected) or the href is malformed, we fall back to the
|
|
90
|
+
* document-relative `relSpec` and leave `servers` to the served spec (`server: null`).
|
|
91
|
+
*
|
|
92
|
+
* This runs in the browser: it is embedded verbatim into the docs page via `.toString()` so the
|
|
93
|
+
* shipped page and these unit tests exercise the exact same code (no drift). Keep it dependency-free
|
|
94
|
+
* and free of template literals / `${}` (it is interpolated into a template literal).
|
|
95
|
+
*/
|
|
96
|
+
export function deriveSwaggerUrls(
|
|
97
|
+
pageHref: string,
|
|
98
|
+
docsBase: string,
|
|
99
|
+
apiBase: string,
|
|
100
|
+
relSpec: string,
|
|
101
|
+
): { specUrl: string; server: string | null } {
|
|
102
|
+
try {
|
|
103
|
+
const loc = new URL(pageHref);
|
|
104
|
+
const path = loc.pathname.replace(/\/+$/, "");
|
|
105
|
+
if (path.length >= docsBase.length && path.slice(path.length - docsBase.length) === docsBase) {
|
|
106
|
+
const mount = loc.origin + path.slice(0, path.length - docsBase.length);
|
|
107
|
+
return { specUrl: mount + docsBase + "/openapi.json", server: mount + apiBase };
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Malformed href — fall through to the document-relative fallback below.
|
|
111
|
+
}
|
|
112
|
+
return { specUrl: relSpec, server: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
/** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
|
|
74
|
-
* unreadable (
|
|
75
|
-
* which keeps the docs route decoupled from spec-load failures.
|
|
76
|
-
|
|
116
|
+
* unreadable (on a fetch/parse failure it hands `specUrl` to Swagger UI, which surfaces the error
|
|
117
|
+
* in-page), which keeps the docs route decoupled from spec-load failures. The client derives
|
|
118
|
+
* absolute spec + server URLs from `window.location` via `deriveSwaggerUrls` so "Try it out"
|
|
119
|
+
* targets the app regardless of a trailing slash on the docs URL or a reverse-proxy prefix. */
|
|
120
|
+
function swaggerUiPage(title: string, specUrl: string, docsBase: string, apiBase: string): string {
|
|
77
121
|
const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
|
|
78
122
|
const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
|
|
79
123
|
return `<!doctype html>
|
|
@@ -89,7 +133,22 @@ function swaggerUiPage(title: string, specUrl: string): string {
|
|
|
89
133
|
<div id="swagger-ui"></div>
|
|
90
134
|
<script src="${bundle}" crossorigin="anonymous"></script>
|
|
91
135
|
<script>
|
|
92
|
-
|
|
136
|
+
(function () {
|
|
137
|
+
var derive = ${deriveSwaggerUrls.toString()};
|
|
138
|
+
var urls = derive(window.location.href, ${JSON.stringify(docsBase)}, ${JSON.stringify(apiBase)}, ${JSON.stringify(specUrl)});
|
|
139
|
+
function boot(opts) { window.ui = SwaggerUIBundle(opts); }
|
|
140
|
+
fetch(urls.specUrl)
|
|
141
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
142
|
+
.then(function (spec) {
|
|
143
|
+
if (spec && urls.server) { spec.servers = [{ url: urls.server }]; }
|
|
144
|
+
boot(spec
|
|
145
|
+
? { spec: spec, dom_id: '#swagger-ui', deepLinking: true }
|
|
146
|
+
: { url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
|
|
147
|
+
})
|
|
148
|
+
.catch(function () {
|
|
149
|
+
boot({ url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
|
|
150
|
+
});
|
|
151
|
+
})();
|
|
93
152
|
</script>
|
|
94
153
|
</body>
|
|
95
154
|
</html>`;
|
|
@@ -312,12 +371,13 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
312
371
|
const dir = binding.dir ?? "operations";
|
|
313
372
|
const surfaceEject = binding.eject === true;
|
|
314
373
|
const specRoutePath = `${docsBase}/openapi.json`;
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
374
|
+
// Fallback values for the RAW served spec + docs page. The interactive Swagger UI computes
|
|
375
|
+
// ABSOLUTE spec + server URLs client-side from `window.location` (see `deriveSwaggerUrls`), which
|
|
376
|
+
// is immune to a trailing slash on the docs URL and to the Nano console reverse-proxy prefix
|
|
377
|
+
// (/console/app-view/<name>/). These document-relative values remain (a) the client's fallback
|
|
378
|
+
// when the page path unexpectedly doesn't end in `docsBase`, and (b) what a non-interactive
|
|
379
|
+
// consumer reading `openapi.json` directly sees. They still rebase onto the app's mount root
|
|
380
|
+
// rather than using a root-absolute "/app/…" that would escape the proxy prefix (issue #151).
|
|
321
381
|
const lastSegment = (p: string): string => p.split("/").filter(Boolean).pop() ?? "";
|
|
322
382
|
const parentPath = (p: string): string => {
|
|
323
383
|
const parts = p.split("/").filter(Boolean);
|
|
@@ -328,8 +388,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
328
388
|
const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
|
|
329
389
|
// `servers` can only be relativized when the operations `base` shares the docs page's parent
|
|
330
390
|
// directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
|
|
331
|
-
// different parent, keep the root-absolute base
|
|
332
|
-
// the documented limitation in #151).
|
|
391
|
+
// different parent, keep the root-absolute base for the raw spec.
|
|
333
392
|
const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
|
|
334
393
|
|
|
335
394
|
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
@@ -741,7 +800,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
741
800
|
return json({ ...d, servers: [{ url: serversUrl }] });
|
|
742
801
|
};
|
|
743
802
|
const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
|
|
744
|
-
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specDocUrl));
|
|
803
|
+
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specDocUrl, docsBase, base));
|
|
745
804
|
// A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
|
|
746
805
|
// exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
|
|
747
806
|
// a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
|
|
@@ -90,12 +90,15 @@ test("GET / serves the renderer shell with the home marker", async () => {
|
|
|
90
90
|
assert.equal(res.status, 200);
|
|
91
91
|
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
92
92
|
assert.match(res.body ?? "", /data-home="home"/);
|
|
93
|
-
// The runtime module is loaded by a *relative* src so it resolves
|
|
94
|
-
// document's mount path — works both at the origin root and under the Nano
|
|
95
|
-
// console's /console/app-view/<name>/ reverse proxy. A root-absolute
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
assert.
|
|
93
|
+
// The runtime module is loaded by a *relative*, *content-fingerprinted* src so it resolves
|
|
94
|
+
// against the document's mount path — works both at the origin root and under the Nano
|
|
95
|
+
// console's /console/app-view/<name>/ reverse proxy. A root-absolute path would 404 against
|
|
96
|
+
// the console origin; a non-fingerprinted URL would let a stale browser cache replay an old
|
|
97
|
+
// module after an upgrade (silently breaking every page action).
|
|
98
|
+
assert.match(res.body ?? "", /src="\.\/app\/runtime\.[0-9a-f]{8}\.js"/);
|
|
99
|
+
assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\./);
|
|
100
|
+
// The shell itself must never be pinned by the browser (it carries the current module hash).
|
|
101
|
+
assert.match(res.headers?.["cache-control"] ?? "", /no-cache/);
|
|
99
102
|
});
|
|
100
103
|
|
|
101
104
|
test("the API docs badge links via a document-relative href (proxy-safe)", async () => {
|
|
@@ -145,6 +148,31 @@ test("GET /app/runtime.js serves the renderer module", async () => {
|
|
|
145
148
|
assert.match(res.body ?? "", /function apiUrl\(u\)/);
|
|
146
149
|
});
|
|
147
150
|
|
|
151
|
+
test("the fingerprinted runtime URL from the shell is served immutable; the shell references it", async () => {
|
|
152
|
+
// The shell's <script src> must point at a route the router actually serves, and that route
|
|
153
|
+
// must carry long-lived immutable caching (the URL is unique per content, so it can never go
|
|
154
|
+
// stale). This is the cache-bust that stops an upgraded app from replaying an old module.
|
|
155
|
+
const shell = await dispatch("GET", "/");
|
|
156
|
+
const m = (shell.body ?? "").match(/src="\.(\/app\/runtime\.[0-9a-f]{8}\.js)"/);
|
|
157
|
+
assert.ok(m, "shell must reference a fingerprinted runtime URL");
|
|
158
|
+
const res = await dispatch("GET", m![1]);
|
|
159
|
+
assert.equal(res.status, 200);
|
|
160
|
+
assert.match(res.headers?.["content-type"] ?? "", /javascript/);
|
|
161
|
+
assert.match(res.body ?? "", /pc:refresh/);
|
|
162
|
+
assert.match(res.headers?.["cache-control"] ?? "", /immutable/);
|
|
163
|
+
assert.match(res.headers?.["cache-control"] ?? "", /max-age=31536000/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the legacy /app/runtime.js URL still serves the module, but no-cache", async () => {
|
|
167
|
+
// Back-compat + defence in depth: an old cached shell (or any client) hitting the unhashed URL
|
|
168
|
+
// still gets the current bytes, and `no-cache` forces revalidation instead of a stale replay.
|
|
169
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
170
|
+
assert.equal(res.status, 200);
|
|
171
|
+
assert.match(res.headers?.["content-type"] ?? "", /javascript/);
|
|
172
|
+
assert.match(res.body ?? "", /pc:refresh/);
|
|
173
|
+
assert.match(res.headers?.["cache-control"] ?? "", /no-cache/);
|
|
174
|
+
});
|
|
175
|
+
|
|
148
176
|
test("the shell renders an API-docs badge only when an apiDocsPath is provided", async () => {
|
|
149
177
|
const routes = createPagesRoutes(
|
|
150
178
|
{ pagesDir: "pages", homePage: "home", sourceName: "app", apiDocsPath: "/app/api-docs" },
|
|
@@ -325,6 +353,22 @@ test("the renderer honours numeric actionForm fields", async () => {
|
|
|
325
353
|
assert.match(js, /if \(!Number\.isFinite\(num\)\) continue;/);
|
|
326
354
|
});
|
|
327
355
|
|
|
356
|
+
test("the renderer honours boolean (checkbox) actionForm fields", async () => {
|
|
357
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
358
|
+
const js = res.body ?? "";
|
|
359
|
+
// Fields declared `type: "checkbox"` render a real <input type="checkbox">…
|
|
360
|
+
assert.match(js, /f\.type === "checkbox"/);
|
|
361
|
+
assert.match(js, /el\("input", \{ type: "checkbox" \}\)/);
|
|
362
|
+
// …whose default checked state comes from `default`/`checked` on the field…
|
|
363
|
+
assert.match(js, /f\.default === true \|\| f\.checked === true/);
|
|
364
|
+
// …and whose submitted value is always a real boolean (never a string), so a
|
|
365
|
+
// strict `=== true` check on the action side sees the intended value.
|
|
366
|
+
assert.match(js, /variables\[k\] = input\.checked === true;/);
|
|
367
|
+
// A post-submit reset restores a checkbox to its declared default (read off the
|
|
368
|
+
// input's defaultChecked) rather than clearing `.value` (a no-op for a checkbox).
|
|
369
|
+
assert.match(js, /input\.checked = input\.defaultChecked;/);
|
|
370
|
+
});
|
|
371
|
+
|
|
328
372
|
test("renderer makes collapsible nodes persist their state across sessions", async () => {
|
|
329
373
|
// A node with `collapsible` gets a clickable header that hides/shows the card
|
|
330
374
|
// body, and the collapsed state is written to localStorage keyed by the home
|