@checkmate-monitor/api-docs-frontend 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @checkmate-monitor/api-docs-frontend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [eff5b4e]
8
+ - Updated dependencies [ffc28f6]
9
+ - Updated dependencies [b354ab3]
10
+ - @checkmate-monitor/ui@0.1.0
11
+ - @checkmate-monitor/common@0.1.0
12
+ - @checkmate-monitor/api-docs-common@0.0.2
13
+ - @checkmate-monitor/frontend-api@0.0.2
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@checkmate-monitor/api-docs-frontend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.tsx",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkmate-monitor/frontend-api": "workspace:*",
13
+ "@checkmate-monitor/common": "workspace:*",
14
+ "@checkmate-monitor/api-docs-common": "workspace:*",
15
+ "@checkmate-monitor/ui": "workspace:*",
16
+ "react": "^18.2.0",
17
+ "react-router-dom": "^6.22.0",
18
+ "lucide-react": "^0.344.0"
19
+ },
20
+ "devDependencies": {
21
+ "typescript": "^5.0.0",
22
+ "@types/react": "^18.2.0",
23
+ "@checkmate-monitor/tsconfig": "workspace:*",
24
+ "@checkmate-monitor/scripts": "workspace:*"
25
+ }
26
+ }
@@ -0,0 +1,32 @@
1
+ import { useNavigate } from "react-router-dom";
2
+ import { FileCode2 } from "lucide-react";
3
+ import { DropdownMenuItem } from "@checkmate-monitor/ui";
4
+ import { useApi, permissionApiRef } from "@checkmate-monitor/frontend-api";
5
+ import { resolveRoute, qualifyPermissionId } from "@checkmate-monitor/common";
6
+ import {
7
+ pluginMetadata,
8
+ permissions,
9
+ } from "@checkmate-monitor/api-docs-common";
10
+ import { apiDocsRoutes } from "./index";
11
+
12
+ const REQUIRED_PERMISSION = qualifyPermissionId(
13
+ pluginMetadata,
14
+ permissions.apiDocsView
15
+ );
16
+
17
+ export function ApiDocsMenuItem() {
18
+ const navigate = useNavigate();
19
+ const permissionApi = useApi(permissionApiRef);
20
+ const canView = permissionApi.usePermission(REQUIRED_PERMISSION);
21
+
22
+ if (canView.loading || !canView.allowed) return;
23
+
24
+ return (
25
+ <DropdownMenuItem
26
+ onClick={() => navigate(resolveRoute(apiDocsRoutes.routes.docs))}
27
+ icon={<FileCode2 className="h-4 w-4" />}
28
+ >
29
+ API Documentation
30
+ </DropdownMenuItem>
31
+ );
32
+ }
@@ -0,0 +1,523 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ Card,
4
+ CardContent,
5
+ CardDescription,
6
+ CardHeader,
7
+ CardTitle,
8
+ Badge,
9
+ Button,
10
+ } from "@checkmate-monitor/ui";
11
+ import {
12
+ ChevronDown,
13
+ ChevronRight,
14
+ Copy,
15
+ Check,
16
+ Lock,
17
+ Globe,
18
+ User,
19
+ Server,
20
+ } from "lucide-react";
21
+
22
+ interface OpenApiSpec {
23
+ info: {
24
+ title: string;
25
+ version: string;
26
+ description?: string;
27
+ };
28
+ paths: Record<string, Record<string, OperationObject>>;
29
+ }
30
+
31
+ interface OperationObject {
32
+ summary?: string;
33
+ description?: string;
34
+ operationId?: string;
35
+ tags?: string[];
36
+ requestBody?: {
37
+ content?: {
38
+ "application/json"?: {
39
+ schema?: SchemaObject;
40
+ };
41
+ };
42
+ };
43
+ responses?: Record<
44
+ string,
45
+ {
46
+ description?: string;
47
+ content?: Record<string, { schema?: SchemaObject }>;
48
+ }
49
+ >;
50
+ "x-orpc-meta"?: {
51
+ userType?: string;
52
+ permissions?: string[];
53
+ };
54
+ }
55
+
56
+ interface SchemaObject {
57
+ type?: string;
58
+ properties?: Record<string, SchemaObject>;
59
+ items?: SchemaObject;
60
+ required?: string[];
61
+ description?: string;
62
+ enum?: string[];
63
+ $ref?: string;
64
+ }
65
+
66
+ function getUserTypeIcon(userType?: string) {
67
+ switch (userType) {
68
+ case "public": {
69
+ return <Globe className="h-4 w-4 text-green-500" />;
70
+ }
71
+ case "user": {
72
+ return <User className="h-4 w-4 text-blue-500" />;
73
+ }
74
+ case "service": {
75
+ return <Server className="h-4 w-4 text-purple-500" />;
76
+ }
77
+ case "authenticated": {
78
+ return <Lock className="h-4 w-4 text-amber-500" />;
79
+ }
80
+ default: {
81
+ return <Lock className="h-4 w-4 text-gray-500" />;
82
+ }
83
+ }
84
+ }
85
+
86
+ function getUserTypeBadge(userType?: string) {
87
+ const colors: Record<string, string> = {
88
+ public:
89
+ "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
90
+ user: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
91
+ service:
92
+ "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
93
+ authenticated:
94
+ "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400",
95
+ };
96
+ return (
97
+ colors[userType ?? ""] ??
98
+ "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Check if an endpoint is accessible via external application tokens.
104
+ */
105
+ function isExternallyAccessible(userType?: string): boolean {
106
+ return userType === "authenticated" || userType === "public";
107
+ }
108
+
109
+ function CopyButton({ text }: { text: string }) {
110
+ const [copied, setCopied] = useState(false);
111
+
112
+ const handleCopy = async () => {
113
+ await navigator.clipboard.writeText(text);
114
+ setCopied(true);
115
+ setTimeout(() => setCopied(false), 2000);
116
+ };
117
+
118
+ return (
119
+ <Button variant="ghost" size="sm" onClick={handleCopy}>
120
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
121
+ </Button>
122
+ );
123
+ }
124
+
125
+ function generateFetchExample(
126
+ path: string,
127
+ method: string,
128
+ operation: OperationObject
129
+ ): string {
130
+ const baseUrl = "http://localhost:3000";
131
+ const hasBody = operation.requestBody?.content?.["application/json"]?.schema;
132
+
133
+ let example = `const response = await fetch("${baseUrl}${path}", {
134
+ method: "${method.toUpperCase()}",
135
+ headers: {
136
+ "Content-Type": "application/json",
137
+ "Authorization": "Bearer ck_<application-id>_<secret>"
138
+ }`;
139
+
140
+ if (hasBody) {
141
+ example += `,
142
+ body: JSON.stringify({
143
+ // Request body - see schema below
144
+ })`;
145
+ }
146
+
147
+ example += `
148
+ });
149
+
150
+ const data = await response.json();`;
151
+
152
+ return example;
153
+ }
154
+
155
+ function SchemaDisplay({
156
+ schema,
157
+ depth = 0,
158
+ }: {
159
+ schema?: SchemaObject;
160
+ depth?: number;
161
+ }) {
162
+ if (!schema) return <span className="text-muted-foreground">unknown</span>;
163
+
164
+ if (schema.$ref) {
165
+ const refName = schema.$ref.split("/").pop();
166
+ return (
167
+ <span className="text-purple-600 dark:text-purple-400">{refName}</span>
168
+ );
169
+ }
170
+
171
+ if (schema.type === "object" && schema.properties) {
172
+ return (
173
+ <div className="font-mono text-sm" style={{ marginLeft: depth * 16 }}>
174
+ {"{"}
175
+ {Object.entries(schema.properties).map(([key, value]) => (
176
+ <div key={key} className="ml-4">
177
+ <span className="text-blue-600 dark:text-blue-400">{key}</span>
178
+ {schema.required?.includes(key) && (
179
+ <span className="text-red-500">*</span>
180
+ )}
181
+ : <SchemaDisplay schema={value} depth={depth + 1} />
182
+ </div>
183
+ ))}
184
+ {"}"}
185
+ </div>
186
+ );
187
+ }
188
+
189
+ if (schema.type === "array" && schema.items) {
190
+ return (
191
+ <span>
192
+ <SchemaDisplay schema={schema.items} depth={depth} />
193
+ []
194
+ </span>
195
+ );
196
+ }
197
+
198
+ if (schema.enum) {
199
+ return (
200
+ <span className="text-green-600 dark:text-green-400">
201
+ {schema.enum.map((e) => `"${e}"`).join(" | ")}
202
+ </span>
203
+ );
204
+ }
205
+
206
+ const typeColors: Record<string, string> = {
207
+ string: "text-green-600 dark:text-green-400",
208
+ number: "text-amber-600 dark:text-amber-400",
209
+ boolean: "text-red-600 dark:text-red-400",
210
+ integer: "text-amber-600 dark:text-amber-400",
211
+ };
212
+
213
+ return (
214
+ <span className={typeColors[schema.type ?? ""] ?? "text-gray-600"}>
215
+ {schema.type ?? "unknown"}
216
+ </span>
217
+ );
218
+ }
219
+
220
+ function EndpointCard({
221
+ path,
222
+ method,
223
+ operation,
224
+ }: {
225
+ path: string;
226
+ method: string;
227
+ operation: OperationObject;
228
+ }) {
229
+ const [isOpen, setIsOpen] = useState(false);
230
+ const meta = operation["x-orpc-meta"];
231
+ const inputSchema =
232
+ operation.requestBody?.content?.["application/json"]?.schema;
233
+ const outputSchema = Object.values(operation.responses ?? {})[0]?.content?.[
234
+ "application/json"
235
+ ]?.schema;
236
+
237
+ const methodColors: Record<string, string> = {
238
+ get: "bg-green-500",
239
+ post: "bg-blue-500",
240
+ put: "bg-amber-500",
241
+ patch: "bg-orange-500",
242
+ delete: "bg-red-500",
243
+ };
244
+
245
+ return (
246
+ <Card className="mb-2">
247
+ <CardHeader
248
+ className="cursor-pointer hover:bg-accent/50 transition-colors py-3"
249
+ onClick={() => setIsOpen(!isOpen)}
250
+ >
251
+ <div className="flex items-center gap-3">
252
+ {isOpen ? (
253
+ <ChevronDown className="h-4 w-4" />
254
+ ) : (
255
+ <ChevronRight className="h-4 w-4" />
256
+ )}
257
+ <Badge
258
+ className={`${methodColors[method]} text-white uppercase text-xs font-mono`}
259
+ >
260
+ {method}
261
+ </Badge>
262
+ <code className="font-mono text-sm flex-1 text-left">{path}</code>
263
+ <div className="flex items-center gap-2">
264
+ {getUserTypeIcon(meta?.userType)}
265
+ <Badge
266
+ variant="outline"
267
+ className={getUserTypeBadge(meta?.userType)}
268
+ >
269
+ {meta?.userType ?? "unknown"}
270
+ </Badge>
271
+ {!isExternallyAccessible(meta?.userType) && (
272
+ <Badge variant="destructive" className="text-xs">
273
+ Internal Only
274
+ </Badge>
275
+ )}
276
+ </div>
277
+ </div>
278
+ {operation.summary && (
279
+ <CardDescription className="ml-8 text-left">
280
+ {operation.summary}
281
+ </CardDescription>
282
+ )}
283
+ </CardHeader>
284
+
285
+ {isOpen && (
286
+ <CardContent className="pt-0 space-y-4">
287
+ {operation.description && (
288
+ <p className="text-sm text-muted-foreground">
289
+ {operation.description}
290
+ </p>
291
+ )}
292
+
293
+ {meta?.permissions && meta.permissions.length > 0 && (
294
+ <div>
295
+ <h4 className="text-sm font-medium mb-2">Required Permissions</h4>
296
+ <div className="flex flex-wrap gap-2">
297
+ {meta.permissions.map((perm) => (
298
+ <Badge key={perm} variant="secondary">
299
+ {perm}
300
+ </Badge>
301
+ ))}
302
+ </div>
303
+ </div>
304
+ )}
305
+
306
+ <div className="grid gap-4 md:grid-cols-2">
307
+ {inputSchema && (
308
+ <div>
309
+ <h4 className="text-sm font-medium mb-2">Input Schema</h4>
310
+ <div className="bg-muted rounded-md p-3 overflow-x-auto">
311
+ <SchemaDisplay schema={inputSchema} />
312
+ </div>
313
+ </div>
314
+ )}
315
+
316
+ {outputSchema && (
317
+ <div>
318
+ <h4 className="text-sm font-medium mb-2">Output Schema</h4>
319
+ <div className="bg-muted rounded-md p-3 overflow-x-auto">
320
+ <SchemaDisplay schema={outputSchema} />
321
+ </div>
322
+ </div>
323
+ )}
324
+ </div>
325
+
326
+ <div>
327
+ <div className="flex items-center justify-between mb-2">
328
+ <h4 className="text-sm font-medium">Fetch Example</h4>
329
+ <CopyButton
330
+ text={generateFetchExample(path, method, operation)}
331
+ />
332
+ </div>
333
+ <pre className="bg-muted rounded-md p-3 overflow-x-auto text-sm">
334
+ <code>{generateFetchExample(path, method, operation)}</code>
335
+ </pre>
336
+ </div>
337
+ </CardContent>
338
+ )}
339
+ </Card>
340
+ );
341
+ }
342
+
343
+ export function ApiDocsPage() {
344
+ const [spec, setSpec] = useState<OpenApiSpec>();
345
+ const [loading, setLoading] = useState(true);
346
+ const [error, setError] = useState<string>();
347
+ // Default to showing externally accessible endpoints only
348
+ const [selectedTypes, setSelectedTypes] = useState<Set<string>>(
349
+ new Set(["authenticated", "public"])
350
+ );
351
+
352
+ const toggleType = (type: string) => {
353
+ setSelectedTypes((prev) => {
354
+ const next = new Set(prev);
355
+ if (next.has(type)) {
356
+ next.delete(type);
357
+ } else {
358
+ next.add(type);
359
+ }
360
+ return next;
361
+ });
362
+ };
363
+
364
+ const showAll = () => {
365
+ setSelectedTypes(new Set());
366
+ };
367
+
368
+ useEffect(() => {
369
+ const fetchSpec = async () => {
370
+ try {
371
+ const response = await fetch("/api/openapi.json");
372
+ if (!response.ok) {
373
+ throw new Error(`Failed to fetch API spec: ${response.statusText}`);
374
+ }
375
+ const data = (await response.json()) as OpenApiSpec;
376
+ setSpec(data);
377
+ } catch (error_) {
378
+ setError(error_ instanceof Error ? error_.message : "Unknown error");
379
+ } finally {
380
+ setLoading(false);
381
+ }
382
+ };
383
+
384
+ void fetchSpec();
385
+ }, []);
386
+
387
+ if (loading) {
388
+ return (
389
+ <div className="container mx-auto py-8">
390
+ <div className="animate-pulse">Loading API documentation...</div>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ if (error || !spec) {
396
+ return (
397
+ <div className="container mx-auto py-8">
398
+ <Card>
399
+ <CardHeader>
400
+ <CardTitle>Error Loading API Documentation</CardTitle>
401
+ <CardDescription>{error ?? "Unknown error"}</CardDescription>
402
+ </CardHeader>
403
+ </Card>
404
+ </div>
405
+ );
406
+ }
407
+
408
+ // Group endpoints by tag/plugin
409
+ const endpointsByPlugin: Record<
410
+ string,
411
+ Array<{ path: string; method: string; operation: OperationObject }>
412
+ > = {};
413
+
414
+ for (const [path, methods] of Object.entries(spec.paths)) {
415
+ for (const [method, operation] of Object.entries(methods)) {
416
+ // Apply userType filter if types are selected
417
+ const meta = operation["x-orpc-meta"];
418
+ const opUserType = meta?.userType ?? "unknown";
419
+ if (selectedTypes.size > 0 && !selectedTypes.has(opUserType)) {
420
+ continue;
421
+ }
422
+
423
+ // Extract plugin name from path (e.g., /catalog/getEntities -> catalog)
424
+ // Path can be /api/plugin/... or /plugin/... depending on OpenAPI prefix setting
425
+ const pluginMatch = path.match(/^\/?(?:api\/)?([^/]+)/);
426
+ const pluginName = pluginMatch?.[1] ?? "other";
427
+
428
+ if (!endpointsByPlugin[pluginName]) {
429
+ endpointsByPlugin[pluginName] = [];
430
+ }
431
+ endpointsByPlugin[pluginName].push({ path, method, operation });
432
+ }
433
+ }
434
+
435
+ return (
436
+ <div className="container mx-auto py-8 space-y-6">
437
+ <div>
438
+ <h1 className="text-3xl font-bold">{spec.info.title}</h1>
439
+ <p className="text-muted-foreground mt-2">{spec.info.description}</p>
440
+ <Badge variant="secondary" className="mt-2">
441
+ v{spec.info.version}
442
+ </Badge>
443
+ </div>
444
+
445
+ <div className="flex flex-wrap items-center gap-2">
446
+ <span className="text-sm text-muted-foreground">Filter by access:</span>
447
+ <Button
448
+ variant={selectedTypes.size === 0 ? "primary" : "outline"}
449
+ size="sm"
450
+ onClick={showAll}
451
+ >
452
+ All
453
+ </Button>
454
+ <Button
455
+ variant={selectedTypes.has("authenticated") ? "primary" : "outline"}
456
+ size="sm"
457
+ onClick={() => toggleType("authenticated")}
458
+ >
459
+ Authenticated
460
+ </Button>
461
+ <Button
462
+ variant={selectedTypes.has("public") ? "primary" : "outline"}
463
+ size="sm"
464
+ onClick={() => toggleType("public")}
465
+ >
466
+ Public
467
+ </Button>
468
+ <Button
469
+ variant={selectedTypes.has("user") ? "primary" : "outline"}
470
+ size="sm"
471
+ onClick={() => toggleType("user")}
472
+ >
473
+ User Only
474
+ </Button>
475
+ <Button
476
+ variant={selectedTypes.has("service") ? "primary" : "outline"}
477
+ size="sm"
478
+ onClick={() => toggleType("service")}
479
+ >
480
+ Service Only
481
+ </Button>
482
+ </div>
483
+
484
+ <Card>
485
+ <CardHeader>
486
+ <CardTitle>Authentication</CardTitle>
487
+ <CardDescription>
488
+ Endpoints marked as <strong>authenticated</strong> or{" "}
489
+ <strong>public</strong> can be accessed using an application token.
490
+ Other endpoints are for internal use only.
491
+ </CardDescription>
492
+ </CardHeader>
493
+ <CardContent>
494
+ <pre className="bg-muted rounded-md p-3 overflow-x-auto text-sm">
495
+ <code>
496
+ Authorization: Bearer ck_{"<application-id>"}_{"<secret>"}
497
+ </code>
498
+ </pre>
499
+ </CardContent>
500
+ </Card>
501
+
502
+ <div className="space-y-8">
503
+ {Object.entries(endpointsByPlugin)
504
+ .toSorted(([a], [b]) => a.localeCompare(b))
505
+ .map(([pluginName, endpoints]) => (
506
+ <div key={pluginName}>
507
+ <h2 className="text-xl font-semibold mb-4 capitalize">
508
+ {pluginName}
509
+ </h2>
510
+ {endpoints.map(({ path, method, operation }) => (
511
+ <EndpointCard
512
+ key={`${method}-${path}`}
513
+ path={path}
514
+ method={method}
515
+ operation={operation}
516
+ />
517
+ ))}
518
+ </div>
519
+ ))}
520
+ </div>
521
+ </div>
522
+ );
523
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,35 @@
1
+ import {
2
+ createFrontendPlugin,
3
+ UserMenuItemsSlot,
4
+ } from "@checkmate-monitor/frontend-api";
5
+ import { createRoutes } from "@checkmate-monitor/common";
6
+ import {
7
+ pluginMetadata,
8
+ permissions,
9
+ } from "@checkmate-monitor/api-docs-common";
10
+ import { ApiDocsPage } from "./ApiDocsPage";
11
+ import { ApiDocsMenuItem } from "./ApiDocsMenuItem";
12
+
13
+ export const apiDocsRoutes = createRoutes(pluginMetadata.pluginId, {
14
+ docs: "/",
15
+ });
16
+
17
+ export const apiDocsPlugin = createFrontendPlugin({
18
+ metadata: pluginMetadata,
19
+ routes: [
20
+ {
21
+ route: apiDocsRoutes.routes.docs,
22
+ element: <ApiDocsPage />,
23
+ permission: permissions.apiDocsView,
24
+ },
25
+ ],
26
+ extensions: [
27
+ {
28
+ id: "api-docs.user-menu.link",
29
+ slot: UserMenuItemsSlot,
30
+ component: ApiDocsMenuItem,
31
+ },
32
+ ],
33
+ });
34
+
35
+ export default apiDocsPlugin;
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }