Files
floci-ui/packages/api/src/adapter-azure/AzureServerlessAdapter.ts
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

230 lines
7.5 KiB
TypeScript

import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import { azure, type AzureRuntimeClient } from '../azure'
import { azureServerlessSchema } from '../cloud-spi/serverlessSchema'
import type {
CloudResource,
CloudServiceAdapter,
CloudServiceDescriptorOverride,
CreateResourceInput,
ResourceQuery,
ServerlessInvokeResult,
ServiceSchema,
} from '../cloud-spi/types'
interface AzureFunctionRecord {
id?: string
name?: string
kind?: string
location?: string
type?: string
properties?: {
state?: string
status?: string
runtime?: string
functionAppName?: string
lastModifiedTimeUtc?: string
scriptHref?: string
invokeUrlTemplate?: string
config?: Record<string, unknown>
files?: Record<string, string>
}
}
interface AzureFunctionListResponse {
value?: AzureFunctionRecord[]
}
export class AzureServerlessAdapter implements CloudServiceAdapter {
readonly cloud = 'azure' as const
readonly service = 'serverless' as const
constructor(private readonly client: AzureRuntimeClient = azure) { }
/**
* floci-az answers 501 NotImplemented on /functions, so this adapter cannot
* currently serve a request even though it is registered. Reporting
* coming_soon keeps the nav honest; remove this once the runtime ships the
* endpoint and the adapter's own tests exercise it against a real response.
*/
descriptorOverride(): CloudServiceDescriptorOverride {
return {
availability: 'coming_soon',
reason: 'TheCloud\'s Azure runtime returns 501 NotImplemented for the Azure Functions endpoint.',
}
}
schema(): ServiceSchema {
return azureServerlessSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const body = await this.azureJson<AzureFunctionListResponse | AzureFunctionRecord[]>(
'/functions',
{ method: 'GET' },
{ emptyOnNotFound: true },
)
const records = Array.isArray(body) ? body : body?.value ?? []
return filterBySearch(records.map(toFunctionResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
const body = await this.azureJson<AzureFunctionRecord>(
`/functions/${encodeURIComponent(id)}`,
{ method: 'GET' },
{ emptyOnNotFound: true },
)
return body ? toFunctionResource(body) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const functionName = stringValue(input.values.functionName ?? input.values.name)
const runtime = stringValue(input.values.runtime) || 'node'
const handler = stringValue(input.values.handler)
const code = stringValue(input.values.code)
const location = stringValue(input.values.location)
const functionAppName = stringValue(input.values.functionAppName)
if (!functionName) throw new ValidationError('functionName is required')
const body = await this.azureJson<AzureFunctionRecord>(
'/functions',
{
method: 'POST',
body: JSON.stringify({
name: functionName,
runtime,
handler: handler || undefined,
code: code || undefined,
location: location || undefined,
functionAppName: functionAppName || undefined,
}),
},
)
if (!body) throw new RuntimeError('Azure Functions create returned an empty response')
return toFunctionResource(body)
}
async delete(id: string): Promise<void> {
await this.client.fetch(
`/functions/${encodeURIComponent(id)}`,
{ method: 'DELETE' },
{ emptyOnNotFound: true },
)
}
async invoke(id: string, payload: string): Promise<ServerlessInvokeResult> {
const startedAt = performance.now()
const body = await this.azureJson<{
statusCode?: number
payload?: unknown
body?: unknown
error?: string
functionError?: string
logResult?: string
}>(
`/functions/${encodeURIComponent(id)}/invoke`,
{
method: 'POST',
body: JSON.stringify({
payload: payload?.trim() ? payload : '{}',
}),
},
)
return {
statusCode: body?.statusCode ?? 200,
payload: stringifyPayload(body?.payload ?? body?.body ?? ''),
functionError: body?.functionError ?? body?.error,
logResult: body?.logResult,
executionDuration: Math.round(performance.now() - startedAt),
}
}
private async azureJson<T>(
path: string,
init: RequestInit,
options?: { emptyOnNotFound?: boolean },
): Promise<T | null> {
const res = await this.client.fetch(
path,
{
...init,
headers: {
accept: 'application/json',
'content-type': 'application/json',
...(init.headers ?? {}),
},
},
options,
)
if (!res || res.status === 204) return null
return await res.json() as T
}
}
function toFunctionResource(record: AzureFunctionRecord): CloudResource {
const name = stringValue(record.name ?? record.id)
const props = record.properties ?? {}
return {
id: name,
name,
cloud: 'azure',
service: 'serverless',
type: 'azure-function',
region: record.location ?? null,
createdAt: props.lastModifiedTimeUtc ?? null,
status: props.state ?? props.status ?? null,
metadata: {
provider: 'azure',
serverlessService: 'functions',
kind: record.kind,
resourceType: record.type,
runtime: props.runtime,
functionAppName: props.functionAppName,
lastModified: props.lastModifiedTimeUtc,
triggerType: getTriggerType(props.config),
scriptHref: props.scriptHref,
invokeUrlTemplate: props.invokeUrlTemplate,
config: props.config,
files: props.files,
},
}
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function stringifyPayload(value: unknown): string {
if (typeof value === 'string') return value
if (value === undefined || value === null) return ''
return JSON.stringify(value)
}
function getTriggerType(config?: Record<string, unknown>): string | undefined {
const bindings = config?.bindings
if (!Array.isArray(bindings)) return undefined
const trigger = bindings.find((binding) => {
if (!binding || typeof binding !== 'object') return false
const type = (binding as Record<string, unknown>).type
return typeof type === 'string' && type.toLowerCase().endsWith('trigger')
})
if (!trigger || typeof trigger !== 'object') return undefined
const type = (trigger as Record<string, unknown>).type
return typeof type === 'string' ? type : undefined
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}