@shaferllc/keel 0.36.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/core/application.d.ts +58 -2
- package/dist/core/application.js +99 -3
- package/dist/core/auth.d.ts +21 -2
- package/dist/core/auth.js +38 -3
- package/dist/core/authorization.d.ts +52 -0
- package/dist/core/authorization.js +97 -0
- package/dist/core/broadcasting.d.ts +49 -0
- package/dist/core/broadcasting.js +84 -0
- package/dist/core/broker.d.ts +398 -0
- package/dist/core/broker.js +602 -0
- package/dist/core/crypto.d.ts +51 -1
- package/dist/core/crypto.js +96 -3
- package/dist/core/database.d.ts +26 -1
- package/dist/core/database.js +65 -2
- package/dist/core/decorators.d.ts +39 -0
- package/dist/core/decorators.js +72 -0
- package/dist/core/exceptions.d.ts +77 -6
- package/dist/core/exceptions.js +168 -10
- package/dist/core/helpers.d.ts +6 -0
- package/dist/core/helpers.js +12 -0
- package/dist/core/http/kernel.js +14 -2
- package/dist/core/http/router.d.ts +14 -0
- package/dist/core/http/router.js +30 -1
- package/dist/core/index.d.ts +31 -8
- package/dist/core/index.js +17 -5
- package/dist/core/logger.d.ts +5 -0
- package/dist/core/logger.js +24 -2
- package/dist/core/migrations.js +3 -3
- package/dist/core/model.d.ts +19 -1
- package/dist/core/model.js +72 -4
- package/dist/core/provider.d.ts +13 -4
- package/dist/core/provider.js +12 -2
- package/dist/core/redis.d.ts +78 -0
- package/dist/core/redis.js +176 -0
- package/dist/core/request-logger.d.ts +26 -0
- package/dist/core/request-logger.js +48 -0
- package/dist/core/request.d.ts +17 -1
- package/dist/core/request.js +27 -1
- package/dist/core/scheduler.d.ts +60 -0
- package/dist/core/scheduler.js +166 -0
- package/dist/core/session.js +17 -2
- package/dist/core/storage.d.ts +57 -0
- package/dist/core/storage.js +98 -0
- package/dist/core/template.d.ts +50 -0
- package/dist/core/template.js +753 -0
- package/dist/core/testing.d.ts +54 -0
- package/dist/core/testing.js +141 -0
- package/dist/core/transformer.d.ts +89 -0
- package/dist/core/transformer.js +152 -0
- package/dist/core/validation.d.ts +20 -0
- package/dist/core/validation.js +52 -1
- package/dist/core/vite.d.ts +117 -0
- package/dist/core/vite.js +258 -0
- package/dist/vite/index.d.ts +40 -0
- package/dist/vite/index.js +146 -0
- package/package.json +16 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test client for Keel apps — inject requests without a live server and assert
|
|
3
|
+
* on the response, the way Fastify's `inject()` does. Wraps the app's Hono
|
|
4
|
+
* instance (which already does fetch-style request injection), adding verb
|
|
5
|
+
* helpers with JSON bodies and fluent response assertions.
|
|
6
|
+
*
|
|
7
|
+
* const client = await testClient(app);
|
|
8
|
+
* const res = await client.post("/users", { email: "a@b.com" });
|
|
9
|
+
* res.assertStatus(201).assertJson({ id: 1, email: "a@b.com" });
|
|
10
|
+
*
|
|
11
|
+
* Edge-safe — no server, no port; the same injection Keel's own suite uses.
|
|
12
|
+
*/
|
|
13
|
+
import { Application } from "./application.js";
|
|
14
|
+
import { HttpKernel } from "./http/kernel.js";
|
|
15
|
+
interface Requestable {
|
|
16
|
+
request(input: string, init?: RequestInit): Promise<Response> | Response;
|
|
17
|
+
}
|
|
18
|
+
/** A response captured by the test client — body pre-read, so reads are sync. */
|
|
19
|
+
export declare class TestResponse {
|
|
20
|
+
readonly raw: Response;
|
|
21
|
+
private readonly bodyText;
|
|
22
|
+
constructor(raw: Response, bodyText: string);
|
|
23
|
+
get status(): number;
|
|
24
|
+
header(name: string): string | null;
|
|
25
|
+
text(): string;
|
|
26
|
+
json<T = unknown>(): T;
|
|
27
|
+
assertStatus(expected: number): this;
|
|
28
|
+
/** Assert a 2xx status. */
|
|
29
|
+
assertOk(): this;
|
|
30
|
+
/** Assert the JSON body deep-equals `expected`. */
|
|
31
|
+
assertJson(expected: unknown): this;
|
|
32
|
+
assertText(expected: string): this;
|
|
33
|
+
assertHeader(name: string, value: string): this;
|
|
34
|
+
/** Assert a redirect (3xx) to `location`. */
|
|
35
|
+
assertRedirect(location?: string): this;
|
|
36
|
+
}
|
|
37
|
+
/** Injects requests into an app and returns `TestResponse`s. */
|
|
38
|
+
export declare class TestClient {
|
|
39
|
+
private target;
|
|
40
|
+
constructor(target: Requestable);
|
|
41
|
+
request(path: string, init?: RequestInit): Promise<TestResponse>;
|
|
42
|
+
get(path: string, init?: RequestInit): Promise<TestResponse>;
|
|
43
|
+
delete(path: string, init?: RequestInit): Promise<TestResponse>;
|
|
44
|
+
post(path: string, body?: unknown, init?: RequestInit): Promise<TestResponse>;
|
|
45
|
+
put(path: string, body?: unknown, init?: RequestInit): Promise<TestResponse>;
|
|
46
|
+
patch(path: string, body?: unknown, init?: RequestInit): Promise<TestResponse>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build a `TestClient` from an `Application`, an `HttpKernel`, or anything with a
|
|
50
|
+
* `request()` (a built Hono instance). An `Application` is built through a fresh
|
|
51
|
+
* `HttpKernel`; pass a kernel yourself if you need global middleware registered.
|
|
52
|
+
*/
|
|
53
|
+
export declare function testClient(target: Application | HttpKernel | Requestable): TestClient;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test client for Keel apps — inject requests without a live server and assert
|
|
3
|
+
* on the response, the way Fastify's `inject()` does. Wraps the app's Hono
|
|
4
|
+
* instance (which already does fetch-style request injection), adding verb
|
|
5
|
+
* helpers with JSON bodies and fluent response assertions.
|
|
6
|
+
*
|
|
7
|
+
* const client = await testClient(app);
|
|
8
|
+
* const res = await client.post("/users", { email: "a@b.com" });
|
|
9
|
+
* res.assertStatus(201).assertJson({ id: 1, email: "a@b.com" });
|
|
10
|
+
*
|
|
11
|
+
* Edge-safe — no server, no port; the same injection Keel's own suite uses.
|
|
12
|
+
*/
|
|
13
|
+
import { Application } from "./application.js";
|
|
14
|
+
import { HttpKernel } from "./http/kernel.js";
|
|
15
|
+
function deepEqual(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return true;
|
|
18
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
|
|
19
|
+
return false;
|
|
20
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
21
|
+
return false;
|
|
22
|
+
const ak = Object.keys(a);
|
|
23
|
+
const bk = Object.keys(b);
|
|
24
|
+
if (ak.length !== bk.length)
|
|
25
|
+
return false;
|
|
26
|
+
return ak.every((k) => deepEqual(a[k], b[k]));
|
|
27
|
+
}
|
|
28
|
+
/** A response captured by the test client — body pre-read, so reads are sync. */
|
|
29
|
+
export class TestResponse {
|
|
30
|
+
raw;
|
|
31
|
+
bodyText;
|
|
32
|
+
constructor(raw, bodyText) {
|
|
33
|
+
this.raw = raw;
|
|
34
|
+
this.bodyText = bodyText;
|
|
35
|
+
}
|
|
36
|
+
get status() {
|
|
37
|
+
return this.raw.status;
|
|
38
|
+
}
|
|
39
|
+
header(name) {
|
|
40
|
+
return this.raw.headers.get(name);
|
|
41
|
+
}
|
|
42
|
+
text() {
|
|
43
|
+
return this.bodyText;
|
|
44
|
+
}
|
|
45
|
+
json() {
|
|
46
|
+
return JSON.parse(this.bodyText);
|
|
47
|
+
}
|
|
48
|
+
/* ------------------------------ assertions ---------------------------- */
|
|
49
|
+
assertStatus(expected) {
|
|
50
|
+
if (this.status !== expected) {
|
|
51
|
+
throw new Error(`Expected status ${expected}, got ${this.status}. Body: ${this.bodyText}`);
|
|
52
|
+
}
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
/** Assert a 2xx status. */
|
|
56
|
+
assertOk() {
|
|
57
|
+
if (this.status < 200 || this.status >= 300) {
|
|
58
|
+
throw new Error(`Expected a 2xx status, got ${this.status}. Body: ${this.bodyText}`);
|
|
59
|
+
}
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
/** Assert the JSON body deep-equals `expected`. */
|
|
63
|
+
assertJson(expected) {
|
|
64
|
+
const actual = this.json();
|
|
65
|
+
if (!deepEqual(actual, expected)) {
|
|
66
|
+
throw new Error(`JSON body mismatch.\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
|
|
67
|
+
}
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
assertText(expected) {
|
|
71
|
+
if (this.bodyText !== expected) {
|
|
72
|
+
throw new Error(`Expected body "${expected}", got "${this.bodyText}"`);
|
|
73
|
+
}
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
assertHeader(name, value) {
|
|
77
|
+
const actual = this.header(name);
|
|
78
|
+
if (actual !== value) {
|
|
79
|
+
throw new Error(`Expected header ${name}: "${value}", got "${actual ?? "(absent)"}"`);
|
|
80
|
+
}
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
/** Assert a redirect (3xx) to `location`. */
|
|
84
|
+
assertRedirect(location) {
|
|
85
|
+
if (this.status < 300 || this.status >= 400) {
|
|
86
|
+
throw new Error(`Expected a redirect (3xx), got ${this.status}`);
|
|
87
|
+
}
|
|
88
|
+
if (location !== undefined)
|
|
89
|
+
this.assertHeader("location", location);
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Injects requests into an app and returns `TestResponse`s. */
|
|
94
|
+
export class TestClient {
|
|
95
|
+
target;
|
|
96
|
+
constructor(target) {
|
|
97
|
+
this.target = target;
|
|
98
|
+
}
|
|
99
|
+
async request(path, init = {}) {
|
|
100
|
+
const res = await this.target.request(path, init);
|
|
101
|
+
const text = await res.text();
|
|
102
|
+
return new TestResponse(res, text);
|
|
103
|
+
}
|
|
104
|
+
get(path, init) {
|
|
105
|
+
return this.request(path, { ...init, method: "GET" });
|
|
106
|
+
}
|
|
107
|
+
delete(path, init) {
|
|
108
|
+
return this.request(path, { ...init, method: "DELETE" });
|
|
109
|
+
}
|
|
110
|
+
post(path, body, init) {
|
|
111
|
+
return this.request(path, withJson("POST", body, init));
|
|
112
|
+
}
|
|
113
|
+
put(path, body, init) {
|
|
114
|
+
return this.request(path, withJson("PUT", body, init));
|
|
115
|
+
}
|
|
116
|
+
patch(path, body, init) {
|
|
117
|
+
return this.request(path, withJson("PATCH", body, init));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function withJson(method, body, init = {}) {
|
|
121
|
+
if (body === undefined)
|
|
122
|
+
return { ...init, method };
|
|
123
|
+
return {
|
|
124
|
+
...init,
|
|
125
|
+
method,
|
|
126
|
+
headers: { "content-type": "application/json", ...init.headers },
|
|
127
|
+
body: JSON.stringify(body),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build a `TestClient` from an `Application`, an `HttpKernel`, or anything with a
|
|
132
|
+
* `request()` (a built Hono instance). An `Application` is built through a fresh
|
|
133
|
+
* `HttpKernel`; pass a kernel yourself if you need global middleware registered.
|
|
134
|
+
*/
|
|
135
|
+
export function testClient(target) {
|
|
136
|
+
if (target instanceof Application)
|
|
137
|
+
return new TestClient(new HttpKernel(target).build());
|
|
138
|
+
if (target instanceof HttpKernel)
|
|
139
|
+
return new TestClient(target.build());
|
|
140
|
+
return new TestClient(target);
|
|
141
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transformers — a presentation layer between your models and your JSON. A model
|
|
3
|
+
* knows the database; a transformer knows the API. Subclass `Transformer`, define
|
|
4
|
+
* one `transform()` that maps a value to the exact shape you want to expose, and
|
|
5
|
+
* get `item` / `collection` / `document` for free.
|
|
6
|
+
*
|
|
7
|
+
* class UserTransformer extends Transformer<User> {
|
|
8
|
+
* transform(user: User) {
|
|
9
|
+
* return {
|
|
10
|
+
* id: user.id,
|
|
11
|
+
* name: user.name,
|
|
12
|
+
* email: this.when(user.id === viewerId, user.email), // omit for others
|
|
13
|
+
* posts: this.whenLoaded(user, "posts", new PostTransformer()),
|
|
14
|
+
* };
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* json(new UserTransformer().item(user)); // one → {…}
|
|
19
|
+
* json(new UserTransformer().collection(users)); // many → [{…}]
|
|
20
|
+
* json(new UserTransformer().document(users, { // wrapped + meta
|
|
21
|
+
* meta: { total: users.length },
|
|
22
|
+
* })); // → { data: [{…}], total }
|
|
23
|
+
*
|
|
24
|
+
* The seam is deliberately thin: a transformer is just a function with helpers.
|
|
25
|
+
* `when` drops a key entirely when a condition is false (no `null` leaking into
|
|
26
|
+
* the payload), and `whenLoaded` includes a relation only if it was eager-loaded
|
|
27
|
+
* — so a transformer never triggers a surprise query. It leans on nothing but the
|
|
28
|
+
* value you hand it, so it runs on Node and the edge alike.
|
|
29
|
+
*/
|
|
30
|
+
/** The shape a transformer produces — a plain, JSON-ready object. */
|
|
31
|
+
export type Attributes = Record<string, unknown>;
|
|
32
|
+
/** Options for `document()` — how to wrap the payload and what meta to attach. */
|
|
33
|
+
export interface DocumentOptions {
|
|
34
|
+
/** Wrap the payload under this key. `null` disables wrapping. Defaults to `wrapKey`. */
|
|
35
|
+
key?: string | null;
|
|
36
|
+
/** Top-level fields merged alongside the wrapper — pagination, counts, links. */
|
|
37
|
+
meta?: Attributes;
|
|
38
|
+
}
|
|
39
|
+
/** A transformer for a related value: another transformer, or a plain mapping fn. */
|
|
40
|
+
type Related = Transformer<never> | ((value: never) => unknown);
|
|
41
|
+
export declare abstract class Transformer<T = unknown> {
|
|
42
|
+
/** The key `document()` wraps under by default. Set to `null` to wrap nothing. */
|
|
43
|
+
wrapKey: string | null;
|
|
44
|
+
/** Map one value to its API shape. The only method a subclass must implement. */
|
|
45
|
+
abstract transform(item: T): Attributes;
|
|
46
|
+
/** Transform a single value (a nullish value passes straight through as `null`). */
|
|
47
|
+
item(value: T | null | undefined): Attributes | null;
|
|
48
|
+
/** Transform an array of values, each through `transform`. */
|
|
49
|
+
collection(values: T[]): Attributes[];
|
|
50
|
+
/**
|
|
51
|
+
* Build a response document: the transformed payload wrapped under a key, with
|
|
52
|
+
* optional top-level `meta`. Pass a single value or an array — arrays become a
|
|
53
|
+
* list under the key, everything else a single object.
|
|
54
|
+
*
|
|
55
|
+
* new UserTransformer().document(users, { meta: { total: 3 } });
|
|
56
|
+
* // → { data: [{…}, {…}, {…}], total: 3 }
|
|
57
|
+
*/
|
|
58
|
+
document(value: T | T[] | null | undefined, options?: DocumentOptions): Attributes;
|
|
59
|
+
/**
|
|
60
|
+
* Include `value` only when `condition` is truthy; otherwise omit the key (or
|
|
61
|
+
* use `fallback` if you pass one). `value` may be a thunk, deferred until the
|
|
62
|
+
* condition holds — handy when computing it is expensive.
|
|
63
|
+
*
|
|
64
|
+
* { email: this.when(isSelf, user.email) } // key vanishes for others
|
|
65
|
+
* { token: this.when(fresh, () => mint(), null) } // explicit fallback
|
|
66
|
+
*/
|
|
67
|
+
protected when<V>(condition: unknown, value: V | (() => V), fallback?: V): V;
|
|
68
|
+
/**
|
|
69
|
+
* Spread several keys in at once, but only when `condition` holds — the merge
|
|
70
|
+
* counterpart to `when`. Returns `{}` when false, so `...mergeWhen(…)` adds
|
|
71
|
+
* nothing.
|
|
72
|
+
*
|
|
73
|
+
* return { id: u.id, ...this.mergeWhen(isAdmin, { role: u.role, flags: u.flags }) };
|
|
74
|
+
*/
|
|
75
|
+
protected mergeWhen(condition: unknown, values: Attributes | (() => Attributes)): Attributes;
|
|
76
|
+
/**
|
|
77
|
+
* Include a relation only if it was already loaded — never fires a query. Reads
|
|
78
|
+
* an eager-loaded relation off a Keel model (via `getRelation`) or a plain
|
|
79
|
+
* property, and, if present, runs it through `map` (a transformer or a function).
|
|
80
|
+
* Omits the key when the relation isn't loaded.
|
|
81
|
+
*
|
|
82
|
+
* posts: this.whenLoaded(user, "posts", new PostTransformer()),
|
|
83
|
+
* roles: this.whenLoaded(user, "roles", (rs) => rs.map((r) => r.name)),
|
|
84
|
+
*/
|
|
85
|
+
protected whenLoaded<V>(model: unknown, name: string, map?: Related): V;
|
|
86
|
+
/** Recursively drop `OMIT` keys/elements so the payload is clean, plain JSON. */
|
|
87
|
+
private prune;
|
|
88
|
+
}
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transformers — a presentation layer between your models and your JSON. A model
|
|
3
|
+
* knows the database; a transformer knows the API. Subclass `Transformer`, define
|
|
4
|
+
* one `transform()` that maps a value to the exact shape you want to expose, and
|
|
5
|
+
* get `item` / `collection` / `document` for free.
|
|
6
|
+
*
|
|
7
|
+
* class UserTransformer extends Transformer<User> {
|
|
8
|
+
* transform(user: User) {
|
|
9
|
+
* return {
|
|
10
|
+
* id: user.id,
|
|
11
|
+
* name: user.name,
|
|
12
|
+
* email: this.when(user.id === viewerId, user.email), // omit for others
|
|
13
|
+
* posts: this.whenLoaded(user, "posts", new PostTransformer()),
|
|
14
|
+
* };
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* json(new UserTransformer().item(user)); // one → {…}
|
|
19
|
+
* json(new UserTransformer().collection(users)); // many → [{…}]
|
|
20
|
+
* json(new UserTransformer().document(users, { // wrapped + meta
|
|
21
|
+
* meta: { total: users.length },
|
|
22
|
+
* })); // → { data: [{…}], total }
|
|
23
|
+
*
|
|
24
|
+
* The seam is deliberately thin: a transformer is just a function with helpers.
|
|
25
|
+
* `when` drops a key entirely when a condition is false (no `null` leaking into
|
|
26
|
+
* the payload), and `whenLoaded` includes a relation only if it was eager-loaded
|
|
27
|
+
* — so a transformer never triggers a surprise query. It leans on nothing but the
|
|
28
|
+
* value you hand it, so it runs on Node and the edge alike.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The sentinel a helper returns to mean "leave this key out". `item`/`collection`
|
|
32
|
+
* prune it before the payload is ever seen, so `{ email: this.when(false, …) }`
|
|
33
|
+
* yields `{}` rather than `{ email: undefined }`.
|
|
34
|
+
*/
|
|
35
|
+
const OMIT = Symbol("keel.transformer.omit");
|
|
36
|
+
export class Transformer {
|
|
37
|
+
/** The key `document()` wraps under by default. Set to `null` to wrap nothing. */
|
|
38
|
+
wrapKey = "data";
|
|
39
|
+
/** Transform a single value (a nullish value passes straight through as `null`). */
|
|
40
|
+
item(value) {
|
|
41
|
+
return value == null ? null : this.prune(this.transform(value));
|
|
42
|
+
}
|
|
43
|
+
/** Transform an array of values, each through `transform`. */
|
|
44
|
+
collection(values) {
|
|
45
|
+
return values.map((value) => this.prune(this.transform(value)));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build a response document: the transformed payload wrapped under a key, with
|
|
49
|
+
* optional top-level `meta`. Pass a single value or an array — arrays become a
|
|
50
|
+
* list under the key, everything else a single object.
|
|
51
|
+
*
|
|
52
|
+
* new UserTransformer().document(users, { meta: { total: 3 } });
|
|
53
|
+
* // → { data: [{…}, {…}, {…}], total: 3 }
|
|
54
|
+
*/
|
|
55
|
+
document(value, options = {}) {
|
|
56
|
+
const key = options.key !== undefined ? options.key : this.wrapKey;
|
|
57
|
+
const meta = options.meta ?? {};
|
|
58
|
+
const payload = Array.isArray(value) ? this.collection(value) : this.item(value);
|
|
59
|
+
if (key)
|
|
60
|
+
return { [key]: payload, ...meta };
|
|
61
|
+
// No wrapper: merge a single object's fields to the top level. An array can't
|
|
62
|
+
// share the top level with meta, so it still gets a `data` home.
|
|
63
|
+
if (payload && !Array.isArray(payload))
|
|
64
|
+
return { ...payload, ...meta };
|
|
65
|
+
return { data: payload, ...meta };
|
|
66
|
+
}
|
|
67
|
+
/* ------------------------------- helpers -------------------------------- */
|
|
68
|
+
/**
|
|
69
|
+
* Include `value` only when `condition` is truthy; otherwise omit the key (or
|
|
70
|
+
* use `fallback` if you pass one). `value` may be a thunk, deferred until the
|
|
71
|
+
* condition holds — handy when computing it is expensive.
|
|
72
|
+
*
|
|
73
|
+
* { email: this.when(isSelf, user.email) } // key vanishes for others
|
|
74
|
+
* { token: this.when(fresh, () => mint(), null) } // explicit fallback
|
|
75
|
+
*/
|
|
76
|
+
when(condition, value, fallback) {
|
|
77
|
+
if (condition)
|
|
78
|
+
return typeof value === "function" ? value() : value;
|
|
79
|
+
return (arguments.length >= 3 ? fallback : OMIT);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Spread several keys in at once, but only when `condition` holds — the merge
|
|
83
|
+
* counterpart to `when`. Returns `{}` when false, so `...mergeWhen(…)` adds
|
|
84
|
+
* nothing.
|
|
85
|
+
*
|
|
86
|
+
* return { id: u.id, ...this.mergeWhen(isAdmin, { role: u.role, flags: u.flags }) };
|
|
87
|
+
*/
|
|
88
|
+
mergeWhen(condition, values) {
|
|
89
|
+
if (!condition)
|
|
90
|
+
return {};
|
|
91
|
+
return typeof values === "function" ? values() : values;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Include a relation only if it was already loaded — never fires a query. Reads
|
|
95
|
+
* an eager-loaded relation off a Keel model (via `getRelation`) or a plain
|
|
96
|
+
* property, and, if present, runs it through `map` (a transformer or a function).
|
|
97
|
+
* Omits the key when the relation isn't loaded.
|
|
98
|
+
*
|
|
99
|
+
* posts: this.whenLoaded(user, "posts", new PostTransformer()),
|
|
100
|
+
* roles: this.whenLoaded(user, "roles", (rs) => rs.map((r) => r.name)),
|
|
101
|
+
*/
|
|
102
|
+
whenLoaded(model, name, map) {
|
|
103
|
+
const related = readRelation(model, name);
|
|
104
|
+
if (related === undefined)
|
|
105
|
+
return OMIT;
|
|
106
|
+
if (map instanceof Transformer) {
|
|
107
|
+
const via = map;
|
|
108
|
+
return (Array.isArray(related) ? via.collection(related) : via.item(related));
|
|
109
|
+
}
|
|
110
|
+
if (typeof map === "function")
|
|
111
|
+
return map(related);
|
|
112
|
+
return related;
|
|
113
|
+
}
|
|
114
|
+
/* ------------------------------ internals ------------------------------- */
|
|
115
|
+
/** Recursively drop `OMIT` keys/elements so the payload is clean, plain JSON. */
|
|
116
|
+
prune(value) {
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
return value.filter((entry) => entry !== OMIT).map((entry) => this.prune(entry));
|
|
119
|
+
}
|
|
120
|
+
if (isPlainObject(value)) {
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
123
|
+
if (entry === OMIT)
|
|
124
|
+
continue;
|
|
125
|
+
out[key] = this.prune(entry);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Read an eager-loaded relation without ever querying: `getRelation`, then a plain prop. */
|
|
133
|
+
function readRelation(model, name) {
|
|
134
|
+
if (model && typeof model === "object") {
|
|
135
|
+
const record = model;
|
|
136
|
+
if (typeof record.getRelation === "function") {
|
|
137
|
+
return record.getRelation(name);
|
|
138
|
+
}
|
|
139
|
+
const value = record[name];
|
|
140
|
+
// A relation *method* isn't a loaded value — only a stored result counts.
|
|
141
|
+
if (typeof value !== "function")
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
/** True for object literals (and `Object.create(null)`) — not class instances, arrays, or Dates. */
|
|
147
|
+
function isPlainObject(value) {
|
|
148
|
+
if (value === null || typeof value !== "object")
|
|
149
|
+
return false;
|
|
150
|
+
const proto = Object.getPrototypeOf(value);
|
|
151
|
+
return proto === Object.prototype || proto === null;
|
|
152
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* (Zod, and anything that mirrors its shape), so the framework never bundles a
|
|
8
8
|
* validation library — bring your own.
|
|
9
9
|
*/
|
|
10
|
+
import type { MiddlewareHandler } from "hono";
|
|
10
11
|
/** The minimal shape Keel needs from a schema — Zod satisfies this. */
|
|
11
12
|
export interface Schema<T> {
|
|
12
13
|
safeParse(data: unknown): {
|
|
@@ -30,3 +31,22 @@ export interface Schema<T> {
|
|
|
30
31
|
* const q = validate(SearchQuery, request.query()); // validates given data
|
|
31
32
|
*/
|
|
32
33
|
export declare function validate<T>(schema: Schema<T>, data?: unknown): Promise<T>;
|
|
34
|
+
/** Schemas to validate parts of the request against, before the handler runs. */
|
|
35
|
+
export interface RequestSchemas {
|
|
36
|
+
body?: Schema<unknown>;
|
|
37
|
+
query?: Schema<unknown>;
|
|
38
|
+
params?: Schema<unknown>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Middleware that validates the request against `schemas` *before* the handler,
|
|
42
|
+
* rejecting with a 422 `ValidationException` if any part fails (errors from all
|
|
43
|
+
* parts are aggregated, keyed `body.field` / `query.field` / `params.field`).
|
|
44
|
+
* On success the parsed, typed values are stashed for `validated()`.
|
|
45
|
+
*
|
|
46
|
+
* router.post("/users", [Users, "store"]).middleware([validateRequest({ body: NewUser })]);
|
|
47
|
+
* // in the handler:
|
|
48
|
+
* const user = validated<NewUser>("body");
|
|
49
|
+
*/
|
|
50
|
+
export declare function validateRequest(schemas: RequestSchemas): MiddlewareHandler;
|
|
51
|
+
/** The validated, typed value for a request part (set by `validateRequest`). */
|
|
52
|
+
export declare function validated<T = unknown>(part?: keyof RequestSchemas): T;
|
package/dist/core/validation.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* validation library — bring your own.
|
|
9
9
|
*/
|
|
10
10
|
import { ValidationException } from "./exceptions.js";
|
|
11
|
-
import { body } from "./request.js";
|
|
11
|
+
import { body, ctx } from "./request.js";
|
|
12
12
|
/**
|
|
13
13
|
* Validate `data` (or the request JSON body, if omitted) against a schema.
|
|
14
14
|
* Returns the parsed, typed value; throws `ValidationException` on failure.
|
|
@@ -31,3 +31,54 @@ export async function validate(schema, data) {
|
|
|
31
31
|
}
|
|
32
32
|
throw new ValidationException(errors);
|
|
33
33
|
}
|
|
34
|
+
const validatedStore = new WeakMap();
|
|
35
|
+
function fieldKey(path) {
|
|
36
|
+
return (path.map((p) => (typeof p === "symbol" ? (p.description ?? "") : String(p))).join(".") || "_");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Middleware that validates the request against `schemas` *before* the handler,
|
|
40
|
+
* rejecting with a 422 `ValidationException` if any part fails (errors from all
|
|
41
|
+
* parts are aggregated, keyed `body.field` / `query.field` / `params.field`).
|
|
42
|
+
* On success the parsed, typed values are stashed for `validated()`.
|
|
43
|
+
*
|
|
44
|
+
* router.post("/users", [Users, "store"]).middleware([validateRequest({ body: NewUser })]);
|
|
45
|
+
* // in the handler:
|
|
46
|
+
* const user = validated<NewUser>("body");
|
|
47
|
+
*/
|
|
48
|
+
export function validateRequest(schemas) {
|
|
49
|
+
return async (c, next) => {
|
|
50
|
+
const parsed = {};
|
|
51
|
+
const errors = {};
|
|
52
|
+
const parts = [
|
|
53
|
+
["body", () => body()],
|
|
54
|
+
["query", () => c.req.query()],
|
|
55
|
+
["params", () => c.req.param()],
|
|
56
|
+
];
|
|
57
|
+
for (const [name, read] of parts) {
|
|
58
|
+
const schema = schemas[name];
|
|
59
|
+
if (!schema)
|
|
60
|
+
continue;
|
|
61
|
+
const result = schema.safeParse(await read());
|
|
62
|
+
if (result.success) {
|
|
63
|
+
parsed[name] = result.data;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
for (const issue of result.error.issues) {
|
|
67
|
+
(errors[`${name}.${fieldKey(issue.path)}`] ??= []).push(issue.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (Object.keys(errors).length)
|
|
72
|
+
throw new ValidationException(errors);
|
|
73
|
+
validatedStore.set(c, parsed);
|
|
74
|
+
await next();
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** The validated, typed value for a request part (set by `validateRequest`). */
|
|
78
|
+
export function validated(part = "body") {
|
|
79
|
+
const store = validatedStore.get(ctx());
|
|
80
|
+
if (!store || !(part in store)) {
|
|
81
|
+
throw new Error(`No validated "${part}". Add validateRequest({ ${part}: schema }) to the route.`);
|
|
82
|
+
}
|
|
83
|
+
return store[part];
|
|
84
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite integration. Wire a modern frontend build — bundling, hashed filenames,
|
|
3
|
+
* hot module reload — to Keel's server-rendered HTML, the way modern full-stack
|
|
4
|
+
* frameworks do. `Vite` is the *server* half: it emits the `<script>`/`<link>`
|
|
5
|
+
* tags for your entrypoints and resolves individual asset URLs, switching
|
|
6
|
+
* automatically between two modes:
|
|
7
|
+
*
|
|
8
|
+
* • Dev — a `public/hot` file (written by the `keelVite()` plugin when the
|
|
9
|
+
* Vite dev server starts) points at `http://localhost:5173`. Tags load
|
|
10
|
+
* straight from that server, with HMR.
|
|
11
|
+
* • Prod — no hot file, so it reads `public/assets/.vite/manifest.json` and
|
|
12
|
+
* emits the hashed, split, preloaded production tags.
|
|
13
|
+
*
|
|
14
|
+
* // in a service provider
|
|
15
|
+
* const vite = await new Vite({ entrypoints: ["resources/js/app.ts"] }).loadFromDisk();
|
|
16
|
+
* singleton(Vite, () => vite);
|
|
17
|
+
*
|
|
18
|
+
* // in a JSX layout <head>
|
|
19
|
+
* {viteReactRefresh()}
|
|
20
|
+
* {viteTags("resources/js/app.ts")}
|
|
21
|
+
*
|
|
22
|
+
* Tag generation is pure string work over an in-memory manifest, so it's
|
|
23
|
+
* edge-safe: on Workers, skip `loadFromDisk` and hand the bundled manifest in
|
|
24
|
+
* with `useManifest(...)`. Only reading from disk touches `node:fs`, and that's
|
|
25
|
+
* imported dynamically (like the static server), so the core still loads on the
|
|
26
|
+
* edge.
|
|
27
|
+
*/
|
|
28
|
+
import type { HtmlEscapedString } from "hono/utils/html";
|
|
29
|
+
/** One chunk in Vite's `manifest.json`. */
|
|
30
|
+
export interface ManifestChunk {
|
|
31
|
+
file: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
src?: string;
|
|
34
|
+
isEntry?: boolean;
|
|
35
|
+
isDynamicEntry?: boolean;
|
|
36
|
+
imports?: string[];
|
|
37
|
+
dynamicImports?: string[];
|
|
38
|
+
css?: string[];
|
|
39
|
+
assets?: string[];
|
|
40
|
+
}
|
|
41
|
+
/** A parsed Vite build manifest: source path → chunk. */
|
|
42
|
+
export type Manifest = Record<string, ManifestChunk>;
|
|
43
|
+
/** An HTML attribute value: a string, `true` (bare attribute), or absent. */
|
|
44
|
+
export type AttrValue = string | boolean | null | undefined;
|
|
45
|
+
/** A bag of HTML attributes, or a function that computes them per asset. */
|
|
46
|
+
export type Attributes = Record<string, AttrValue> | ((asset: {
|
|
47
|
+
src: string;
|
|
48
|
+
url: string;
|
|
49
|
+
}) => Record<string, AttrValue> | undefined);
|
|
50
|
+
export interface ViteOptions {
|
|
51
|
+
/** Entrypoints to tag when `generateEntryPointsTags()` is called with none. */
|
|
52
|
+
entrypoints?: string | string[];
|
|
53
|
+
/** The dev-server marker file. Default: `public/hot`. */
|
|
54
|
+
hotFile?: string;
|
|
55
|
+
/** Where the build lands (holds `.vite/manifest.json`). Default: `public/assets`. */
|
|
56
|
+
buildDirectory?: string;
|
|
57
|
+
/** Override the manifest path. Default: `<buildDirectory>/.vite/manifest.json`. */
|
|
58
|
+
manifestFile?: string;
|
|
59
|
+
/** Public URL prefix for built assets (a CDN base works too). Default: `/assets`. */
|
|
60
|
+
assetsUrl?: string;
|
|
61
|
+
/** Extra attributes for generated `<script>` tags. */
|
|
62
|
+
scriptAttributes?: Attributes;
|
|
63
|
+
/** Extra attributes for generated `<link rel="stylesheet">` tags. */
|
|
64
|
+
styleAttributes?: Attributes;
|
|
65
|
+
}
|
|
66
|
+
export declare class Vite {
|
|
67
|
+
private entrypoints;
|
|
68
|
+
private hotFile;
|
|
69
|
+
private manifestFile;
|
|
70
|
+
private assetsUrl;
|
|
71
|
+
private scriptAttributes;
|
|
72
|
+
private styleAttributes;
|
|
73
|
+
private hotUrl;
|
|
74
|
+
private parsed;
|
|
75
|
+
private fs;
|
|
76
|
+
constructor(options?: ViteOptions);
|
|
77
|
+
/**
|
|
78
|
+
* Read the hot file (dev) or the build manifest (prod) from disk. Call once at
|
|
79
|
+
* boot from a service provider. Node only — uses `node:fs`.
|
|
80
|
+
*/
|
|
81
|
+
loadFromDisk(): Promise<this>;
|
|
82
|
+
/** Inject a manifest directly — for the edge, where there's no filesystem. */
|
|
83
|
+
useManifest(manifest: Manifest): this;
|
|
84
|
+
/** Force the dev-server URL (or `null` for prod). Bypasses the hot file. */
|
|
85
|
+
useHotUrl(url: string | null): this;
|
|
86
|
+
/** Re-read the hot file if we're on Node and no manifest has claimed prod mode. */
|
|
87
|
+
private refreshHot;
|
|
88
|
+
/** The dev-server URL when running Vite, else `null` (production). */
|
|
89
|
+
hot(): string | null;
|
|
90
|
+
/** The parsed production manifest. Throws if it hasn't been loaded. */
|
|
91
|
+
manifest(): Manifest;
|
|
92
|
+
/**
|
|
93
|
+
* The `<script>`/`<link>` tags for one or more entrypoints — dev-server URLs
|
|
94
|
+
* with HMR while developing, hashed + preloaded tags in production. Falls back
|
|
95
|
+
* to the entrypoints passed to the constructor.
|
|
96
|
+
*/
|
|
97
|
+
generateEntryPointsTags(entrypoints?: string | string[]): HtmlEscapedString;
|
|
98
|
+
/** The public URL for a single asset (an image, a font) — dev or hashed prod. */
|
|
99
|
+
assetPath(asset: string): string;
|
|
100
|
+
/**
|
|
101
|
+
* The React Fast Refresh preamble — required before your entry script when
|
|
102
|
+
* using `@vitejs/plugin-react`. Empty in production.
|
|
103
|
+
*/
|
|
104
|
+
reactHMR(): HtmlEscapedString;
|
|
105
|
+
private devTags;
|
|
106
|
+
private productionTags;
|
|
107
|
+
/** In dev, a style entrypoint is a `<link>`; everything else a module script. */
|
|
108
|
+
private tagFor;
|
|
109
|
+
private scriptTag;
|
|
110
|
+
private styleTag;
|
|
111
|
+
}
|
|
112
|
+
/** The entrypoint tags for the current app's Vite instance. Use in a JSX `<head>`. */
|
|
113
|
+
export declare function viteTags(entrypoints?: string | string[]): HtmlEscapedString;
|
|
114
|
+
/** The URL for a single asset through the current app's Vite instance. */
|
|
115
|
+
export declare function viteAsset(asset: string): string;
|
|
116
|
+
/** The React Fast Refresh preamble (dev only) for the current app's Vite instance. */
|
|
117
|
+
export declare function viteReactRefresh(): HtmlEscapedString;
|