feat: work on e2e gateway tests

pull/715/head
Travis Fischer 2025-06-03 23:12:50 +07:00
rodzic 99e86f12e5
commit 004b745897
16 zmienionych plików z 236 dodań i 42 usunięć

Wyświetl plik

@ -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))

Wyświetl plik

@ -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,

Wyświetl plik

@ -0,0 +1,21 @@
import { assert } from '@agentic/platform-core'
export type PublicCacheControlLevels = '1s' | '10s' | '1m' | '1h' | '1d'
const publicCacheControlLevelsMap: Record<PublicCacheControlLevels, string> = {
'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)
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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=

Wyświetl plik

@ -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"
}
}

Wyświetl plik

@ -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()
}
}
)
}

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,74 @@
export type E2ETestFixture = {
path: string
/** @default 60_000 milliseconds */
timeout?: number
request?: {
/** @default 'GET' */
method?: 'GET' | 'POST'
searchParams?: Record<string, string | number | boolean>
headers?: Record<string, string>
json?: Record<string, unknown>
body?: any
}
response?: {
/** @default 200 */
status?: number
/** @default 'application/json' */
contentType?: string
headers?: Record<string, string>
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
}
}
}
]

Wyświetl plik

@ -39,6 +39,7 @@
"eventid": "catalog:",
"fast-content-type-parse": "^3.0.0",
"hono": "catalog:",
"ky": "catalog:",
"plur": "^5.1.0",
"type-fest": "catalog:"
},

Wyświetl plik

@ -21,7 +21,7 @@ export function cfValidateJsonSchemaObject<
errorMessage
}: {
schema: any
data: unknown
data: Record<string, unknown>
errorMessage?: string
}): T {
// Special-case check for required fields to give better error messages

Wyświetl plik

@ -26,6 +26,7 @@ export async function createRequestForOpenAPIOperation({
let incomingRequestParams: Record<string, any> = {}
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,

Wyświetl plik

@ -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)
})
)

Wyświetl plik

@ -16,6 +16,7 @@ export type Context = ExecutionContext & {
req: Request
env: AgenticEnv
client: AgenticApiClient
cache: Cache
}
export interface ResolvedOriginRequest {

Wyświetl plik

@ -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') {

Wyświetl plik

@ -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