diff --git a/src/constants/tools.ts b/src/constants/tools.ts index 86f3900..626c63d 100644 --- a/src/constants/tools.ts +++ b/src/constants/tools.ts @@ -258,6 +258,13 @@ export const tools: Tool[] = [ icon: ``, }, { + name: "OpenAPI / Swagger Viewer", + description: "Parse and view OpenAPI/Swagger specifications visually in an interactive, clean API documentation viewer.", + href: "/tools/openapi-swagger-viewer", + + }, + { + icon: ``, name: "JWT Generator & Validator", description: "Generate, inspect, decode, and verify JSON Web Tokens directly in your browser.", href: "/tools/jwt-generator-validator", diff --git a/src/features/openapi-swagger-viewer/OpenApiSwaggerViewer.tsx b/src/features/openapi-swagger-viewer/OpenApiSwaggerViewer.tsx new file mode 100644 index 0000000..91711f4 --- /dev/null +++ b/src/features/openapi-swagger-viewer/OpenApiSwaggerViewer.tsx @@ -0,0 +1,934 @@ +import { useState, useMemo, useEffect } from "react"; +import YAML from "yaml"; +import CopyButton from "../../ui/CopyButton"; +import ToolTextarea from "../../components/tool/ToolTextarea"; + +// Helper to resolve ref schema from components +function resolveRef(ref: string, components: any): any { + if (!ref || !ref.startsWith("#/components/")) return null; + const parts = ref.split("/"); + let current = components; + for (let i = 2; i < parts.length; i++) { + if (!current) return null; + current = current[parts[i]]; + } + return current; +} + +// Generate sample JSON structure recursively from OpenAPI schema +function generateSampleJson(schema: any, components: any, visited = new Set()): any { + if (!schema) return null; + + if (schema.$ref) { + if (visited.has(schema.$ref)) return {}; // Prevent infinite recursion + const resolved = resolveRef(schema.$ref, components); + if (!resolved) return {}; + visited.add(schema.$ref); + const result = generateSampleJson(resolved, components, visited); + visited.delete(schema.$ref); + return result; + } + + if (schema.type === "object") { + const obj: any = {}; + if (schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + obj[key] = generateSampleJson(value, components, visited); + } + } + return obj; + } + + if (schema.type === "array") { + return [generateSampleJson(schema.items, components, visited)]; + } + + if (schema.example !== undefined) return schema.example; + if (schema.default !== undefined) return schema.default; + + switch (schema.type) { + case "string": + if (schema.format === "date-time") return new Date().toISOString(); + return "string"; + case "integer": + case "number": + return 0; + case "boolean": + return true; + default: + return null; + } +} + +// Generate copyable mockup curl command +function generateCurl(path: string, method: string, operation: any, components: any, serverUrl: string): string { + const base = serverUrl || "https://api.example.com"; + let finalPath = path; + + // Resolve path parameters + if (operation.parameters) { + const pathParams = operation.parameters.filter((p: any) => p.in === "path"); + pathParams.forEach((p: any) => { + const name = p.name; + const example = p.example || p.schema?.example || `{${name}}`; + finalPath = finalPath.replace(`{${name}}`, example); + }); + } + + // Resolve query parameters + const queryParts: string[] = []; + if (operation.parameters) { + const queryParams = operation.parameters.filter((p: any) => p.in === "query"); + queryParams.forEach((p: any) => { + const name = p.name; + const example = p.example || p.schema?.example || "value"; + queryParts.push(`${name}=${encodeURIComponent(example)}`); + }); + } + + const queryStr = queryParts.length > 0 ? `?${queryParts.join("&")}` : ""; + let curl = `curl -X ${method.toUpperCase()} "${base}${finalPath}${queryStr}"`; + + // Add JSON request body if present + if (operation.requestBody) { + const content = operation.requestBody.content; + if (content && content["application/json"]) { + const schema = content["application/json"].schema; + const sampleObj = generateSampleJson(schema, components); + const jsonStr = JSON.stringify(sampleObj, null, 2); + curl += ` \\\n -H "Content-Type: application/json" \\\n -d '${jsonStr.replace(/'/g, "'\\''")}'`; + } + } + + return curl; +} + +// Recursive React component to render OpenAPI Schemas +function SchemaItem({ + name, + schema, + components, + required, + depth = 0, +}: { + name?: string; + schema: any; + components: any; + required?: boolean; + depth?: number; +}) { + if (!schema) return null; + + let resolvedSchema = { ...schema }; + if (schema.$ref) { + const refSchema = resolveRef(schema.$ref, components); + if (refSchema) { + resolvedSchema = { ...refSchema, ...schema }; + } + } + + const hasChildren = resolvedSchema.type === "object" && resolvedSchema.properties; + const isArray = resolvedSchema.type === "array" && resolvedSchema.items; + + let typeLabel = resolvedSchema.type || "any"; + if (isArray) { + let itemType = resolvedSchema.items.type || "any"; + if (resolvedSchema.items.$ref) { + const refName = resolvedSchema.items.$ref.split("/").pop(); + itemType = refName || "object"; + } + typeLabel = `array of ${itemType}`; + } else if (resolvedSchema.type === "object" && resolvedSchema.properties && schema.$ref) { + const refName = schema.$ref.split("/").pop(); + typeLabel = refName || "object"; + } + + return ( +
0 ? "1.25rem" : "0" }}> +
+ {name && {name}} + + {typeLabel} + + {required && required} + {resolvedSchema.default !== undefined && ( + + default: {String(resolvedSchema.default)} + + )} + {resolvedSchema.description && ( + {resolvedSchema.description} + )} +
+ {hasChildren && ( +
+ {Object.keys(resolvedSchema.properties).map((propName) => { + const isPropRequired = + Array.isArray(resolvedSchema.required) && resolvedSchema.required.includes(propName); + return ( + + ); + })} +
+ )} + {isArray && resolvedSchema.items && (resolvedSchema.items.type === "object" || resolvedSchema.items.$ref) && ( +
+ +
+ )} +
+ ); +} + +const SAMPLE_SPEC = `openapi: 3.0.0 +info: + title: User Directory API + description: A sample API to manage users, roles, and retrieve profile information. + version: 1.0.0 +servers: + - url: https://api.userdirectory.com/v1 + description: Production Server + - url: https://staging-api.userdirectory.com/v1 + description: Staging Server +paths: + /users: + get: + summary: Retrieve a list of users + description: Returns a paginated list of users. You can filter by role or status. + tags: + - Users + parameters: + - name: limit + in: query + description: Max number of users to return + required: false + schema: + type: integer + default: 10 + example: 20 + - name: role + in: query + description: Filter users by role + required: false + schema: + type: string + enum: [admin, member, guest] + example: member + responses: + '200': + description: Successful response returning list of users + content: + application/json: + schema: + type: object + properties: + total: + type: integer + example: 120 + users: + type: array + items: + $ref: '#/components/schemas/User' + '401': + description: Unauthorized request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + summary: Create a new user + description: Register a new user in the directory. + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreateInput' + responses: + '201': + description: User created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Bad request (validation failed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /users/{id}: + get: + summary: Get user by ID + description: Retrieve profile information for a specific user. + tags: + - Users + parameters: + - name: id + in: path + description: Unique identifier of the user + required: true + schema: + type: string + example: "usr_998822" + responses: + '200': + description: Profile information retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Delete user + description: Permanently delete a user from the directory. + tags: + - Users + parameters: + - name: id + in: path + description: Unique identifier of the user + required: true + schema: + type: string + example: "usr_998822" + responses: + '204': + description: User deleted successfully + '404': + description: User not found +components: + schemas: + User: + type: object + required: + - id + - email + - name + - role + properties: + id: + type: string + description: Unique identifier of the user + example: "usr_998822" + email: + type: string + description: Email address of the user + example: "john.doe@example.com" + name: + type: string + description: Full name of the user + example: "John Doe" + role: + type: string + description: Access role of the user + example: "member" + createdAt: + type: string + description: Timestamp when the user was created + example: "2026-06-11T09:44:43Z" + UserCreateInput: + type: object + required: + - email + - name + properties: + email: + type: string + description: Email address for the new account + example: "jane.smith@example.com" + name: + type: string + description: Full name of the new user + example: "Jane Smith" + role: + type: string + description: Default role to assign + default: "member" + example: "member" + ErrorResponse: + type: object + required: + - code + - message + properties: + code: + type: string + example: "INVALID_REQUEST" + message: + type: string + example: "The request body failed validation checks." +`; + +export default function OpenApiSwaggerViewer() { + const [inputSpec, setInputSpec] = useState(SAMPLE_SPEC); + const [parsedSpec, setParsedSpec] = useState(null); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [expandedEndpoints, setExpandedEndpoints] = useState>({}); + + // Parse Spec trigger + const handleParse = (value: string) => { + setError(null); + if (!value.trim()) { + setParsedSpec(null); + return; + } + try { + let parsed: any; + try { + parsed = JSON.parse(value); + } catch { + parsed = YAML.parse(value); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Parsed spec is not a valid OpenAPI Object."); + } + if (!parsed.openapi && !parsed.swagger) { + throw new Error("Could not find openapi or swagger key in specification."); + } + + setParsedSpec(parsed); + // Auto expand all by default + const initialExpand: Record = {}; + if (parsed.paths) { + Object.keys(parsed.paths).forEach((p) => { + Object.keys(parsed.paths[p]).forEach((m) => { + initialExpand[`${m}-${p}`] = true; + }); + }); + } + setExpandedEndpoints(initialExpand); + } catch (err: any) { + setError(err.message || "Failed to parse JSON/YAML."); + setParsedSpec(null); + } + }; + + // Run initial parse on sample load + useEffect(() => { + handleParse(inputSpec); + }, []); + + const handleClear = () => { + setInputSpec(""); + setParsedSpec(null); + setError(null); + }; + + const handleLoadSample = () => { + setInputSpec(SAMPLE_SPEC); + handleParse(SAMPLE_SPEC); + }; + + // Grouped and filtered endpoints + const groupedEndpoints = useMemo(() => { + if (!parsedSpec || !parsedSpec.paths) return []; + + const endpoints: any[] = []; + Object.entries(parsedSpec.paths).forEach(([pathName, pathObj]: [string, any]) => { + Object.entries(pathObj).forEach(([methodName, operationObj]: [string, any]) => { + // Only accept HTTP Methods + if (!["get", "post", "put", "delete", "patch", "options", "head"].includes(methodName.toLowerCase())) { + return; + } + + const tags = operationObj.tags || ["Default"]; + tags.forEach((tag: string) => { + endpoints.push({ + tag, + path: pathName, + method: methodName.toUpperCase(), + operation: operationObj, + }); + }); + }); + }); + + // Filter by query + const filtered = endpoints.filter((item) => { + const q = searchQuery.toLowerCase(); + + return ( + item.tag.toLowerCase().includes(q) || + item.path.toLowerCase().includes(q) || + item.method.toLowerCase().includes(q) || + (item.operation.summary && + item.operation.summary.toLowerCase().includes(q)) || + (item.operation.description && + item.operation.description.toLowerCase().includes(q)) + ); + }); + + // Group by tag name + const groups: Record = {}; + filtered.forEach((item) => { + if (!groups[item.tag]) { + groups[item.tag] = []; + } + groups[item.tag].push(item); + }); + + return Object.entries(groups).map(([tag, items]) => ({ + tag, + items, + })); + }, [parsedSpec, searchQuery]); + + // Styling helper for HTTP methods + const getMethodBadge = (method: string) => { + const base = "px-2.5 py-1 text-xs font-bold rounded-md font-mono border uppercase tracking-wider "; + switch (method) { + case "GET": + return base + "text-blue-500 bg-blue-500/10 border-blue-500/20"; + case "POST": + return base + "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"; + case "PUT": + return base + "text-amber-500 bg-amber-500/10 border-amber-500/20"; + case "PATCH": + return base + "text-orange-500 bg-orange-500/10 border-orange-500/20"; + case "DELETE": + return base + "text-rose-500 bg-rose-500/10 border-rose-500/20"; + default: + return base + "text-secondary bg-elevated border-border"; + } + }; + + const toggleEndpoint = (key: string) => { + setExpandedEndpoints((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + + const activeServerUrl = parsedSpec?.servers?.[0]?.url || ""; + + return ( +
+
+ {/* Editor (Left Column) */} +
+
+
+ Swagger Specification +
+ + +
+
+ + { + setInputSpec(val); + handleParse(val); + }} + placeholder="Paste your OpenAPI spec (YAML or JSON) here..." + rows={25} + /> + + {error && ( +
+ {error} +
+ )} +
+
+ + {/* Documentation Viewer (Right Column) */} +
+ {!parsedSpec ? ( +
+ + + + + + +

No API Spec Loaded

+

Paste a valid OpenAPI 3.0 configuration or load the example to get started.

+
+ ) : ( +
+ {/* Overview Card */} +
+
+

{parsedSpec.info?.title || "API Documentation"}

+ {parsedSpec.info?.version && ( + + v{parsedSpec.info.version} + + )} +
+ + {parsedSpec.info?.description && ( +

{parsedSpec.info.description}

+ )} +
+
+
OpenAPI
+
+ {parsedSpec.openapi || parsedSpec.swagger} +
+
+ +
+
Endpoints
+
+ {Object.keys(parsedSpec.paths || {}).length} +
+
+ +
+
Servers
+
+ {parsedSpec.servers?.length || 0} +
+
+ +
+
Schemas
+
+ {Object.keys(parsedSpec.components?.schemas || {}).length} +
+
+
+ + {parsedSpec.servers && parsedSpec.servers.length > 0 && ( +
+ Servers +
+ {parsedSpec.servers.map((server: any, idx: number) => ( +
+ + {server.url} + + {server.description && ( + ({server.description}) + )} +
+ ))} +
+
+ )} +
+ + {/* Endpoints & Search */} +
+
+ setSearchQuery(e.target.value)} + placeholder="Search operations by path, method, or description..." + className="w-full rounded-xl border border-border bg-surface px-4 py-3 pl-10 text-sm outline-none focus:border-accent/50" + /> +
+ + + + +
+
+ + {groupedEndpoints.length === 0 ? ( +
+ No matching endpoints found. +
+ ) : ( + groupedEndpoints.map((group) => ( +
+

{group.tag}

+ +
+ {group.items.map((endpoint) => { + const curlCommand = generateCurl( + endpoint.path, + endpoint.method, + endpoint.operation, + parsedSpec.components, + activeServerUrl + ); + const key = `${endpoint.method.toLowerCase()}-${endpoint.path}`; + const isExpanded = !!expandedEndpoints[key]; + + return ( +
+ {/* Accordion Trigger */} + + + {/* Accordion Content */} + {isExpanded && ( +
+ {/* Operation ID */} + {endpoint.operation.operationId && ( +
+ operationId: {endpoint.operation.operationId} +
+ )} + + {/* Description */} + {endpoint.operation.description && ( +
+ {endpoint.operation.description} +
+ )} + + + {/* Copy Path Helper */} +
+ + Endpoint Path: {endpoint.path} + + +
+ + {/* Parameters */} + {endpoint.operation.parameters && + endpoint.operation.parameters.length > 0 && ( +
+
+ Parameters +
+
+ + + + + + + + + + + + {endpoint.operation.parameters.map((param: any, pIdx: number) => ( + + + + + + + + ))} + +
NameLocated InTypeRequiredDescription
+ {param.name} + + {param.in} + + {param.schema?.type || "any"} + + {param.required ? ( + + yes + + ) : ( + no + )} + + {param.description} +
+
+
+ )} + + {/* Request Body */} + {endpoint.operation.requestBody && ( +
+
+ Request Body +
+ {endpoint.operation.requestBody.description && ( +

+ {endpoint.operation.requestBody.description} +

+ )} + {Object.entries(endpoint.operation.requestBody.content || {}).map( + ([mediaType, contentObj]: [string, any]) => ( +
+ + {mediaType} + + {contentObj.schema && ( +
+ +
+ )} +
+ ) + )} +
+ )} + + {/* Responses */} + {endpoint.operation.responses && ( +
+
+ Responses +
+
+ {Object.entries(endpoint.operation.responses).map( + ([statusCode, responseObj]: [string, any]) => { + const isSuccess = statusCode.startsWith("2"); + const isError = statusCode.startsWith("4") || statusCode.startsWith("5"); + let codeBadge = + "px-2 py-0.5 text-xs font-bold rounded-md font-mono border "; + if (isSuccess) { + codeBadge += "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"; + } else if (isError) { + codeBadge += "text-rose-500 bg-rose-500/10 border-rose-500/20"; + } else { + codeBadge += "text-secondary bg-elevated border-border"; + } + + return ( +
+
+ {statusCode} + + {responseObj.description} + +
+ + {responseObj.content && + Object.entries(responseObj.content).map( + ([resMediaType, resContentObj]: [string, any]) => ( +
+ + {resMediaType} + + {resContentObj.schema && ( +
+ +
+ )} +
+ ) + )} +
+ ); + } + )} +
+
+ )} + + {/* Mock Curl Command */} +
+
+
+ Mock Curl Request +
+ +
+ +
+                                      {curlCommand}
+                                    
+
+
+ ) + } +
+ ); + })} +
+
+ )) + )} +
+
+ )} +
+
+
+ ); +} diff --git a/src/pages/tools/openapi-swagger-viewer.astro b/src/pages/tools/openapi-swagger-viewer.astro new file mode 100644 index 0000000..90276fa --- /dev/null +++ b/src/pages/tools/openapi-swagger-viewer.astro @@ -0,0 +1,13 @@ +--- +import BaseLayout from "../../layouts/BaseLayout.astro"; +import OpenApiSwaggerViewer from "../../features/openapi-swagger-viewer/OpenApiSwaggerViewer"; +--- + + +

OpenAPI / Swagger Viewer

+ + +