@afora/core 0.0.8 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -23
- package/dist/adapters.d.ts +25 -0
- package/dist/adapters.js +32 -0
- package/dist/core/application/application.d.ts +30 -0
- package/dist/core/application/application.js +1291 -0
- package/dist/core/application/exceptions.d.ts +17 -0
- package/dist/core/application/exceptions.js +85 -0
- package/dist/core/application/migrate.d.ts +19 -0
- package/dist/core/application/migrate.js +67 -0
- package/dist/core/application/providers.js +108 -0
- package/dist/core/application/registry.js +23 -0
- package/dist/core/application/schema.d.ts +30 -0
- package/dist/core/application/schema.js +398 -0
- package/dist/core/application/start.js +162 -0
- package/dist/core/application/types/application.interface.d.ts +60 -3
- package/dist/core/auth/jwt.d.ts +30 -0
- package/dist/core/auth/jwt.js +200 -0
- package/dist/core/auth/types/jwt.interface.d.ts +74 -0
- package/dist/core/blueprint/blueprint-plugin.js +7 -0
- package/dist/core/blueprint/blueprint.js +146 -0
- package/dist/core/blueprint/callable.js +13 -0
- package/dist/core/blueprint/exceptions.d.ts +5 -0
- package/dist/core/blueprint/exceptions.js +20 -0
- package/dist/core/blueprint/is-blueprint.js +4 -0
- package/dist/core/blueprint/list.js +19 -0
- package/dist/core/blueprint/optional.d.ts +3 -1
- package/dist/core/blueprint/optional.js +69 -0
- package/dist/core/blueprint/record-field.js +14 -0
- package/dist/core/blueprint/slice.js +127 -0
- package/dist/core/blueprint/types/blueprint.interface.d.ts +95 -5
- package/dist/core/blueprint/variant.d.ts +32 -0
- package/dist/core/blueprint/variant.js +72 -0
- package/dist/core/command/command.js +428 -0
- package/dist/core/command/exceptions.js +30 -0
- package/dist/core/command/types/command.interface.d.ts +10 -1
- package/dist/core/entity/abstract-entity.d.ts +24 -0
- package/dist/core/entity/abstract-entity.js +181 -0
- package/dist/core/entity/entity.d.ts +1 -1
- package/dist/core/entity/entity.js +209 -0
- package/dist/core/entity/event.d.ts +39 -0
- package/dist/core/entity/event.js +80 -0
- package/dist/core/entity/exceptions.d.ts +9 -0
- package/dist/core/entity/exceptions.js +27 -0
- package/dist/core/entity/trust.d.ts +24 -0
- package/dist/core/entity/trust.js +7 -0
- package/dist/core/entity/types/entity.interface.d.ts +30 -0
- package/dist/core/entity/version.js +13 -0
- package/dist/core/environment/environment.d.ts +26 -3
- package/dist/core/environment/environment.js +71 -0
- package/dist/core/event-policy/event-policy.js +76 -0
- package/dist/core/event-policy/exceptions.js +12 -0
- package/dist/core/event-policy/publish.js +98 -0
- package/dist/core/event-policy/publishers.js +18 -0
- package/dist/core/event-policy/types/event-policy.interface.d.ts +8 -2
- package/dist/core/exception/enums/exception-layers.js +15 -0
- package/dist/core/exception/exception.d.ts +15 -0
- package/dist/core/exception/exception.js +128 -0
- package/dist/core/exception/exported.js +49 -0
- package/dist/core/exception/guard.d.ts +20 -1
- package/dist/core/exception/guard.js +16 -0
- package/dist/core/exception/types/exception.interface.d.ts +13 -0
- package/dist/core/json.js +19 -0
- package/dist/core/logger/exceptions.js +7 -0
- package/dist/core/logger/logger.d.ts +14 -1
- package/dist/core/logger/logger.js +178 -0
- package/dist/core/openapi/exceptions.js +7 -0
- package/dist/core/openapi/openapi.js +132 -0
- package/dist/core/openapi/types/openapi.interface.d.ts +7 -1
- package/dist/core/output/access.d.ts +16 -0
- package/dist/core/output/access.js +66 -0
- package/dist/core/output/coerce.js +38 -0
- package/dist/core/output/exceptions.d.ts +16 -0
- package/dist/core/output/exceptions.js +22 -0
- package/dist/core/output/http.d.ts +36 -0
- package/dist/core/output/http.js +351 -0
- package/dist/core/output/openapi-sink.d.ts +33 -4
- package/dist/core/output/openapi-sink.js +571 -0
- package/dist/core/output/output.js +192 -0
- package/dist/core/output/problem.js +30 -0
- package/dist/core/output/select.js +28 -0
- package/dist/core/output/stage.d.ts +7 -2
- package/dist/core/output/stage.js +111 -0
- package/dist/core/output/status.d.ts +1 -1
- package/dist/core/output/status.js +52 -0
- package/dist/core/output/types/http.interface.d.ts +134 -0
- package/dist/core/output/types/output.interface.d.ts +49 -5
- package/dist/core/parse/issue.d.ts +28 -3
- package/dist/core/parse/issue.js +31 -0
- package/dist/core/parse/parse.d.ts +16 -0
- package/dist/core/parse/parse.js +323 -0
- package/dist/core/parse/strip.js +48 -0
- package/dist/core/property/abstract-property.d.ts +9 -0
- package/dist/core/property/abstract-property.js +37 -0
- package/dist/core/property/define-property.d.ts +34 -6
- package/dist/core/property/define-property.js +209 -0
- package/dist/core/property/enums/property-states.js +8 -0
- package/dist/core/property/exceptions.js +12 -0
- package/dist/core/property/input.js +10 -0
- package/dist/core/property/is-property.js +3 -0
- package/dist/core/property/lineage.js +8 -0
- package/dist/core/property/property.d.ts +27 -0
- package/dist/core/property/property.js +46 -0
- package/dist/core/property/refine.js +103 -0
- package/dist/core/property/types/property-options.interface.d.ts +10 -0
- package/dist/core/property/types/property-type.interface.d.ts +4 -0
- package/dist/core/record/abstract-record.d.ts +8 -0
- package/dist/core/record/abstract-record.js +307 -0
- package/dist/core/record/exceptions.js +49 -0
- package/dist/core/record/marks.js +7 -0
- package/dist/core/record/mask.js +60 -0
- package/dist/core/record/name.js +23 -0
- package/dist/core/record/plugin-catalog.js +115 -0
- package/dist/core/record/plugin.js +198 -0
- package/dist/core/record/record.js +117 -0
- package/dist/core/record/types/record.interface.d.ts +17 -1
- package/dist/core/repository/cursor.js +83 -0
- package/dist/core/repository/exceptions.d.ts +13 -0
- package/dist/core/repository/exceptions.js +72 -0
- package/dist/core/repository/in-memory.d.ts +2 -2
- package/dist/core/repository/in-memory.js +196 -0
- package/dist/core/repository/migrate.d.ts +20 -0
- package/dist/core/repository/migrate.js +254 -0
- package/dist/core/repository/plan.js +27 -0
- package/dist/core/repository/provider.js +29 -0
- package/dist/core/repository/query.js +153 -0
- package/dist/core/repository/references.js +196 -0
- package/dist/core/repository/repository.d.ts +22 -2
- package/dist/core/repository/repository.js +720 -0
- package/dist/core/repository/state.d.ts +2 -0
- package/dist/core/repository/state.js +138 -0
- package/dist/core/repository/types/query.interface.d.ts +13 -0
- package/dist/core/repository/types/repository.interface.d.ts +50 -5
- package/dist/core/repository/types/schema.interface.d.ts +132 -0
- package/dist/core/repository/upcast.d.ts +64 -0
- package/dist/core/repository/upcast.js +179 -0
- package/dist/core/schema/schema.d.ts +37 -0
- package/dist/core/schema/schema.js +542 -0
- package/dist/core/standard/standard-schema.js +1 -0
- package/dist/core/standard/standard.js +23 -0
- package/dist/core/symbols/demo.d.ts +4 -0
- package/dist/core/symbols/demo.js +1 -0
- package/dist/core/symbols/kind.d.ts +1 -1
- package/dist/core/symbols/kind.js +1 -0
- package/dist/core/symbols/meta.js +1 -0
- package/dist/core/symbols/state.d.ts +5 -0
- package/dist/core/symbols/state.js +1 -0
- package/dist/core/tracing/exceptions.d.ts +4 -0
- package/dist/core/tracing/exceptions.js +7 -0
- package/dist/core/tracing/tracing.d.ts +72 -0
- package/dist/core/tracing/tracing.js +101 -0
- package/dist/core/use-case/access.d.ts +12 -0
- package/dist/core/use-case/access.js +56 -0
- package/dist/core/use-case/decorators.d.ts +40 -0
- package/dist/core/use-case/decorators.js +65 -0
- package/dist/core/use-case/exceptions.d.ts +5 -1
- package/dist/core/use-case/exceptions.js +48 -0
- package/dist/core/use-case/plugin-catalog.js +68 -0
- package/dist/core/use-case/plugin.js +133 -0
- package/dist/core/use-case/route.d.ts +18 -0
- package/dist/core/use-case/route.js +200 -0
- package/dist/core/use-case/scope.d.ts +5 -0
- package/dist/core/use-case/scope.js +4 -0
- package/dist/core/use-case/steps/describe.d.ts +5 -1
- package/dist/core/use-case/steps/describe.js +24 -0
- package/dist/core/use-case/steps/references.js +105 -0
- package/dist/core/use-case/steps/run.js +139 -0
- package/dist/core/use-case/steps/types/check.interface.d.ts +233 -0
- package/dist/core/use-case/steps/types/steps.interface.d.ts +8 -1
- package/dist/core/use-case/steps/validate.js +368 -0
- package/dist/core/use-case/target.js +100 -0
- package/dist/core/use-case/types/access.interface.d.ts +39 -0
- package/dist/core/use-case/types/route.interface.d.ts +86 -0
- package/dist/core/use-case/use-case.d.ts +127 -2
- package/dist/core/use-case/use-case.js +493 -0
- package/dist/experimental.d.ts +18 -1
- package/dist/experimental.js +18 -123
- package/dist/index.d.ts +49 -51
- package/dist/index.js +213 -443
- package/dist/std/blueprint-plugin.js +42 -0
- package/dist/std/blueprint.d.ts +21 -0
- package/dist/std/blueprint.js +3 -0
- package/dist/std/command.js +3 -0
- package/dist/std/entity.d.ts +20 -1
- package/dist/std/entity.js +30 -0
- package/dist/std/event-policies.js +3 -0
- package/dist/std/properties/contact.d.ts +3 -0
- package/dist/std/properties/contact.js +29 -0
- package/dist/std/properties/enum.js +26 -0
- package/dist/std/properties/exceptions.js +14 -0
- package/dist/std/properties/headless.js +45 -0
- package/dist/std/properties/identity.js +28 -0
- package/dist/std/properties/json.js +161 -0
- package/dist/std/properties/password.d.ts +0 -2
- package/dist/std/properties/password.js +79 -0
- package/dist/std/properties/primitives.d.ts +4 -0
- package/dist/std/properties/primitives.js +28 -0
- package/dist/std/properties/time.js +55 -0
- package/dist/std/record-plugins.d.ts +2 -2
- package/dist/std/record-plugins.js +73 -0
- package/dist/std/record.d.ts +20 -1
- package/dist/std/record.js +3 -0
- package/dist/std/resource.d.ts +31 -0
- package/dist/std/resource.js +39 -0
- package/dist/std/runtime.d.ts +6 -0
- package/dist/std/runtime.js +193 -0
- package/dist/std/stages.js +4 -0
- package/dist/std/use-cases/auth.d.ts +5 -1
- package/dist/std/use-cases/auth.js +193 -0
- package/dist/std/use-cases/crud.js +261 -0
- package/dist/std/use-cases/index.d.ts +1 -1
- package/dist/std/use-cases/index.js +12 -0
- package/dist/std/use-cases/lifecycle.js +97 -0
- package/dist/std/use-cases/shared.d.ts +2 -2
- package/dist/std/use-cases/shared.js +232 -0
- package/dist/std/use-cases/types.d.ts +102 -2
- package/dist/std/use-cases/types.js +0 -0
- package/dist/testing/http-provider-contract.d.ts +32 -0
- package/dist/testing/http-provider-contract.js +344 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +3 -944
- package/dist/testing/memory-publisher.js +12 -0
- package/dist/testing/provider-contract.d.ts +26 -1
- package/dist/testing/provider-contract.js +1071 -0
- package/package.json +23 -3
- package/dist/index-qxfkyd6k.js +0 -1721
- package/dist/index-t5qwtyj5.js +0 -8194
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import type { DescribedJwtOptions } from "../../core/auth/types/jwt.interface.js";
|
|
1
2
|
import type { AnyBlueprint, AnyBlueprintList, AnyRecordClass, BlueprintField, BlueprintValue, IBlueprint, IBlueprintCollection, IBlueprintList, OptionalBlueprint, OptionalBlueprintList, OptionalRecord } from "../../core/blueprint/types/blueprint.interface.js";
|
|
2
3
|
import type { EntityIdentityKey } from "../../core/entity/types/entity.interface.js";
|
|
3
4
|
import type { EntityVersion } from "../../core/entity/version.js";
|
|
4
5
|
import type { OptionalProperty } from "../../core/property/types/optional-property.type.js";
|
|
5
6
|
import type { AnyPropertyConstructor } from "../../core/property/types/property-collection.type.js";
|
|
6
7
|
import type { AnyRepository, AnyRepositoryEntity, EntityInstanceOf } from "../../core/repository/types/repository.interface.js";
|
|
8
|
+
import type { AccessDeclaration } from "../../core/use-case/types/access.interface.js";
|
|
7
9
|
import type { IUseCasePlugin, UseCaseTarget } from "../../core/use-case/types/plugin.interface.js";
|
|
10
|
+
import type { RouteSettings } from "../../core/use-case/types/route.interface.js";
|
|
8
11
|
import type { AnyExceptionClass, DependencyFunctions, StepsUseCaseConstructor, UseCaseCollection } from "../../core/use-case/use-case.js";
|
|
9
12
|
import type { Integer, StringProperty } from "../properties/primitives.js";
|
|
10
13
|
type EntityOf<Repository> = Repository extends {
|
|
@@ -44,7 +47,41 @@ export interface StdUseCaseOptions {
|
|
|
44
47
|
readonly tags?: readonly string[];
|
|
45
48
|
readonly deprecated?: boolean;
|
|
46
49
|
readonly errors?: StdUseCaseErrors;
|
|
50
|
+
/** Where the use case is served over HTTP: the `route` of `useCaseOf`. */
|
|
51
|
+
readonly route?: RouteSettings;
|
|
52
|
+
/** Who may call it: the `access` of `useCaseOf`. */
|
|
53
|
+
readonly access?: AccessDeclaration;
|
|
47
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The tokens `login` and `refresh` issue: the options of `jwtOf`, plus what
|
|
57
|
+
* a token says of the entity. JSON, so it lives in an `afora.json`: the
|
|
58
|
+
* secret is named by its environment variable (`{ env: "JWT_SECRET" }`).
|
|
59
|
+
* Experimental: it may change in any release.
|
|
60
|
+
*/
|
|
61
|
+
export interface StdTokenOptions extends DescribedJwtOptions {
|
|
62
|
+
/** Also issues a refresh token, which `refresh` exchanges; `expiresIn` is its life in seconds. */
|
|
63
|
+
readonly refresh?: {
|
|
64
|
+
readonly expiresIn: number;
|
|
65
|
+
};
|
|
66
|
+
/** Scopes every token gets. */
|
|
67
|
+
readonly scopes?: readonly string[];
|
|
68
|
+
/** A key of the entity that holds a list of scopes, added to `scopes`. */
|
|
69
|
+
readonly scopesFrom?: string;
|
|
70
|
+
/** Claims of the token, each from a key of the entity: `{ tenant: "tenantId" }`. */
|
|
71
|
+
readonly claims?: {
|
|
72
|
+
readonly [claim: string]: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** The response keys the `token` option adds. */
|
|
76
|
+
type TokenResponse<Options> = Options extends {
|
|
77
|
+
readonly token: infer Token;
|
|
78
|
+
} ? {
|
|
79
|
+
readonly token: typeof StringProperty;
|
|
80
|
+
} & (Token extends {
|
|
81
|
+
readonly refresh: object;
|
|
82
|
+
} ? {
|
|
83
|
+
readonly refreshToken: typeof StringProperty;
|
|
84
|
+
} : unknown) : unknown;
|
|
48
85
|
/** Who owns the entities: the caller, whose id the context gives. */
|
|
49
86
|
export interface StdOwnerOption<Repository> {
|
|
50
87
|
/**
|
|
@@ -61,7 +98,11 @@ export interface StdOwnerOption<Repository> {
|
|
|
61
98
|
}
|
|
62
99
|
/** The options of a standard use case that gives one entity. */
|
|
63
100
|
export interface StdEntityOptions<Repository> extends StdUseCaseOptions, StdOwnerOption<Repository> {
|
|
64
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* The key of the response that holds the entity.
|
|
103
|
+
*
|
|
104
|
+
* @default "item"
|
|
105
|
+
*/
|
|
65
106
|
readonly as?: string;
|
|
66
107
|
}
|
|
67
108
|
/** The options of `create` and `register`. */
|
|
@@ -258,12 +299,34 @@ export type RegisterMaker = <Repository extends AnyRepository, const Options ext
|
|
|
258
299
|
/** `login`: for an authenticatable entity, with `findByEmail`. */
|
|
259
300
|
export type LoginMaker = <Repository extends AnyRepository, const Options extends StdUseCaseOptions & {
|
|
260
301
|
readonly as?: string;
|
|
302
|
+
/** Issues a token for the entity (and a refresh token, with `refresh`), in the response. */
|
|
303
|
+
readonly token?: StdTokenOptions;
|
|
261
304
|
} & StdNoHookOptions = None>(repository: Repository & UseCaseTarget<Repository, "findByEmail", Authenticatable, "authenticatable">, options?: Options) => StepsUseCaseConstructor<{
|
|
262
305
|
readonly email: FieldAt<Props<Repository>, "email">;
|
|
263
306
|
readonly password: typeof StringProperty;
|
|
264
|
-
}, EntityResponse<Repository, Options>, {
|
|
307
|
+
}, EntityResponse<Repository, Options> & TokenResponse<Options>, {
|
|
265
308
|
readonly repository: Repository;
|
|
266
309
|
}>;
|
|
310
|
+
/** `refresh`: exchanges a refresh token of `login` for new tokens; needs `findById`. */
|
|
311
|
+
export type RefreshMaker = <Repository extends AnyRepository>(repository: Repository & UseCaseTarget<Repository, "findById">, options: StdUseCaseOptions & {
|
|
312
|
+
readonly token: StdTokenOptions & {
|
|
313
|
+
readonly refresh: {
|
|
314
|
+
readonly expiresIn: number;
|
|
315
|
+
};
|
|
316
|
+
};
|
|
317
|
+
} & StdNoHookOptions) => StepsUseCaseConstructor<{
|
|
318
|
+
readonly refreshToken: typeof StringProperty;
|
|
319
|
+
}, {
|
|
320
|
+
readonly token: typeof StringProperty;
|
|
321
|
+
readonly refreshToken: typeof StringProperty;
|
|
322
|
+
}, {
|
|
323
|
+
readonly repository: Repository;
|
|
324
|
+
}>;
|
|
325
|
+
/** `logout`: gives empty tokens, which a route with `setCookie` turns into removed cookies. */
|
|
326
|
+
export type LogoutMaker = <Repository extends AnyRepository>(repository: Repository, options?: StdUseCaseOptions & StdNoHookOptions) => StepsUseCaseConstructor<Record<never, never>, {
|
|
327
|
+
readonly token: typeof StringProperty;
|
|
328
|
+
readonly refreshToken: typeof StringProperty;
|
|
329
|
+
}, Record<never, never>>;
|
|
267
330
|
/**
|
|
268
331
|
* The standard use cases, by name: what the `useCases` of `commandOf` gets
|
|
269
332
|
* to pick from, each called with a repository and its options.
|
|
@@ -283,9 +346,46 @@ export interface StdUseCaseContext {
|
|
|
283
346
|
readonly untag: TagMaker;
|
|
284
347
|
readonly register: RegisterMaker;
|
|
285
348
|
readonly login: LoginMaker;
|
|
349
|
+
readonly refresh: RefreshMaker;
|
|
350
|
+
readonly logout: LogoutMaker;
|
|
286
351
|
}
|
|
287
352
|
/** The plugins of `StdUseCases`, by name. */
|
|
288
353
|
export type StdUseCasePlugins = {
|
|
289
354
|
readonly [Name in keyof StdUseCaseContext]: IUseCasePlugin<Name, StdUseCaseContext[Name]>;
|
|
290
355
|
};
|
|
356
|
+
/** The operations `resourceOf` can give. */
|
|
357
|
+
export type ResourceAction = "create" | "get" | "list" | "update" | "delete";
|
|
358
|
+
/** The use cases of `resourceOf`, by operation: the standard ones, with no option but their route and access. */
|
|
359
|
+
export interface ResourceUseCases<Repository extends AnyRepository> {
|
|
360
|
+
readonly create: EntityUseCase<Repository, None, CreateInput<Repository, None>>;
|
|
361
|
+
readonly get: EntityUseCase<Repository, None, ById<Repository>>;
|
|
362
|
+
readonly list: StepsUseCaseConstructor<{
|
|
363
|
+
readonly limit: OptionalProperty<typeof Integer>;
|
|
364
|
+
readonly offset: OptionalProperty<typeof Integer>;
|
|
365
|
+
}, {
|
|
366
|
+
readonly items: IBlueprintList<EntityOf<Repository>>;
|
|
367
|
+
readonly total: typeof Integer;
|
|
368
|
+
readonly limit: typeof Integer;
|
|
369
|
+
readonly offset: typeof Integer;
|
|
370
|
+
}, {
|
|
371
|
+
readonly repository: Repository;
|
|
372
|
+
}>;
|
|
373
|
+
readonly update: EntityUseCase<Repository, None, UpdateInput<Repository>>;
|
|
374
|
+
readonly delete: EntityUseCase<Repository, None, ById<Repository>>;
|
|
375
|
+
}
|
|
376
|
+
/** The options of `resourceOf`. */
|
|
377
|
+
export interface ResourceOptions<Actions extends readonly ResourceAction[] = readonly ResourceAction[]> {
|
|
378
|
+
/** The path of the collection, e.g. `"/posts"`; one entity is at `{path}/{id}`. */
|
|
379
|
+
readonly path: string;
|
|
380
|
+
/** The operations to give; all five by default. Each needs its repository action. */
|
|
381
|
+
readonly actions?: Actions;
|
|
382
|
+
/**
|
|
383
|
+
* Who may call: one declaration for every operation, or one per operation
|
|
384
|
+
* (`{ create: { scopes: ["posts:write"] }, get: { public: true } }`; an
|
|
385
|
+
* operation left out declares none).
|
|
386
|
+
*/
|
|
387
|
+
readonly access?: AccessDeclaration | {
|
|
388
|
+
readonly [Action in ResourceAction]?: AccessDeclaration;
|
|
389
|
+
};
|
|
390
|
+
}
|
|
291
391
|
export {};
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Authenticator, HttpOptions, SinkStage } from "../adapters.js";
|
|
2
|
+
import type { ContractRunner } from "./provider-contract.js";
|
|
3
|
+
/** What the kit needs of a sink that serves HTTP. */
|
|
4
|
+
export interface HttpProviderContractSettings<Server> {
|
|
5
|
+
/** The sink under test, made with these options and this authenticator. */
|
|
6
|
+
sink(options: HttpOptions, hooks: {
|
|
7
|
+
readonly authenticator?: Authenticator;
|
|
8
|
+
}): SinkStage<Server, HttpOptions>;
|
|
9
|
+
/** Sends a request to the server the sink built, and gives its response. */
|
|
10
|
+
fetch(server: Server, request: Request): Response | Promise<Response>;
|
|
11
|
+
/** Releases the server (a listening socket), after each case. */
|
|
12
|
+
close?(server: Server): void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The cases every sink that serves HTTP must pass, whatever its server: the
|
|
16
|
+
* routes use cases declare, where each input key is read from, the context,
|
|
17
|
+
* access, statuses, problems, cookies, the request id and the document. A
|
|
18
|
+
* sink built on `compileRoutes` passes when it routes each method and path
|
|
19
|
+
* to `handle` and sends the result as is.
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { describe, expect, it } from "bun:test";
|
|
23
|
+
* import { httpProviderContract } from "@afora/core/testing";
|
|
24
|
+
*
|
|
25
|
+
* httpProviderContract(
|
|
26
|
+
* "hono",
|
|
27
|
+
* { sink: (options, hooks) => hono(options, hooks), fetch: (app, request) => app.fetch(request) },
|
|
28
|
+
* { describe, it, expect },
|
|
29
|
+
* );
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function httpProviderContract<Server>(name: string, settings: HttpProviderContractSettings<Server>, runner: ContractRunner): void;
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import {
|
|
2
|
+
blueprintOf,
|
|
3
|
+
commandOf,
|
|
4
|
+
ExceptionLayers,
|
|
5
|
+
exceptionOf,
|
|
6
|
+
outputOf,
|
|
7
|
+
recordOf,
|
|
8
|
+
useCaseOf
|
|
9
|
+
} from "../index.js";
|
|
10
|
+
const Note = recordOf(blueprintOf((ctx) => ({ title: ctx.string, secret: ctx.password })));
|
|
11
|
+
const Rejected = exceptionOf({
|
|
12
|
+
layer: ExceptionLayers.UseCase,
|
|
13
|
+
name: "ContractRejected",
|
|
14
|
+
message: "rejected"
|
|
15
|
+
});
|
|
16
|
+
const Echo = blueprintOf((ctx) => ({
|
|
17
|
+
id: ctx.optional.string,
|
|
18
|
+
page: ctx.optional.integer,
|
|
19
|
+
flag: ctx.optional.boolean,
|
|
20
|
+
locale: ctx.optional.string,
|
|
21
|
+
session: ctx.optional.string,
|
|
22
|
+
title: ctx.optional.string,
|
|
23
|
+
caller: ctx.optional.string,
|
|
24
|
+
tenant: ctx.optional.string
|
|
25
|
+
}));
|
|
26
|
+
function operations() {
|
|
27
|
+
|
|
28
|
+
class Get extends useCaseOf({
|
|
29
|
+
name: "ContractGet",
|
|
30
|
+
blueprint: blueprintOf((ctx) => ({
|
|
31
|
+
id: ctx.string,
|
|
32
|
+
page: ctx.optional.integer,
|
|
33
|
+
flag: ctx.optional.boolean
|
|
34
|
+
})),
|
|
35
|
+
response: Echo,
|
|
36
|
+
route: { method: "get", path: "/items/{id}" }
|
|
37
|
+
}) {
|
|
38
|
+
async execute() {
|
|
39
|
+
return { ...this.input };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class Change extends useCaseOf({
|
|
44
|
+
name: "ContractChange",
|
|
45
|
+
blueprint: blueprintOf((ctx) => ({
|
|
46
|
+
id: ctx.string,
|
|
47
|
+
page: ctx.integer,
|
|
48
|
+
locale: ctx.optional.string,
|
|
49
|
+
session: ctx.optional.string,
|
|
50
|
+
title: ctx.string
|
|
51
|
+
})),
|
|
52
|
+
response: Echo,
|
|
53
|
+
context: blueprintOf((ctx) => ({ tenant: ctx.optional.string })),
|
|
54
|
+
errors: [Rejected],
|
|
55
|
+
route: {
|
|
56
|
+
method: "put",
|
|
57
|
+
path: "/items/{id}",
|
|
58
|
+
status: 201,
|
|
59
|
+
query: ["page"],
|
|
60
|
+
headers: { locale: "accept-language" },
|
|
61
|
+
cookie: { session: "sid" }
|
|
62
|
+
}
|
|
63
|
+
}) {
|
|
64
|
+
async execute() {
|
|
65
|
+
if (this.input.title === "reject")
|
|
66
|
+
throw new Rejected({ source: "x" });
|
|
67
|
+
if (this.input.title === "crash")
|
|
68
|
+
throw new Error("internal detail");
|
|
69
|
+
return { ...this.input, tenant: this.context.tenant };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class Remove extends useCaseOf({
|
|
74
|
+
name: "ContractRemove",
|
|
75
|
+
blueprint: blueprintOf((ctx) => ({ id: ctx.string })),
|
|
76
|
+
response: blueprintOf(() => ({})),
|
|
77
|
+
route: { method: "delete", path: "/items/{id}", status: 204 }
|
|
78
|
+
}) {
|
|
79
|
+
async execute() {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class Reveal extends useCaseOf({
|
|
85
|
+
name: "ContractReveal",
|
|
86
|
+
blueprint: blueprintOf(() => ({})),
|
|
87
|
+
response: blueprintOf(() => ({ note: Note })),
|
|
88
|
+
route: { method: "get", path: "/note" }
|
|
89
|
+
}) {
|
|
90
|
+
async execute() {
|
|
91
|
+
return { note: new Note({ title: "t", secret: await hashed() }) };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class Mine extends useCaseOf({
|
|
96
|
+
name: "ContractMine",
|
|
97
|
+
blueprint: blueprintOf(() => ({})),
|
|
98
|
+
response: Echo,
|
|
99
|
+
context: blueprintOf((ctx) => ({ caller: ctx.string })),
|
|
100
|
+
route: { method: "get", path: "/mine" },
|
|
101
|
+
access: { scopes: ["items:read"], context: { caller: "subject" } }
|
|
102
|
+
}) {
|
|
103
|
+
async execute() {
|
|
104
|
+
return { caller: this.context.caller };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class Enter extends useCaseOf({
|
|
109
|
+
name: "ContractEnter",
|
|
110
|
+
blueprint: blueprintOf((ctx) => ({ title: ctx.string })),
|
|
111
|
+
response: blueprintOf((ctx) => ({
|
|
112
|
+
title: ctx.string,
|
|
113
|
+
token: ctx.string,
|
|
114
|
+
refreshToken: ctx.string
|
|
115
|
+
})),
|
|
116
|
+
route: {
|
|
117
|
+
method: "post",
|
|
118
|
+
path: "/enter",
|
|
119
|
+
setCookie: {
|
|
120
|
+
token: { name: "sid", secure: false },
|
|
121
|
+
refreshToken: { name: "rid", secure: false, path: "/refresh" }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}) {
|
|
125
|
+
async execute() {
|
|
126
|
+
return { title: this.input.title, token: "t 1", refreshToken: "" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return commandOf({
|
|
130
|
+
name: "Contract",
|
|
131
|
+
useCases: {
|
|
132
|
+
get: Get,
|
|
133
|
+
change: Change,
|
|
134
|
+
remove: Remove,
|
|
135
|
+
reveal: Reveal,
|
|
136
|
+
mine: Mine,
|
|
137
|
+
enter: Enter
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async function hashed() {
|
|
142
|
+
return "$argon2id$v=19$m=65536,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$4VbQ3OqJ7xk8Y0mG0iZ1c2k1m2Qe0sQeJ7xk8Y0mG0i";
|
|
143
|
+
}
|
|
144
|
+
const authenticator = (request) => {
|
|
145
|
+
const token = request.header("authorization");
|
|
146
|
+
if (token === "Bearer reader")
|
|
147
|
+
return { subject: "u1", scopes: ["items:read"] };
|
|
148
|
+
if (token === "Bearer other")
|
|
149
|
+
return { subject: "u2", scopes: [] };
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
export function httpProviderContract(name, settings, runner) {
|
|
153
|
+
const { describe, it, expect } = runner;
|
|
154
|
+
const origin = "http://contract.test";
|
|
155
|
+
const withServer = async (options, body, hooks = { authenticator }) => {
|
|
156
|
+
const server = outputOf({
|
|
157
|
+
name: "Contract",
|
|
158
|
+
commands: [operations()],
|
|
159
|
+
statuses: { ContractRejected: 409 },
|
|
160
|
+
pipe: [settings.sink(options, hooks)]
|
|
161
|
+
}).build();
|
|
162
|
+
try {
|
|
163
|
+
await body(async (method, path, init = {}) => {
|
|
164
|
+
const hasBody = init.body !== undefined;
|
|
165
|
+
const response = await settings.fetch(server, new Request(`${origin}${path}`, {
|
|
166
|
+
method,
|
|
167
|
+
headers: {
|
|
168
|
+
...hasBody ? { "content-type": "application/json" } : {},
|
|
169
|
+
...init.headers
|
|
170
|
+
},
|
|
171
|
+
...hasBody ? {
|
|
172
|
+
body: typeof init.body === "string" ? init.body : JSON.stringify(init.body)
|
|
173
|
+
} : {}
|
|
174
|
+
}));
|
|
175
|
+
const text = await response.text();
|
|
176
|
+
let json;
|
|
177
|
+
try {
|
|
178
|
+
json = text === "" ? undefined : JSON.parse(text);
|
|
179
|
+
} catch {
|
|
180
|
+
json = undefined;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
status: response.status,
|
|
184
|
+
type: (response.headers.get("content-type") ?? "").split(";")[0],
|
|
185
|
+
headers: response.headers,
|
|
186
|
+
text,
|
|
187
|
+
json
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
} finally {
|
|
191
|
+
await settings.close?.(server);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
describe(`${name}: HTTP provider contract`, () => {
|
|
195
|
+
it("serves the route a use case declares, path and query coerced", async () => {
|
|
196
|
+
await withServer({ basePath: "/api" }, async (send) => {
|
|
197
|
+
const found = await send("GET", "/api/items/a%20b?page=2&flag=true");
|
|
198
|
+
expect(found.status).toBe(200);
|
|
199
|
+
expect(found.type).toBe("application/json");
|
|
200
|
+
expect(found.json).toEqual({ id: "a b", page: 2, flag: true });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
it("reads each key where the route places it, over the body", async () => {
|
|
204
|
+
await withServer({}, async (send) => {
|
|
205
|
+
const changed = await send("PUT", "/items/p?page=3", {
|
|
206
|
+
headers: { "accept-language": "pt-BR", cookie: "x=1; sid=s%201" },
|
|
207
|
+
body: { id: "from-body", page: 9, title: "T" }
|
|
208
|
+
});
|
|
209
|
+
expect(changed.status).toBe(201);
|
|
210
|
+
expect(changed.json).toEqual({
|
|
211
|
+
id: "p",
|
|
212
|
+
page: 3,
|
|
213
|
+
locale: "pt-BR",
|
|
214
|
+
session: "s 1",
|
|
215
|
+
title: "T"
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
it("fills the context from headers", async () => {
|
|
220
|
+
await withServer({ context: { headers: { tenant: "x-tenant" } } }, async (send) => {
|
|
221
|
+
const changed = await send("PUT", "/items/p?page=1", {
|
|
222
|
+
headers: { "x-tenant": "acme" },
|
|
223
|
+
body: { title: "T" }
|
|
224
|
+
});
|
|
225
|
+
expect(changed.json.tenant).toBe("acme");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
it("answers invalid input and invalid JSON with a 400 problem", async () => {
|
|
229
|
+
await withServer({}, async (send) => {
|
|
230
|
+
const invalid = await send("PUT", "/items/p?page=1", {
|
|
231
|
+
body: { title: 1 }
|
|
232
|
+
});
|
|
233
|
+
expect(invalid.status).toBe(400);
|
|
234
|
+
expect(invalid.type).toBe("application/problem+json");
|
|
235
|
+
const problem = invalid.json;
|
|
236
|
+
expect(problem.title).toBe("InvalidUseCaseInput");
|
|
237
|
+
expect(problem.issues.map((issue) => issue.path)).toEqual([["title"]]);
|
|
238
|
+
const notJson = await send("PUT", "/items/p?page=1", {
|
|
239
|
+
body: "{nope"
|
|
240
|
+
});
|
|
241
|
+
expect(notJson.status).toBe(400);
|
|
242
|
+
expect(notJson.json.title).toBe("InvalidRequestBody");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
it("gives errors the status of the output, and hides internal ones", async () => {
|
|
246
|
+
await withServer({}, async (send) => {
|
|
247
|
+
const rejected = await send("PUT", "/items/p?page=1", {
|
|
248
|
+
body: { title: "reject" }
|
|
249
|
+
});
|
|
250
|
+
expect(rejected.status).toBe(409);
|
|
251
|
+
expect(rejected.type).toBe("application/problem+json");
|
|
252
|
+
const crashed = await send("PUT", "/items/p?page=1", {
|
|
253
|
+
body: { title: "crash" }
|
|
254
|
+
});
|
|
255
|
+
expect(crashed.status).toBe(500);
|
|
256
|
+
expect(crashed.text.includes("internal detail")).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
await withServer({ debug: true }, async (send) => {
|
|
259
|
+
const crashed = await send("PUT", "/items/p?page=1", {
|
|
260
|
+
body: { title: "crash" }
|
|
261
|
+
});
|
|
262
|
+
expect(crashed.text.includes("internal detail")).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
it("answers a 204 without a body, and an unknown route with a 404 problem", async () => {
|
|
266
|
+
await withServer({}, async (send) => {
|
|
267
|
+
const removed = await send("DELETE", "/items/p");
|
|
268
|
+
expect(removed.status).toBe(204);
|
|
269
|
+
expect(removed.text).toBe("");
|
|
270
|
+
const unknown = await send("GET", "/nowhere");
|
|
271
|
+
expect(unknown.status).toBe(404);
|
|
272
|
+
expect(unknown.type).toBe("application/problem+json");
|
|
273
|
+
expect(unknown.json.title).toBe("NotFound");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
it("masks sensitive values", async () => {
|
|
277
|
+
await withServer({}, async (send) => {
|
|
278
|
+
const revealed = await send("GET", "/note");
|
|
279
|
+
expect(revealed.json).toEqual({
|
|
280
|
+
note: { title: "t", secret: "********" }
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
it("enforces the access of a use case", async () => {
|
|
285
|
+
await withServer({}, async (send) => {
|
|
286
|
+
const anonymous = await send("GET", "/mine");
|
|
287
|
+
expect(anonymous.status).toBe(401);
|
|
288
|
+
expect(anonymous.json.title).toBe("Unauthenticated");
|
|
289
|
+
const other = await send("GET", "/mine", {
|
|
290
|
+
headers: { authorization: "Bearer other" }
|
|
291
|
+
});
|
|
292
|
+
expect(other.status).toBe(403);
|
|
293
|
+
const reader = await send("GET", "/mine", {
|
|
294
|
+
headers: { authorization: "Bearer reader" }
|
|
295
|
+
});
|
|
296
|
+
expect(reader.status).toBe(200);
|
|
297
|
+
expect(reader.json).toEqual({ caller: "u1" });
|
|
298
|
+
});
|
|
299
|
+
await withServer({}, async (send) => {
|
|
300
|
+
const closed = await send("GET", "/mine", {
|
|
301
|
+
headers: { authorization: "Bearer reader" }
|
|
302
|
+
});
|
|
303
|
+
expect(closed.status).toBe(401);
|
|
304
|
+
}, {});
|
|
305
|
+
});
|
|
306
|
+
it("sends the cookies of a route, out of the body", async () => {
|
|
307
|
+
await withServer({}, async (send) => {
|
|
308
|
+
const entered = await send("POST", "/enter", { body: { title: "T" } });
|
|
309
|
+
expect(entered.status).toBe(200);
|
|
310
|
+
expect(entered.json).toEqual({ title: "T" });
|
|
311
|
+
expect(entered.headers.getSetCookie()).toEqual([
|
|
312
|
+
"sid=t%201; Path=/; HttpOnly; SameSite=Lax",
|
|
313
|
+
"rid=; Path=/refresh; HttpOnly; SameSite=Lax; Max-Age=0"
|
|
314
|
+
]);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
it("puts the request id on successes and problems", async () => {
|
|
318
|
+
await withServer({ requestId: true }, async (send) => {
|
|
319
|
+
const sent = await send("GET", "/items/a", {
|
|
320
|
+
headers: { "x-request-id": "abc-1" }
|
|
321
|
+
});
|
|
322
|
+
expect(sent.headers.get("x-request-id")).toBe("abc-1");
|
|
323
|
+
const problem = await send("GET", "/mine");
|
|
324
|
+
expect((problem.headers.get("x-request-id") ?? "").length).toBe(36);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
it("serves the document of the same routes", async () => {
|
|
328
|
+
await withServer({
|
|
329
|
+
basePath: "/api",
|
|
330
|
+
docs: { path: "/openapi.json", info: { title: "C", version: "1" } }
|
|
331
|
+
}, async (send) => {
|
|
332
|
+
const docs = await send("GET", "/openapi.json");
|
|
333
|
+
expect(docs.status).toBe(200);
|
|
334
|
+
expect(docs.type).toBe("application/json");
|
|
335
|
+
expect(Object.keys(docs.json.paths).sort()).toEqual([
|
|
336
|
+
"/api/enter",
|
|
337
|
+
"/api/items/{id}",
|
|
338
|
+
"/api/mine",
|
|
339
|
+
"/api/note"
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { HttpProviderContractSettings } from "./http-provider-contract.js";
|
|
2
|
+
export { httpProviderContract } from "./http-provider-contract.js";
|
|
1
3
|
export type { MemoryPublisher } from "./memory-publisher.js";
|
|
2
4
|
export { memoryPublisher } from "./memory-publisher.js";
|
|
3
5
|
export type { ContractRunner, ProviderContractOptions, } from "./provider-contract.js";
|