@beignet/core 0.0.1
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 +5 -0
- package/README.md +288 -0
- package/dist/application/index.d.ts +260 -0
- package/dist/application/index.d.ts.map +1 -0
- package/dist/application/index.js +324 -0
- package/dist/application/index.js.map +1 -0
- package/dist/client/client.d.ts +241 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +531 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/index.d.ts +10 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +8 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +139 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +2 -0
- package/dist/client/types.js.map +1 -0
- package/dist/config/index.d.ts +122 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +216 -0
- package/dist/config/index.js.map +1 -0
- package/dist/contracts/contract-builder.d.ts +121 -0
- package/dist/contracts/contract-builder.d.ts.map +1 -0
- package/dist/contracts/contract-builder.js +346 -0
- package/dist/contracts/contract-builder.js.map +1 -0
- package/dist/contracts/contract-group.d.ts +106 -0
- package/dist/contracts/contract-group.d.ts.map +1 -0
- package/dist/contracts/contract-group.js +240 -0
- package/dist/contracts/contract-group.js.map +1 -0
- package/dist/contracts/contract-like.d.ts +21 -0
- package/dist/contracts/contract-like.d.ts.map +1 -0
- package/dist/contracts/contract-like.js +9 -0
- package/dist/contracts/contract-like.js.map +1 -0
- package/dist/contracts/index.d.ts +15 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +11 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/contracts/openapi-meta.d.ts +23 -0
- package/dist/contracts/openapi-meta.d.ts.map +1 -0
- package/dist/contracts/openapi-meta.js +2 -0
- package/dist/contracts/openapi-meta.js.map +1 -0
- package/dist/contracts/path-template.d.ts +17 -0
- package/dist/contracts/path-template.d.ts.map +1 -0
- package/dist/contracts/path-template.js +50 -0
- package/dist/contracts/path-template.js.map +1 -0
- package/dist/contracts/rate-limit.d.ts +50 -0
- package/dist/contracts/rate-limit.d.ts.map +1 -0
- package/dist/contracts/rate-limit.js +2 -0
- package/dist/contracts/rate-limit.js.map +1 -0
- package/dist/contracts/types.d.ts +97 -0
- package/dist/contracts/types.d.ts.map +1 -0
- package/dist/contracts/types.js +54 -0
- package/dist/contracts/types.js.map +1 -0
- package/dist/contracts/utils.d.ts +3 -0
- package/dist/contracts/utils.d.ts.map +1 -0
- package/dist/contracts/utils.js +44 -0
- package/dist/contracts/utils.js.map +1 -0
- package/dist/domain/entity.d.ts +87 -0
- package/dist/domain/entity.d.ts.map +1 -0
- package/dist/domain/entity.js +155 -0
- package/dist/domain/entity.js.map +1 -0
- package/dist/domain/events.d.ts +41 -0
- package/dist/domain/events.d.ts.map +1 -0
- package/dist/domain/events.js +21 -0
- package/dist/domain/events.js.map +1 -0
- package/dist/domain/index.d.ts +14 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +14 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/value-object.d.ts +60 -0
- package/dist/domain/value-object.d.ts.map +1 -0
- package/dist/domain/value-object.js +87 -0
- package/dist/domain/value-object.js.map +1 -0
- package/dist/errors/catalog.d.ts +71 -0
- package/dist/errors/catalog.d.ts.map +1 -0
- package/dist/errors/catalog.js +71 -0
- package/dist/errors/catalog.js.map +1 -0
- package/dist/errors/http.d.ts +77 -0
- package/dist/errors/http.d.ts.map +1 -0
- package/dist/errors/http.js +74 -0
- package/dist/errors/http.js.map +1 -0
- package/dist/errors/index.d.ts +10 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +14 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/response.d.ts +26 -0
- package/dist/errors/response.d.ts.map +1 -0
- package/dist/errors/response.js +34 -0
- package/dist/errors/response.js.map +1 -0
- package/dist/errors/validation.d.ts +18 -0
- package/dist/errors/validation.d.ts.map +1 -0
- package/dist/errors/validation.js +21 -0
- package/dist/errors/validation.js.map +1 -0
- package/dist/events/index.d.ts +58 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +102 -0
- package/dist/events/index.js.map +1 -0
- package/dist/jobs/index.d.ts +56 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +89 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/mail/index.d.ts +75 -0
- package/dist/mail/index.d.ts.map +1 -0
- package/dist/mail/index.js +84 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/openapi/index.d.ts +207 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +449 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/openapi/schema-introspector.d.ts +38 -0
- package/dist/openapi/schema-introspector.d.ts.map +1 -0
- package/dist/openapi/schema-introspector.js +67 -0
- package/dist/openapi/schema-introspector.js.map +1 -0
- package/dist/ports/audit.d.ts +58 -0
- package/dist/ports/audit.d.ts.map +1 -0
- package/dist/ports/audit.js +74 -0
- package/dist/ports/audit.js.map +1 -0
- package/dist/ports/auth.d.ts +23 -0
- package/dist/ports/auth.d.ts.map +1 -0
- package/dist/ports/auth.js +31 -0
- package/dist/ports/auth.js.map +1 -0
- package/dist/ports/builder.d.ts +61 -0
- package/dist/ports/builder.d.ts.map +1 -0
- package/dist/ports/builder.js +48 -0
- package/dist/ports/builder.js.map +1 -0
- package/dist/ports/cache.d.ts +15 -0
- package/dist/ports/cache.d.ts.map +1 -0
- package/dist/ports/cache.js +57 -0
- package/dist/ports/cache.js.map +1 -0
- package/dist/ports/clock.d.ts +10 -0
- package/dist/ports/clock.d.ts.map +1 -0
- package/dist/ports/clock.js +21 -0
- package/dist/ports/clock.js.map +1 -0
- package/dist/ports/events.d.ts +71 -0
- package/dist/ports/events.d.ts.map +1 -0
- package/dist/ports/events.js +2 -0
- package/dist/ports/events.js.map +1 -0
- package/dist/ports/id-generator.d.ts +12 -0
- package/dist/ports/id-generator.d.ts.map +1 -0
- package/dist/ports/id-generator.js +22 -0
- package/dist/ports/id-generator.js.map +1 -0
- package/dist/ports/index.d.ts +98 -0
- package/dist/ports/index.d.ts.map +1 -0
- package/dist/ports/index.js +67 -0
- package/dist/ports/index.js.map +1 -0
- package/dist/ports/logger.d.ts +22 -0
- package/dist/ports/logger.d.ts.map +1 -0
- package/dist/ports/logger.js +34 -0
- package/dist/ports/logger.js.map +1 -0
- package/dist/ports/mailer.d.ts +6 -0
- package/dist/ports/mailer.d.ts.map +1 -0
- package/dist/ports/mailer.js +2 -0
- package/dist/ports/mailer.js.map +1 -0
- package/dist/ports/policy.d.ts +53 -0
- package/dist/ports/policy.d.ts.map +1 -0
- package/dist/ports/policy.js +81 -0
- package/dist/ports/policy.js.map +1 -0
- package/dist/ports/rate-limit.d.ts +41 -0
- package/dist/ports/rate-limit.d.ts.map +1 -0
- package/dist/ports/rate-limit.js +37 -0
- package/dist/ports/rate-limit.js.map +1 -0
- package/dist/ports/redaction.d.ts +26 -0
- package/dist/ports/redaction.d.ts.map +1 -0
- package/dist/ports/redaction.js +126 -0
- package/dist/ports/redaction.js.map +1 -0
- package/dist/ports/schedules.d.ts +9 -0
- package/dist/ports/schedules.d.ts.map +1 -0
- package/dist/ports/schedules.js +2 -0
- package/dist/ports/schedules.js.map +1 -0
- package/dist/ports/storage.d.ts +47 -0
- package/dist/ports/storage.d.ts.map +1 -0
- package/dist/ports/storage.js +185 -0
- package/dist/ports/storage.js.map +1 -0
- package/dist/ports/testing.d.ts +73 -0
- package/dist/ports/testing.d.ts.map +1 -0
- package/dist/ports/testing.js +105 -0
- package/dist/ports/testing.js.map +1 -0
- package/dist/ports/unit-of-work.d.ts +56 -0
- package/dist/ports/unit-of-work.d.ts.map +1 -0
- package/dist/ports/unit-of-work.js +64 -0
- package/dist/ports/unit-of-work.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +8 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/instrumentation.d.ts +91 -0
- package/dist/providers/instrumentation.d.ts.map +1 -0
- package/dist/providers/instrumentation.js +93 -0
- package/dist/providers/instrumentation.js.map +1 -0
- package/dist/providers/provider.d.ts +146 -0
- package/dist/providers/provider.d.ts.map +1 -0
- package/dist/providers/provider.js +31 -0
- package/dist/providers/provider.js.map +1 -0
- package/dist/schedules/index.d.ts +105 -0
- package/dist/schedules/index.d.ts.map +1 -0
- package/dist/schedules/index.js +178 -0
- package/dist/schedules/index.js.map +1 -0
- package/dist/server/contract-like.d.ts +5 -0
- package/dist/server/contract-like.d.ts.map +1 -0
- package/dist/server/contract-like.js +5 -0
- package/dist/server/contract-like.js.map +1 -0
- package/dist/server/health.d.ts +41 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +46 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/hooks/auth.d.ts +42 -0
- package/dist/server/hooks/auth.d.ts.map +1 -0
- package/dist/server/hooks/auth.js +61 -0
- package/dist/server/hooks/auth.js.map +1 -0
- package/dist/server/hooks/cors.d.ts +13 -0
- package/dist/server/hooks/cors.d.ts.map +1 -0
- package/dist/server/hooks/cors.js +70 -0
- package/dist/server/hooks/cors.js.map +1 -0
- package/dist/server/hooks/errors.d.ts +66 -0
- package/dist/server/hooks/errors.d.ts.map +1 -0
- package/dist/server/hooks/errors.js +83 -0
- package/dist/server/hooks/errors.js.map +1 -0
- package/dist/server/hooks/index.d.ts +12 -0
- package/dist/server/hooks/index.d.ts.map +1 -0
- package/dist/server/hooks/index.js +12 -0
- package/dist/server/hooks/index.js.map +1 -0
- package/dist/server/hooks/logging.d.ts +33 -0
- package/dist/server/hooks/logging.d.ts.map +1 -0
- package/dist/server/hooks/logging.js +90 -0
- package/dist/server/hooks/logging.js.map +1 -0
- package/dist/server/hooks/rate-limit.d.ts +29 -0
- package/dist/server/hooks/rate-limit.d.ts.map +1 -0
- package/dist/server/hooks/rate-limit.js +93 -0
- package/dist/server/hooks/rate-limit.js.map +1 -0
- package/dist/server/hooks/utils.d.ts +9 -0
- package/dist/server/hooks/utils.d.ts.map +1 -0
- package/dist/server/hooks/utils.js +16 -0
- package/dist/server/hooks/utils.js.map +1 -0
- package/dist/server/hooks.d.ts +2 -0
- package/dist/server/hooks.d.ts.map +1 -0
- package/dist/server/hooks.js +2 -0
- package/dist/server/hooks.js.map +1 -0
- package/dist/server/http.d.ts +124 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +2 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/index.d.ts +19 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +15 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/openapi.d.ts +32 -0
- package/dist/server/openapi.d.ts.map +1 -0
- package/dist/server/openapi.js +43 -0
- package/dist/server/openapi.js.map +1 -0
- package/dist/server/providers/index.d.ts +4 -0
- package/dist/server/providers/index.d.ts.map +1 -0
- package/dist/server/providers/index.js +4 -0
- package/dist/server/providers/index.js.map +1 -0
- package/dist/server/providers/loadProviderConfig.d.ts +7 -0
- package/dist/server/providers/loadProviderConfig.d.ts.map +1 -0
- package/dist/server/providers/loadProviderConfig.js +42 -0
- package/dist/server/providers/loadProviderConfig.js.map +1 -0
- package/dist/server/server.d.ts +86 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +1031 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/types.d.ts +3 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +3 -0
- package/dist/server/types.js.map +1 -0
- package/package.json +129 -0
- package/src/application/index.ts +747 -0
- package/src/client/client.ts +1105 -0
- package/src/client/index.ts +45 -0
- package/src/client/types.ts +305 -0
- package/src/config/index.ts +497 -0
- package/src/contracts/contract-builder.ts +583 -0
- package/src/contracts/contract-group.ts +502 -0
- package/src/contracts/contract-like.ts +29 -0
- package/src/contracts/index.ts +53 -0
- package/src/contracts/openapi-meta.ts +22 -0
- package/src/contracts/path-template.ts +91 -0
- package/src/contracts/rate-limit.ts +50 -0
- package/src/contracts/types.ts +207 -0
- package/src/contracts/utils.ts +56 -0
- package/src/domain/entity.ts +256 -0
- package/src/domain/events.ts +52 -0
- package/src/domain/index.ts +18 -0
- package/src/domain/value-object.ts +135 -0
- package/src/errors/catalog.ts +149 -0
- package/src/errors/http.ts +80 -0
- package/src/errors/index.ts +28 -0
- package/src/errors/response.ts +54 -0
- package/src/errors/validation.ts +35 -0
- package/src/events/index.ts +246 -0
- package/src/jobs/index.ts +211 -0
- package/src/mail/index.ts +177 -0
- package/src/openapi/index.ts +865 -0
- package/src/openapi/schema-introspector.ts +107 -0
- package/src/ports/audit.ts +176 -0
- package/src/ports/auth.ts +76 -0
- package/src/ports/builder.ts +97 -0
- package/src/ports/cache.ts +94 -0
- package/src/ports/clock.ts +34 -0
- package/src/ports/events.ts +100 -0
- package/src/ports/id-generator.ts +36 -0
- package/src/ports/index.ts +221 -0
- package/src/ports/logger.ts +67 -0
- package/src/ports/policy.ts +242 -0
- package/src/ports/rate-limit.ts +91 -0
- package/src/ports/redaction.ts +199 -0
- package/src/ports/storage.ts +282 -0
- package/src/ports/testing.ts +234 -0
- package/src/ports/unit-of-work.ts +134 -0
- package/src/providers/index.ts +40 -0
- package/src/providers/instrumentation.ts +248 -0
- package/src/providers/provider.ts +191 -0
- package/src/schedules/index.ts +442 -0
- package/src/server/contract-like.ts +8 -0
- package/src/server/health.ts +82 -0
- package/src/server/hooks/auth.ts +147 -0
- package/src/server/hooks/cors.ts +87 -0
- package/src/server/hooks/errors.ts +126 -0
- package/src/server/hooks/index.ts +43 -0
- package/src/server/hooks/logging.ts +121 -0
- package/src/server/hooks/rate-limit.ts +171 -0
- package/src/server/hooks/utils.ts +16 -0
- package/src/server/hooks.ts +1 -0
- package/src/server/http.ts +189 -0
- package/src/server/index.ts +35 -0
- package/src/server/openapi.ts +72 -0
- package/src/server/providers/index.ts +3 -0
- package/src/server/providers/loadProviderConfig.ts +72 -0
- package/src/server/server.ts +1521 -0
- package/src/server/types.ts +2 -0
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BEIGNET_ERROR_OWNER_HEADER,
|
|
3
|
+
type ContractLike,
|
|
4
|
+
getContractHeaderSchemas,
|
|
5
|
+
type HttpContractConfig,
|
|
6
|
+
methodSupportsRequestBody,
|
|
7
|
+
parsePathTemplate,
|
|
8
|
+
type ResolveContract,
|
|
9
|
+
resolveContract,
|
|
10
|
+
type StandardErrorResponseBody,
|
|
11
|
+
type StandardSchema,
|
|
12
|
+
type StandardSchemaV1,
|
|
13
|
+
} from "../contracts";
|
|
14
|
+
import { isErrorResponseBody, SchemaValidationError } from "../errors";
|
|
15
|
+
import type {
|
|
16
|
+
CallArgs,
|
|
17
|
+
ClientConfig,
|
|
18
|
+
EndpointCallArgs,
|
|
19
|
+
EndpointResult,
|
|
20
|
+
InferEndpointErrorResponse,
|
|
21
|
+
InferEndpointErrorResponseByStatus,
|
|
22
|
+
InferEndpointErrorStatus,
|
|
23
|
+
InferSuccessResponse,
|
|
24
|
+
} from "./types";
|
|
25
|
+
|
|
26
|
+
export type ContractErrorSource = "http" | "client" | "network" | "contract";
|
|
27
|
+
|
|
28
|
+
export type ContractErrorWithSource<
|
|
29
|
+
TError,
|
|
30
|
+
TSource extends ContractErrorSource,
|
|
31
|
+
> = Extract<TError, { readonly source: TSource }>;
|
|
32
|
+
|
|
33
|
+
export type ContractErrorWithStatus<TError, TStatus extends number> = Extract<
|
|
34
|
+
TError,
|
|
35
|
+
{ readonly status: TStatus }
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
export type ContractErrorWithCode<TError, TCode extends string> = Extract<
|
|
39
|
+
TError,
|
|
40
|
+
{ readonly code: TCode }
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
export type HttpContractError<
|
|
44
|
+
TBody = unknown,
|
|
45
|
+
TStatus extends number = number,
|
|
46
|
+
> = ContractError<TBody, TStatus, "http"> & {
|
|
47
|
+
readonly source: "http";
|
|
48
|
+
readonly status: TStatus;
|
|
49
|
+
readonly response: Response;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ClientContractError = ContractError<
|
|
53
|
+
undefined,
|
|
54
|
+
undefined,
|
|
55
|
+
"client"
|
|
56
|
+
> & {
|
|
57
|
+
readonly source: "client";
|
|
58
|
+
readonly status: undefined;
|
|
59
|
+
readonly response: undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type NetworkContractError = ContractError<
|
|
63
|
+
undefined,
|
|
64
|
+
undefined,
|
|
65
|
+
"network"
|
|
66
|
+
> & {
|
|
67
|
+
readonly source: "network";
|
|
68
|
+
readonly status: undefined;
|
|
69
|
+
readonly response: undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type ResponseContractError<
|
|
73
|
+
TBody = unknown,
|
|
74
|
+
TStatus extends number | undefined = number | undefined,
|
|
75
|
+
> = ContractError<TBody, TStatus, "contract"> & {
|
|
76
|
+
readonly source: "contract";
|
|
77
|
+
readonly status: TStatus;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type AnyContractError =
|
|
81
|
+
| HttpContractError
|
|
82
|
+
| ClientContractError
|
|
83
|
+
| NetworkContractError
|
|
84
|
+
| ResponseContractError;
|
|
85
|
+
|
|
86
|
+
type EndpointCatalogErrorDefinition<TContract extends HttpContractConfig> =
|
|
87
|
+
TContract["metadata"] extends { errors: infer TErrors }
|
|
88
|
+
? TErrors extends Record<
|
|
89
|
+
string,
|
|
90
|
+
{ code: string; status: number; message: string }
|
|
91
|
+
>
|
|
92
|
+
? TErrors[keyof TErrors]
|
|
93
|
+
: never
|
|
94
|
+
: never;
|
|
95
|
+
|
|
96
|
+
type InferErrorDefinitionDetails<TDef> = TDef extends {
|
|
97
|
+
details: StandardSchemaV1;
|
|
98
|
+
}
|
|
99
|
+
? StandardSchemaV1.InferOutput<TDef["details"]>
|
|
100
|
+
: unknown;
|
|
101
|
+
|
|
102
|
+
type StandardErrorBodyForDefinition<TDef extends { code: string }> =
|
|
103
|
+
StandardErrorResponseBody & {
|
|
104
|
+
code: TDef["code"];
|
|
105
|
+
details?: InferErrorDefinitionDetails<TDef>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type EndpointCatalogContractError<TContract extends HttpContractConfig> =
|
|
109
|
+
EndpointCatalogErrorDefinition<TContract> extends infer TDef
|
|
110
|
+
? TDef extends { code: string; status: number }
|
|
111
|
+
? HttpContractError<
|
|
112
|
+
StandardErrorBodyForDefinition<TDef>,
|
|
113
|
+
TDef["status"]
|
|
114
|
+
> & {
|
|
115
|
+
readonly code: TDef["code"];
|
|
116
|
+
readonly details?: InferErrorDefinitionDetails<TDef>;
|
|
117
|
+
}
|
|
118
|
+
: never
|
|
119
|
+
: never;
|
|
120
|
+
|
|
121
|
+
export type InferEndpointErrorCode<TContract extends HttpContractConfig> =
|
|
122
|
+
EndpointCatalogErrorDefinition<TContract>["code"];
|
|
123
|
+
|
|
124
|
+
export type InferEndpointContractError<TContract extends HttpContractConfig> =
|
|
125
|
+
| EndpointCatalogContractError<TContract>
|
|
126
|
+
| {
|
|
127
|
+
[TStatus in InferEndpointErrorStatus<TContract>]: HttpContractError<
|
|
128
|
+
InferEndpointErrorResponseByStatus<TContract, TStatus>,
|
|
129
|
+
TStatus
|
|
130
|
+
>;
|
|
131
|
+
}[InferEndpointErrorStatus<TContract>]
|
|
132
|
+
| HttpContractError<InferEndpointErrorResponse<TContract>, number>
|
|
133
|
+
| ClientContractError
|
|
134
|
+
| NetworkContractError
|
|
135
|
+
| ResponseContractError<
|
|
136
|
+
InferEndpointErrorResponse<TContract>,
|
|
137
|
+
number | undefined
|
|
138
|
+
>;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* HTTP client error with contract metadata
|
|
142
|
+
*/
|
|
143
|
+
export class ContractError<
|
|
144
|
+
TBody = unknown,
|
|
145
|
+
TStatus extends number | undefined = number | undefined,
|
|
146
|
+
TSource extends ContractErrorSource = ContractErrorSource,
|
|
147
|
+
> extends Error {
|
|
148
|
+
readonly source: TSource;
|
|
149
|
+
readonly status: TStatus;
|
|
150
|
+
readonly code?: string;
|
|
151
|
+
readonly body?: TBody;
|
|
152
|
+
readonly details?: unknown;
|
|
153
|
+
readonly response?: Response;
|
|
154
|
+
override cause?: unknown;
|
|
155
|
+
|
|
156
|
+
constructor(args: {
|
|
157
|
+
source: TSource;
|
|
158
|
+
status?: TStatus;
|
|
159
|
+
code?: string;
|
|
160
|
+
message: string;
|
|
161
|
+
body?: TBody;
|
|
162
|
+
details?: unknown;
|
|
163
|
+
response?: Response;
|
|
164
|
+
cause?: unknown;
|
|
165
|
+
}) {
|
|
166
|
+
super(args.message);
|
|
167
|
+
this.name = "ContractError";
|
|
168
|
+
this.source = args.source;
|
|
169
|
+
this.status = args.status as TStatus;
|
|
170
|
+
this.code = args.code;
|
|
171
|
+
this.body = args.body;
|
|
172
|
+
this.details = args.details;
|
|
173
|
+
this.response = args.response;
|
|
174
|
+
this.cause = args.cause;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check if this error has a specific HTTP status code
|
|
179
|
+
*/
|
|
180
|
+
hasStatus<S extends number>(
|
|
181
|
+
status: S,
|
|
182
|
+
): this is this & { readonly status: S } {
|
|
183
|
+
return (this.status as number | undefined) === status;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if this error came from a specific source.
|
|
188
|
+
*/
|
|
189
|
+
hasSource<S extends ContractErrorSource>(
|
|
190
|
+
source: S,
|
|
191
|
+
): this is this & { readonly source: S } {
|
|
192
|
+
return (this.source as ContractErrorSource) === source;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if this error has a specific error code
|
|
197
|
+
*/
|
|
198
|
+
hasCode<C extends string>(code: C): this is this & { code: C } {
|
|
199
|
+
return this.code === code;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Type guard to check if an unknown error is a ContractError,
|
|
205
|
+
* optionally narrowing by HTTP status code.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* try { await endpoint.call(...) }
|
|
210
|
+
* catch (err) {
|
|
211
|
+
* if (isContractError(err, 404)) {
|
|
212
|
+
* // err.status is 404
|
|
213
|
+
* }
|
|
214
|
+
* if (isContractError(err)) {
|
|
215
|
+
* // err is ContractError
|
|
216
|
+
* }
|
|
217
|
+
* }
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
export function isContractError(err: unknown): err is AnyContractError;
|
|
221
|
+
export function isContractError<S extends number>(
|
|
222
|
+
err: unknown,
|
|
223
|
+
status: S,
|
|
224
|
+
): err is HttpContractError<unknown, S>;
|
|
225
|
+
export function isContractError<
|
|
226
|
+
TError extends AnyContractError,
|
|
227
|
+
S extends number,
|
|
228
|
+
>(
|
|
229
|
+
err: TError,
|
|
230
|
+
criteria: { status: S },
|
|
231
|
+
): err is ContractErrorWithStatus<TError, S>;
|
|
232
|
+
export function isContractError<
|
|
233
|
+
TError extends AnyContractError,
|
|
234
|
+
S extends ContractErrorSource,
|
|
235
|
+
>(
|
|
236
|
+
err: TError,
|
|
237
|
+
criteria: { source: S },
|
|
238
|
+
): err is ContractErrorWithSource<TError, S>;
|
|
239
|
+
export function isContractError<
|
|
240
|
+
TError extends ContractError,
|
|
241
|
+
Status extends number,
|
|
242
|
+
Source extends ContractErrorSource,
|
|
243
|
+
>(
|
|
244
|
+
err: TError,
|
|
245
|
+
criteria: { status: Status; source: Source },
|
|
246
|
+
): err is ContractErrorWithSource<
|
|
247
|
+
ContractErrorWithStatus<TError, Status>,
|
|
248
|
+
Source
|
|
249
|
+
>;
|
|
250
|
+
export function isContractError<
|
|
251
|
+
TError extends ContractError,
|
|
252
|
+
Code extends string,
|
|
253
|
+
>(
|
|
254
|
+
err: TError,
|
|
255
|
+
criteria: { code: Code },
|
|
256
|
+
): err is ContractErrorWithCode<TError, Code>;
|
|
257
|
+
export function isContractError<
|
|
258
|
+
TError extends ContractError,
|
|
259
|
+
Status extends number,
|
|
260
|
+
Code extends string,
|
|
261
|
+
>(
|
|
262
|
+
err: TError,
|
|
263
|
+
criteria: { status: Status; code: Code },
|
|
264
|
+
): err is ContractErrorWithCode<ContractErrorWithStatus<TError, Status>, Code>;
|
|
265
|
+
export function isContractError<
|
|
266
|
+
TError extends ContractError,
|
|
267
|
+
Source extends ContractErrorSource,
|
|
268
|
+
Code extends string,
|
|
269
|
+
>(
|
|
270
|
+
err: TError,
|
|
271
|
+
criteria: { source: Source; code: Code },
|
|
272
|
+
): err is ContractErrorWithCode<ContractErrorWithSource<TError, Source>, Code>;
|
|
273
|
+
export function isContractError<
|
|
274
|
+
TError extends ContractError,
|
|
275
|
+
Status extends number,
|
|
276
|
+
Source extends ContractErrorSource,
|
|
277
|
+
Code extends string,
|
|
278
|
+
>(
|
|
279
|
+
err: TError,
|
|
280
|
+
criteria: { status: Status; source: Source; code: Code },
|
|
281
|
+
): err is ContractErrorWithCode<
|
|
282
|
+
ContractErrorWithSource<ContractErrorWithStatus<TError, Status>, Source>,
|
|
283
|
+
Code
|
|
284
|
+
>;
|
|
285
|
+
export function isContractError<S extends number>(
|
|
286
|
+
err: unknown,
|
|
287
|
+
criteria: { status: S },
|
|
288
|
+
): err is ContractError<unknown, S>;
|
|
289
|
+
export function isContractError<S extends ContractErrorSource>(
|
|
290
|
+
err: unknown,
|
|
291
|
+
criteria: { source: S },
|
|
292
|
+
): err is ContractError<unknown, number | undefined, S>;
|
|
293
|
+
export function isContractError<
|
|
294
|
+
Status extends number,
|
|
295
|
+
Source extends ContractErrorSource,
|
|
296
|
+
>(
|
|
297
|
+
err: unknown,
|
|
298
|
+
criteria: { status: Status; source: Source },
|
|
299
|
+
): err is ContractErrorWithSource<HttpContractError<unknown, Status>, Source>;
|
|
300
|
+
export function isContractError<Code extends string>(
|
|
301
|
+
err: unknown,
|
|
302
|
+
criteria: { code: Code },
|
|
303
|
+
): err is ContractError<unknown, number | undefined> & { readonly code: Code };
|
|
304
|
+
export function isContractError(
|
|
305
|
+
err: unknown,
|
|
306
|
+
criteria?:
|
|
307
|
+
| number
|
|
308
|
+
| { code?: string; source?: ContractErrorSource; status?: number },
|
|
309
|
+
): err is AnyContractError {
|
|
310
|
+
const status = typeof criteria === "number" ? criteria : criteria?.status;
|
|
311
|
+
const source = typeof criteria === "object" ? criteria.source : undefined;
|
|
312
|
+
const code = typeof criteria === "object" ? criteria.code : undefined;
|
|
313
|
+
return (
|
|
314
|
+
err instanceof ContractError &&
|
|
315
|
+
(status === undefined || err.status === status) &&
|
|
316
|
+
(source === undefined || err.source === source) &&
|
|
317
|
+
(code === undefined || err.code === code)
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function formatQuotedList(values: string[]): string {
|
|
322
|
+
return values.map((value) => `"${value}"`).join(", ");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function createMissingPathParamsMessage(
|
|
326
|
+
path: string,
|
|
327
|
+
missing: string[],
|
|
328
|
+
provided: string[],
|
|
329
|
+
): string {
|
|
330
|
+
const label = missing.length === 1 ? "parameter" : "parameters";
|
|
331
|
+
const providedSuffix = provided.length
|
|
332
|
+
? ` (provided: ${provided.join(", ")})`
|
|
333
|
+
: "";
|
|
334
|
+
|
|
335
|
+
return `Missing required path ${label} ${formatQuotedList(missing)} for path "${path}"${providedSuffix}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Validate data using a Standard Schema validator
|
|
340
|
+
* Throws SchemaValidationError if validation fails
|
|
341
|
+
*/
|
|
342
|
+
async function validateSchema<T>(
|
|
343
|
+
schema: StandardSchemaV1<unknown, T>,
|
|
344
|
+
data: unknown,
|
|
345
|
+
): Promise<T> {
|
|
346
|
+
const result = await schema["~standard"].validate(data);
|
|
347
|
+
if (result.issues?.length) {
|
|
348
|
+
throw new SchemaValidationError(result.issues);
|
|
349
|
+
}
|
|
350
|
+
if ("value" in result) {
|
|
351
|
+
return result.value;
|
|
352
|
+
}
|
|
353
|
+
throw new Error("Invalid Standard Schema result: missing value");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function normalizeHeaderRecord(
|
|
357
|
+
headers: Record<string, string | undefined>,
|
|
358
|
+
): Record<string, string> {
|
|
359
|
+
const normalized: Record<string, string> = {};
|
|
360
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
361
|
+
if (value !== undefined) {
|
|
362
|
+
normalized[key.toLowerCase()] = value;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return normalized;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function serializeParsedHeaders(parsed: unknown): Record<string, string> {
|
|
369
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
370
|
+
return {};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const headers: Record<string, string> = {};
|
|
374
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
375
|
+
if (value !== undefined && value !== null) {
|
|
376
|
+
headers[key.toLowerCase()] = String(value);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return headers;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function validateHeaderSchemas(
|
|
383
|
+
schemas: readonly StandardSchema[],
|
|
384
|
+
headers: Record<string, string>,
|
|
385
|
+
): Promise<Record<string, string>> {
|
|
386
|
+
let validatedHeaders = headers;
|
|
387
|
+
|
|
388
|
+
for (const schema of schemas) {
|
|
389
|
+
const parsed = await validateSchema(schema, headers);
|
|
390
|
+
validatedHeaders = {
|
|
391
|
+
...validatedHeaders,
|
|
392
|
+
...serializeParsedHeaders(parsed),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return validatedHeaders;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
type PrimitiveParam = string | number | boolean;
|
|
400
|
+
type QueryParamValue =
|
|
401
|
+
| PrimitiveParam
|
|
402
|
+
| null
|
|
403
|
+
| undefined
|
|
404
|
+
| Array<PrimitiveParam>;
|
|
405
|
+
|
|
406
|
+
type PathParams = Record<string, PrimitiveParam>;
|
|
407
|
+
type QueryParams = Record<string, QueryParamValue>;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Endpoint wrapper for a specific contract
|
|
411
|
+
*/
|
|
412
|
+
export class Endpoint<
|
|
413
|
+
TContract extends HttpContractConfig,
|
|
414
|
+
TProvidedHeaders extends string = never,
|
|
415
|
+
> {
|
|
416
|
+
constructor(
|
|
417
|
+
private contract: TContract,
|
|
418
|
+
private config: ClientConfig<TProvidedHeaders>,
|
|
419
|
+
) {}
|
|
420
|
+
|
|
421
|
+
isError(err: unknown): err is InferEndpointContractError<TContract>;
|
|
422
|
+
isError<S extends InferEndpointErrorStatus<TContract>>(
|
|
423
|
+
err: unknown,
|
|
424
|
+
status: S,
|
|
425
|
+
): err is ContractErrorWithStatus<InferEndpointContractError<TContract>, S>;
|
|
426
|
+
isError<S extends number>(
|
|
427
|
+
err: unknown,
|
|
428
|
+
status: S,
|
|
429
|
+
): err is HttpContractError<unknown, S>;
|
|
430
|
+
isError<S extends InferEndpointErrorStatus<TContract>>(
|
|
431
|
+
err: unknown,
|
|
432
|
+
criteria: { status: S },
|
|
433
|
+
): err is ContractErrorWithStatus<InferEndpointContractError<TContract>, S>;
|
|
434
|
+
isError<S extends ContractErrorSource>(
|
|
435
|
+
err: unknown,
|
|
436
|
+
criteria: { source: S },
|
|
437
|
+
): err is ContractErrorWithSource<InferEndpointContractError<TContract>, S>;
|
|
438
|
+
isError<C extends InferEndpointErrorCode<TContract>>(
|
|
439
|
+
err: unknown,
|
|
440
|
+
criteria: { code: C },
|
|
441
|
+
): err is ContractErrorWithCode<InferEndpointContractError<TContract>, C>;
|
|
442
|
+
isError<C extends string>(
|
|
443
|
+
err: unknown,
|
|
444
|
+
criteria: { code: C },
|
|
445
|
+
): err is InferEndpointContractError<TContract> & { readonly code: C };
|
|
446
|
+
isError<
|
|
447
|
+
Status extends InferEndpointErrorStatus<TContract>,
|
|
448
|
+
Source extends ContractErrorSource,
|
|
449
|
+
>(
|
|
450
|
+
err: unknown,
|
|
451
|
+
criteria: { status: Status; source: Source },
|
|
452
|
+
): err is ContractErrorWithSource<
|
|
453
|
+
ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
|
|
454
|
+
Source
|
|
455
|
+
>;
|
|
456
|
+
isError<
|
|
457
|
+
Status extends InferEndpointErrorStatus<TContract>,
|
|
458
|
+
C extends InferEndpointErrorCode<TContract>,
|
|
459
|
+
>(
|
|
460
|
+
err: unknown,
|
|
461
|
+
criteria: { status: Status; code: C },
|
|
462
|
+
): err is ContractErrorWithCode<
|
|
463
|
+
ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
|
|
464
|
+
C
|
|
465
|
+
>;
|
|
466
|
+
isError<
|
|
467
|
+
Source extends ContractErrorSource,
|
|
468
|
+
C extends InferEndpointErrorCode<TContract>,
|
|
469
|
+
>(
|
|
470
|
+
err: unknown,
|
|
471
|
+
criteria: { source: Source; code: C },
|
|
472
|
+
): err is ContractErrorWithCode<
|
|
473
|
+
ContractErrorWithSource<InferEndpointContractError<TContract>, Source>,
|
|
474
|
+
C
|
|
475
|
+
>;
|
|
476
|
+
isError<
|
|
477
|
+
Status extends InferEndpointErrorStatus<TContract>,
|
|
478
|
+
Source extends ContractErrorSource,
|
|
479
|
+
C extends InferEndpointErrorCode<TContract>,
|
|
480
|
+
>(
|
|
481
|
+
err: unknown,
|
|
482
|
+
criteria: { status: Status; source: Source; code: C },
|
|
483
|
+
): err is ContractErrorWithCode<
|
|
484
|
+
ContractErrorWithSource<
|
|
485
|
+
ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
|
|
486
|
+
Source
|
|
487
|
+
>,
|
|
488
|
+
C
|
|
489
|
+
>;
|
|
490
|
+
isError(
|
|
491
|
+
err: unknown,
|
|
492
|
+
criteria?:
|
|
493
|
+
| number
|
|
494
|
+
| { code?: string; source?: ContractErrorSource; status?: number },
|
|
495
|
+
): err is InferEndpointContractError<TContract> {
|
|
496
|
+
return isContractError(err, criteria as never);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Call the endpoint with the given arguments
|
|
501
|
+
*/
|
|
502
|
+
async call(
|
|
503
|
+
...callArgs: CallArgs<TContract, TProvidedHeaders>
|
|
504
|
+
): Promise<InferSuccessResponse<TContract>> {
|
|
505
|
+
const result = await this.safeCall(...callArgs);
|
|
506
|
+
if (!result.ok) {
|
|
507
|
+
throw result.error;
|
|
508
|
+
}
|
|
509
|
+
return result.data;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Call the endpoint and return a typed result instead of throwing ContractError.
|
|
514
|
+
*/
|
|
515
|
+
async safeCall(
|
|
516
|
+
...callArgs: CallArgs<TContract, TProvidedHeaders>
|
|
517
|
+
): Promise<EndpointResult<TContract, InferEndpointContractError<TContract>>> {
|
|
518
|
+
const args = (callArgs[0] ?? {}) as EndpointCallArgs<
|
|
519
|
+
TContract,
|
|
520
|
+
TProvidedHeaders
|
|
521
|
+
>;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
if (args.body !== undefined && args.rawBody !== undefined) {
|
|
525
|
+
throw this.createError(
|
|
526
|
+
undefined,
|
|
527
|
+
"INVALID_REQUEST_BODY",
|
|
528
|
+
"Pass either body or rawBody, not both.",
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const methodSupportsBody = methodSupportsRequestBody(
|
|
533
|
+
this.contract.method,
|
|
534
|
+
);
|
|
535
|
+
if (
|
|
536
|
+
(args.body !== undefined || args.rawBody !== undefined) &&
|
|
537
|
+
!methodSupportsBody
|
|
538
|
+
) {
|
|
539
|
+
throw this.createError(
|
|
540
|
+
undefined,
|
|
541
|
+
"INVALID_REQUEST_BODY",
|
|
542
|
+
`Request bodies are not supported for ${this.contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let requestBody: BodyInit | undefined;
|
|
547
|
+
let requestBodyType: "json" | "raw" | undefined;
|
|
548
|
+
if (args.rawBody !== undefined && methodSupportsBody) {
|
|
549
|
+
requestBody = args.rawBody;
|
|
550
|
+
requestBodyType = "raw";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (args.body !== undefined && methodSupportsBody) {
|
|
554
|
+
let bodyToSend = args.body;
|
|
555
|
+
if (this.config.validate && this.contract.body) {
|
|
556
|
+
try {
|
|
557
|
+
bodyToSend = (await validateSchema(
|
|
558
|
+
this.contract.body,
|
|
559
|
+
args.body,
|
|
560
|
+
)) as typeof bodyToSend;
|
|
561
|
+
} catch (err) {
|
|
562
|
+
if (err instanceof SchemaValidationError) {
|
|
563
|
+
throw this.createError(
|
|
564
|
+
422,
|
|
565
|
+
"VALIDATION_ERROR",
|
|
566
|
+
"Body validation failed",
|
|
567
|
+
err.issues,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
throw err;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
requestBody = JSON.stringify(bodyToSend);
|
|
574
|
+
requestBodyType = "json";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const url = await this.buildUrl(
|
|
578
|
+
args.path as PathParams | undefined,
|
|
579
|
+
args.query as QueryParams | undefined,
|
|
580
|
+
);
|
|
581
|
+
let headers = await this.buildHeaders(
|
|
582
|
+
args.headers as Record<string, string> | undefined,
|
|
583
|
+
requestBodyType === "json",
|
|
584
|
+
);
|
|
585
|
+
if (this.config.validate) {
|
|
586
|
+
const headerSchemas = getContractHeaderSchemas(this.contract.headers);
|
|
587
|
+
if (headerSchemas.length > 0) {
|
|
588
|
+
try {
|
|
589
|
+
headers = await validateHeaderSchemas(headerSchemas, headers);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
if (err instanceof SchemaValidationError) {
|
|
592
|
+
throw this.createError(
|
|
593
|
+
422,
|
|
594
|
+
"VALIDATION_ERROR",
|
|
595
|
+
"Headers validation failed",
|
|
596
|
+
err.issues,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
throw err;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const fetchFn = this.config.fetch || fetch;
|
|
604
|
+
|
|
605
|
+
const options: RequestInit = {
|
|
606
|
+
method: this.contract.method,
|
|
607
|
+
headers,
|
|
608
|
+
signal: args.signal,
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
if (requestBody !== undefined) {
|
|
612
|
+
options.body = requestBody;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const response = await fetchFn(url, options);
|
|
616
|
+
|
|
617
|
+
// Handle non-2xx responses
|
|
618
|
+
if (!response.ok) {
|
|
619
|
+
let errorBody: unknown;
|
|
620
|
+
try {
|
|
621
|
+
errorBody = await parseResponseBody(response);
|
|
622
|
+
} catch (parseErr) {
|
|
623
|
+
// JSON parse failed — still report the HTTP error
|
|
624
|
+
throw this.createError(
|
|
625
|
+
response.status,
|
|
626
|
+
"INVALID_JSON",
|
|
627
|
+
createInvalidJsonMessage("error", response.status),
|
|
628
|
+
undefined,
|
|
629
|
+
parseErr,
|
|
630
|
+
undefined,
|
|
631
|
+
response,
|
|
632
|
+
"contract",
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const validatedError = await validateErrorBodyOrStandardEnvelope(
|
|
637
|
+
this.contract,
|
|
638
|
+
response,
|
|
639
|
+
errorBody,
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const errorPayload = getErrorPayload(validatedError);
|
|
643
|
+
throw this.createError(
|
|
644
|
+
response.status,
|
|
645
|
+
errorPayload.code || "HTTP_ERROR",
|
|
646
|
+
errorPayload.message || response.statusText,
|
|
647
|
+
errorPayload.details,
|
|
648
|
+
undefined,
|
|
649
|
+
validatedError,
|
|
650
|
+
response,
|
|
651
|
+
"http",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Parse response
|
|
656
|
+
let data: unknown;
|
|
657
|
+
try {
|
|
658
|
+
data = await parseResponseBody(response);
|
|
659
|
+
} catch (parseErr) {
|
|
660
|
+
throw this.createError(
|
|
661
|
+
response.status,
|
|
662
|
+
"INVALID_JSON",
|
|
663
|
+
createInvalidJsonMessage("success", response.status),
|
|
664
|
+
undefined,
|
|
665
|
+
parseErr,
|
|
666
|
+
undefined,
|
|
667
|
+
response,
|
|
668
|
+
"contract",
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Validate response if schema exists for this status
|
|
673
|
+
const statusKey = String(response.status);
|
|
674
|
+
const hasSchema = statusKey in this.contract.responses;
|
|
675
|
+
const hasDeclaredResponses =
|
|
676
|
+
Object.keys(this.contract.responses).length > 0;
|
|
677
|
+
const responseSchema = this.contract.responses[response.status];
|
|
678
|
+
if (hasSchema && responseSchema === null) {
|
|
679
|
+
if (data !== undefined && data !== null) {
|
|
680
|
+
throw this.createError(
|
|
681
|
+
response.status,
|
|
682
|
+
"RESPONSE_VALIDATION_ERROR",
|
|
683
|
+
`Response validation failed for ${this.contract.method} ${this.contract.path} (status ${response.status}, contract: ${this.contract.name})`,
|
|
684
|
+
[
|
|
685
|
+
{
|
|
686
|
+
message:
|
|
687
|
+
"Response body must be empty for a null response schema.",
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
undefined,
|
|
691
|
+
data,
|
|
692
|
+
response,
|
|
693
|
+
"contract",
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
} else if (hasSchema && responseSchema) {
|
|
697
|
+
try {
|
|
698
|
+
data = await validateSchema(responseSchema, data);
|
|
699
|
+
} catch (err) {
|
|
700
|
+
if (err instanceof SchemaValidationError) {
|
|
701
|
+
throw this.createError(
|
|
702
|
+
response.status,
|
|
703
|
+
"RESPONSE_VALIDATION_ERROR",
|
|
704
|
+
`Response validation failed for ${this.contract.method} ${this.contract.path} (status ${response.status}, contract: ${this.contract.name})`,
|
|
705
|
+
err.issues,
|
|
706
|
+
err,
|
|
707
|
+
data,
|
|
708
|
+
response,
|
|
709
|
+
"contract",
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
throw err;
|
|
713
|
+
}
|
|
714
|
+
} else if (!hasSchema && hasDeclaredResponses) {
|
|
715
|
+
throw this.createError(
|
|
716
|
+
response.status,
|
|
717
|
+
"UNDECLARED_RESPONSE_STATUS",
|
|
718
|
+
`Server returned undeclared status ${response.status} for ${this.contract.method} ${this.contract.path} (contract: ${this.contract.name})`,
|
|
719
|
+
undefined,
|
|
720
|
+
undefined,
|
|
721
|
+
data,
|
|
722
|
+
response,
|
|
723
|
+
"contract",
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
ok: true,
|
|
729
|
+
status: response.status,
|
|
730
|
+
data: data as InferSuccessResponse<TContract>,
|
|
731
|
+
response,
|
|
732
|
+
};
|
|
733
|
+
} catch (err: unknown) {
|
|
734
|
+
if (err instanceof ContractError) {
|
|
735
|
+
const error = err as InferEndpointContractError<TContract>;
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
status: error.status,
|
|
739
|
+
error,
|
|
740
|
+
response: error.response,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const error = this.createError(
|
|
744
|
+
undefined,
|
|
745
|
+
"NETWORK_ERROR",
|
|
746
|
+
err instanceof Error ? err.message : "Network request failed",
|
|
747
|
+
undefined,
|
|
748
|
+
err,
|
|
749
|
+
undefined,
|
|
750
|
+
undefined,
|
|
751
|
+
"network",
|
|
752
|
+
) as InferEndpointContractError<TContract>;
|
|
753
|
+
return {
|
|
754
|
+
ok: false,
|
|
755
|
+
status: error.status,
|
|
756
|
+
error,
|
|
757
|
+
response: error.response,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Build the full URL with path and query parameters
|
|
764
|
+
*/
|
|
765
|
+
private async buildUrl(
|
|
766
|
+
path?: PathParams,
|
|
767
|
+
query?: QueryParams,
|
|
768
|
+
): Promise<string> {
|
|
769
|
+
let parsedPath: ReturnType<typeof parsePathTemplate>;
|
|
770
|
+
try {
|
|
771
|
+
parsedPath = parsePathTemplate(this.contract.path);
|
|
772
|
+
} catch (cause) {
|
|
773
|
+
throw new ContractError({
|
|
774
|
+
source: "client",
|
|
775
|
+
code: "INVALID_PATH_TEMPLATE",
|
|
776
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
777
|
+
cause,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
let pathToSerialize = path;
|
|
781
|
+
|
|
782
|
+
// Replace path parameters
|
|
783
|
+
if (path && parsedPath.keys.length > 0) {
|
|
784
|
+
// Validate path params if schema exists and validation is enabled
|
|
785
|
+
if (this.config.validate && this.contract.pathParams) {
|
|
786
|
+
try {
|
|
787
|
+
pathToSerialize = (await validateSchema(
|
|
788
|
+
this.contract.pathParams,
|
|
789
|
+
path,
|
|
790
|
+
)) as PathParams;
|
|
791
|
+
} catch (err) {
|
|
792
|
+
if (err instanceof SchemaValidationError) {
|
|
793
|
+
throw this.createError(
|
|
794
|
+
422,
|
|
795
|
+
"VALIDATION_ERROR",
|
|
796
|
+
"Path params validation failed",
|
|
797
|
+
err.issues,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
throw err;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const normalizedPath = pathToSerialize ?? path;
|
|
806
|
+
const missingPathParams = [
|
|
807
|
+
...new Set(
|
|
808
|
+
parsedPath.keys.filter((key) => normalizedPath?.[key] === undefined),
|
|
809
|
+
),
|
|
810
|
+
];
|
|
811
|
+
if (missingPathParams.length) {
|
|
812
|
+
const provided = path ? Object.keys(path) : [];
|
|
813
|
+
throw new ContractError({
|
|
814
|
+
source: "client",
|
|
815
|
+
code: "MISSING_PATH_PARAMS",
|
|
816
|
+
message: createMissingPathParamsMessage(
|
|
817
|
+
this.contract.path,
|
|
818
|
+
missingPathParams,
|
|
819
|
+
provided,
|
|
820
|
+
),
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let url = `/${parsedPath.segments
|
|
825
|
+
.map((segment) =>
|
|
826
|
+
segment.kind === "static"
|
|
827
|
+
? segment.value
|
|
828
|
+
: encodeURIComponent(String(normalizedPath?.[segment.name])),
|
|
829
|
+
)
|
|
830
|
+
.join("/")}`;
|
|
831
|
+
|
|
832
|
+
// Add query parameters
|
|
833
|
+
let queryToSerialize = query;
|
|
834
|
+
if (query) {
|
|
835
|
+
// Validate query params if schema exists and validation is enabled
|
|
836
|
+
if (this.config.validate && this.contract.query) {
|
|
837
|
+
try {
|
|
838
|
+
queryToSerialize = (await validateSchema(
|
|
839
|
+
this.contract.query,
|
|
840
|
+
query,
|
|
841
|
+
)) as QueryParams;
|
|
842
|
+
} catch (err) {
|
|
843
|
+
if (err instanceof SchemaValidationError) {
|
|
844
|
+
throw this.createError(
|
|
845
|
+
422,
|
|
846
|
+
"VALIDATION_ERROR",
|
|
847
|
+
"Query params validation failed",
|
|
848
|
+
err.issues,
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
throw err;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const params = new URLSearchParams();
|
|
856
|
+
const normalizedQuery = queryToSerialize ?? query;
|
|
857
|
+
for (const [key, value] of Object.entries(normalizedQuery)) {
|
|
858
|
+
if (value !== undefined && value !== null) {
|
|
859
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
860
|
+
throw new ContractError({
|
|
861
|
+
source: "client",
|
|
862
|
+
code: "INVALID_QUERY_PARAM",
|
|
863
|
+
message: `Query parameter "${key}" contains a non-serializable object. Use primitive values or arrays of primitives.`,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (Array.isArray(value)) {
|
|
867
|
+
for (const v of value) {
|
|
868
|
+
if (typeof v === "object" && v !== null) {
|
|
869
|
+
throw new ContractError({
|
|
870
|
+
source: "client",
|
|
871
|
+
code: "INVALID_QUERY_PARAM",
|
|
872
|
+
message: `Query parameter "${key}" contains a non-serializable array element. Use primitive values only.`,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
params.append(key, String(v));
|
|
876
|
+
}
|
|
877
|
+
} else {
|
|
878
|
+
params.append(key, String(value));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const queryString = params.toString();
|
|
883
|
+
if (queryString) {
|
|
884
|
+
url += `?${queryString}`;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Prepend base URL, normalizing trailing/leading slashes
|
|
889
|
+
const baseUrl = this.config.baseUrl || "";
|
|
890
|
+
if (baseUrl && url.startsWith("/") && baseUrl.endsWith("/")) {
|
|
891
|
+
return baseUrl + url.slice(1);
|
|
892
|
+
}
|
|
893
|
+
return baseUrl + url;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Build request headers
|
|
898
|
+
*/
|
|
899
|
+
private async buildHeaders(
|
|
900
|
+
customHeaders?: Record<string, string>,
|
|
901
|
+
hasJsonBody = false,
|
|
902
|
+
): Promise<Record<string, string>> {
|
|
903
|
+
const configHeaders =
|
|
904
|
+
typeof this.config.headers === "function"
|
|
905
|
+
? await this.config.headers()
|
|
906
|
+
: this.config.headers || {};
|
|
907
|
+
|
|
908
|
+
const headers = normalizeHeaderRecord({
|
|
909
|
+
...configHeaders,
|
|
910
|
+
...customHeaders,
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// Only set Content-Type for methods that can have a body
|
|
914
|
+
if (hasJsonBody && methodSupportsRequestBody(this.contract.method)) {
|
|
915
|
+
const hasContentType = Object.keys(headers).some(
|
|
916
|
+
(k) => k.toLowerCase() === "content-type",
|
|
917
|
+
);
|
|
918
|
+
if (!hasContentType) {
|
|
919
|
+
headers["content-type"] = "application/json";
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return headers;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Create a contract error
|
|
928
|
+
*/
|
|
929
|
+
private createError(
|
|
930
|
+
status: number | undefined,
|
|
931
|
+
code: string,
|
|
932
|
+
message: string,
|
|
933
|
+
details?: unknown,
|
|
934
|
+
cause?: unknown,
|
|
935
|
+
body?: unknown,
|
|
936
|
+
response?: Response,
|
|
937
|
+
source: ContractErrorSource = "client",
|
|
938
|
+
): AnyContractError {
|
|
939
|
+
return new ContractError({
|
|
940
|
+
source,
|
|
941
|
+
status: source === "client" || source === "network" ? undefined : status,
|
|
942
|
+
code,
|
|
943
|
+
message,
|
|
944
|
+
body: source === "client" || source === "network" ? undefined : body,
|
|
945
|
+
details,
|
|
946
|
+
response:
|
|
947
|
+
source === "client" || source === "network" ? undefined : response,
|
|
948
|
+
cause,
|
|
949
|
+
}) as AnyContractError;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Client for making contract-based requests
|
|
955
|
+
*/
|
|
956
|
+
export class Client<TProvidedHeaders extends string = never> {
|
|
957
|
+
constructor(private config: ClientConfig<TProvidedHeaders>) {}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Create an endpoint wrapper for a contract
|
|
961
|
+
*
|
|
962
|
+
* Accepts either an HttpContractConfig object or a ContractBuilder
|
|
963
|
+
* (which has a .config property that returns the config)
|
|
964
|
+
*/
|
|
965
|
+
endpoint<TContractLike extends ContractLike>(
|
|
966
|
+
contract: TContractLike,
|
|
967
|
+
): Endpoint<ResolveContract<TContractLike>, TProvidedHeaders> {
|
|
968
|
+
const resolved = resolveContract(contract);
|
|
969
|
+
return new Endpoint(resolved, this.config);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Create a configured client
|
|
975
|
+
*/
|
|
976
|
+
export function createClient<const TProvidedHeaders extends string = never>(
|
|
977
|
+
config: ClientConfig<TProvidedHeaders> = {},
|
|
978
|
+
): Client<TProvidedHeaders> {
|
|
979
|
+
return new Client(config);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function parseResponseBody(response: Response): Promise<unknown> {
|
|
983
|
+
if (response.status === 204 || response.status === 205) {
|
|
984
|
+
return undefined;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const text = await response.text().catch(() => "");
|
|
988
|
+
if (text === "") {
|
|
989
|
+
return undefined;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const contentType = response.headers.get("content-type");
|
|
993
|
+
if (contentType?.includes("application/json")) {
|
|
994
|
+
return JSON.parse(text);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return text;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function createInvalidJsonMessage(
|
|
1001
|
+
context: "success" | "error",
|
|
1002
|
+
status: number,
|
|
1003
|
+
): string {
|
|
1004
|
+
return `Failed to parse JSON ${context} response (status ${status})`;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function createErrorResponseValidationMessage(
|
|
1008
|
+
contract: HttpContractConfig,
|
|
1009
|
+
status: number,
|
|
1010
|
+
): string {
|
|
1011
|
+
return `Error response validation failed for ${contract.method} ${contract.path} (status ${status}, contract: ${contract.name})`;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function hasFrameworkErrorOwner(response: Response): boolean {
|
|
1015
|
+
return response.headers.get(BEIGNET_ERROR_OWNER_HEADER) === "framework";
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function validateErrorBodyOrStandardEnvelope(
|
|
1019
|
+
contract: HttpContractConfig,
|
|
1020
|
+
response: Response,
|
|
1021
|
+
errorBody: unknown,
|
|
1022
|
+
): Promise<unknown> {
|
|
1023
|
+
const errorSchema = contract.responses[response.status];
|
|
1024
|
+
const hasDeclaredResponses = Object.keys(contract.responses).length > 0;
|
|
1025
|
+
|
|
1026
|
+
if (errorSchema === null) {
|
|
1027
|
+
if (errorBody === undefined || errorBody === null) {
|
|
1028
|
+
return undefined;
|
|
1029
|
+
}
|
|
1030
|
+
if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
|
|
1031
|
+
return errorBody;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
throw new ContractError({
|
|
1035
|
+
source: "contract",
|
|
1036
|
+
status: response.status,
|
|
1037
|
+
code: "ERROR_RESPONSE_VALIDATION_ERROR",
|
|
1038
|
+
message: createErrorResponseValidationMessage(contract, response.status),
|
|
1039
|
+
details: [
|
|
1040
|
+
{
|
|
1041
|
+
message: "Response body must be empty for a null response schema.",
|
|
1042
|
+
},
|
|
1043
|
+
],
|
|
1044
|
+
body: errorBody,
|
|
1045
|
+
response,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (!errorSchema) {
|
|
1050
|
+
if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
|
|
1051
|
+
return errorBody;
|
|
1052
|
+
}
|
|
1053
|
+
if (hasDeclaredResponses) {
|
|
1054
|
+
throw new ContractError({
|
|
1055
|
+
source: "contract",
|
|
1056
|
+
status: response.status,
|
|
1057
|
+
code: "UNDECLARED_ERROR_STATUS",
|
|
1058
|
+
message: `Server returned undeclared error status ${response.status} for ${contract.method} ${contract.path} (contract: ${contract.name})`,
|
|
1059
|
+
body: errorBody,
|
|
1060
|
+
response,
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
return errorBody;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
try {
|
|
1067
|
+
return await validateSchema(errorSchema, errorBody);
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
if (!(err instanceof SchemaValidationError)) {
|
|
1070
|
+
throw err;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
|
|
1074
|
+
return errorBody;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
throw new ContractError({
|
|
1078
|
+
source: "contract",
|
|
1079
|
+
status: response.status,
|
|
1080
|
+
code: "ERROR_RESPONSE_VALIDATION_ERROR",
|
|
1081
|
+
message: createErrorResponseValidationMessage(contract, response.status),
|
|
1082
|
+
details: err.issues,
|
|
1083
|
+
cause: err,
|
|
1084
|
+
body: errorBody,
|
|
1085
|
+
response,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function getErrorPayload(body: unknown): {
|
|
1091
|
+
code?: string;
|
|
1092
|
+
message?: string;
|
|
1093
|
+
details?: unknown;
|
|
1094
|
+
} {
|
|
1095
|
+
if (typeof body !== "object" || body === null) {
|
|
1096
|
+
return {};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const payload = body as Record<string, unknown>;
|
|
1100
|
+
return {
|
|
1101
|
+
code: typeof payload.code === "string" ? payload.code : undefined,
|
|
1102
|
+
message: typeof payload.message === "string" ? payload.message : undefined,
|
|
1103
|
+
details: payload.details,
|
|
1104
|
+
};
|
|
1105
|
+
}
|