@gridframe/server 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,710 +0,0 @@
1
- import {
2
- AddDashboardCardRequestSchema,
3
- CardLibraryResponseSchema,
4
- DashboardApiErrorSchema,
5
- DashboardBootstrapRequestSchema,
6
- DashboardDocumentSchema,
7
- DASHBOARD_GRID_COLUMNS,
8
- PanelCardDataResponseSchema,
9
- RemoveDashboardCardRequestSchema,
10
- UpdateDashboardCardRequestSchema,
11
- UpdateDashboardLayoutRequestSchema,
12
- type CardDeeplinkConfig,
13
- type CardLibraryItem,
14
- type DashboardApiError,
15
- type DashboardBootstrapResponse,
16
- type DashboardCardLayout,
17
- type DashboardFooterConfig,
18
- type DashboardLayoutItem,
19
- type DashboardSummary,
20
- type PanelCardDataResponse,
21
- type VisualizationType,
22
- } from "@gridframe/core";
23
- import { validateDashboardLayout } from "@gridframe/core";
24
-
25
- type MaybePromise<T> = T | Promise<T>;
26
-
27
- type CardLibraryTemplate = {
28
- key: string;
29
- name: string;
30
- description?: string;
31
- visualization: VisualizationType;
32
- defaultLayout: {
33
- width: number;
34
- height: number;
35
- };
36
- deeplinkLabel?: string;
37
- };
38
-
39
- type DashboardSeedCard = {
40
- libraryItemKey: string;
41
- layout?: DashboardCardLayout;
42
- };
43
-
44
- type DashboardSeed = {
45
- title: string;
46
- description?: string;
47
- footer?: DashboardFooterConfig;
48
- cards: DashboardSeedCard[];
49
- };
50
-
51
- type PersistedDashboardCard = {
52
- id: string;
53
- dashboardId: string;
54
- libraryItemKey?: string;
55
- name: string;
56
- visualization: VisualizationType;
57
- deeplink?: Omit<CardDeeplinkConfig, "href">;
58
- layout: DashboardCardLayout;
59
- sortOrder: number;
60
- };
61
-
62
- type PersistedDashboard = {
63
- id: string;
64
- ownerUserId: string;
65
- title: string;
66
- description?: string;
67
- footer?: DashboardFooterConfig;
68
- isDefault: boolean;
69
- revision: number;
70
- cards: PersistedDashboardCard[];
71
- };
72
-
73
- type DashboardBootstrap = {
74
- dashboards: DashboardSummary[];
75
- dashboard: PersistedDashboard;
76
- };
77
-
78
- type DashboardCardCreate = Omit<
79
- PersistedDashboardCard,
80
- "id" | "dashboardId" | "sortOrder"
81
- >;
82
-
83
- interface DashboardRepository {
84
- bootstrap(
85
- ownerUserId: string,
86
- dashboardId: string | undefined,
87
- seed: DashboardSeed,
88
- cardLibrary: readonly CardLibraryTemplate[],
89
- ): MaybePromise<DashboardBootstrap>;
90
- loadDashboard(
91
- ownerUserId: string,
92
- dashboardId: string,
93
- ): MaybePromise<PersistedDashboard>;
94
- updateLayout(
95
- ownerUserId: string,
96
- dashboardId: string,
97
- revision: number,
98
- cards: DashboardLayoutItem[],
99
- ): MaybePromise<PersistedDashboard>;
100
- updateCardName(
101
- ownerUserId: string,
102
- dashboardId: string,
103
- cardId: string,
104
- revision: number,
105
- name: string,
106
- ): MaybePromise<PersistedDashboard>;
107
- addCard(
108
- ownerUserId: string,
109
- dashboardId: string,
110
- revision: number,
111
- card: DashboardCardCreate,
112
- ): MaybePromise<PersistedDashboard>;
113
- removeCard(
114
- ownerUserId: string,
115
- dashboardId: string,
116
- cardId: string,
117
- revision: number,
118
- ): MaybePromise<PersistedDashboard>;
119
- findOwnedCard(
120
- ownerUserId: string,
121
- dashboardId: string,
122
- cardId: string,
123
- ): MaybePromise<PersistedDashboardCard | undefined>;
124
- }
125
-
126
- type DashboardHandlerOptions = {
127
- repository: DashboardRepository;
128
- cardLibrary:
129
- | readonly CardLibraryTemplate[]
130
- | ((context: DashboardContext) => MaybePromise<readonly CardLibraryTemplate[]>);
131
- defaultDashboard: (context: DashboardContext) => MaybePromise<DashboardSeed>;
132
- resolveCardData: (
133
- input: CardDataResolverInput,
134
- ) => MaybePromise<PanelCardDataResponse>;
135
- urls?: DashboardUrlOptions;
136
- };
137
-
138
- type DashboardUrlOptions = {
139
- apiBasePath?: string;
140
- dashboardBasePath?: string;
141
- };
142
-
143
- type DashboardContext = {
144
- userId: string;
145
- };
146
-
147
- type DashboardIdentity = DashboardContext & {
148
- dashboardId: string;
149
- };
150
-
151
- type CardIdentity = DashboardIdentity & {
152
- cardId: string;
153
- };
154
-
155
- type CardDataResolverInput = CardIdentity & {
156
- card: PersistedDashboardCard;
157
- request: Request;
158
- };
159
-
160
- class DashboardNotFoundError extends Error {
161
- constructor() {
162
- super("Dashboard not found");
163
- this.name = "DashboardNotFoundError";
164
- }
165
- }
166
-
167
- class DashboardRevisionConflictError extends Error {
168
- constructor() {
169
- super("Dashboard revision conflict");
170
- this.name = "DashboardRevisionConflictError";
171
- }
172
- }
173
-
174
- class DashboardInvalidLayoutError extends Error {
175
- constructor(readonly errors: string[]) {
176
- super(errors.join("; "));
177
- this.name = "DashboardInvalidLayoutError";
178
- }
179
- }
180
-
181
- class DashboardCardAlreadyAddedError extends Error {
182
- constructor() {
183
- super("Card is already on this Dashboard");
184
- this.name = "DashboardCardAlreadyAddedError";
185
- }
186
- }
187
-
188
- class DashboardInvalidLibraryItemError extends Error {
189
- constructor() {
190
- super("Unknown Card library item");
191
- this.name = "DashboardInvalidLibraryItemError";
192
- }
193
- }
194
-
195
- function createDashboardHandlers(options: DashboardHandlerOptions) {
196
- const urls = {
197
- apiBasePath: options.urls?.apiBasePath ?? "/api/gridframe",
198
- dashboardBasePath: options.urls?.dashboardBasePath ?? "/gridframe",
199
- };
200
-
201
- return {
202
- bootstrap: async (request: Request, context: DashboardContext) => {
203
- const parsedRequest = DashboardBootstrapRequestSchema.safeParse(
204
- await readJson(request),
205
- );
206
-
207
- if (!parsedRequest.success || !isIdentitySegment(context.userId)) {
208
- return errorResponse(
209
- 400,
210
- "INVALID_REQUEST",
211
- "Invalid bootstrap request",
212
- );
213
- }
214
-
215
- try {
216
- const [seed, cardLibrary] = await Promise.all([
217
- options.defaultDashboard(context),
218
- resolveCardLibrary(options.cardLibrary, context),
219
- ]);
220
- const result = await options.repository.bootstrap(
221
- context.userId,
222
- parsedRequest.data.dashboardId,
223
- seed,
224
- cardLibrary,
225
- );
226
-
227
- return Response.json(serializeDashboardBootstrap(result, urls));
228
- } catch (error) {
229
- if (error instanceof DashboardNotFoundError) {
230
- return errorResponse(
231
- 404,
232
- "DASHBOARD_NOT_FOUND",
233
- "Dashboard not found",
234
- );
235
- }
236
- if (error instanceof DashboardInvalidLibraryItemError) {
237
- return errorResponse(
238
- 400,
239
- "INVALID_REQUEST",
240
- "Unknown Card library item",
241
- );
242
- }
243
-
244
- return errorResponse(
245
- 500,
246
- "DASHBOARD_LOAD_FAILED",
247
- "Dashboard could not be loaded",
248
- );
249
- }
250
- },
251
-
252
- updateLayout: async (request: Request, identity: DashboardIdentity) => {
253
- const parsed = UpdateDashboardLayoutRequestSchema.safeParse(
254
- await readJson(request),
255
- );
256
- const revision = parsed.success
257
- ? parseRevision(parsed.data.revision)
258
- : undefined;
259
-
260
- if (!parsed.success || revision === undefined || !isDashboard(identity)) {
261
- return errorResponse(
262
- 400,
263
- "INVALID_REQUEST",
264
- "Invalid Dashboard layout request",
265
- );
266
- }
267
-
268
- try {
269
- const dashboard = await options.repository.loadDashboard(
270
- identity.userId,
271
- identity.dashboardId,
272
- );
273
- const validation = validateDashboardLayout(
274
- parsed.data.cards,
275
- dashboard.cards.map((card) => card.id),
276
- );
277
-
278
- if (!validation.valid) {
279
- throw new DashboardInvalidLayoutError(validation.errors);
280
- }
281
-
282
- const updated = await options.repository.updateLayout(
283
- identity.userId,
284
- identity.dashboardId,
285
- revision,
286
- parsed.data.cards,
287
- );
288
-
289
- return Response.json(serializeDashboardDocument(updated, urls));
290
- } catch (error) {
291
- return mutationError(error, "Invalid Dashboard layout");
292
- }
293
- },
294
-
295
- updateCard: async (request: Request, identity: CardIdentity) => {
296
- const parsed = UpdateDashboardCardRequestSchema.safeParse(
297
- await readJson(request),
298
- );
299
- const revision = parsed.success
300
- ? parseRevision(parsed.data.revision)
301
- : undefined;
302
- const name = parsed.success ? parsed.data.name.trim() : "";
303
-
304
- if (
305
- !parsed.success ||
306
- revision === undefined ||
307
- !name ||
308
- !isCard(identity)
309
- ) {
310
- return errorResponse(
311
- 400,
312
- "INVALID_REQUEST",
313
- "Invalid Card update request",
314
- );
315
- }
316
-
317
- try {
318
- const updated = await options.repository.updateCardName(
319
- identity.userId,
320
- identity.dashboardId,
321
- identity.cardId,
322
- revision,
323
- name,
324
- );
325
-
326
- return Response.json(serializeDashboardDocument(updated, urls));
327
- } catch (error) {
328
- return mutationError(error, "Invalid Card update request");
329
- }
330
- },
331
-
332
- listCardLibrary: async (_request: Request, identity: DashboardIdentity) => {
333
- if (!isDashboard(identity)) {
334
- return errorResponse(
335
- 400,
336
- "INVALID_REQUEST",
337
- "Invalid Card library request",
338
- );
339
- }
340
-
341
- try {
342
- const [dashboard, cardLibrary] = await Promise.all([
343
- options.repository.loadDashboard(identity.userId, identity.dashboardId),
344
- resolveCardLibrary(options.cardLibrary, identity),
345
- ]);
346
-
347
- return Response.json(buildCardLibraryResponse(dashboard, cardLibrary));
348
- } catch (error) {
349
- return libraryError(error);
350
- }
351
- },
352
-
353
- addCard: async (request: Request, identity: DashboardIdentity) => {
354
- const parsed = AddDashboardCardRequestSchema.safeParse(
355
- await readJson(request),
356
- );
357
- const revision = parsed.success
358
- ? parseRevision(parsed.data.revision)
359
- : undefined;
360
-
361
- if (!parsed.success || revision === undefined || !isDashboard(identity)) {
362
- return errorResponse(400, "INVALID_REQUEST", "Invalid Card add request");
363
- }
364
-
365
- try {
366
- const [dashboard, cardLibrary] = await Promise.all([
367
- options.repository.loadDashboard(identity.userId, identity.dashboardId),
368
- resolveCardLibrary(options.cardLibrary, identity),
369
- ]);
370
- const template = cardLibrary.find(
371
- (item) => item.key === parsed.data.libraryItemKey,
372
- );
373
-
374
- if (!template) {
375
- throw new DashboardInvalidLibraryItemError();
376
- }
377
- if (
378
- dashboard.cards.some(
379
- (card) => card.libraryItemKey === parsed.data.libraryItemKey,
380
- )
381
- ) {
382
- throw new DashboardCardAlreadyAddedError();
383
- }
384
-
385
- const updated = await options.repository.addCard(
386
- identity.userId,
387
- identity.dashboardId,
388
- revision,
389
- {
390
- libraryItemKey: template.key,
391
- name: template.name,
392
- visualization: template.visualization,
393
- deeplink: template.deeplinkLabel
394
- ? { label: template.deeplinkLabel }
395
- : undefined,
396
- layout: firstAvailableLayout(dashboard.cards, template.defaultLayout),
397
- },
398
- );
399
-
400
- return Response.json({
401
- dashboard: serializeDashboardDocument(updated, urls),
402
- cardLibrary: buildCardLibraryResponse(updated, cardLibrary),
403
- });
404
- } catch (error) {
405
- return libraryError(error);
406
- }
407
- },
408
-
409
- removeCard: async (request: Request, identity: CardIdentity) => {
410
- const parsed = RemoveDashboardCardRequestSchema.safeParse(
411
- await readJson(request),
412
- );
413
- const revision = parsed.success
414
- ? parseRevision(parsed.data.revision)
415
- : undefined;
416
-
417
- if (!parsed.success || revision === undefined || !isCard(identity)) {
418
- return errorResponse(
419
- 400,
420
- "INVALID_REQUEST",
421
- "Invalid Card remove request",
422
- );
423
- }
424
-
425
- try {
426
- const cardLibrary = await resolveCardLibrary(
427
- options.cardLibrary,
428
- identity,
429
- );
430
- const updated = await options.repository.removeCard(
431
- identity.userId,
432
- identity.dashboardId,
433
- identity.cardId,
434
- revision,
435
- );
436
-
437
- return Response.json({
438
- dashboard: serializeDashboardDocument(updated, urls),
439
- cardLibrary: buildCardLibraryResponse(updated, cardLibrary),
440
- });
441
- } catch (error) {
442
- return libraryError(error);
443
- }
444
- },
445
-
446
- fetchCardData: async (request: Request, identity: CardIdentity) => {
447
- if (!isCard(identity)) {
448
- return errorResponse(400, "INVALID_REQUEST", "Invalid Card data request");
449
- }
450
-
451
- try {
452
- const card = await options.repository.findOwnedCard(
453
- identity.userId,
454
- identity.dashboardId,
455
- identity.cardId,
456
- );
457
-
458
- if (!card) {
459
- return errorResponse(
460
- 404,
461
- "DASHBOARD_CARD_NOT_FOUND",
462
- "Dashboard Card not found",
463
- );
464
- }
465
-
466
- const result = await options.resolveCardData({
467
- ...identity,
468
- card,
469
- request,
470
- });
471
- const parsed = PanelCardDataResponseSchema.safeParse(result);
472
-
473
- if (!parsed.success) {
474
- return cardQueryFailed();
475
- }
476
-
477
- return Response.json(parsed.data);
478
- } catch {
479
- return cardQueryFailed();
480
- }
481
- },
482
- };
483
- }
484
-
485
- function serializeDashboardBootstrap(
486
- result: DashboardBootstrap,
487
- urls: Required<DashboardUrlOptions>,
488
- ): DashboardBootstrapResponse {
489
- return {
490
- dashboards: result.dashboards,
491
- dashboard: serializeDashboardDocument(result.dashboard, urls),
492
- };
493
- }
494
-
495
- function serializeDashboardDocument(
496
- dashboard: PersistedDashboard,
497
- urls: Required<DashboardUrlOptions>,
498
- ) {
499
- const ownerId = encodeURIComponent(dashboard.ownerUserId);
500
- const dashboardId = encodeURIComponent(dashboard.id);
501
-
502
- return DashboardDocumentSchema.parse({
503
- id: dashboard.id,
504
- revision: String(dashboard.revision),
505
- config: {
506
- title: dashboard.title,
507
- description: dashboard.description,
508
- footer: dashboard.footer,
509
- cards: dashboard.cards.map((card) => {
510
- const cardId = encodeURIComponent(card.id);
511
-
512
- return {
513
- id: card.id,
514
- name: card.name,
515
- visualization: card.visualization,
516
- query:
517
- `${urls.apiBasePath}/users/${ownerId}` +
518
- `/dashboards/${dashboardId}/cards/${cardId}/data`,
519
- deeplink: card.deeplink
520
- ? {
521
- href:
522
- `${urls.dashboardBasePath}/users/${ownerId}` +
523
- `/dashboards/${dashboardId}/cards/${cardId}`,
524
- label: card.deeplink.label,
525
- }
526
- : undefined,
527
- layout: card.layout,
528
- };
529
- }),
530
- },
531
- });
532
- }
533
-
534
- function buildCardLibraryResponse(
535
- dashboard: PersistedDashboard,
536
- cardLibrary: readonly CardLibraryTemplate[],
537
- ) {
538
- const installed = new Map(
539
- dashboard.cards.map((card) => [card.libraryItemKey, card.id]),
540
- );
541
-
542
- return CardLibraryResponseSchema.parse({
543
- items: cardLibrary.map(
544
- (template): CardLibraryItem => ({
545
- key: template.key,
546
- name: template.name,
547
- description: template.description,
548
- visualization: template.visualization,
549
- defaultLayout: template.defaultLayout,
550
- addedCardId: installed.get(template.key),
551
- }),
552
- ),
553
- });
554
- }
555
-
556
- function firstAvailableLayout(
557
- cards: readonly PersistedDashboardCard[],
558
- size: { width: number; height: number },
559
- ): DashboardCardLayout {
560
- if (size.width > DASHBOARD_GRID_COLUMNS) {
561
- throw new DashboardInvalidLayoutError([
562
- `Card width ${size.width} exceeds the ${DASHBOARD_GRID_COLUMNS}-column grid`,
563
- ]);
564
- }
565
- for (let y = 0; ; y += 1) {
566
- for (let x = 0; x + size.width <= DASHBOARD_GRID_COLUMNS; x += 1) {
567
- const candidate = { x, y, ...size };
568
- if (!cards.some((card) => layoutsOverlap(candidate, card.layout))) {
569
- return candidate;
570
- }
571
- }
572
- }
573
- }
574
-
575
- function layoutsOverlap(a: DashboardCardLayout, b: DashboardCardLayout) {
576
- return (
577
- a.x < b.x + b.width &&
578
- a.x + a.width > b.x &&
579
- a.y < b.y + b.height &&
580
- a.y + a.height > b.y
581
- );
582
- }
583
-
584
- async function resolveCardLibrary(
585
- cardLibrary: DashboardHandlerOptions["cardLibrary"],
586
- context: DashboardContext,
587
- ) {
588
- return typeof cardLibrary === "function"
589
- ? cardLibrary(context)
590
- : cardLibrary;
591
- }
592
-
593
- function mutationError(error: unknown, invalidMessage: string) {
594
- if (error instanceof DashboardRevisionConflictError) {
595
- return errorResponse(
596
- 409,
597
- "REVISION_CONFLICT",
598
- "Dashboard was changed by another request",
599
- );
600
- }
601
- if (error instanceof DashboardNotFoundError) {
602
- return errorResponse(404, "DASHBOARD_NOT_FOUND", "Dashboard not found");
603
- }
604
- if (error instanceof DashboardInvalidLayoutError) {
605
- return errorResponse(400, "INVALID_REQUEST", invalidMessage);
606
- }
607
- return errorResponse(
608
- 500,
609
- "DASHBOARD_LOAD_FAILED",
610
- "Dashboard could not be updated",
611
- );
612
- }
613
-
614
- function libraryError(error: unknown) {
615
- if (error instanceof DashboardRevisionConflictError) {
616
- return errorResponse(
617
- 409,
618
- "REVISION_CONFLICT",
619
- "Dashboard was changed by another request",
620
- );
621
- }
622
- if (error instanceof DashboardCardAlreadyAddedError) {
623
- return errorResponse(
624
- 409,
625
- "CARD_ALREADY_ADDED",
626
- "Card is already on this Dashboard",
627
- );
628
- }
629
- if (error instanceof DashboardInvalidLibraryItemError) {
630
- return errorResponse(400, "INVALID_REQUEST", "Unknown Card library item");
631
- }
632
- if (error instanceof DashboardNotFoundError) {
633
- return errorResponse(404, "DASHBOARD_NOT_FOUND", "Dashboard not found");
634
- }
635
- return errorResponse(
636
- 500,
637
- "DASHBOARD_LOAD_FAILED",
638
- "Dashboard could not be updated",
639
- );
640
- }
641
-
642
- function errorResponse(
643
- status: number,
644
- code: DashboardApiError["error"]["code"],
645
- message: string,
646
- ) {
647
- return Response.json(
648
- DashboardApiErrorSchema.parse({ error: { code, message } }),
649
- { status },
650
- );
651
- }
652
-
653
- async function readJson(request: Request): Promise<unknown> {
654
- try {
655
- return await request.json();
656
- } catch {
657
- return undefined;
658
- }
659
- }
660
-
661
- function parseRevision(revision: string) {
662
- if (!/^[1-9]\d*$/.test(revision)) {
663
- return undefined;
664
- }
665
- const value = Number(revision);
666
- return Number.isSafeInteger(value) ? value : undefined;
667
- }
668
-
669
- function isDashboard(identity: DashboardIdentity) {
670
- return (
671
- isIdentitySegment(identity.userId) &&
672
- isIdentitySegment(identity.dashboardId)
673
- );
674
- }
675
-
676
- function isCard(identity: CardIdentity) {
677
- return isDashboard(identity) && isIdentitySegment(identity.cardId);
678
- }
679
-
680
- function isIdentitySegment(value: string) {
681
- return value.trim().length > 0 && value.length <= 256;
682
- }
683
-
684
- function cardQueryFailed() {
685
- return errorResponse(
686
- 502,
687
- "CARD_QUERY_FAILED",
688
- "Card data could not be loaded",
689
- );
690
- }
691
-
692
- export {
693
- DashboardCardAlreadyAddedError,
694
- DashboardInvalidLayoutError,
695
- DashboardInvalidLibraryItemError,
696
- DashboardNotFoundError,
697
- DashboardRevisionConflictError,
698
- createDashboardHandlers,
699
- };
700
- export type {
701
- CardDataResolverInput,
702
- CardLibraryTemplate,
703
- DashboardBootstrap,
704
- DashboardHandlerOptions,
705
- DashboardRepository,
706
- DashboardSeed,
707
- DashboardSeedCard,
708
- PersistedDashboard,
709
- PersistedDashboardCard,
710
- };