diff --git a/package.json b/package.json index a72390554..fa1ce8fce 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "eslint-plugin-react": "^7.34.1", "eslint-plugin-simple-import-sort": "^12.0.0", "fuse.js": "^7.0.0", + "httpsnippet-lite": "^3.0.5", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lodash-es": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc4ffb485..a31c314ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ dependencies: fuse.js: specifier: ^7.0.0 version: 7.0.0 + httpsnippet-lite: + specifier: ^3.0.5 + version: 3.0.5 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -2453,6 +2456,10 @@ packages: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: false + /@types/har-format@1.2.15: + resolution: {integrity: sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==} + dev: false + /@types/hast@3.0.4: resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} dependencies: @@ -4751,6 +4758,14 @@ packages: engines: {node: '>=0.4.x'} dev: false + /formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + dev: false + /fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} requiresBuild: true @@ -4840,6 +4855,10 @@ packages: hasown: 2.0.1 dev: false + /get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + dev: false + /get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -5209,6 +5228,15 @@ packages: - supports-color dev: false + /httpsnippet-lite@3.0.5: + resolution: {integrity: sha512-So4qTXY5iFj5XtFDwyz2PicUu+8NWrI8e8h+ZeZoVtMNcFQp4FFIntBHUE+JPUG6QQU8o1VHCy+X4ETRDwt9CA==} + engines: {node: '>=14.13'} + dependencies: + '@types/har-format': 1.2.15 + formdata-node: 4.4.1 + stringify-object: 3.3.0 + dev: false + /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -5469,6 +5497,11 @@ packages: engines: {node: '>=0.12.0'} dev: false + /is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + dev: false + /is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} @@ -5493,6 +5526,11 @@ packages: has-tostringtag: 1.0.2 dev: false + /is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + dev: false + /is-set@2.0.2: resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} dev: false @@ -6593,6 +6631,11 @@ packages: dev: false optional: true + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-fetch-native@1.6.4: resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} dev: false @@ -8414,6 +8457,15 @@ packages: character-entities-legacy: 3.0.0 dev: false + /stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + dev: false + /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -9346,6 +9398,11 @@ packages: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} dev: false + /web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + dev: false + /web-vitals@3.4.0: resolution: {integrity: sha512-n9fZ5/bG1oeDkyxLWyep0eahrNcPDF6bFqoyispt7xkW0xhDzpUBTgyDKqWDi1twT0MgH4HvvqzpUyh0ZxZV4A==} dev: false diff --git a/src/layouts/rest-api/editor/RequestJsonEditor.tsx b/src/layouts/rest-api/editor/RequestJsonEditor.tsx index 368da8e01..ce98f3db9 100644 --- a/src/layouts/rest-api/editor/RequestJsonEditor.tsx +++ b/src/layouts/rest-api/editor/RequestJsonEditor.tsx @@ -120,10 +120,11 @@ export function getInitialJsonText( } function getDefaultValue(_schema: unknown, param: Parameter): unknown { - const type = param.type || "object"; + if (param.example) return param.example; + const type = param.type ? param.type : param.schema?.type || "object"; if (type === "boolean") return false; if (type === "number" || type === "integer") return 0; - if (type === "string") return ""; + if (type === "string") return param.name; if (type === "object") return {}; if (type === "array") return []; return null; diff --git a/src/layouts/rest-api/endpoint/playground/try/Req.tsx b/src/layouts/rest-api/endpoint/playground/try/Req.tsx index 1011deb47..3d751ba6e 100644 --- a/src/layouts/rest-api/endpoint/playground/try/Req.tsx +++ b/src/layouts/rest-api/endpoint/playground/try/Req.tsx @@ -1,4 +1,10 @@ -import { type Signal, signal, useSignal } from "@preact/signals"; +import { + type Signal, + signal, + useSignal, + useSignalEffect, +} from "@preact/signals"; +import { type HarRequest } from "httpsnippet-lite"; import json5 from "json5"; import { useMemo } from "preact/hooks"; import { encode as encodeQs } from "querystring"; @@ -23,6 +29,7 @@ export interface ReqProps { schema: unknown; endpoint: Endpoint; operation: Operation; + harRequestSignal: Signal; execute: (fn: () => Promise) => void; } export default function Req({ @@ -30,6 +37,7 @@ export default function Req({ schema, endpoint, operation, + harRequestSignal, execute, }: ReqProps) { const { path, method } = endpoint; @@ -39,6 +47,17 @@ export default function Req({ const reqPathParams = useReqParams(schema, operation, "path"); const reqQueryParams = useReqParams(schema, operation, "query"); const reqBodyParams = useReqParams(schema, operation, "body"); + useSignalEffect(() => { + harRequestSignal.value = createHarRequest( + apiHost, + path, + method, + reqHeaderSignal, + reqPathParams, + reqQueryParams, + reqBodyParams, + ); + }); return ( { const body = (await res.json()) as unknown; return { status, headers, body }; } + +function createHarRequest( + apiHost: string, + path: string, + method: string, + reqHeaderSignal: Signal, + reqPathParams: ReqParams, + reqQueryParams: ReqParams, + reqBodyParams: ReqParams, +): HarRequest { + const headers = kvListToObject(reqHeaderSignal.value); + const reqPath = reqPathParams.parseJson(); + const reqQuery = reqQueryParams.parseJson(); + const reqBody = reqBodyParams.parseJson(); + return { + url: createUrl(apiHost, path, reqPath, reqQuery).toString(), + method, + headers: Object.entries(headers).map(([name, value]) => ({ name, value })), + cookies: [], + httpVersion: "HTTP/1.1", + queryString: Object.entries(reqQuery as Record).map( + ([name, value]) => ({ name, value }), + ), + postData: { + mimeType: "application/json", + text: JSON.stringify(reqBody), + }, + bodySize: -1, + headersSize: -1, + } satisfies HarRequest; +} diff --git a/src/layouts/rest-api/endpoint/playground/try/ReqSample.tsx b/src/layouts/rest-api/endpoint/playground/try/ReqSample.tsx new file mode 100644 index 000000000..18a85e882 --- /dev/null +++ b/src/layouts/rest-api/endpoint/playground/try/ReqSample.tsx @@ -0,0 +1,177 @@ +import { + type ReadonlySignal, + type Signal, + useComputed, + useSignal, + useSignalEffect, +} from "@preact/signals"; +import { + availableTargets as _availableTargets, + type HarRequest, + HTTPSnippet, +} from "httpsnippet-lite"; + +import MonacoEditor, { + commonEditorConfig, +} from "~/layouts/rest-api/editor/MonacoEditor"; + +import Card from "../Card"; + +export interface ReqSampleProps { + harRequestSignal: Signal; +} + +const httpSnippetLanguageMap = new Map( + Object.entries({ + c: "c", + clojure: "clojure", + csharp: "csharp", + go: "go", + java: "java", + javascript: "javascript", + kotlin: "kotlin", + node: "javascript", + objc: "objective-c", + php: "php", + powershell: "powershell", + python: "python", + r: "r", + ruby: "ruby", + shell: "shell", + swift: "swift", + }), +); +const availableTargets = _availableTargets() + .map((target) => ({ + ...target, + language: httpSnippetLanguageMap.get(target.key), + })) + .filter((target) => target.language); +type AvailableTarget = (typeof availableTargets)[number]; +type ClientInfo = AvailableTarget["clients"][number]; + +export default function ReqSample({ harRequestSignal }: ReqSampleProps) { + const targetKeySignal = useSignal("shell"); + const clientKeySignal = useSignal("curl"); + const targetInfoSignal = useTargetInfo(targetKeySignal); + const clientInfoSignal = useClientInfo(targetInfoSignal, clientKeySignal); + const snippetSignal = useHTTPSnippet( + harRequestSignal, + targetInfoSignal, + clientInfoSignal, + ); + + return ( + + Request Sample +
+ + +
+ + } + > + {snippetSignal.value ? ( + + monaco.editor.create(domElement, { + ...commonEditorConfig, + value: snippetSignal.value || "", + language: targetInfoSignal.value?.language ?? "plaintext", + readOnly: true, + }) + } + /> + ) : ( + N/A + )} +
+ ); +} + +function useTargetInfo( + keySignal: ReadonlySignal, +): ReadonlySignal { + return useComputed( + () => + availableTargets.find((target) => target.key === keySignal.value) ?? null, + ); +} + +function useClientInfo( + targetInfoSignal: ReadonlySignal, + clientKeySignal: Signal, +): ReadonlySignal { + return useComputed(() => { + const clientInfo = + targetInfoSignal.value?.clients.find( + (client) => client.key === clientKeySignal.value, + ) ?? + targetInfoSignal.value?.clients.find( + (client) => client.key === targetInfoSignal.value?.default, + ) ?? + targetInfoSignal.value?.clients[0] ?? + null; + if (clientInfo) { + clientKeySignal.value = clientInfo.key; + } + return clientInfo; + }); +} + +function useHTTPSnippet( + harRequestSignal: Signal, + targetIdSignal: ReadonlySignal, + clientIdSignal: ReadonlySignal, +): Signal { + const snippetSignal = useSignal(null); + useSignalEffect(() => { + if ( + harRequestSignal.value && + targetIdSignal.value && + clientIdSignal.value + ) { + new HTTPSnippet(harRequestSignal.value) + .convert(targetIdSignal.value.key, clientIdSignal.value.key) + .then((snippet) => { + if (Array.isArray(snippet)) { + snippet = snippet.join("\n"); + } + snippetSignal.value = snippet; + }) + .catch((err) => { + console.error(err); + }); + } else { + snippetSignal.value = null; + } + }); + return snippetSignal; +} diff --git a/src/layouts/rest-api/endpoint/playground/try/ResExample.tsx b/src/layouts/rest-api/endpoint/playground/try/ResExample.tsx new file mode 100644 index 000000000..c4ed14309 --- /dev/null +++ b/src/layouts/rest-api/endpoint/playground/try/ResExample.tsx @@ -0,0 +1,18 @@ +import JsonViewer from "../../../editor/JsonViewer"; +import Card from "../Card"; + +export interface ResExampleProps { + example: { [key: string]: unknown } | undefined; +} + +export default function ResExample({ example }: ResExampleProps) { + return ( + + {example ? ( + + ) : ( + N/A + )} + + ); +} diff --git a/src/layouts/rest-api/endpoint/playground/try/Tabs.tsx b/src/layouts/rest-api/endpoint/playground/try/Tabs.tsx index 7b178a90a..db5dd42bc 100644 --- a/src/layouts/rest-api/endpoint/playground/try/Tabs.tsx +++ b/src/layouts/rest-api/endpoint/playground/try/Tabs.tsx @@ -1,4 +1,4 @@ -import { useSignal } from "@preact/signals"; +import { Signal, useSignal } from "@preact/signals"; import type React from "preact/compat"; export interface Tab { @@ -8,10 +8,11 @@ export interface Tab { } export interface TabsProps { tabs: (Tab | false | 0)[]; + tabIdSignal?: Signal; } -export function Tabs({ tabs }: TabsProps) { +export function Tabs({ tabs, tabIdSignal }: TabsProps) { const _tabs = tabs.filter(Boolean); - const currTabIdSignal = useSignal(_tabs[0]?.id || ""); + const currTabIdSignal = tabIdSignal ?? useSignal(_tabs[0]?.id || ""); const currTabId = currTabIdSignal.value; const currTab = _tabs.find((tab) => tab.id === currTabId); return ( diff --git a/src/layouts/rest-api/endpoint/playground/try/Try.tsx b/src/layouts/rest-api/endpoint/playground/try/Try.tsx index 554c6cea6..9181aca37 100644 --- a/src/layouts/rest-api/endpoint/playground/try/Try.tsx +++ b/src/layouts/rest-api/endpoint/playground/try/Try.tsx @@ -1,10 +1,14 @@ import { useSignal } from "@preact/signals"; +import { type HarRequest } from "httpsnippet-lite"; import type { Endpoint } from "../../../schema-utils/endpoint"; import type { Operation } from "../../../schema-utils/operation"; import Err from "./Err"; import Req from "./Req"; +import ReqSample from "./ReqSample"; import ResComponent, { type Res } from "./Res"; +import ResExample from "./ResExample"; +import { Tabs } from "./Tabs"; export interface TryProps { apiHost: string; @@ -18,29 +22,60 @@ export default function Try(props: TryProps) { const errSignal = useSignal(""); const err = errSignal.value; const resSignal = useSignal(undefined); + const harRequestSignal = useSignal(undefined); + const example = + props.operation.responses?.["200"]?.content?.["application/json"]?.example; + const tabIdSignal = useSignal<"request" | "response">("request"); return (
- - void (async () => { - try { - waitingSignal.value = true; - resSignal.value = await fn(); - errSignal.value = ""; - } catch (err) { - errSignal.value = (err as Error).message; - } finally { - waitingSignal.value = false; - } - })() - } - /> - {err ? {err} : } + ( +
+ + void (async () => { + try { + waitingSignal.value = true; + resSignal.value = await fn(); + errSignal.value = ""; + } catch (err) { + errSignal.value = (err as Error).message; + } finally { + waitingSignal.value = false; + tabIdSignal.value = "response"; + } + })() + } + /> + +
+ ), + }, + { + id: "response", + label: "response", + render: (key) => ( +
+ {err ? ( + {err} + ) : ( + + )} + +
+ ), + }, + ]} + >
); } diff --git a/src/layouts/rest-api/schema-utils/operation.ts b/src/layouts/rest-api/schema-utils/operation.ts index 7dba466f8..322313779 100644 --- a/src/layouts/rest-api/schema-utils/operation.ts +++ b/src/layouts/rest-api/schema-utils/operation.ts @@ -13,7 +13,14 @@ export interface Operation { summary?: string | undefined; description?: string | undefined; requestBody?: - | { content: { "application/json": { schema: TypeDef } } } + | { + content: { + "application/json": { + schema: TypeDef; + example?: { [key: string]: unknown }; + }; + }; + } | undefined; parameters?: Parameter[] | undefined; responses: { [statusCode: number]: Response }; @@ -36,7 +43,12 @@ export interface Parameter extends Property { export interface Response { description?: string | undefined; schema?: TypeDef | undefined; - content?: { "application/json": { schema: TypeDef } }; + content?: { + "application/json": { + schema: TypeDef; + example?: { [key: string]: unknown }; + }; + }; } export function getOperation( @@ -61,9 +73,9 @@ export function getBodyParameters( schema: unknown, operation: Operation, ): Parameter[] { - const requestSchema = - operation.requestBody?.content["application/json"]?.schema; - if (requestSchema) return bakeProperties(schema, requestSchema); + const { schema: requestSchema, example } = + operation.requestBody?.content["application/json"] ?? {}; + if (requestSchema) return bakeProperties(schema, requestSchema, example); return ( operation.parameters?.filter((p) => p.in !== "path" && p.in !== "query") || [] diff --git a/src/layouts/rest-api/schema-utils/type-def.ts b/src/layouts/rest-api/schema-utils/type-def.ts index d69600900..58df05c0d 100644 --- a/src/layouts/rest-api/schema-utils/type-def.ts +++ b/src/layouts/rest-api/schema-utils/type-def.ts @@ -40,6 +40,7 @@ export interface Property { format?: string | undefined; items?: string | TypeDef | undefined; deprecated?: boolean | undefined; + example?: unknown; /** * @deprecated use `x-portone-title` */ @@ -64,6 +65,7 @@ export interface BakedProperty extends Property { export function bakeProperties( schema: unknown, typeDef: TypeDef, + example?: { [key: string]: unknown }, ): BakedProperty[] { filter: if (!typeDef.$ref && typeDef.type) { switch (typeDef.type) { @@ -81,7 +83,14 @@ export function bakeProperties( const resolvedProperty = resolveTypeDef(schema, property); const type = $ref ? getTypenameByRef($ref) : resolvedProperty.type; const required = resolvedDef.required?.includes(name); - return { ...resolvedProperty, $ref, type, name, required } as BakedProperty; + return { + ...resolvedProperty, + $ref, + type, + name, + required, + example: example?.[name], + } as BakedProperty; }); }