ebely 0.0.2 → 0.0.4
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 +34 -0
- package/bin/ebely.mjs +97 -2
- package/clone/tests/ebely/ebely.ts +21 -0
- package/clone/tests/ebely/generate-client.ts +3 -0
- package/clone/tests/ebely/generated.ts +552 -0
- package/clone/tests/ebely/handlers.ts +19 -0
- package/clone/tests/ebely/hooks.ts +28 -0
- package/clone/tests/ebely/userStore.ts +56 -0
- package/clone/tests/ebely/worldStore.ts +26 -0
- package/clone/tests/package.json +19 -0
- package/clone/tests/swagger.json +426 -0
- package/clone/tests/tests/test1.test.ts +62 -0
- package/clone/tests/tsconfig.json +4 -0
- package/package.json +5 -2
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// AUTO-GENERATED by ebely (generateClient) — НЕ редактировать вручную.
|
|
2
|
+
// Источник: Test Backend (oRPC) v1.0.0
|
|
3
|
+
// Режим клиента: test
|
|
4
|
+
// Перегенерация: pnpm run client:generate
|
|
5
|
+
|
|
6
|
+
import { BaseStore, ApiResponse, HookRegistry } from "ebely"
|
|
7
|
+
import type { BeforeHook, AfterHook } from "ebely"
|
|
8
|
+
import { ebely } from "./ebely"
|
|
9
|
+
|
|
10
|
+
type RequestInput = {
|
|
11
|
+
path?: Record<string, string>
|
|
12
|
+
query?: Record<string, string | number | boolean | undefined>
|
|
13
|
+
body?: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type RequestFn = (req: {
|
|
17
|
+
method: string
|
|
18
|
+
path: string
|
|
19
|
+
opKey: string
|
|
20
|
+
input?: RequestInput
|
|
21
|
+
}) => Promise<{ status: number; body: unknown }>
|
|
22
|
+
|
|
23
|
+
export type CreateUserArgs = {
|
|
24
|
+
/** Заголовки, которые будут добавляться ко всем запросам этого пользователя. */
|
|
25
|
+
headers?: Record<string, string>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type WorldApi = {
|
|
29
|
+
"posts": {
|
|
30
|
+
"list": (input?: {}) => Promise<ApiResponse<{ 200: Array<{
|
|
31
|
+
"id": string
|
|
32
|
+
"title": string
|
|
33
|
+
"content": string
|
|
34
|
+
"createdAt": string
|
|
35
|
+
"updatedAt": string
|
|
36
|
+
}> }>>
|
|
37
|
+
"create": (input: { body: {
|
|
38
|
+
"title": string
|
|
39
|
+
"content": string
|
|
40
|
+
} }) => Promise<ApiResponse<{ 200: {
|
|
41
|
+
"id": string
|
|
42
|
+
"title": string
|
|
43
|
+
"content": string
|
|
44
|
+
"createdAt": string
|
|
45
|
+
"updatedAt": string
|
|
46
|
+
} }>>
|
|
47
|
+
"get": (input: { path: { "id": string } }) => Promise<ApiResponse<{ 200: {
|
|
48
|
+
"id": string
|
|
49
|
+
"title": string
|
|
50
|
+
"content": string
|
|
51
|
+
"createdAt": string
|
|
52
|
+
"updatedAt": string
|
|
53
|
+
} }>>
|
|
54
|
+
"update": (input: { path: { "id": string }; body?: {
|
|
55
|
+
"title"?: string
|
|
56
|
+
"content"?: string
|
|
57
|
+
} }) => Promise<ApiResponse<{ 200: {
|
|
58
|
+
"id": string
|
|
59
|
+
"title": string
|
|
60
|
+
"content": string
|
|
61
|
+
"createdAt": string
|
|
62
|
+
"updatedAt": string
|
|
63
|
+
} }>>
|
|
64
|
+
"delete": (input: { path: { "id": string } }) => Promise<ApiResponse<{ 200: {
|
|
65
|
+
"success": boolean
|
|
66
|
+
} }>>
|
|
67
|
+
}
|
|
68
|
+
"auth": {
|
|
69
|
+
"register": (input: { body: {
|
|
70
|
+
"email": string
|
|
71
|
+
"password": string
|
|
72
|
+
} }) => Promise<ApiResponse<{ 200: {
|
|
73
|
+
"email": string
|
|
74
|
+
"password": string
|
|
75
|
+
} }>>
|
|
76
|
+
"confirm": (input: { body: {
|
|
77
|
+
"code": string
|
|
78
|
+
} }) => Promise<ApiResponse<{ 200: {
|
|
79
|
+
"code": string
|
|
80
|
+
} }>>
|
|
81
|
+
}
|
|
82
|
+
"admin": {
|
|
83
|
+
"clearDatabase": (input?: {}) => Promise<ApiResponse<{ 200: {
|
|
84
|
+
"success": boolean
|
|
85
|
+
} }>>
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type EbelyHookTree<Store extends BaseStore> = {
|
|
90
|
+
"posts": {
|
|
91
|
+
"list": {
|
|
92
|
+
before(fn: BeforeHook<Store, undefined>): void
|
|
93
|
+
after(fn: AfterHook<Store, undefined, Array<{
|
|
94
|
+
"id": string
|
|
95
|
+
"title": string
|
|
96
|
+
"content": string
|
|
97
|
+
"createdAt": string
|
|
98
|
+
"updatedAt": string
|
|
99
|
+
}>>): void
|
|
100
|
+
}
|
|
101
|
+
"create": {
|
|
102
|
+
before(fn: BeforeHook<Store, {
|
|
103
|
+
"title": string
|
|
104
|
+
"content": string
|
|
105
|
+
}>): void
|
|
106
|
+
after(fn: AfterHook<Store, {
|
|
107
|
+
"title": string
|
|
108
|
+
"content": string
|
|
109
|
+
}, {
|
|
110
|
+
"id": string
|
|
111
|
+
"title": string
|
|
112
|
+
"content": string
|
|
113
|
+
"createdAt": string
|
|
114
|
+
"updatedAt": string
|
|
115
|
+
}>): void
|
|
116
|
+
}
|
|
117
|
+
"get": {
|
|
118
|
+
before(fn: BeforeHook<Store, undefined>): void
|
|
119
|
+
after(fn: AfterHook<Store, undefined, {
|
|
120
|
+
"id": string
|
|
121
|
+
"title": string
|
|
122
|
+
"content": string
|
|
123
|
+
"createdAt": string
|
|
124
|
+
"updatedAt": string
|
|
125
|
+
}>): void
|
|
126
|
+
}
|
|
127
|
+
"update": {
|
|
128
|
+
before(fn: BeforeHook<Store, {
|
|
129
|
+
"title"?: string
|
|
130
|
+
"content"?: string
|
|
131
|
+
}>): void
|
|
132
|
+
after(fn: AfterHook<Store, {
|
|
133
|
+
"title"?: string
|
|
134
|
+
"content"?: string
|
|
135
|
+
}, {
|
|
136
|
+
"id": string
|
|
137
|
+
"title": string
|
|
138
|
+
"content": string
|
|
139
|
+
"createdAt": string
|
|
140
|
+
"updatedAt": string
|
|
141
|
+
}>): void
|
|
142
|
+
}
|
|
143
|
+
"delete": {
|
|
144
|
+
before(fn: BeforeHook<Store, undefined>): void
|
|
145
|
+
after(fn: AfterHook<Store, undefined, {
|
|
146
|
+
"success": boolean
|
|
147
|
+
}>): void
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
"auth": {
|
|
151
|
+
"register": {
|
|
152
|
+
before(fn: BeforeHook<Store, {
|
|
153
|
+
"email": string
|
|
154
|
+
"password": string
|
|
155
|
+
}>): void
|
|
156
|
+
after(fn: AfterHook<Store, {
|
|
157
|
+
"email": string
|
|
158
|
+
"password": string
|
|
159
|
+
}, {
|
|
160
|
+
"email": string
|
|
161
|
+
"password": string
|
|
162
|
+
}>): void
|
|
163
|
+
}
|
|
164
|
+
"confirm": {
|
|
165
|
+
before(fn: BeforeHook<Store, {
|
|
166
|
+
"code": string
|
|
167
|
+
}>): void
|
|
168
|
+
after(fn: AfterHook<Store, {
|
|
169
|
+
"code": string
|
|
170
|
+
}, {
|
|
171
|
+
"code": string
|
|
172
|
+
}>): void
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
"admin": {
|
|
176
|
+
"clearDatabase": {
|
|
177
|
+
before(fn: BeforeHook<Store, undefined>): void
|
|
178
|
+
after(fn: AfterHook<Store, undefined, {
|
|
179
|
+
"success": boolean
|
|
180
|
+
}>): void
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Тип регистратора хуков для ЭТОГО бэкенда. Объявите хуки в отдельном
|
|
187
|
+
* файле и положите одной переменной в конфиг (`EbelyConfig.hooks`).
|
|
188
|
+
* Передайте СВОЙ класс store параметром, чтобы `ctx` был типизирован:
|
|
189
|
+
*
|
|
190
|
+
* import type { UserStore } from './userStore'
|
|
191
|
+
* export const hooks: Hooks<UserStore> = (h) => {
|
|
192
|
+
* h.posts.create.after(({ response, ctx }) => { … })
|
|
193
|
+
* }
|
|
194
|
+
*
|
|
195
|
+
* (Параметр НЕ выводится из `ebely.userStore` намеренно: это создало
|
|
196
|
+
* бы цикл типов `ebely` ⇄ `Hooks`, т.к. `hooks` лежит внутри `ebely`.)
|
|
197
|
+
*/
|
|
198
|
+
export type Hooks<Store extends BaseStore = BaseStore> = (
|
|
199
|
+
h: EbelyHookTree<Store>,
|
|
200
|
+
) => void
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* База `World` — это сконфигурированный `ebely.worldStore` (или пустой
|
|
204
|
+
* `BaseStore`, если не задан). Поэтому `world.<сценарий>()` и
|
|
205
|
+
* `world.get/set` доступны и типизированы ровно как у пользователя,
|
|
206
|
+
* только область — весь мир. Тип берётся из `ebely` тем же приёмом, что
|
|
207
|
+
* и `Store` (никаких рантайм-условий — см. ARCHITECTURE.md §8).
|
|
208
|
+
*/
|
|
209
|
+
type ConfiguredWorldStore =
|
|
210
|
+
typeof ebely extends { worldStore: new () => infer I extends BaseStore }
|
|
211
|
+
? I
|
|
212
|
+
: BaseStore
|
|
213
|
+
const WorldStoreBase = ((ebely as { worldStore?: new () => BaseStore })
|
|
214
|
+
.worldStore ?? BaseStore) as new () => ConfiguredWorldStore
|
|
215
|
+
|
|
216
|
+
export class World<
|
|
217
|
+
Store extends BaseStore = InstanceType<typeof ebely.userStore>,
|
|
218
|
+
> extends WorldStoreBase {
|
|
219
|
+
/**
|
|
220
|
+
* Общий реестр хуков. Регистрация — статическая (один раз из конфига),
|
|
221
|
+
* но `ctx` подставляется в момент запроса = store конкретного юзера.
|
|
222
|
+
*/
|
|
223
|
+
private hookRegistry = new HookRegistry()
|
|
224
|
+
|
|
225
|
+
constructor(
|
|
226
|
+
public args: {
|
|
227
|
+
/** URL бэкенда. Если не задан — берётся ebely.url из конфига. */
|
|
228
|
+
url?: string
|
|
229
|
+
/** Класс-хранилище. Если не задан — берётся ebely.userStore. */
|
|
230
|
+
store?: new () => Store
|
|
231
|
+
} = {},
|
|
232
|
+
) {
|
|
233
|
+
super()
|
|
234
|
+
const registrar = (ebely as { hooks?: (h: unknown) => void }).hooks
|
|
235
|
+
if (registrar) registrar(this.buildHookTree())
|
|
236
|
+
// world-store ходит АНОНИМНЫМ клиентом (без per-user заголовков);
|
|
237
|
+
// ctx хуков для таких вызовов = сам world.
|
|
238
|
+
;(this as unknown as { api: unknown }).api = this.buildApiTree(
|
|
239
|
+
this.makeRequest({ headers: {}, store: this }),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Базовый URL с учётом server.url из схемы. */
|
|
244
|
+
private baseUrl(): string {
|
|
245
|
+
return (this.args.url ?? ebely.url).replace(/\/$/, '') + "/api"
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private buildHookTree(): EbelyHookTree<Store> {
|
|
249
|
+
const r = this.hookRegistry
|
|
250
|
+
return {
|
|
251
|
+
"posts": {
|
|
252
|
+
"list": {
|
|
253
|
+
before: (fn: BeforeHook<Store>) => r.before({ key: "posts.list", fn }),
|
|
254
|
+
after: (fn: AfterHook<Store>) => r.after({ key: "posts.list", fn }),
|
|
255
|
+
},
|
|
256
|
+
"create": {
|
|
257
|
+
before: (fn: BeforeHook<Store>) => r.before({ key: "posts.create", fn }),
|
|
258
|
+
after: (fn: AfterHook<Store>) => r.after({ key: "posts.create", fn }),
|
|
259
|
+
},
|
|
260
|
+
"get": {
|
|
261
|
+
before: (fn: BeforeHook<Store>) => r.before({ key: "posts.get", fn }),
|
|
262
|
+
after: (fn: AfterHook<Store>) => r.after({ key: "posts.get", fn }),
|
|
263
|
+
},
|
|
264
|
+
"update": {
|
|
265
|
+
before: (fn: BeforeHook<Store>) => r.before({ key: "posts.update", fn }),
|
|
266
|
+
after: (fn: AfterHook<Store>) => r.after({ key: "posts.update", fn }),
|
|
267
|
+
},
|
|
268
|
+
"delete": {
|
|
269
|
+
before: (fn: BeforeHook<Store>) => r.before({ key: "posts.delete", fn }),
|
|
270
|
+
after: (fn: AfterHook<Store>) => r.after({ key: "posts.delete", fn }),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
"auth": {
|
|
274
|
+
"register": {
|
|
275
|
+
before: (fn: BeforeHook<Store>) => r.before({ key: "auth.register", fn }),
|
|
276
|
+
after: (fn: AfterHook<Store>) => r.after({ key: "auth.register", fn }),
|
|
277
|
+
},
|
|
278
|
+
"confirm": {
|
|
279
|
+
before: (fn: BeforeHook<Store>) => r.before({ key: "auth.confirm", fn }),
|
|
280
|
+
after: (fn: AfterHook<Store>) => r.after({ key: "auth.confirm", fn }),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
"admin": {
|
|
284
|
+
"clearDatabase": {
|
|
285
|
+
before: (fn: BeforeHook<Store>) => r.before({ key: "admin.clearDatabase", fn }),
|
|
286
|
+
after: (fn: AfterHook<Store>) => r.after({ key: "admin.clearDatabase", fn }),
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
} as unknown as EbelyHookTree<Store>
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Создаёт `request`, замкнутый на заголовки и `store` (он же `ctx`
|
|
294
|
+
* хуков). Один и тот же движок и для пользователя, и для world.
|
|
295
|
+
*/
|
|
296
|
+
private makeRequest(cfg: {
|
|
297
|
+
headers: Record<string, string>
|
|
298
|
+
store: BaseStore
|
|
299
|
+
}): RequestFn {
|
|
300
|
+
const baseUrl = this.baseUrl()
|
|
301
|
+
const registry = this.hookRegistry
|
|
302
|
+
const { headers: baseHeaders, store } = cfg
|
|
303
|
+
|
|
304
|
+
return async (req): Promise<{ status: number; body: unknown }> => {
|
|
305
|
+
const { method, path, opKey, input } = req
|
|
306
|
+
|
|
307
|
+
const hookReq = {
|
|
308
|
+
method,
|
|
309
|
+
path,
|
|
310
|
+
pathParams: { ...(input?.path ?? {}) },
|
|
311
|
+
query: { ...(input?.query ?? {}) },
|
|
312
|
+
body: input?.body,
|
|
313
|
+
headers: { ...baseHeaders },
|
|
314
|
+
}
|
|
315
|
+
await registry.runBefore({ key: opKey, request: hookReq, ctx: store })
|
|
316
|
+
|
|
317
|
+
let resolvedPath = path
|
|
318
|
+
for (const [k, v] of Object.entries(hookReq.pathParams)) {
|
|
319
|
+
resolvedPath = resolvedPath.replace(
|
|
320
|
+
`{${k}}`,
|
|
321
|
+
encodeURIComponent(String(v)),
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const url = new URL(baseUrl + resolvedPath)
|
|
326
|
+
for (const [k, v] of Object.entries(hookReq.query)) {
|
|
327
|
+
if (v !== undefined) url.searchParams.set(k, String(v))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const hasBody = hookReq.body !== undefined
|
|
331
|
+
const response = await fetch(url, {
|
|
332
|
+
method,
|
|
333
|
+
headers: {
|
|
334
|
+
...(hasBody ? { 'content-type': 'application/json' } : {}),
|
|
335
|
+
...hookReq.headers,
|
|
336
|
+
},
|
|
337
|
+
body: hasBody ? JSON.stringify(hookReq.body) : undefined,
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
const text = await response.text()
|
|
341
|
+
let data: unknown
|
|
342
|
+
try {
|
|
343
|
+
data = text ? JSON.parse(text) : undefined
|
|
344
|
+
} catch {
|
|
345
|
+
data = text
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await registry.runAfter({
|
|
349
|
+
key: opKey,
|
|
350
|
+
request: hookReq,
|
|
351
|
+
response: { status: response.status, body: data },
|
|
352
|
+
ctx: store,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
return { status: response.status, body: data }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Дерево типизированных вызовов эндпоинтов поверх одного `request`. */
|
|
360
|
+
private buildApiTree(request: RequestFn) {
|
|
361
|
+
return {
|
|
362
|
+
"posts": {
|
|
363
|
+
|
|
364
|
+
/** List all posts */
|
|
365
|
+
"list": async (input?: {}): Promise<ApiResponse<{ 200: Array<{
|
|
366
|
+
"id": string
|
|
367
|
+
"title": string
|
|
368
|
+
"content": string
|
|
369
|
+
"createdAt": string
|
|
370
|
+
"updatedAt": string
|
|
371
|
+
}> }>> => {
|
|
372
|
+
const res = await request({
|
|
373
|
+
method: "GET",
|
|
374
|
+
path: "/posts",
|
|
375
|
+
opKey: "posts.list",
|
|
376
|
+
input: input as RequestInput,
|
|
377
|
+
})
|
|
378
|
+
return new ApiResponse(res) as unknown as ApiResponse<{ 200: Array<{
|
|
379
|
+
"id": string
|
|
380
|
+
"title": string
|
|
381
|
+
"content": string
|
|
382
|
+
"createdAt": string
|
|
383
|
+
"updatedAt": string
|
|
384
|
+
}> }>
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
/** Create a post */
|
|
388
|
+
"create": async (input: { body: {
|
|
389
|
+
"title": string
|
|
390
|
+
"content": string
|
|
391
|
+
} }): Promise<ApiResponse<{ 200: {
|
|
392
|
+
"id": string
|
|
393
|
+
"title": string
|
|
394
|
+
"content": string
|
|
395
|
+
"createdAt": string
|
|
396
|
+
"updatedAt": string
|
|
397
|
+
} }>> => {
|
|
398
|
+
const res = await request({
|
|
399
|
+
method: "POST",
|
|
400
|
+
path: "/posts",
|
|
401
|
+
opKey: "posts.create",
|
|
402
|
+
input: input as RequestInput,
|
|
403
|
+
})
|
|
404
|
+
return new ApiResponse(res) as unknown as ApiResponse<{ 200: {
|
|
405
|
+
"id": string
|
|
406
|
+
"title": string
|
|
407
|
+
"content": string
|
|
408
|
+
"createdAt": string
|
|
409
|
+
"updatedAt": string
|
|
410
|
+
} }>
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
/** Get a single post by id */
|
|
414
|
+
"get": async (input: { path: { "id": string } }): Promise<ApiResponse<{ 200: {
|
|
415
|
+
"id": string
|
|
416
|
+
"title": string
|
|
417
|
+
"content": string
|
|
418
|
+
"createdAt": string
|
|
419
|
+
"updatedAt": string
|
|
420
|
+
} }>> => {
|
|
421
|
+
const res = await request({
|
|
422
|
+
method: "GET",
|
|
423
|
+
path: "/posts/{id}",
|
|
424
|
+
opKey: "posts.get",
|
|
425
|
+
input: input as RequestInput,
|
|
426
|
+
})
|
|
427
|
+
return new ApiResponse(res) as unknown as ApiResponse<{ 200: {
|
|
428
|
+
"id": string
|
|
429
|
+
"title": string
|
|
430
|
+
"content": string
|
|
431
|
+
"createdAt": string
|
|
432
|
+
"updatedAt": string
|
|
433
|
+
} }>
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
/** Update a post */
|
|
437
|
+
"update": async (input: { path: { "id": string }; body?: {
|
|
438
|
+
"title"?: string
|
|
439
|
+
"content"?: string
|
|
440
|
+
} }): Promise<ApiResponse<{ 200: {
|
|
441
|
+
"id": string
|
|
442
|
+
"title": string
|
|
443
|
+
"content": string
|
|
444
|
+
"createdAt": string
|
|
445
|
+
"updatedAt": string
|
|
446
|
+
} }>> => {
|
|
447
|
+
const res = await request({
|
|
448
|
+
method: "PATCH",
|
|
449
|
+
path: "/posts/{id}",
|
|
450
|
+
opKey: "posts.update",
|
|
451
|
+
input: input as RequestInput,
|
|
452
|
+
})
|
|
453
|
+
return new ApiResponse(res) as unknown as ApiResponse<{ 200: {
|
|
454
|
+
"id": string
|
|
455
|
+
"title": string
|
|
456
|
+
"content": string
|
|
457
|
+
"createdAt": string
|
|
458
|
+
"updatedAt": string
|
|
459
|
+
} }>
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
/** Delete a post */
|
|
463
|
+
"delete": async (input: { path: { "id": string } }): Promise<ApiResponse<{ 200: {
|
|
464
|
+
"success": boolean
|
|
465
|
+
} }>> => {
|
|
466
|
+
const res = await request({
|
|
467
|
+
method: "DELETE",
|
|
468
|
+
path: "/posts/{id}",
|
|
469
|
+
opKey: "posts.delete",
|
|
470
|
+
input: input as RequestInput,
|
|
471
|
+
})
|
|
472
|
+
return new ApiResponse(res) as unknown as ApiResponse<{ 200: {
|
|
473
|
+
"success": boolean
|
|
474
|
+
} }>
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
"auth": {
|
|
478
|
+
|
|
479
|
+
/** Register (stub: echoes input) */
|
|
480
|
+
"register": async (input: { body: {
|
|
481
|
+
"email": string
|
|
482
|
+
"password": string
|
|
483
|
+
} }): Promise<ApiResponse<{ 200: {
|
|
484
|
+
"email": string
|
|
485
|
+
"password": string
|
|
486
|
+
} }>> => {
|
|
487
|
+
const res = await request({
|
|
488
|
+
method: "POST",
|
|
489
|
+
path: "/auth/register",
|
|
490
|
+
opKey: "auth.register",
|
|
491
|
+
input: input as RequestInput,
|
|
492
|
+
})
|
|
493
|
+
return new ApiResponse(res) as unknown as ApiResponse<{ 200: {
|
|
494
|
+
"email": string
|
|
495
|
+
"password": string
|
|
496
|
+
} }>
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
/** Confirm registration code (stub: echoes input) */
|
|
500
|
+
"confirm": async (input: { body: {
|
|
501
|
+
"code": string
|
|
502
|
+
} }): Promise<ApiResponse<{ 200: {
|
|
503
|
+
"code": string
|
|
504
|
+
} }>> => {
|
|
505
|
+
const res = await request({
|
|
506
|
+
method: "POST",
|
|
507
|
+
path: "/auth/confirm",
|
|
508
|
+
opKey: "auth.confirm",
|
|
509
|
+
input: input as RequestInput,
|
|
510
|
+
})
|
|
511
|
+
return new ApiResponse(res) as unknown as ApiResponse<{ 200: {
|
|
512
|
+
"code": string
|
|
513
|
+
} }>
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
"admin": {
|
|
517
|
+
|
|
518
|
+
/** Wipe all in-memory data */
|
|
519
|
+
"clearDatabase": async (input?: {}): Promise<ApiResponse<{ 200: {
|
|
520
|
+
"success": boolean
|
|
521
|
+
} }>> => {
|
|
522
|
+
const res = await request({
|
|
523
|
+
method: "POST",
|
|
524
|
+
path: "/admin/clear-database",
|
|
525
|
+
opKey: "admin.clearDatabase",
|
|
526
|
+
input: input as RequestInput,
|
|
527
|
+
})
|
|
528
|
+
return new ApiResponse(res) as unknown as ApiResponse<{ 200: {
|
|
529
|
+
"success": boolean
|
|
530
|
+
} }>
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Создаёт «пользователя» — изолированный набор типизированных вызовов
|
|
538
|
+
* эндпоинтов, который под капотом ходит fetch-запросами. `store.api`
|
|
539
|
+
* указывает на то же дерево, поэтому методы-сценарии этого store
|
|
540
|
+
* (`fullRegister` и т.п.) ходят от лица именно этого пользователя.
|
|
541
|
+
*/
|
|
542
|
+
createUser(userArgs: CreateUserArgs = {}) {
|
|
543
|
+
const StoreClass =
|
|
544
|
+
this.args.store ?? (ebely.userStore as unknown as new () => Store)
|
|
545
|
+
const store = new StoreClass()
|
|
546
|
+
const tree = this.buildApiTree(
|
|
547
|
+
this.makeRequest({ headers: { ...userArgs.headers }, store }),
|
|
548
|
+
)
|
|
549
|
+
;(store as unknown as { api: unknown }).api = tree
|
|
550
|
+
return Object.assign(store, tree)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Переиспользуемые именованные хендлеры. Типизируются библиотечными
|
|
2
|
+
// дженериками `BeforeHook` / `AfterHook` (привязаны к твоему `UserStore`),
|
|
3
|
+
// поэтому один хендлер можно вешать на разные endpoint'ы из `hooks.ts`.
|
|
4
|
+
|
|
5
|
+
import type { AfterHook, BeforeHook } from 'ebely'
|
|
6
|
+
import type { UserStore } from './userStore'
|
|
7
|
+
|
|
8
|
+
/** Логирует любой ответ: метод, путь, статус, тело. */
|
|
9
|
+
export const logResponse: AfterHook<UserStore> = ({ request, response }) => {
|
|
10
|
+
console.log(
|
|
11
|
+
`[${request.method}] ${request.path} → ${response.status}`,
|
|
12
|
+
response.body,
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Подставляет общий заголовок трассировки в любой запрос. */
|
|
17
|
+
export const withTrace: BeforeHook<UserStore> = ({ request }) => {
|
|
18
|
+
request.headers['x-trace'] = 'demo'
|
|
19
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Хуки before/after для этого бэкенда. Файл ПОЛНОСТЬЮ типизирован: тип
|
|
2
|
+
// `Hooks` экспортирует сгенерированный клиент (`generated.ts`), поэтому
|
|
3
|
+
// `h.posts.create` автокомплитится, `response.body.id` типизирован, а
|
|
4
|
+
// `ctx` — это твой `UserStore`.
|
|
5
|
+
//
|
|
6
|
+
// Регистрировать можно где угодно (это просто функция) — кладётся одной
|
|
7
|
+
// переменной в конфиг (`ebely.ts`). Вызывается один раз при `new World()`.
|
|
8
|
+
// Важно: `ctx` внутри хука — store КОНКРЕТНОГО пользователя, сделавшего
|
|
9
|
+
// запрос, поэтому запись в переменные не «течёт» между пользователями.
|
|
10
|
+
|
|
11
|
+
import type { Hooks } from './generated'
|
|
12
|
+
import type { UserStore } from './userStore'
|
|
13
|
+
import { logResponse } from './handlers'
|
|
14
|
+
|
|
15
|
+
export const hooks: Hooks<UserStore> = (h) => {
|
|
16
|
+
// после создания поста — сохранить id в переменные ИМЕННО этого юзера
|
|
17
|
+
h.posts.create.after(({ response, ctx }) => {
|
|
18
|
+
ctx.set({ key: 'lastPostId', value: response.body.id })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// перед получением поста — подставить заголовок (before может править запрос)
|
|
22
|
+
h.posts.get.before(({ request }) => {
|
|
23
|
+
request.headers['x-trace'] = 'demo'
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// несколько хуков на один endpoint складываются в очередь
|
|
27
|
+
h.posts.create.after(logResponse)
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Внутренние переменные и СЦЕНАРИИ одного пользователя.
|
|
2
|
+
//
|
|
3
|
+
// Второй дженерик `BaseStore<Vars, WorldApi>` даёт `this.api` —
|
|
4
|
+
// типизированное дерево вызовов эндпоинтов ОТ ЛИЦА ЭТОГО пользователя
|
|
5
|
+
// (его заголовки, его переменные, его `ctx` в хуках). Поэтому
|
|
6
|
+
// многошаговую подготовку (регистрация → подтверждение по коду) можно
|
|
7
|
+
// спрятать за одним методом `fullRegister`, а в тестах звать его одной
|
|
8
|
+
// строкой. `WorldApi` — `import type` → рантайм-цикла нет (как hooks.ts).
|
|
9
|
+
|
|
10
|
+
import { BaseStore } from "ebely"
|
|
11
|
+
import type { WorldApi } from "./generated"
|
|
12
|
+
|
|
13
|
+
export type InternalVariable = {
|
|
14
|
+
email: string
|
|
15
|
+
password: string
|
|
16
|
+
accessToken: string
|
|
17
|
+
deviceId: string
|
|
18
|
+
telegramUsername: string
|
|
19
|
+
createdPosts: { postId: string; date: string }[]
|
|
20
|
+
lastPostId: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class UserStore extends BaseStore<InternalVariable, WorldApi> {
|
|
24
|
+
/** Пример производного метода: id первого созданного поста. */
|
|
25
|
+
public getFirstPostId(): string | undefined {
|
|
26
|
+
const createdPosts = this.get({ key: "createdPosts" })
|
|
27
|
+
return createdPosts?.[0]?.postId
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Сценарий: полная регистрация одним вызовом. Под капотом — серия
|
|
32
|
+
* эндпоинтов (register → confirm), как было бы в реальном флоу с
|
|
33
|
+
* кодом из письма. Тест зовёт это одной строкой и не дублирует шаги.
|
|
34
|
+
*/
|
|
35
|
+
public async fullRegister(args: {
|
|
36
|
+
email: string
|
|
37
|
+
password: string
|
|
38
|
+
}): Promise<void> {
|
|
39
|
+
const { email, password } = args
|
|
40
|
+
|
|
41
|
+
const registered = await this.api.auth.register({
|
|
42
|
+
body: { email, password },
|
|
43
|
+
})
|
|
44
|
+
registered.assert(200, { email })
|
|
45
|
+
|
|
46
|
+
// «код из письма» — здесь просто заглушка (бэкенд его не проверяет)
|
|
47
|
+
const confirmed = await this.api.auth.confirm({
|
|
48
|
+
body: { code: "0000" },
|
|
49
|
+
})
|
|
50
|
+
confirmed.assert(200)
|
|
51
|
+
|
|
52
|
+
this.set({ key: "email", value: email })
|
|
53
|
+
this.set({ key: "password", value: password })
|
|
54
|
+
this.set({ key: "accessToken", value: `token-for-${email}` })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Внутренние переменные и СЦЕНАРИИ уровня WORLD — то же, что
|
|
2
|
+
// `userStore.ts`, но область не «один пользователь», а весь мир.
|
|
3
|
+
// Экземпляр этого класса и есть `world` (генерируемый `World` наследует
|
|
4
|
+
// его), поэтому всё объявленное здесь доступно как `world.<...>()`.
|
|
5
|
+
//
|
|
6
|
+
// `this.api` здесь — АНОНИМНЫЙ клиент (без per-user заголовков): сценарии
|
|
7
|
+
// подготовки/очистки бьют по бэкенду «от имени мира». `WorldApi`
|
|
8
|
+
// импортируется как `import type` → рантайм-цикла нет (см. hooks.ts /
|
|
9
|
+
// ARCHITECTURE.md §8).
|
|
10
|
+
|
|
11
|
+
import { BaseStore } from "ebely"
|
|
12
|
+
import type { WorldApi } from "./generated"
|
|
13
|
+
|
|
14
|
+
export type WorldVariable = {
|
|
15
|
+
/** Сколько раз за прогон чистили базу — пример world-переменной. */
|
|
16
|
+
resets: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class WorldStore extends BaseStore<WorldVariable, WorldApi> {
|
|
20
|
+
/** Сценарий: очистить базу одним вызовом `world.clearDatabase()`. */
|
|
21
|
+
public async clearDatabase(): Promise<void> {
|
|
22
|
+
const res = await this.api.admin.clearDatabase()
|
|
23
|
+
res.assert(200, { success: true })
|
|
24
|
+
this.set({ key: "resets", value: (this.get({ key: "resets" }) ?? 0) + 1 })
|
|
25
|
+
}
|
|
26
|
+
}
|