diff --git a/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts b/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts index 7604ada8..208a22f3 100644 --- a/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts +++ b/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts @@ -4,6 +4,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { aclAdmin } from '@/lib/acl-admin' +import { setPublicCacheControl } from '@/lib/cache-control' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, @@ -58,15 +59,9 @@ export function registerV1AdminConsumersGetConsumerByToken( !consumer.activated || !consumer.isStripeSubscriptionActive ) { - c.res.headers.set( - 'cache-control', - 'public, max-age=1, s-maxage=1 stale-while-revalidate=1' - ) + setPublicCacheControl(c.res, '1s') } else { - c.res.headers.set( - 'cache-control', - 'public, max-age=120, s-maxage=120, stale-while-revalidate=10' - ) + setPublicCacheControl(c.res, '1m') } return c.json(parseZodSchema(schema.consumerSelectSchema, consumer)) diff --git a/apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts b/apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts index 41deeb9c..ba4eb75b 100644 --- a/apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts +++ b/apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts @@ -62,20 +62,6 @@ export function registerV1AdminDeploymentsGetDeploymentByIdentifier( const hasPopulateProject = populate.includes('project') - // TODO - // TODO: switch from published to publishedAt? - // if (deployment.published) { - // c.res.headers.set( - // 'cache-control', - // 'public, max-age=1, s-maxage=1 stale-while-revalidate=1' - // ) - // } else { - // c.res.headers.set( - // 'cache-control', - // 'public, max-age=120, s-maxage=120, stale-while-revalidate=10' - // ) - // } - return c.json( parseZodSchema(schema.deploymentAdminSelectSchema, { ...deployment, diff --git a/apps/api/src/lib/cache-control.ts b/apps/api/src/lib/cache-control.ts new file mode 100644 index 00000000..d1f5c5fe --- /dev/null +++ b/apps/api/src/lib/cache-control.ts @@ -0,0 +1,21 @@ +import { assert } from '@agentic/platform-core' + +export type PublicCacheControlLevels = '1s' | '10s' | '1m' | '1h' | '1d' + +const publicCacheControlLevelsMap: Record = { + '1s': 'public, max-age=1, s-maxage=1 stale-while-revalidate=0', + '10s': 'public, max-age=10, s-maxage=10 stale-while-revalidate=1', + '1m': 'public, max-age=60, s-maxage=60 stale-while-revalidate=10', + '1h': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=300', + '1d': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600' +} + +export function setPublicCacheControl( + res: Response, + level: PublicCacheControlLevels +) { + const cacheControl = publicCacheControlLevelsMap[level] + assert(cacheControl, `Invalid cache control level "${level}"`) + + res.headers.set('cache-control', cacheControl) +} diff --git a/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts b/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts index 336691ed..e3b80db3 100644 --- a/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts +++ b/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts @@ -10,6 +10,7 @@ import { type RawDeployment, schema } from '@/db' +import { setPublicCacheControl } from '@/lib/cache-control' import { ensureAuthUser } from '@/lib/ensure-auth-user' /** @@ -44,6 +45,7 @@ export async function tryGetDeploymentByIdentifier( where: eq(schema.deployments.id, deploymentIdentifier) }) assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) + setPublicCacheControl(ctx.res, '1h') return deployment } @@ -68,6 +70,7 @@ export async function tryGetDeploymentByIdentifier( where: eq(schema.deployments.identifier, deploymentIdentifier) }) assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) + setPublicCacheControl(ctx.res, '1h') return deployment } else if (version) { @@ -93,6 +96,7 @@ export async function tryGetDeploymentByIdentifier( 404, `Deployment not found "${project.lastPublishedDeploymentId}"` ) + setPublicCacheControl(ctx.res, '10s') return deployment } else if (version === 'dev') { @@ -111,6 +115,7 @@ export async function tryGetDeploymentByIdentifier( 404, `Deployment not found "${project.lastDeploymentId}"` ) + setPublicCacheControl(ctx.res, '10s') return deployment } else { @@ -126,6 +131,7 @@ export async function tryGetDeploymentByIdentifier( 404, `Deployment not found "${projectIdentifier}@${version}"` ) + setPublicCacheControl(ctx.res, '1h') return deployment } diff --git a/apps/e2e/.env.example b/apps/e2e/.env.example index 10baa6ed..66e21a68 100644 --- a/apps/e2e/.env.example +++ b/apps/e2e/.env.example @@ -5,4 +5,7 @@ # a local .env file in order to run this project. # ------------------------------------------------------------------------------ +AGENTIC_API_BASE_URL= AGENTIC_API_ACCESS_TOKEN= + +AGENTIC_GATEWAY_BASE_URL= diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 8dcc05a4..f89a8b48 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -17,13 +17,15 @@ "test:typecheck": "tsc --noEmit", "test:e2e": "vitest run" }, + "dependencies": { + "ky": "catalog:", + "p-map": "catalog:" + }, "devDependencies": { "@agentic/platform": "workspace:*", "@agentic/platform-api-client": "workspace:*", "@agentic/platform-core": "workspace:*", - "@agentic/platform-fixtures": "workspace:*" - }, - "dependencies": { - "p-map": "catalog:" + "@agentic/platform-fixtures": "workspace:*", + "fast-content-type-parse": "^3.0.0" } } diff --git a/apps/e2e/src/e2e.test.ts b/apps/e2e/src/e2e.test.ts index ff605bd5..41cb630f 100644 --- a/apps/e2e/src/e2e.test.ts +++ b/apps/e2e/src/e2e.test.ts @@ -1,11 +1,64 @@ +import contentType from 'fast-content-type-parse' +import defaultKy from 'ky' import { expect, test } from 'vitest' -test( - `${fixture}`, - { - timeout: 60_000 - }, - async () => { - 'dev/test-basic-openapi@8d1a4900' - } -) +import { env } from './env' +import { fixtures } from './fixtures' + +const ky = defaultKy.extend({ + prefixUrl: env.AGENTIC_GATEWAY_BASE_URL, + + // Disable automatic retries for testing. + retry: 0 +}) + +for (const [i, fixture] of fixtures.entries()) { + const method = fixture.request?.method ?? 'GET' + const { + status = 200, + snapshot = true, + contentType: expectedContentType = 'application/json', + headers: expectedHeaders, + body: expectedBody + } = fixture.response ?? {} + + test( + `${i}) ${method} ${fixture.path}`, + { + timeout: fixture.timeout ?? 60_000 + }, + async () => { + const res = await ky(fixture.path, fixture.request) + expect(res.status).toBe(status) + + const { type } = contentType.safeParse( + res.headers.get('content-type') ?? '' + ) + expect(type).toBe(expectedContentType) + + if (expectedHeaders) { + for (const [key, value] of Object.entries(expectedHeaders)) { + expect(res.headers.get(key)).toBe(value) + } + } + + let body: any + + if (type.includes('json')) { + body = await res.json() + } else if (type.includes('text')) { + body = await res.text() + } else { + body = await res.arrayBuffer() + } + + if (expectedBody) { + expect(body).toEqual(expectedBody) + } + + if (snapshot) { + expect(body).toMatchSnapshot() + } + } + ) +} diff --git a/apps/e2e/src/env.ts b/apps/e2e/src/env.ts index dbac197a..ceb25dcb 100644 --- a/apps/e2e/src/env.ts +++ b/apps/e2e/src/env.ts @@ -3,13 +3,22 @@ import 'dotenv/config' import { parseZodSchema } from '@agentic/platform-core' import { z } from 'zod' +// TODO: derive AGENTIC_API_BASE_URL and AGENTIC_GATEWAY_BASE_URL based on +// environment. + export const envSchema = z.object({ NODE_ENV: z .enum(['development', 'test', 'production']) .default('development'), AGENTIC_API_BASE_URL: z.string().url().optional(), - AGENTIC_API_ACCESS_TOKEN: z.string().nonempty() + AGENTIC_API_ACCESS_TOKEN: z.string().nonempty(), + + AGENTIC_GATEWAY_BASE_URL: z + .string() + .url() + .optional() + .default('http://localhost:8787') }) // eslint-disable-next-line no-process-env diff --git a/apps/e2e/src/fixtures.ts b/apps/e2e/src/fixtures.ts new file mode 100644 index 00000000..7b111984 --- /dev/null +++ b/apps/e2e/src/fixtures.ts @@ -0,0 +1,74 @@ +export type E2ETestFixture = { + path: string + + /** @default 60_000 milliseconds */ + timeout?: number + + request?: { + /** @default 'GET' */ + method?: 'GET' | 'POST' + searchParams?: Record + headers?: Record + json?: Record + body?: any + } + + response?: { + /** @default 200 */ + status?: number + /** @default 'application/json' */ + contentType?: string + headers?: Record + body?: any + validate?: (body: any) => void + /** @default true */ + snapshot?: boolean + } +} + +export const fixtures: E2ETestFixture[] = [ + { + path: 'dev/test-basic-openapi/getPost', + request: { + searchParams: { + postId: 1 + } + } + }, + { + path: 'dev/test-basic-openapi@8d1a4900/getPost?postId=1' + }, + { + path: 'test-basic-openapi/getPost', + request: { + searchParams: { + postId: 1 + } + } + }, + { + path: 'test-basic-openapi@8d1a4900/getPost', + request: { + searchParams: { + postId: 1 + } + } + }, + { + path: 'dev/test-basic-openapi@8d1a4900/getPost', + request: { + searchParams: { + postId: 1 + } + } + }, + { + path: 'dev/test-basic-openapi/getPost', + request: { + method: 'POST', + json: { + postId: 1 + } + } + } +] diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 7d0886a2..e5166b73 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -39,6 +39,7 @@ "eventid": "catalog:", "fast-content-type-parse": "^3.0.0", "hono": "catalog:", + "ky": "catalog:", "plur": "^5.1.0", "type-fest": "catalog:" }, diff --git a/apps/gateway/src/lib/cf-validate-json-schema-object.ts b/apps/gateway/src/lib/cf-validate-json-schema-object.ts index 18c93d2d..45319179 100644 --- a/apps/gateway/src/lib/cf-validate-json-schema-object.ts +++ b/apps/gateway/src/lib/cf-validate-json-schema-object.ts @@ -21,7 +21,7 @@ export function cfValidateJsonSchemaObject< errorMessage }: { schema: any - data: unknown + data: Record errorMessage?: string }): T { // Special-case check for required fields to give better error messages diff --git a/apps/gateway/src/lib/create-request-for-openapi-operation.ts b/apps/gateway/src/lib/create-request-for-openapi-operation.ts index 5344e338..9cb6d006 100644 --- a/apps/gateway/src/lib/create-request-for-openapi-operation.ts +++ b/apps/gateway/src/lib/create-request-for-openapi-operation.ts @@ -26,6 +26,7 @@ export async function createRequestForOpenAPIOperation({ let incomingRequestParams: Record = {} if (request.method === 'GET') { + // TODO: coerce data types to match input schema since all values will be strings incomingRequestParams = Object.fromEntries( new URL(request.url).searchParams.entries() ) @@ -43,6 +44,8 @@ export async function createRequestForOpenAPIOperation({ } // TODO: Validate incoming request params against the tool's input JSON schema + // TODO: we want to coerce data types to match the schema for booleans, dates, etc + // Currently, these will fail if given as body params, for instance, on the origin server. cfValidateJsonSchemaObject({ schema: tool.inputSchema, data: incomingRequestParams, diff --git a/apps/gateway/src/lib/fetch-cache.ts b/apps/gateway/src/lib/fetch-cache.ts index 6eba1ff5..b81adb42 100644 --- a/apps/gateway/src/lib/fetch-cache.ts +++ b/apps/gateway/src/lib/fetch-cache.ts @@ -1,7 +1,5 @@ import type { Context } from './types' -const cache = caches.default - export async function fetchCache( ctx: Context, { @@ -15,7 +13,7 @@ export async function fetchCache( let response: Response | undefined if (cacheKey) { - response = await cache.match(cacheKey) + response = await ctx.cache.match(cacheKey) } if (!response) { @@ -26,7 +24,7 @@ export async function fetchCache( if (response.headers.has('Cache-Control')) { // Note that cloudflare's `cache` should respect response headers. ctx.waitUntil( - cache.put(cacheKey, response.clone()).catch((err) => { + ctx.cache.put(cacheKey, response.clone()).catch((err) => { console.warn('cache put error', cacheKey, err) }) ) diff --git a/apps/gateway/src/lib/types.ts b/apps/gateway/src/lib/types.ts index e9601d91..4d5215e1 100644 --- a/apps/gateway/src/lib/types.ts +++ b/apps/gateway/src/lib/types.ts @@ -16,6 +16,7 @@ export type Context = ExecutionContext & { req: Request env: AgenticEnv client: AgenticApiClient + cache: Cache } export interface ResolvedOriginRequest { diff --git a/apps/gateway/src/worker.ts b/apps/gateway/src/worker.ts index 5ab46423..3f258517 100644 --- a/apps/gateway/src/worker.ts +++ b/apps/gateway/src/worker.ts @@ -1,5 +1,6 @@ import { AgenticApiClient } from '@agentic/platform-api-client' import { assert, parseZodSchema } from '@agentic/platform-core' +import defaultKy from 'ky' import type { Context } from './lib/types' import { type AgenticEnv, envSchema } from './lib/env' @@ -44,9 +45,40 @@ export default { gatewayTimespan = now - gatewayStartTime } + const cache = caches.default const client = new AgenticApiClient({ apiBaseUrl: env.AGENTIC_API_BASE_URL, - apiKey: env.AGENTIC_API_KEY + apiKey: env.AGENTIC_API_KEY, + ky: defaultKy.extend({ + hooks: { + // NOTE: The order of the `beforeRequest` hook matters, and it only + // works alongside the one in AgenticApiClient because that one's body + // should never be run. This only works because we're using `apiKey` + // authentication, which is a lil hacky since it's actually a long- + // lived access token. + beforeRequest: [ + async (request) => { + // Check the cache first before making a request to Agentic's + // backend API. + return cache.match(request) + } + ], + + afterResponse: [ + async (request, _options, response) => { + if (response.headers.has('Cache-Control')) { + // Asynchronously update the cache with the response from + // Agentic's backend API. + inputCtx.waitUntil( + cache.put(request, response.clone()).catch((err) => { + console.warn('cache put error', request, err) + }) + ) + } + } + ] + } + }) }) // NOTE: We have to mutate the given ExecutionContext because spreading it @@ -55,6 +87,7 @@ export default { ctx.req = inputReq ctx.env = env ctx.client = client + ctx.cache = cache try { if (inputReq.method === 'OPTIONS') { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8371ce9a..f80b37b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -348,6 +348,9 @@ importers: apps/e2e: dependencies: + ky: + specifier: 'catalog:' + version: 1.8.1 p-map: specifier: 'catalog:' version: 7.0.3 @@ -364,6 +367,9 @@ importers: '@agentic/platform-fixtures': specifier: workspace:* version: link:../../packages/fixtures + fast-content-type-parse: + specifier: ^3.0.0 + version: 3.0.0 apps/gateway: dependencies: @@ -400,6 +406,9 @@ importers: hono: specifier: 'catalog:' version: 4.7.10 + ky: + specifier: 'catalog:' + version: 1.8.1 plur: specifier: ^5.1.0 version: 5.1.0