kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: work on e2e gateway tests
rodzic
99e86f12e5
commit
004b745897
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
|
@ -39,6 +39,7 @@
|
|||
"eventid": "catalog:",
|
||||
"fast-content-type-parse": "^3.0.0",
|
||||
"hono": "catalog:",
|
||||
"ky": "catalog:",
|
||||
"plur": "^5.1.0",
|
||||
"type-fest": "catalog:"
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
)
|
||||
|
|
|
@ -16,6 +16,7 @@ export type Context = ExecutionContext & {
|
|||
req: Request
|
||||
env: AgenticEnv
|
||||
client: AgenticApiClient
|
||||
cache: Cache
|
||||
}
|
||||
|
||||
export interface ResolvedOriginRequest {
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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
|
||||
|
|
Ładowanie…
Reference in New Issue