@aventara/client 1.0.0-rc.0 → 1.0.0-rc.10
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/CHANGELOG.md +651 -0
- package/README.md +195 -141
- package/dist/cli/command.parser.d.ts +7 -6
- package/dist/cli/command.parser.js +13 -13
- package/dist/cli/generation-failure.renderer.d.ts +2 -1
- package/dist/cli/generation-failure.renderer.js +24 -24
- package/dist/cli/generation-success.renderer.d.ts +2 -1
- package/dist/cli/step.reporter.d.ts +2 -1
- package/dist/cli/terminal.prompter.d.ts +2 -1
- package/dist/config/client-config.interface.d.ts +24 -7
- package/dist/config/config.loader.d.ts +12 -7
- package/dist/config/config.loader.js +26 -8
- package/dist/config/config.resolver.d.ts +16 -9
- package/dist/config/config.resolver.js +40 -12
- package/dist/config/env.cascade.d.ts +4 -8
- package/dist/config/env.cascade.js +7 -7
- package/dist/config/module-style.resolver.d.ts +4 -7
- package/dist/config/module-style.resolver.js +4 -4
- package/dist/config/tsconfig.locator.d.ts +3 -2
- package/dist/config/tsconfig.locator.js +16 -4
- package/dist/contract/contract.acceptance.d.ts +4 -5
- package/dist/contract/contract.acceptance.js +5 -5
- package/dist/contract/contract.fetcher.d.ts +5 -4
- package/dist/contract/contract.fetcher.js +6 -6
- package/dist/contract/contract.loader.d.ts +9 -8
- package/dist/contract/contract.loader.js +2 -2
- package/dist/emit/access.emitter.d.ts +12 -0
- package/dist/emit/access.emitter.js +263 -0
- package/dist/emit/banner.emitter.d.ts +6 -1
- package/dist/emit/banner.emitter.js +12 -8
- package/dist/emit/client-surface.emitter.d.ts +2 -2
- package/dist/emit/client-surface.emitter.js +136 -41
- package/dist/emit/client-tree.emitter.d.ts +11 -4
- package/dist/emit/client-tree.emitter.js +51 -17
- package/dist/emit/contract-carrier.emitter.d.ts +3 -3
- package/dist/emit/derivation.emitter.d.ts +3 -3
- package/dist/emit/derivation.emitter.js +7 -3
- package/dist/emit/descriptor.emitter.d.ts +2 -2
- package/dist/emit/descriptor.emitter.js +1 -1
- package/dist/emit/emitted-tree.interface.d.ts +1 -1
- package/dist/emit/enum.emitter.d.ts +2 -2
- package/dist/emit/field-check.emitter.d.ts +22 -0
- package/dist/emit/field-check.emitter.js +592 -0
- package/dist/emit/json-shape.emitter.d.ts +2 -2
- package/dist/emit/module-specifier.scanner.d.ts +2 -1
- package/dist/emit/name.deriver.d.ts +8 -9
- package/dist/emit/name.deriver.js +18 -15
- package/dist/emit/named-type.emitter.d.ts +2 -2
- package/dist/emit/named-type.emitter.js +12 -2
- package/dist/emit/runtime.emitter.d.ts +6 -5
- package/dist/emit/runtime.emitter.js +215 -116
- package/dist/emit/scalar.codec.d.ts +1 -1
- package/dist/emit/scalar.codec.js +246 -29
- package/dist/emit/transaction.emitter.js +30 -14
- package/dist/generate.d.ts +5 -4
- package/dist/generate.js +3 -3
- package/dist/hydration/hydration-tag.format.d.ts +11 -0
- package/dist/hydration/hydration-tag.format.js +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/init/client-init.errors.d.ts +4 -4
- package/dist/init/client-init.errors.js +4 -4
- package/dist/init/client-init.orchestrator.js +7 -7
- package/dist/init/client-init.planner.d.ts +3 -2
- package/dist/init/client-init.questions.d.ts +5 -16
- package/dist/init/client-init.questions.js +12 -12
- package/dist/init/client-project.inspector.d.ts +2 -2
- package/dist/init/client-project.inspector.js +3 -3
- package/dist/internal.d.ts +25 -0
- package/dist/internal.js +11 -0
- package/dist/output/output.validator.d.ts +3 -4
- package/dist/output/output.validator.js +2 -2
- package/dist/output/output.writer.d.ts +8 -7
- package/dist/output/output.writer.js +12 -12
- package/package.json +9 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createFilesMatcher, getTsconfig, parseTsconfig, } from "get-tsconfig";
|
|
4
|
-
import {
|
|
4
|
+
import { AvClientConfigError } from "./config.resolver.js";
|
|
5
5
|
export function locateTsconfig(location) {
|
|
6
6
|
if (location.tsconfigFile !== undefined) {
|
|
7
7
|
return namedTsconfig(location.tsconfigFile);
|
|
@@ -11,18 +11,30 @@ export function locateTsconfig(location) {
|
|
|
11
11
|
return undefined;
|
|
12
12
|
}
|
|
13
13
|
if (found.config.references === undefined ||
|
|
14
|
-
|
|
14
|
+
includesEntry(found, location.entryFile)) {
|
|
15
15
|
return found;
|
|
16
16
|
}
|
|
17
17
|
for (const reference of found.config.references) {
|
|
18
18
|
const referenced = referencedTsconfig(found.path, reference.path);
|
|
19
19
|
if (referenced !== undefined &&
|
|
20
|
-
|
|
20
|
+
includesEntry(referenced, location.entryFile)) {
|
|
21
21
|
return referenced;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
return found;
|
|
25
25
|
}
|
|
26
|
+
function includesEntry(tsconfig, file) {
|
|
27
|
+
const directory = path.dirname(tsconfig.path);
|
|
28
|
+
const local = (entries) => entries.map((entry) => path.isAbsolute(entry) ? path.relative(directory, entry) || "." : entry);
|
|
29
|
+
const { files, include, exclude } = tsconfig.config;
|
|
30
|
+
const config = {
|
|
31
|
+
...tsconfig.config,
|
|
32
|
+
...(files === undefined ? {} : { files: local(files) }),
|
|
33
|
+
...(include === undefined ? {} : { include: local(include) }),
|
|
34
|
+
...(exclude === undefined ? {} : { exclude: local(exclude) }),
|
|
35
|
+
};
|
|
36
|
+
return (createFilesMatcher({ path: tsconfig.path, config })(file) !== undefined);
|
|
37
|
+
}
|
|
26
38
|
function namedTsconfig(file) {
|
|
27
39
|
try {
|
|
28
40
|
readFileSync(file, "utf8");
|
|
@@ -34,7 +46,7 @@ function namedTsconfig(file) {
|
|
|
34
46
|
: error instanceof Error
|
|
35
47
|
? error.message
|
|
36
48
|
: String(error);
|
|
37
|
-
throw new
|
|
49
|
+
throw new AvClientConfigError(`tsconfigFile names ${file}, which could not be read (${reason}); it must name the tsconfig.json your project compiles the generated client with.`);
|
|
38
50
|
}
|
|
39
51
|
}
|
|
40
52
|
function referencedTsconfig(from, reference) {
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare const SUPPORTED_PROTOCOL_VERSIONS: ReadonlySet<number>;
|
|
1
|
+
import type { AvContractClient } from "@aventara/core";
|
|
3
2
|
export type ContractRejectionReason = "protocol-unsupported" | "hash-mismatch" | "structure-invalid";
|
|
4
3
|
export type ContractAccepted = {
|
|
5
4
|
readonly accepted: true;
|
|
6
|
-
readonly contract:
|
|
5
|
+
readonly contract: AvContractClient;
|
|
7
6
|
};
|
|
8
7
|
export type ContractRejected = {
|
|
9
8
|
readonly accepted: false;
|
|
@@ -16,8 +15,8 @@ export type ContractRejected = {
|
|
|
16
15
|
export type ContractAcceptance = ContractAccepted | ContractRejected;
|
|
17
16
|
export declare function acceptClientContract(body: unknown): Promise<ContractAcceptance>;
|
|
18
17
|
/** The refusal that stops generation, carrying the rejection's reason and sentence. */
|
|
19
|
-
export declare class
|
|
20
|
-
readonly name = "
|
|
18
|
+
export declare class AvContractProtocolError extends Error {
|
|
19
|
+
readonly name = "AvContractProtocolError";
|
|
21
20
|
readonly reason: ContractRejectionReason;
|
|
22
21
|
readonly exitCode: number;
|
|
23
22
|
constructor(rejection: ContractRejected, contractUrl: string);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AVENTARA_PROTOCOL_VERSION,
|
|
2
|
-
|
|
1
|
+
import { AVENTARA_PROTOCOL_VERSION, AvCanonicalJsonError, computeClientContractHash, validateClientContractStructure, } from "@aventara/core/internal";
|
|
2
|
+
const SUPPORTED_PROTOCOL_VERSIONS = new Set([
|
|
3
3
|
AVENTARA_PROTOCOL_VERSION,
|
|
4
4
|
]);
|
|
5
5
|
const REFUSED_EXIT_CODE = 1;
|
|
@@ -22,7 +22,7 @@ export async function acceptClientContract(body) {
|
|
|
22
22
|
recomputed = await computeClientContractHash(contract);
|
|
23
23
|
}
|
|
24
24
|
catch (error) {
|
|
25
|
-
if (!(error instanceof
|
|
25
|
+
if (!(error instanceof AvCanonicalJsonError)) {
|
|
26
26
|
throw error;
|
|
27
27
|
}
|
|
28
28
|
return reject("hash-mismatch", `it holds a value canonical JSON cannot represent (${error.message}), so its ` +
|
|
@@ -36,8 +36,8 @@ export async function acceptClientContract(body) {
|
|
|
36
36
|
}
|
|
37
37
|
return { accepted: true, contract };
|
|
38
38
|
}
|
|
39
|
-
export class
|
|
40
|
-
name = "
|
|
39
|
+
export class AvContractProtocolError extends Error {
|
|
40
|
+
name = "AvContractProtocolError";
|
|
41
41
|
reason;
|
|
42
42
|
exitCode;
|
|
43
43
|
constructor(rejection, contractUrl) {
|
|
@@ -7,7 +7,7 @@ export type ContractFetch = (url: URL, init: {
|
|
|
7
7
|
readonly headers?: Readonly<Record<string, string>>;
|
|
8
8
|
}) => Promise<ContractResponse>;
|
|
9
9
|
export declare const CONTRACT_NOT_MODIFIED: unique symbol;
|
|
10
|
-
|
|
10
|
+
type ContractTransportFailureReason =
|
|
11
11
|
/** No response at all: DNS, refused connection, TLS, abort. */
|
|
12
12
|
"request-failed"
|
|
13
13
|
/** A response that is not 2xx — nothing was served for the protocol to judge. */
|
|
@@ -16,15 +16,16 @@ export type ContractTransportFailureReason =
|
|
|
16
16
|
| "body-unreadable"
|
|
17
17
|
/** A body that is not JSON — an HTML page, an empty body, a truncation. */
|
|
18
18
|
| "body-not-json";
|
|
19
|
-
export declare class
|
|
19
|
+
export declare class AvContractTransportError extends Error {
|
|
20
20
|
readonly reason: ContractTransportFailureReason;
|
|
21
|
-
readonly name = "
|
|
21
|
+
readonly name = "AvContractTransportError";
|
|
22
22
|
constructor(reason: ContractTransportFailureReason, message: string, options?: ErrorOptions);
|
|
23
23
|
}
|
|
24
24
|
export declare function contractUrlOf(entrypoint: ClientEntrypoint): URL;
|
|
25
25
|
/** The URL as it may be printed: credentials in the entrypoint never are. */
|
|
26
26
|
export declare function displayUrl(url: URL): string;
|
|
27
27
|
/**
|
|
28
|
-
* @throws
|
|
28
|
+
* @throws AvContractTransportError when no JSON value arrives.
|
|
29
29
|
*/
|
|
30
30
|
export declare function fetchClientContractBody(entrypoint: ClientEntrypoint, fetch?: ContractFetch, ifNoneMatch?: string): Promise<unknown>;
|
|
31
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const CONTRACT_NOT_MODIFIED = Symbol("aventara.contract-not-modified");
|
|
2
|
-
export class
|
|
2
|
+
export class AvContractTransportError extends Error {
|
|
3
3
|
reason;
|
|
4
|
-
name = "
|
|
4
|
+
name = "AvContractTransportError";
|
|
5
5
|
constructor(reason, message, options) {
|
|
6
6
|
super(message, options);
|
|
7
7
|
this.reason = reason;
|
|
@@ -29,26 +29,26 @@ export async function fetchClientContractBody(entrypoint, fetch = globalThis.fet
|
|
|
29
29
|
: { method: "GET", headers: { "If-None-Match": ifNoneMatch } });
|
|
30
30
|
}
|
|
31
31
|
catch (error) {
|
|
32
|
-
throw new
|
|
32
|
+
throw new AvContractTransportError("request-failed", `Could not fetch the ClientContract from ${shown}: ${describeCause(error)}. ${remedy}`, { cause: error });
|
|
33
33
|
}
|
|
34
34
|
if (response.status === 304 && ifNoneMatch !== undefined) {
|
|
35
35
|
return CONTRACT_NOT_MODIFIED;
|
|
36
36
|
}
|
|
37
37
|
if (!response.ok) {
|
|
38
|
-
throw new
|
|
38
|
+
throw new AvContractTransportError("http-status", `GET ${shown} answered HTTP ${response.status}, not the ClientContract. ${remedy}`);
|
|
39
39
|
}
|
|
40
40
|
let text;
|
|
41
41
|
try {
|
|
42
42
|
text = await response.text();
|
|
43
43
|
}
|
|
44
44
|
catch (error) {
|
|
45
|
-
throw new
|
|
45
|
+
throw new AvContractTransportError("body-unreadable", `The response from ${shown} could not be read: ${describeCause(error)}. ${remedy}`, { cause: error });
|
|
46
46
|
}
|
|
47
47
|
try {
|
|
48
48
|
return JSON.parse(text);
|
|
49
49
|
}
|
|
50
50
|
catch (error) {
|
|
51
|
-
throw new
|
|
51
|
+
throw new AvContractTransportError("body-not-json", `The response from ${shown} is not JSON, so it is not a ClientContract. ${remedy}`, { cause: error });
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
function describeCause(error) {
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AvContractClient } from "@aventara/core";
|
|
2
2
|
import type { ClientEntrypoint } from "../config/client-config.interface.js";
|
|
3
3
|
import { type ContractFetch } from "./contract.fetcher.js";
|
|
4
|
-
|
|
4
|
+
interface ClientContractLoadInput {
|
|
5
5
|
readonly entrypoint: ClientEntrypoint;
|
|
6
6
|
readonly fetch?: ContractFetch;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
* @throws
|
|
10
|
-
* @throws
|
|
9
|
+
* @throws AvContractTransportError when no JSON value arrives.
|
|
10
|
+
* @throws AvContractProtocolError when what arrived is refused.
|
|
11
11
|
*/
|
|
12
|
-
export declare function loadClientContract(input: ClientContractLoadInput): Promise<
|
|
13
|
-
|
|
14
|
-
readonly contract:
|
|
12
|
+
export declare function loadClientContract(input: ClientContractLoadInput): Promise<AvContractClient>;
|
|
13
|
+
interface ClientContractSince {
|
|
14
|
+
readonly contract: AvContractClient;
|
|
15
15
|
/** The deployment answered `304`: `contract` is the one the caller held. */
|
|
16
16
|
readonly notModified: boolean;
|
|
17
17
|
}
|
|
@@ -21,4 +21,5 @@ export interface ClientContractSince {
|
|
|
21
21
|
* `If-None-Match: "<its hash>"`, and a `304` answers with `stored` itself. Without
|
|
22
22
|
* `stored` the GET is unconditional.
|
|
23
23
|
*/
|
|
24
|
-
export declare function loadClientContractSince(input: ClientContractLoadInput, stored:
|
|
24
|
+
export declare function loadClientContractSince(input: ClientContractLoadInput, stored: AvContractClient | undefined): Promise<ClientContractSince>;
|
|
25
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AvContractProtocolError, acceptClientContract, } from "./contract.acceptance.js";
|
|
2
2
|
import { CONTRACT_NOT_MODIFIED, contractUrlOf, displayUrl, fetchClientContractBody, } from "./contract.fetcher.js";
|
|
3
3
|
export async function loadClientContract(input) {
|
|
4
4
|
return accepted(await fetchClientContractBody(input.entrypoint, input.fetch), input);
|
|
@@ -16,7 +16,7 @@ export async function loadClientContractSince(input, stored) {
|
|
|
16
16
|
async function accepted(body, input) {
|
|
17
17
|
const acceptance = await acceptClientContract(body);
|
|
18
18
|
if (!acceptance.accepted) {
|
|
19
|
-
throw new
|
|
19
|
+
throw new AvContractProtocolError(acceptance, displayUrl(contractUrlOf(input.entrypoint)));
|
|
20
20
|
}
|
|
21
21
|
return acceptance.contract;
|
|
22
22
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AvContractClient } from "@aventara/core";
|
|
2
|
+
import { ACCESS_VERB_NAMES, ACCESS_VERBS } from "@aventara/core/internal";
|
|
3
|
+
import type { EmittedModule } from "./emitted-tree.interface.js";
|
|
4
|
+
import { type ClientModuleStyle } from "./module-style.interface.js";
|
|
5
|
+
/** One verb `$access` offers beside `can`. */
|
|
6
|
+
export type AccessVerbName = (typeof ACCESS_VERB_NAMES)[number];
|
|
7
|
+
/** Whether a verb takes arguments, and what it resolves (core's verb table). */
|
|
8
|
+
export declare function accessVerbShape(name: AccessVerbName): (typeof ACCESS_VERBS)[AccessVerbName];
|
|
9
|
+
/** The verbs the section states, in `$access`'s order; none without `verbs`. */
|
|
10
|
+
export declare function accessVerbsOf(contract: AvContractClient): readonly AccessVerbName[];
|
|
11
|
+
/** `runtime/access.ts`, or nothing when the section states no verb. */
|
|
12
|
+
export declare function emitAccessModules(contract: AvContractClient, style: ClientModuleStyle): readonly EmittedModule[];
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { ACCESS_VERB_NAMES, ACCESS_VERBS } from "@aventara/core/internal";
|
|
2
|
+
import { importSpecifier, } from "./module-style.interface.js";
|
|
3
|
+
export function accessVerbShape(name) {
|
|
4
|
+
return ACCESS_VERBS[name];
|
|
5
|
+
}
|
|
6
|
+
export function accessVerbsOf(contract) {
|
|
7
|
+
const verbs = contract.access?.verbs;
|
|
8
|
+
return verbs === undefined
|
|
9
|
+
? []
|
|
10
|
+
: ACCESS_VERB_NAMES.filter((name) => verbs[name] !== undefined);
|
|
11
|
+
}
|
|
12
|
+
export function emitAccessModules(contract, style) {
|
|
13
|
+
const verbs = contract.access?.verbs;
|
|
14
|
+
const names = accessVerbsOf(contract);
|
|
15
|
+
if (verbs === undefined || names.length === 0) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const from = (module) => JSON.stringify(importSpecifier(module, style));
|
|
19
|
+
const table = names
|
|
20
|
+
.map((name) => {
|
|
21
|
+
const verb = verbs[name];
|
|
22
|
+
return `\t${name}: { method: ${JSON.stringify(verb?.method)}, path: ${JSON.stringify(verb?.path)}, result: ${JSON.stringify(verb?.result)} },\n`;
|
|
23
|
+
})
|
|
24
|
+
.join("");
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
path: "runtime/access.ts",
|
|
28
|
+
source: `import { AvTransportError } from ${from("./errors")};
|
|
29
|
+
import type { AvCallOptions, PlatformSignal, TransportConnection } from ${from("./transport")};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Aventara Access's verbs, as this deployment's ClientContract states them:
|
|
33
|
+
* each one a request to Access's own route, its result projected onto the
|
|
34
|
+
* schema the ClientContract promises.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Aventara Access answered a verb with a status that is not 2xx. It carries the
|
|
39
|
+
* status and Access's own code and message; it is no AvError, because Access's
|
|
40
|
+
* answers are not framework envelopes.
|
|
41
|
+
*/
|
|
42
|
+
export class AvAccessError extends Error {
|
|
43
|
+
override readonly name: string = "AvAccessError";
|
|
44
|
+
/** The HTTP status Access answered. */
|
|
45
|
+
readonly status: number;
|
|
46
|
+
/** Access's own error code, or null when its answer named none. */
|
|
47
|
+
readonly code: string | null;
|
|
48
|
+
|
|
49
|
+
constructor(message: string, status: number, code: string | null) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.status = status;
|
|
52
|
+
this.code = code;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where the app mounted Aventara Access. */
|
|
57
|
+
const ACCESS_URL = ${JSON.stringify(contract.access?.url ?? "")};
|
|
58
|
+
|
|
59
|
+
/** Each verb this client offers: its route under ACCESS_URL and its result's schema. */
|
|
60
|
+
const VERBS = {
|
|
61
|
+
${table}} as const;
|
|
62
|
+
|
|
63
|
+
/** One verb this client offers. */
|
|
64
|
+
export type AccessVerb = keyof typeof VERBS;
|
|
65
|
+
|
|
66
|
+
/** A schema node, as the projection reads it. */
|
|
67
|
+
type Schema = { readonly [key: string]: unknown };
|
|
68
|
+
|
|
69
|
+
/** What a verb hands fetch. */
|
|
70
|
+
interface AccessInit {
|
|
71
|
+
readonly method: "GET" | "POST";
|
|
72
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
73
|
+
readonly body?: string;
|
|
74
|
+
readonly cache?: "no-store";
|
|
75
|
+
readonly signal?: PlatformSignal;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a verb reads of fetch's answer. */
|
|
79
|
+
interface AccessAnswer {
|
|
80
|
+
readonly status: number;
|
|
81
|
+
text(): Promise<string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The URL of a verb's route: a root-relative ACCESS_URL put under an absolute entrypoint's origin. */
|
|
85
|
+
function routeOf(entrypoint: string, path: string): string {
|
|
86
|
+
const base = ACCESS_URL.replace(/\\/+$/, "");
|
|
87
|
+
const origin = /^[a-z][a-z0-9+.-]*:\\/\\/[^/?#]*/i.exec(entrypoint)?.[0];
|
|
88
|
+
return base.startsWith("/") && origin !== undefined ? origin + base + path : base + path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
92
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* \`schema\` as a member's schema.
|
|
97
|
+
*
|
|
98
|
+
* @throws TypeError when it is none (a free-form object or list, a tuple read
|
|
99
|
+
* past its length): outside the forms a ClientContract states.
|
|
100
|
+
*/
|
|
101
|
+
function memberSchema(schema: unknown): Schema {
|
|
102
|
+
if (!isRecord(schema)) {
|
|
103
|
+
throw new TypeError("the schema is not one of the forms a ClientContract states");
|
|
104
|
+
}
|
|
105
|
+
return schema as Schema;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether \`value\` can be read by \`schema\` at all: its JSON type. */
|
|
109
|
+
function fits(value: unknown, schema: Schema): boolean {
|
|
110
|
+
if (Object.hasOwn(schema, "const")) {
|
|
111
|
+
return value === schema["const"];
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(schema["enum"])) {
|
|
114
|
+
return schema["enum"].includes(value);
|
|
115
|
+
}
|
|
116
|
+
for (const keyword of ["anyOf", "oneOf"]) {
|
|
117
|
+
const options = schema[keyword];
|
|
118
|
+
if (Array.isArray(options)) {
|
|
119
|
+
return options.some((option) => fits(value, option as Schema));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
switch (schema["type"]) {
|
|
123
|
+
case "null":
|
|
124
|
+
return value === null;
|
|
125
|
+
case "boolean":
|
|
126
|
+
return typeof value === "boolean";
|
|
127
|
+
case "string":
|
|
128
|
+
return typeof value === "string";
|
|
129
|
+
case "number":
|
|
130
|
+
return typeof value === "number";
|
|
131
|
+
case "integer":
|
|
132
|
+
return Number.isInteger(value);
|
|
133
|
+
case "array":
|
|
134
|
+
return Array.isArray(value);
|
|
135
|
+
case "object":
|
|
136
|
+
return isRecord(value);
|
|
137
|
+
default:
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* \`value\` projected onto \`schema\`: every object down to the members the schema
|
|
144
|
+
* declares, a list member by member. The schema is the promise.
|
|
145
|
+
*
|
|
146
|
+
* @throws TypeError when the value is not one the schema describes.
|
|
147
|
+
*/
|
|
148
|
+
function projected(value: unknown, schema: Schema): unknown {
|
|
149
|
+
if (!fits(value, schema)) {
|
|
150
|
+
throw new TypeError("the value is not the one the ClientContract describes");
|
|
151
|
+
}
|
|
152
|
+
for (const keyword of ["anyOf", "oneOf"]) {
|
|
153
|
+
const options = schema[keyword];
|
|
154
|
+
if (Array.isArray(options)) {
|
|
155
|
+
const option = options.find((candidate) => fits(value, candidate as Schema)) as Schema;
|
|
156
|
+
return projected(value, option);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (Array.isArray(value)) {
|
|
160
|
+
const prefix = schema["prefixItems"];
|
|
161
|
+
const items = Array.isArray(prefix) ? undefined : memberSchema(schema["items"]);
|
|
162
|
+
return Object.freeze(
|
|
163
|
+
value.map((member: unknown, index: number) =>
|
|
164
|
+
projected(member, items ?? memberSchema((prefix as readonly unknown[])[index])),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (isRecord(value)) {
|
|
169
|
+
const properties = schema["properties"];
|
|
170
|
+
if (isRecord(properties)) {
|
|
171
|
+
const copy: Record<string, unknown> = {};
|
|
172
|
+
for (const [key, member] of Object.entries(properties)) {
|
|
173
|
+
if (Object.hasOwn(value, key) && value[key] !== undefined) {
|
|
174
|
+
copy[key] = projected(value[key], member as Schema);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return Object.freeze(copy);
|
|
178
|
+
}
|
|
179
|
+
const values = memberSchema(schema["additionalProperties"]);
|
|
180
|
+
return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, member]) => [key, projected(member, values)])));
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Access's error answer, read for its code and message. */
|
|
186
|
+
function refusalOf(verb: AccessVerb, status: number, text: string): AvAccessError {
|
|
187
|
+
let body: unknown;
|
|
188
|
+
try {
|
|
189
|
+
body = JSON.parse(text);
|
|
190
|
+
} catch {
|
|
191
|
+
body = undefined;
|
|
192
|
+
}
|
|
193
|
+
const code = isRecord(body) && typeof body["code"] === "string" ? body["code"] : null;
|
|
194
|
+
const message =
|
|
195
|
+
isRecord(body) && typeof body["message"] === "string" ? body["message"] : "Aventara Access refused $access." + verb + " (HTTP " + status + ").";
|
|
196
|
+
return new AvAccessError(message, status, code);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Runs one Access verb: one request to its route, its 2xx answer projected onto
|
|
201
|
+
* its result schema (\`null\` stays \`null\`: no one is signed in).
|
|
202
|
+
*
|
|
203
|
+
* @throws AvAccessError on a non-2xx answer; AvTransportError when no answer
|
|
204
|
+
* arrived, or the answer is not the value the ClientContract describes. A
|
|
205
|
+
* caller's abort rejects with what fetch rejected with.
|
|
206
|
+
*/
|
|
207
|
+
export async function callAccess(
|
|
208
|
+
connection: TransportConnection,
|
|
209
|
+
verb: AccessVerb,
|
|
210
|
+
args: unknown,
|
|
211
|
+
options: AvCallOptions = {},
|
|
212
|
+
): Promise<unknown> {
|
|
213
|
+
const { method, path, result } = VERBS[verb];
|
|
214
|
+
const operation = "$access." + verb;
|
|
215
|
+
const headers: Record<string, string> = { ...options.headers };
|
|
216
|
+
let init: AccessInit;
|
|
217
|
+
if (method === "GET") {
|
|
218
|
+
init = { method, headers, cache: "no-store" };
|
|
219
|
+
} else {
|
|
220
|
+
headers["Content-Type"] = "application/json";
|
|
221
|
+
init = { method, headers, body: JSON.stringify(args ?? {}) };
|
|
222
|
+
}
|
|
223
|
+
if (options.signal !== undefined) {
|
|
224
|
+
init = { ...init, signal: options.signal };
|
|
225
|
+
}
|
|
226
|
+
const fetch = connection.fetch as unknown as (url: string, init: AccessInit) => Promise<AccessAnswer>;
|
|
227
|
+
let answer: AccessAnswer;
|
|
228
|
+
let text: string;
|
|
229
|
+
try {
|
|
230
|
+
answer = await fetch(routeOf(connection.entrypoint, path), init);
|
|
231
|
+
text = await answer.text();
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (options.signal?.aborted === true) {
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
throw new AvTransportError("The request for " + operation + " failed before any answer arrived.", null);
|
|
237
|
+
}
|
|
238
|
+
if (answer.status < 200 || answer.status > 299) {
|
|
239
|
+
throw refusalOf(verb, answer.status, text);
|
|
240
|
+
}
|
|
241
|
+
let value: unknown;
|
|
242
|
+
try {
|
|
243
|
+
value = text.trim() === "" ? null : JSON.parse(text);
|
|
244
|
+
} catch {
|
|
245
|
+
throw new AvTransportError("The answer to " + operation + " (HTTP " + answer.status + ") is not JSON.", answer.status);
|
|
246
|
+
}
|
|
247
|
+
if (value === null) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
return projected(value, result as Schema);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new AvTransportError(
|
|
254
|
+
"The answer to " + operation + " (HTTP " + answer.status + ") holds a value this client cannot read: " +
|
|
255
|
+
(error instanceof Error ? error.message : String(error)),
|
|
256
|
+
answer.status,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
`,
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export declare const GENERATED_OWNERSHIP_LINE = "/* !!! Generated by @aventara/client. Do not edit. !!! */";
|
|
2
|
+
/** The command a generated file says to regenerate it with: a command in backticks, then any words about it. */
|
|
3
|
+
export type RegenerateCommand = `\`${string}\`${string}`;
|
|
4
|
+
/** `avclient generate`'s own: what its banner and errors say. */
|
|
5
|
+
export declare const AVCLIENT_REGENERATE: RegenerateCommand;
|
|
6
|
+
/** `avclient generate`'s banner. */
|
|
2
7
|
export declare const GENERATED_BANNER: readonly string[];
|
|
3
8
|
/** `source` with the banner before it and one blank line between. */
|
|
4
|
-
export declare function withGeneratedBanner(source: string): string;
|
|
9
|
+
export declare function withGeneratedBanner(source: string, regenerate?: RegenerateCommand): string;
|
|
5
10
|
/**
|
|
6
11
|
* How far into a file the ownership line is looked for. The banner opens every
|
|
7
12
|
* emitted file, so the line sits well inside this; a reader never has to read a
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
export const GENERATED_OWNERSHIP_LINE = "/* !!! Generated by @aventara/client. Do not edit. !!! */";
|
|
2
|
-
export const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
export const AVCLIENT_REGENERATE = "`avclient generate`";
|
|
3
|
+
function generatedBanner(regenerate = AVCLIENT_REGENERATE) {
|
|
4
|
+
return [
|
|
5
|
+
"// biome-ignore-all format: generated output",
|
|
6
|
+
"// biome-ignore-all lint: generated output",
|
|
7
|
+
GENERATED_OWNERSHIP_LINE,
|
|
8
|
+
`/* Regenerate with ${regenerate}. */`,
|
|
9
|
+
];
|
|
10
|
+
}
|
|
11
|
+
export const GENERATED_BANNER = generatedBanner();
|
|
12
|
+
export function withGeneratedBanner(source, regenerate = AVCLIENT_REGENERATE) {
|
|
13
|
+
return `${generatedBanner(regenerate).join("\n")}\n\n${source}`;
|
|
10
14
|
}
|
|
11
15
|
export const GENERATED_OWNERSHIP_HEAD_BYTES = 1024;
|
|
12
16
|
export function carriesGeneratedOwnership(head) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AvContractClient } from "@aventara/core";
|
|
2
2
|
import type { EmittedModule } from "./emitted-tree.interface.js";
|
|
3
3
|
import { type ClientModuleStyle } from "./module-style.interface.js";
|
|
4
4
|
import { type EmittedNames } from "./name.deriver.js";
|
|
5
|
-
export declare function emitClientSurfaceModule(contract:
|
|
5
|
+
export declare function emitClientSurfaceModule(contract: AvContractClient, names: EmittedNames, style: ClientModuleStyle): EmittedModule;
|