@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 +13 -0
- package/package.json +26 -0
- package/src/ApiDocsMenuItem.tsx +32 -0
- package/src/ApiDocsPage.tsx +523 -0
- package/src/index.tsx +35 -0
- package/tsconfig.json +6 -0
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;
|