From 731e56ab38c9926f993436e6e8c1d93586a21d0c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 25 Sep 2024 14:53:54 -0700 Subject: [PATCH] feat: send ingress requests via http (#2825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2720 Fixes #2753 (will add similar functionality to request/events page in the future) With this change we can now see `ingress` events are part of the request since we're using fetch. ![Screenshot 2024-09-25 at 1 06 11 PM](https://github.com/user-attachments/assets/23ad70e4-30cc-4e92-a85c-f57f627fdb10) Copy `curl` ![Screenshot 2024-09-25 at 1 06 36 PM](https://github.com/user-attachments/assets/9a28bf54-fc3e-42a8-a84a-d4cc9be40b38) Copy `ftl.call` ![Screenshot 2024-09-25 at 1 06 42 PM](https://github.com/user-attachments/assets/018634ac-2e53-404f-b16e-697521803960) --- frontend/cli/cmd_serve.go | 9 + .../src/features/verbs/VerbFormInput.tsx | 16 +- .../src/features/verbs/VerbRequestForm.tsx | 160 ++++++++++++++---- .../console/src/features/verbs/verb.utils.ts | 28 +++ frontend/console/src/layout/Notification.tsx | 4 +- .../console/src/providers/app-providers.tsx | 2 + 6 files changed, 169 insertions(+), 50 deletions(-) diff --git a/frontend/cli/cmd_serve.go b/frontend/cli/cmd_serve.go index 6a5965d933..fa9208a9d4 100644 --- a/frontend/cli/cmd_serve.go +++ b/frontend/cli/cmd_serve.go @@ -113,6 +113,15 @@ func (s *serveCmd) run(ctx context.Context, projConfig projectconfig.Config, ini controllerAddresses = append(controllerAddresses, bindAllocator.Next()) } + for _, addr := range controllerAddresses { + // Add controller address to allow origins for console requests. + // The console is run on `localhost` so we replace 127.0.0.1 with localhost. + if addr.Hostname() == "127.0.0.1" { + addr.Host = "localhost" + ":" + addr.Port() + } + s.CommonConfig.AllowOrigins = append(s.CommonConfig.AllowOrigins, addr) + } + runnerScaling, err := localscaling.NewLocalScaling(bindAllocator, controllerAddresses, projConfig.Path, devMode && !projConfig.DisableIDEIntegration) if err != nil { return err diff --git a/frontend/console/src/features/verbs/VerbFormInput.tsx b/frontend/console/src/features/verbs/VerbFormInput.tsx index 23488ee58e..2f24465050 100644 --- a/frontend/console/src/features/verbs/VerbFormInput.tsx +++ b/frontend/console/src/features/verbs/VerbFormInput.tsx @@ -1,29 +1,23 @@ -import { useEffect, useState } from 'react' - export const VerbFormInput = ({ requestType, - initialPath, + path, + setPath, requestPath, readOnly, onSubmit, }: { requestType: string - initialPath: string + path: string + setPath: (path: string) => void requestPath: string readOnly: boolean onSubmit: (path: string) => void }) => { - const [path, setPath] = useState(initialPath) - const handleSubmit: React.FormEventHandler = async (event) => { event.preventDefault() onSubmit(path) } - useEffect(() => { - setPath(initialPath) - }, [initialPath]) - return (
@@ -41,7 +35,7 @@ export const VerbFormInput = ({ Send
- {!readOnly && {requestPath}} + {!readOnly && {requestPath}}
) } diff --git a/frontend/console/src/features/verbs/VerbRequestForm.tsx b/frontend/console/src/features/verbs/VerbRequestForm.tsx index 84100bd779..3adcf224e9 100644 --- a/frontend/console/src/features/verbs/VerbRequestForm.tsx +++ b/frontend/console/src/features/verbs/VerbRequestForm.tsx @@ -1,16 +1,28 @@ -import { useEffect, useState } from 'react' +import { Copy01Icon } from 'hugeicons-react' +import { useContext, useEffect, useState } from 'react' import { CodeEditor, type InitialState } from '../../components/CodeEditor' import { ResizableVerticalPanels } from '../../components/ResizableVerticalPanels' import { useClient } from '../../hooks/use-client' import type { Module, Verb } from '../../protos/xyz/block/ftl/v1/console/console_pb' import { VerbService } from '../../protos/xyz/block/ftl/v1/ftl_connect' import type { Ref } from '../../protos/xyz/block/ftl/v1/schema/schema_pb' +import { NotificationType, NotificationsContext } from '../../providers/notifications-provider' import { classNames } from '../../utils' import { VerbFormInput } from './VerbFormInput' -import { createVerbRequest, defaultRequest, fullRequestPath, httpPopulatedRequestPath, isHttpIngress, requestType, simpleJsonSchema } from './verb.utils' +import { + createVerbRequest as createCallRequest, + defaultRequest, + fullRequestPath, + generateCliCommand, + httpPopulatedRequestPath, + isHttpIngress, + requestType, + simpleJsonSchema, +} from './verb.utils' export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb }) => { const client = useClient(VerbService) + const { showNotification } = useContext(NotificationsContext) const [activeTabId, setActiveTabId] = useState('body') const [initialEditorState, setInitialEditorText] = useState({ initialText: '' }) const [editorText, setEditorText] = useState('') @@ -18,10 +30,15 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb const [headersText, setHeadersText] = useState('') const [response, setResponse] = useState(null) const [error, setError] = useState(null) + const [path, setPath] = useState('') const editorTextKey = `${module?.name}-${verb?.verb?.name}-editor-text` const headersTextKey = `${module?.name}-${verb?.verb?.name}-headers-text` + useEffect(() => { + setPath(httpPopulatedRequestPath(module, verb)) + }, [module, verb]) + useEffect(() => { if (verb) { const savedEditorValue = localStorage.getItem(editorTextKey) @@ -42,7 +59,7 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb if (savedHeadersValue != null && savedHeadersValue !== '') { headerValue = savedHeadersValue } else { - headerValue = '{\n "console": ["example"]\n}' + headerValue = '{}' } setInitialHeadersText({ initialText: headerValue }) setHeadersText(headerValue) @@ -73,26 +90,64 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb tabs.push({ id: 'verbschema', name: 'Verb Schema' }, { id: 'jsonschema', name: 'JSONSchema' }) + const httpCall = (path: string) => { + const method = requestType(verb) + + fetch(path, { + method, + headers: { + 'Content-Type': 'application/json', + ...JSON.parse(headersText), + }, + ...(method === 'POST' || method === 'PUT' ? { body: editorText } : {}), + }) + .then(async (response) => { + if (response.ok) { + const json = await response.json() + setResponse(JSON.stringify(json, null, 2)) + } else { + const text = await response.text() + setError(text) + } + }) + .catch((error) => { + setError(String(error)) + }) + } + + const ftlCall = (path: string) => { + const verbRef: Ref = { + name: verb?.verb?.name, + module: module?.name, + } as Ref + + const requestBytes = createCallRequest(path, verb, editorText, headersText) + client + .call({ verb: verbRef, body: requestBytes }) + .then((response) => { + if (response.response.case === 'body') { + const textDecoder = new TextDecoder('utf-8') + const jsonString = textDecoder.decode(response.response.value) + + setResponse(JSON.stringify(JSON.parse(jsonString), null, 2)) + } else if (response.response.case === 'error') { + setError(response.response.value.message) + } + }) + .catch((error) => { + console.error(error) + }) + } + const handleSubmit = async (path: string) => { setResponse(null) setError(null) try { - const verbRef: Ref = { - name: verb?.verb?.name, - module: module?.name, - } as Ref - - const requestBytes = createVerbRequest(path, verb, editorText, headersText) - const response = await client.call({ verb: verbRef, body: requestBytes }) - - if (response.response.case === 'body') { - const textDecoder = new TextDecoder('utf-8') - const jsonString = textDecoder.decode(response.response.value) - - setResponse(JSON.stringify(JSON.parse(jsonString), null, 2)) - } else if (response.response.case === 'error') { - setError(response.response.value.message) + if (isHttpIngress(verb)) { + httpCall(path) + } else { + ftlCall(path) } } catch (error) { console.error('There was an error with the request:', error) @@ -100,6 +155,26 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb } } + const handleCopyButton = () => { + if (!verb) { + return + } + + const cliCommand = generateCliCommand(verb, path, headersText, editorText) + navigator.clipboard + .writeText(cliCommand) + .then(() => { + showNotification({ + title: 'Copied to clipboard', + message: cliCommand, + type: NotificationType.Info, + }) + }) + .catch((err) => { + console.error('Failed to copy text: ', err) + }) + } + const bottomText = response ?? error ?? '' const bodyEditor = @@ -117,31 +192,42 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb
- +
+ + +
diff --git a/frontend/console/src/features/verbs/verb.utils.ts b/frontend/console/src/features/verbs/verb.utils.ts index 83cb4b9b0f..354df7ec13 100644 --- a/frontend/console/src/features/verbs/verb.utils.ts +++ b/frontend/console/src/features/verbs/verb.utils.ts @@ -179,3 +179,31 @@ export const createVerbRequest = (path: string, verb?: Verb, editorText?: string export const verbCalls = (verb?: Verb) => { return verb?.verb?.metadata.filter((meta) => meta.value.case === 'calls').map((meta) => meta.value.value as MetadataCalls) ?? null } + +export const generateCliCommand = (verb: Verb, path: string, header: string, body: string) => { + const method = requestType(verb) + return method === 'CALL' ? generateFtlCallCommand(path, body) : generateCurlCommand(method, path, header, body) +} + +const generateFtlCallCommand = (path: string, editorText: string) => { + const command = `ftl call ${path} '${editorText}'` + return command +} + +const generateCurlCommand = (method: string, path: string, header: string, body: string) => { + const headers = JSON.parse(header) + + let curlCommand = `curl -X ${method.toUpperCase()} "${path}"` + + for (const [key, value] of Object.entries(headers)) { + curlCommand += ` -H "${key}: ${value}"` + } + + curlCommand += ' -H "Content-Type: application/json"' + + if (method === 'POST' || method === 'PUT') { + curlCommand += ` -d '${body}'` + } + + return curlCommand +} diff --git a/frontend/console/src/layout/Notification.tsx b/frontend/console/src/layout/Notification.tsx index 822f717c72..7c7f062753 100644 --- a/frontend/console/src/layout/Notification.tsx +++ b/frontend/console/src/layout/Notification.tsx @@ -1,5 +1,5 @@ import { Transition } from '@headlessui/react' -import { Alert02Icon, AlertCircleIcon, Cancel02Icon, CheckmarkCircle02Icon, InformationCircleIcon } from 'hugeicons-react' +import { Alert02Icon, AlertCircleIcon, Cancel01Icon, CheckmarkCircle02Icon, InformationCircleIcon } from 'hugeicons-react' import { Fragment, useContext } from 'react' import { NotificationType, NotificationsContext } from '../providers/notifications-provider' import { textColor } from '../utils' @@ -67,7 +67,7 @@ export const Notification = () => { }} > Close -
diff --git a/frontend/console/src/providers/app-providers.tsx b/frontend/console/src/providers/app-providers.tsx index 1be306849c..2e6322b93c 100644 --- a/frontend/console/src/providers/app-providers.tsx +++ b/frontend/console/src/providers/app-providers.tsx @@ -1,3 +1,4 @@ +import { Notification } from '../layout/Notification' import { NotificationsProvider } from './notifications-provider' import { ReactQueryProvider } from './react-query-provider' import { RoutingProvider } from './routing-provider' @@ -9,6 +10,7 @@ export const AppProvider = () => { +