Skip to content

Commit

Permalink
feat: send ingress requests via http (#2825)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
wesbillman authored Sep 25, 2024
1 parent 1297472 commit 731e56a
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 50 deletions.
9 changes: 9 additions & 0 deletions frontend/cli/cmd_serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 5 additions & 11 deletions frontend/console/src/features/verbs/VerbFormInput.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement> = async (event) => {
event.preventDefault()
onSubmit(path)
}

useEffect(() => {
setPath(initialPath)
}, [initialPath])

return (
<form onSubmit={handleSubmit} className='rounded-lg'>
<div className='flex rounded-md shadow-sm'>
Expand All @@ -41,7 +35,7 @@ export const VerbFormInput = ({
Send
</button>
</div>
{!readOnly && <span className='text-xs text-gray-500'>{requestPath}</span>}
{!readOnly && <span className='ml-4 text-xs text-gray-500'>{requestPath}</span>}
</form>
)
}
160 changes: 123 additions & 37 deletions frontend/console/src/features/verbs/VerbRequestForm.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
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<InitialState>({ initialText: '' })
const [editorText, setEditorText] = useState('')
const [initialHeadersState, setInitialHeadersText] = useState<InitialState>({ initialText: '' })
const [headersText, setHeadersText] = useState('')
const [response, setResponse] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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)
Expand All @@ -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)
Expand Down Expand Up @@ -73,33 +90,91 @@ 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)
setError(String(error))
}
}

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 = <CodeEditor initialState={initialEditorState} onTextChanged={handleEditorTextChanged} />
Expand All @@ -117,31 +192,42 @@ export const VerbRequestForm = ({ module, verb }: { module?: Module; verb?: Verb
<div className='flex flex-col h-full overflow-hidden pt-4'>
<VerbFormInput
requestType={requestType(verb)}
initialPath={httpPopulatedRequestPath(module, verb)}
path={path}
setPath={setPath}
requestPath={fullRequestPath(module, verb)}
readOnly={!isHttpIngress(verb)}
onSubmit={handleSubmit}
/>
<div>
<div className='border-b border-gray-200 dark:border-white/10'>
<nav className='-mb-px flex space-x-6 pl-4' aria-label='Tabs'>
{tabs.map((tab) => (
<button
type='button'
key={tab.name}
className={classNames(
activeTabId === tab.id
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:border-gray-500 dark:text-gray-500 dark:hover:text-gray-300',
'whitespace-nowrap cursor-pointer border-b-2 py-2 px-1 text-sm font-medium',
)}
aria-current={activeTabId === tab.id ? 'page' : undefined}
onClick={(e) => handleTabClick(e, tab.id)}
>
{tab.name}
</button>
))}
</nav>
<div className='flex justify-between items-center pr-4'>
<nav className='-mb-px flex space-x-6 pl-4' aria-label='Tabs'>
{tabs.map((tab) => (
<button
type='button'
key={tab.name}
className={classNames(
activeTabId === tab.id
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:border-gray-500 dark:text-gray-500 dark:hover:text-gray-300',
'whitespace-nowrap cursor-pointer border-b-2 py-2 px-1 text-sm font-medium',
)}
aria-current={activeTabId === tab.id ? 'page' : undefined}
onClick={(e) => handleTabClick(e, tab.id)}
>
{tab.name}
</button>
))}
</nav>
<button
type='button'
title='Copy'
className='flex items-center p-1 rounded text-indigo-500 hover:bg-gray-200 dark:hover:bg-gray-600 cursor-pointer'
onClick={handleCopyButton}
>
<Copy01Icon className='size-5' />
</button>
</div>
</div>
</div>
<div className='flex-1 overflow-hidden'>
Expand Down
28 changes: 28 additions & 0 deletions frontend/console/src/features/verbs/verb.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 2 additions & 2 deletions frontend/console/src/layout/Notification.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -67,7 +67,7 @@ export const Notification = () => {
}}
>
<span className='sr-only'>Close</span>
<Cancel02Icon className='h-5 w-5' aria-hidden='true' />
<Cancel01Icon className='h-5 w-5' aria-hidden='true' />
</button>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/console/src/providers/app-providers.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,6 +10,7 @@ export const AppProvider = () => {
<UserPreferencesProvider>
<NotificationsProvider>
<RoutingProvider />
<Notification />
</NotificationsProvider>
</UserPreferencesProvider>
</ReactQueryProvider>
Expand Down

0 comments on commit 731e56a

Please sign in to comment.