diff --git a/apps/wing-console/console/app/demo/main.w b/apps/wing-console/console/app/demo/main.w index 8b830672933..a509e5401af 100644 --- a/apps/wing-console/console/app/demo/main.w +++ b/apps/wing-console/console/app/demo/main.w @@ -155,3 +155,113 @@ class WidgetService { } new WidgetService(); + +class ApiUsersService { + api: cloud.Api; + db: cloud.Bucket; + + new() { + this.api = new cloud.Api(); + this.db = new cloud.Bucket(); + + this.api.post("/users", inflight (request: cloud.ApiRequest): cloud.ApiResponse => { + let input = Json.tryParse(request.body ?? "") ?? ""; + let name = input.tryGet("name")?.tryAsStr() ?? ""; + if name == "" { + return cloud.ApiResponse { + status: 400, + body: "Body parameter 'name' is required" + }; + } + this.db.put("user-{name}", Json.stringify(input)); + return cloud.ApiResponse { + status: 200, + body: Json.stringify(input) + }; + }); + this.api.get("/users", inflight (request: cloud.ApiRequest): cloud.ApiResponse => { + let name = request.query.tryGet("name") ?? ""; + + if name != "" { + try { + return cloud.ApiResponse { + status: 200, + body: this.db.get("user-{name}") + }; + } catch { + return cloud.ApiResponse { + status: 404, + body: "User not found" + }; + } + } + + return cloud.ApiResponse { + status: 200, + body: Json.stringify(this.db.list()) + }; + }); + + new ui.HttpClient( + "Test HttpClient UI component", + inflight () => { + return this.api.url; + }, + inflight () => { + return Json.stringify({ + "paths": { + "/users": { + "post": { + "summary": "Create a new user", + "parameters": [ + { + "in": "header", + "name": "cookie", + }, + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the user" + }, + "email": { + "type": "string", + "description": "The email of the user", + } + } + } + } + } + }, + }, + "get": { + "summary": "List all widgets", + "parameters": [ + { + "in": "query", + "name": "name", + "schema": { + "type": "string" + }, + "description": "The name of the user" + } + ], + } + }, + } + }); + } + ); + } +} + +new ApiUsersService(); diff --git a/apps/wing-console/console/design-system/src/key-value-list.tsx b/apps/wing-console/console/design-system/src/key-value-list.tsx index e51fbde78ac..af67f45bcaf 100644 --- a/apps/wing-console/console/design-system/src/key-value-list.tsx +++ b/apps/wing-console/console/design-system/src/key-value-list.tsx @@ -14,7 +14,10 @@ import { useTheme } from "./theme-provider.js"; export interface KeyValueItem { key: string; - value: string; + value?: string; + type?: string; + required?: boolean; + description?: string; } export interface UseKeyValueListOptions { @@ -159,9 +162,11 @@ export const KeyValueList = ({ /> { onItemChange(index, { key: item.key, diff --git a/apps/wing-console/console/server/src/router/app.ts b/apps/wing-console/console/server/src/router/app.ts index b8e806d1f55..44bb1ab4c27 100644 --- a/apps/wing-console/console/server/src/router/app.ts +++ b/apps/wing-console/console/server/src/router/app.ts @@ -1,5 +1,6 @@ import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; +import type { HttpClient } from "@winglang/sdk/lib/ui/http-client.js"; import uniqby from "lodash.uniqby"; import { z } from "zod"; @@ -487,6 +488,31 @@ export const createAppRouter = () => { value: await client.invoke(""), }; }), + "app.getResourceUiHttpClient": createProcedure + .input( + z.object({ + getUrlResourcePath: z.string(), + getApiSpecResourcePath: z.string(), + }), + ) + .query(async ({ input, ctx }) => { + const simulator = await ctx.simulator(); + const getUrlClient = simulator.getResource( + input.getUrlResourcePath, + ) as IFunctionClient; + + const url = await getUrlClient.invoke(""); + + const getApiSpecClient = simulator.getResource( + input.getApiSpecResourcePath, + ) as IFunctionClient; + const openApiSpec = await getApiSpecClient.invoke(""); + + return { + url: url, + openApiSpec: JSON.parse(openApiSpec ?? "{}"), + }; + }), "app.invokeResourceUiButton": createProcedure .input( @@ -501,7 +527,6 @@ export const createAppRouter = () => { ) as IFunctionClient; await client.invoke(""); }), - "app.analytics": createProcedure.query(async ({ ctx }) => { const requireSignIn = (await ctx.requireSignIn?.()) ?? false; if (requireSignIn) { diff --git a/apps/wing-console/console/ui/src/features/api-interaction-view.tsx b/apps/wing-console/console/ui/src/features/api-interaction-view.tsx index 720854be3a5..0bac5ab408c 100644 --- a/apps/wing-console/console/ui/src/features/api-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/api-interaction-view.tsx @@ -2,6 +2,7 @@ import type { OpenApiSpec } from "@wingconsole/server/src/wingsdk"; import { memo, useCallback, useContext, useState } from "react"; import { AppContext } from "../AppContext.js"; +import { trpc } from "../services/trpc.js"; import { useApi } from "../services/use-api.js"; import type { ApiResponse } from "../shared/api.js"; import { ApiInteraction } from "../ui/api-interaction.js"; @@ -12,19 +13,16 @@ export interface ApiViewProps { export const ApiInteractionView = memo(({ resourcePath }: ApiViewProps) => { const { appMode } = useContext(AppContext); - const [schemaData, setSchemaData] = useState(); + const [apiResponse, setApiResponse] = useState(); const onFetchDataUpdate = useCallback( (data: ApiResponse) => setApiResponse(data), [], ); - const onSchemaDataUpdate = useCallback( - (data: OpenApiSpec) => setSchemaData(data), - [], - ); + + const schema = trpc["api.schema"].useQuery({ resourcePath }); + const { isLoading, callFetch } = useApi({ - resourcePath, - onSchemaDataUpdate, onFetchDataUpdate, }); @@ -32,7 +30,8 @@ export const ApiInteractionView = memo(({ resourcePath }: ApiViewProps) => { void; - onSchemaDataUpdate: (data: any) => void; } -export const useApi = ({ - resourcePath, - onFetchDataUpdate, - onSchemaDataUpdate, -}: UseApiOptions) => { +export const useApi = ({ onFetchDataUpdate }: UseApiOptions) => { const fetch = trpc["api.fetch"].useMutation(); - const schema = trpc["api.schema"].useQuery({ resourcePath }); useEffect(() => { - // if (!fetch.data?.textResponse) { - // return; - // } onFetchDataUpdate(fetch.data); }, [fetch.data, onFetchDataUpdate]); - useEffect(() => { - if (!schema.data) { - return; - } - onSchemaDataUpdate(schema.data); - }, [onSchemaDataUpdate, schema.data]); - const callFetch = useCallback( async ({ url, route, method, variables, headers, body }: ApiRequest) => { if (!url || !method || !route) { @@ -50,8 +33,8 @@ export const useApi = ({ ); const isLoading = useMemo(() => { - return fetch.isLoading || schema.isLoading; - }, [fetch.isLoading, schema.isLoading]); + return fetch.isLoading; + }, [fetch.isLoading]); return { isLoading, diff --git a/apps/wing-console/console/ui/src/shared/api.ts b/apps/wing-console/console/ui/src/shared/api.ts index e8981ee3f21..a310e72ba44 100644 --- a/apps/wing-console/console/ui/src/shared/api.ts +++ b/apps/wing-console/console/ui/src/shared/api.ts @@ -1,4 +1,3 @@ -import { KeyValueItem } from "@wingconsole/design-system"; import type { OpenApiSpec } from "@wingconsole/server/src/wingsdk"; export const HTTP_METHODS = [ @@ -83,6 +82,11 @@ export const getHeaderValues = (header: string): string[] => { export const getRoutesFromOpenApi = (openApi: OpenApiSpec): ApiRoute[] => { let routes: ApiRoute[] = []; + + if (!openApi?.paths) { + return routes; + } + for (const route of Object.keys(openApi.paths)) { const methods = Object.keys(openApi.paths[route]); for (const method of methods) { @@ -94,3 +98,100 @@ export const getRoutesFromOpenApi = (openApi: OpenApiSpec): ApiRoute[] => { } return routes; }; + +export interface Parameter { + key: string; + value: string; + type: string; + required: boolean; + description?: string; +} + +export interface GetParametersFromOpenApiProps { + path: string; + method?: string; + openApi: OpenApiSpec; + type: "query" | "header" | "path"; +} + +export const getParametersFromOpenApi = ({ + path, + method = "", + openApi, + type, +}: GetParametersFromOpenApiProps): Parameter[] => { + try { + if (!openApi?.paths[path]) { + return []; + } + const pathParameters = openApi.paths[path].parameters; + const methodParameters = + openApi.paths[path][method.toLowerCase()]?.parameters; + + const parametersList = [ + ...(pathParameters || []), + ...(methodParameters || []), + ]; + + let parameters = []; + for (const parameter of parametersList) { + if (parameter.in === type) { + const required = parameter.required || false; + const type = parameter.schema?.type || "string"; + parameters.push({ + key: parameter.name, + value: "", + type: type as string, + required: required, + description: parameter.description, + }); + } + } + + return parameters; + } catch (error) { + console.log(error); + return []; + } +}; + +export const getRequestBodyFromOpenApi = ( + path: string, + method: string, + openApi: OpenApiSpec, +): Record | undefined => { + try { + const requestBody = + openApi?.paths[path]?.[method.toLowerCase()]?.requestBody; + if (!requestBody) { + return undefined; + } + + const jsonContentType = requestBody.content?.["application/json"]; + if (!jsonContentType) { + return undefined; + } + + const schema = jsonContentType.schema; + if (!schema) { + return undefined; + } + + const bodyProperties = schema.properties; + + let response = {} as Record; + for (const key in bodyProperties) { + const type = bodyProperties[key].type; + const required = schema.required?.includes(key) ? "required" : "optional"; + const description = bodyProperties[key].description; + response[key] = `${type} (${required}) ${ + description ? `- ${description}` : "" + }`; + } + + return response; + } catch (error) { + console.log(error); + return {}; + } +}; diff --git a/apps/wing-console/console/ui/src/ui/api-interaction.tsx b/apps/wing-console/console/ui/src/ui/api-interaction.tsx index 58c08e23645..d38c90fd0d1 100644 --- a/apps/wing-console/console/ui/src/ui/api-interaction.tsx +++ b/apps/wing-console/console/ui/src/ui/api-interaction.tsx @@ -9,6 +9,7 @@ import { useKeyValueList, useTheme, } from "@wingconsole/design-system"; +import type { OpenApiSpec } from "@wingconsole/server/src/wingsdk.js"; import { createPersistentState } from "@wingconsole/use-persistent-state"; import classNames from "classnames"; import { memo, useCallback, useEffect, useId, useState } from "react"; @@ -16,10 +17,12 @@ import { memo, useCallback, useEffect, useId, useState } from "react"; import type { AppMode } from "../AppContext.js"; import type { ApiRequest, ApiResponse, ApiRoute } from "../shared/api.js"; import { + getParametersFromOpenApi, getHeaderValues, getRoutesFromOpenApi, HTTP_HEADERS, HTTP_METHODS, + getRequestBodyFromOpenApi, } from "../shared/api.js"; import { ApiResponseBodyPanel } from "./api-response-body-panel.js"; @@ -27,9 +30,10 @@ import { ApiResponseHeadersPanel } from "./api-response-headers-panel.js"; export interface ApiInteractionProps { resourceId: string; - appMode: AppMode; + appMode?: AppMode; apiResponse?: ApiResponse; - schemaData: any; + url: string; + openApiSpec: OpenApiSpec; callFetch: (data: ApiRequest) => void; isLoading: boolean; } @@ -40,23 +44,28 @@ export const ApiInteraction = memo( appMode, apiResponse, callFetch, - schemaData, + url, + openApiSpec, isLoading, }: ApiInteractionProps) => { const { theme } = useTheme(); const { usePersistentState } = createPersistentState(resourceId); - const bodyId = useId(); - const [url, setUrl] = useState(""); const [routes, setRoutes] = useState([]); - const [valuesList, setValuesList] = useState([]); - const [currentHeaderKey, setCurrentHeaderKey] = usePersistentState(""); + const [currentHeaderValues, setCurrentHeaderValues] = useState( + [], + ); const [currentRoute, setCurrentRoute] = usePersistentState(""); const [currentMethod, setCurrentMethod] = usePersistentState("GET"); + + const bodyId = useId(); + const [bodyPlaceholder, setBodyPlaceholder] = useState< + string | undefined + >(); const [body, setBody] = usePersistentState(""); const [currentOptionsTab, setCurrentOptionsTab] = @@ -69,7 +78,7 @@ export const ApiInteraction = memo( addItem: addHeader, removeItem: removeHeader, editItem: editHeader, - removeAll: removeAllHeaders, + setItems: setHeaders, } = useKeyValueList({ useState: usePersistentState, }); @@ -80,7 +89,6 @@ export const ApiInteraction = memo( removeItem: removeQueryParameter, editItem: editQueryParameter, setItems: setQueryParameters, - removeAll: removeAllQueryParameters, } = useKeyValueList({ useState: usePersistentState, }); @@ -93,6 +101,21 @@ export const ApiInteraction = memo( useState: usePersistentState, }); + // TODO revisit + const openResponseTab = useCallback( + (tabId: string) => { + setCurrentResponseTab(tabId); + }, + [setCurrentResponseTab], + ); + + const openOptionTab = useCallback( + (tabId: string) => { + setCurrentOptionsTab(tabId); + }, + [setCurrentOptionsTab], + ); + const apiCall = useCallback(async () => { if (!url || !currentMethod || !currentRoute) { return; @@ -100,9 +123,19 @@ export const ApiInteraction = memo( callFetch({ url, route: currentRoute, - variables: pathVariables, + variables: pathVariables.map((variable) => { + return { + key: variable.key, + value: variable.value ?? "", + }; + }), method: currentMethod, - headers, + headers: headers.map((header) => { + return { + key: header.key, + value: header.value ?? "", + }; + }), body, }); }, [ @@ -115,35 +148,117 @@ export const ApiInteraction = memo( body, ]); - // TODO revisit - const openResponseTab = useCallback( - (tabId: string) => { - setCurrentResponseTab(tabId); + const loadDataFromOpenApi = useCallback( + (path: string, method: string) => { + // Set the headers + const headersFromSpec = getParametersFromOpenApi({ + path: path, + method: method, + openApi: openApiSpec, + type: "header", + }); + const newHeaders = headersFromSpec.filter( + (header) => + !headers.some( + (existingHeader) => existingHeader.key === header.key, + ), + ); + setHeaders((headers) => [...headers, ...newHeaders]); + + // Set Query Parameters + const queryParametersFromSpec = getParametersFromOpenApi({ + path: path, + method: method, + openApi: openApiSpec, + type: "query", + }); + const newQueryParameters = queryParametersFromSpec.filter( + (parameter) => + !queryParameters.some( + (existingParameter) => existingParameter.key === parameter.key, + ), + ); + + setQueryParameters((queryParameters) => [ + ...queryParameters, + ...newQueryParameters, + ]); + + // Set Path Variables + const variablesFromSpec = getParametersFromOpenApi({ + path: path, + method: method, + openApi: openApiSpec, + type: "path", + }); + const newPathVariables = variablesFromSpec.filter( + (variable) => + !pathVariables.some( + (existingVariable) => existingVariable.key === variable.key, + ), + ); + setPathVariables((pathVariables) => [ + ...pathVariables, + ...newPathVariables, + ]); + + // Set the body + const bodyFromSpec = getRequestBodyFromOpenApi( + path, + method, + openApiSpec, + ); + setBody(JSON.stringify(bodyFromSpec, undefined, 2)); + setBodyPlaceholder(JSON.stringify(bodyFromSpec, undefined, 2)); }, - [setCurrentResponseTab], + [ + openApiSpec, + setHeaders, + setBody, + setQueryParameters, + setPathVariables, + queryParameters, + pathVariables, + headers, + ], ); - const openOptionTab = useCallback( - (tabId: string) => { - setCurrentOptionsTab(tabId); + + const handleMethodChange = useCallback( + (route: string, method: string) => { + setCurrentMethod(method); + loadDataFromOpenApi(route, method); }, - [setCurrentOptionsTab], + [setCurrentMethod, loadDataFromOpenApi], ); - useEffect(() => { - if (!currentHeaderKey) { - setValuesList([]); - return; - } - setValuesList(getHeaderValues(currentHeaderKey)); - }, [currentHeaderKey]); - const handleRouteChange = useCallback( (value: string) => { - const newRoute = value && !value.startsWith("/") ? `/${value}` : value; + let [method, route] = value.split(" "); + if (!route) { + method = currentMethod; + route = value; + } + + const newRoute = route && !route.startsWith("/") ? `/${route}` : route; setCurrentRoute(newRoute); + const search = newRoute.split(/\?(.*)/s)[1]; const urlParameters = new URLSearchParams(search || ""); + const path = newRoute.split(/\?(.*)/s)[0] || ""; + + const isListedRoute = routes.some( + (item) => item.route === path && item.method === method, + ); + + if (!isListedRoute) { + setHeaders([]); + setBody(""); + setBodyPlaceholder(undefined); + setPathVariables([]); + setQueryParameters([]); + } + setQueryParameters(() => { const newUrlParameters: { key: string; @@ -176,17 +291,65 @@ export const ApiInteraction = memo( } return newPathVariables; }); + + if (isListedRoute && method) { + handleMethodChange(path, method); + } }, - [setCurrentRoute, setPathVariables, setQueryParameters], + [ + setHeaders, + routes, + setBody, + setCurrentRoute, + currentMethod, + setPathVariables, + setQueryParameters, + handleMethodChange, + ], ); + const loadRoutesFromOpenApi = useCallback(() => { + const routes = getRoutesFromOpenApi(openApiSpec); + setRoutes(routes); + + if (!currentMethod) { + const methods = routes + .filter((item) => { + return item.route === currentRoute; + }) + .map((route) => route.method); + handleMethodChange(currentRoute, methods[0] ?? "GET"); + } + }, [ + openApiSpec, + currentMethod, + currentRoute, + setRoutes, + handleMethodChange, + ]); + + // Load the routes from the OpenAPI spec + useEffect(() => { + loadRoutesFromOpenApi(); + }, [openApiSpec, loadRoutesFromOpenApi]); + + // Load the possible values for the current header key + useEffect(() => { + if (!currentHeaderKey) { + setCurrentHeaderValues([]); + return; + } + setCurrentHeaderValues(getHeaderValues(currentHeaderKey)); + }, [currentHeaderKey]); + + // Sync the query parameters with the current route. useEffect(() => { if (!currentRoute) { return; } const urlParameters = new URLSearchParams(); for (const item of queryParameters) { - urlParameters.append(item.key, item.value); + urlParameters.append(item.key, item.value ?? ""); } let newRoute = currentRoute.split("?")[0] || ""; if (urlParameters.toString()) { @@ -195,26 +358,7 @@ export const ApiInteraction = memo( setCurrentRoute(newRoute); }, [currentRoute, queryParameters, setCurrentRoute]); - useEffect(() => { - if (!schemaData) { - return; - } - - setUrl(schemaData.url); - const routes = getRoutesFromOpenApi(schemaData.openApiSpec); - setRoutes(routes); - const methods = routes - .filter((item) => { - return item.route === currentRoute; - }) - .map((route) => route.method); - if (methods.length > 0 && methods[0] && !currentMethod) { - setCurrentMethod(methods[0]); - } - }, [currentRoute, schemaData, setCurrentMethod, currentMethod]); - // TODO: Refactor inline functions below. For example, with `useCallback` or with additional memo components. - return (
@@ -241,9 +385,9 @@ export const ApiInteraction = memo( return { label: method, value: method }; })} value={currentMethod} - onChange={(method) => { - setCurrentMethod(method); - }} + onChange={(value) => + handleMethodChange(currentRoute, value) + } btnClassName={classNames( theme.bgInput, theme.bgInputHover, @@ -263,16 +407,9 @@ export const ApiInteraction = memo( value: `${route.method} ${route.route}`, }; })} + filter={false} value={currentRoute} - onChange={(value) => { - const [method, route] = value.split(" "); - if (method && HTTP_METHODS.includes(method) && route) { - setCurrentMethod(method); - handleRouteChange(route); - } else { - handleRouteChange(value); - } - }} + onChange={handleRouteChange} className="w-full" inputClassName={classNames( "border-none focus-none text-xs outline-none w-full rounded-r", @@ -322,7 +459,7 @@ export const ApiInteraction = memo( onEditItem={editHeader} disabled={isLoading} keysList={Object.keys(HTTP_HEADERS)} - valuesList={valuesList} + valuesList={currentHeaderValues} onKeyChange={setCurrentHeaderKey} />
@@ -368,14 +505,14 @@ export const ApiInteraction = memo( }, { id: "body", - name: `Body`, + name: `Body ${body || bodyPlaceholder ? "*" : ""}`, panel: (