From 2005a64d7c661cafb1c3f651a9248a206db032c3 Mon Sep 17 00:00:00 2001 From: Adishwar Rishi Date: Tue, 16 Apr 2024 16:05:03 +1000 Subject: [PATCH] feat: Match the new Turborepo OpenAPI spec (#394) --- .changeset/eight-horses-breathe.md | 7 ++ .dev.vars | 4 +- src/routes/v8/artifacts.ts | 110 ++++++++++++++++++++++------- tests/routes/v8/artifacts.test.ts | 100 +++++++++++++++++--------- 4 files changed, 163 insertions(+), 58 deletions(-) create mode 100644 .changeset/eight-horses-breathe.md diff --git a/.changeset/eight-horses-breathe.md b/.changeset/eight-horses-breathe.md new file mode 100644 index 000000000..cdb75ef3c --- /dev/null +++ b/.changeset/eight-horses-breathe.md @@ -0,0 +1,7 @@ +--- +'turborepo-remote-cache-cf': minor +--- + +Update routes to conform to the new Turborepo OpenAPI spec. + +Overall this is not a breaking change as most additions are optional. diff --git a/.dev.vars b/.dev.vars index 24e5d498b..c165ba64b 100644 --- a/.dev.vars +++ b/.dev.vars @@ -1,2 +1,2 @@ -ENVIRONMENT = 'development' -TURBO_TOKEN = 'SECRET' +ENVIRONMENT=development +TURBO_TOKEN=SECRET diff --git a/src/routes/v8/artifacts.ts b/src/routes/v8/artifacts.ts index a046e917f..0b6cde34e 100644 --- a/src/routes/v8/artifacts.ts +++ b/src/routes/v8/artifacts.ts @@ -5,6 +5,8 @@ import { bearerAuth } from 'hono/bearer-auth'; import { cache } from 'hono/cache'; import { z } from 'zod'; +export const DEFAULT_TEAM_ID = 'team_default_team'; + // Route - /v8/artifacts export const artifactRouter = new Hono<{ Bindings: Env }>(); @@ -13,42 +15,69 @@ artifactRouter.use('*', async (c, next) => { await middleware(c, next); }); +artifactRouter.post( + '/', + zValidator( + 'json', + z.object({ + hashes: z.array(z.string()), // artifactIds + }) + ), + zValidator('query', z.object({ teamId: z.string().optional(), slug: z.string().optional() })), + (c) => { + const data = c.req.valid('json'); + const { teamId: teamIdQuery, slug } = c.req.valid('query'); + const teamId = teamIdQuery ?? slug ?? DEFAULT_TEAM_ID; + void data; + void teamId; + // TODO: figure out what this route actually does, the OpenAPI spec is unclear + return c.json({}); + } +); + +artifactRouter.get('/status', (c) => { + const status: 'disabled' | 'enabled' | 'over_limit' | 'paused' = 'enabled'; + return c.json({ status }, 200); +}); + artifactRouter.put( '/:artifactId', zValidator('param', z.object({ artifactId: z.string() })), zValidator('query', z.object({ teamId: z.string().optional(), slug: z.string().optional() })), + zValidator( + 'header', + z.object({ + 'content-type': z.literal('application/octet-stream'), + 'content-length': z.coerce.number().optional(), + 'x-artifact-duration': z.coerce.number().optional(), + 'x-artifact-client-ci': z.string().optional(), + 'x-artifact-client-interactive': z.coerce.number().min(0).max(1).optional(), + 'x-artifact-tag': z.string().optional(), + }) + ), async (c) => { const { artifactId } = c.req.valid('param'); const { teamId: teamIdQuery, slug } = c.req.valid('query'); - const teamId = teamIdQuery ?? slug; - - if (!teamId) { - return c.json({ error: 'MISSING_TEAM_ID' }, 400); - } + const teamId = teamIdQuery ?? slug ?? DEFAULT_TEAM_ID; + const validatedHeaders = c.req.valid('header'); - const contentType = c.req.raw.headers.get('Content-Type'); - if (contentType !== 'application/octet-stream') { - return c.json({ error: 'EXPECTED_CONTENT_TYPE_OCTET_STREAM' }, 400); - } - - // if present the turborepo client has signed the artifact body - const artifactTag = c.req.raw.headers.get('x-artifact-tag'); const storage = c.env.STORAGE_MANAGER.getActiveStorage(); const objectKey = `${teamId}/${artifactId}`; const storageMetadata: Record = {}; - if (artifactTag) { - storageMetadata.artifactTag = artifactTag; + if (validatedHeaders['x-artifact-tag']) { + storageMetadata.artifactTag = validatedHeaders['x-artifact-tag']; } await storage.write(objectKey, c.req.raw.body!, storageMetadata); - return c.json({ teamId, artifactId, storagePath: objectKey }, 201); + const uploadUrl = new URL(`${artifactId}?teamId=${teamId}`, c.req.raw.url).toString(); + return c.json({ urls: [uploadUrl] }, 202); } ); // Hono router .get() method captures both GET and HEAD requests artifactRouter.get( - '/:artifactId/:teamId?', + '/:artifactId', cache({ cacheName: 'r2-artifacts', wait: false, @@ -56,20 +85,24 @@ artifactRouter.get( }), zValidator('param', z.object({ artifactId: z.string() })), zValidator('query', z.object({ teamId: z.string().optional(), slug: z.string().optional() })), + zValidator( + 'header', + z.object({ + 'x-artifact-client-ci': z.string().optional(), + 'x-artifact-client-interactive': z.coerce.number().min(0).max(1).optional(), + }) + ), async (c) => { const { artifactId } = c.req.valid('param'); const { teamId: teamIdQuery, slug } = c.req.valid('query'); - const teamId = teamIdQuery ?? slug; + const teamId = teamIdQuery ?? slug ?? DEFAULT_TEAM_ID; - if (!teamId) { - return c.json({ error: 'MISSING_TEAM_ID' }, 400); - } const storage = c.env.STORAGE_MANAGER.getActiveStorage(); const objectKey = `${teamId}/${artifactId}`; const storedObject = await storage.readWithMetadata(objectKey); if (!storedObject.data) { - return c.json({ error: 'NOT_FOUND' }, 404); + return c.json({}, 404); } c.header('Content-Type', 'application/octet-stream'); @@ -81,6 +114,35 @@ artifactRouter.get( } ); -artifactRouter.post('/events', (c) => { - return c.json({}); -}); +artifactRouter.post( + '/events', + zValidator( + 'json', + z.array( + z.object({ + sessionId: z.string().uuid(), + source: z.union([z.literal('LOCAL'), z.literal('REMOTE')]), + event: z.union([z.literal('HIT'), z.literal('MISS')]), + hash: z.string(), // artifactId + duration: z.coerce.number().optional(), + }) + ) + ), + zValidator('query', z.object({ teamId: z.string().optional(), slug: z.string().optional() })), + zValidator( + 'header', + z.object({ + 'x-artifact-client-ci': z.string().optional(), + 'x-artifact-client-interactive': z.coerce.number().min(0).max(1).optional(), + }) + ), + (c) => { + const data = c.req.valid('json'); + const { teamId: teamIdQuery, slug } = c.req.valid('query'); + const teamId = teamIdQuery ?? slug ?? DEFAULT_TEAM_ID; + // TODO: track these events and store them to query later + void data; + void teamId; + return c.json({}); + } +); diff --git a/tests/routes/v8/artifacts.test.ts b/tests/routes/v8/artifacts.test.ts index 67e4b91aa..cc53e6d9c 100644 --- a/tests/routes/v8/artifacts.test.ts +++ b/tests/routes/v8/artifacts.test.ts @@ -1,5 +1,6 @@ import { beforeEach, expect, test } from 'vitest'; import { Env, workerHandler } from '~/index'; +import { DEFAULT_TEAM_ID } from '~/routes/v8/artifacts'; import { StorageManager } from '~/storage'; const describe = setupMiniflareIsolatedStorage(); @@ -19,7 +20,7 @@ describe('v8 Artifacts API', () => { workerEnv.STORAGE_MANAGER = new StorageManager(workerEnv); ctx = new ExecutionContext(); await workerEnv.STORAGE_MANAGER.getActiveStorage().write( - `${teamId}/existing-${artifactId}`, + `${teamId}/${artifactId}`, artifactContent, { artifactTag } ); @@ -32,16 +33,25 @@ describe('v8 Artifacts API', () => { }); } - test('should return 400 when teamId is missing', async () => { - const request = createArtifactGetRequest(`http://localhost/v8/artifacts/${artifactId}`); - const res = await app.fetch(request, workerEnv, ctx); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'MISSING_TEAM_ID' }); + test('should use the default team ID when none is provided', async () => { + let request = createArtifactGetRequest(`http://localhost/v8/artifacts/${artifactId}`); + let res = await app.fetch(request, workerEnv, ctx); + expect(res.status).toBe(404); + + await workerEnv.STORAGE_MANAGER.getActiveStorage().write( + `${DEFAULT_TEAM_ID}/${artifactId}`, + artifactContent, + { artifactTag } + ); + + request = createArtifactGetRequest(`http://localhost/v8/artifacts/${artifactId}`); + res = await app.fetch(request, workerEnv, ctx); + expect(res.status).toBe(200); }); test('should return 404 when artifact does not exist', async () => { const request = createArtifactGetRequest( - `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/missing-${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(404); @@ -49,7 +59,7 @@ describe('v8 Artifacts API', () => { test('should return 200 when artifact exists', async () => { const request = createArtifactGetRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(200); @@ -57,13 +67,13 @@ describe('v8 Artifacts API', () => { test('should accept both teamId and slug as query params', async () => { const request = createArtifactGetRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(200); const request2 = createArtifactGetRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?slug=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?slug=${teamId}` ); const res2 = await app.fetch(request2, workerEnv, ctx); expect(res2.status).toBe(200); @@ -71,7 +81,7 @@ describe('v8 Artifacts API', () => { test('should return artifact content when artifact exists', async () => { const request = createArtifactGetRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(200); @@ -80,7 +90,7 @@ describe('v8 Artifacts API', () => { test('should return the proper content type when artifact exists', async () => { const request = createArtifactGetRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(200); @@ -90,7 +100,7 @@ describe('v8 Artifacts API', () => { test('should return the artifact tag', async () => { const request = createArtifactGetRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(200); @@ -101,7 +111,7 @@ describe('v8 Artifacts API', () => { test('should return cache headers on every request', async () => { const request = createArtifactGetRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(200); @@ -131,10 +141,16 @@ describe('v8 Artifacts API', () => { return request; } - test('should return 400 when teamId is missing', async () => { + test('should use the default team ID when none is provided', async () => { const request = createArtifactPutRequest(`http://localhost/v8/artifacts/${artifactId}`); const res = await app.fetch(request, workerEnv, ctx); - expect(res.status).toBe(400); + expect(res.status).toBe(202); + + const artifactStream = await workerEnv.STORAGE_MANAGER.getActiveStorage().read( + `${DEFAULT_TEAM_ID}/${artifactId}` + ); + const artifact = await StorageManager.readableStreamToText(artifactStream!); + expect(artifact).toEqual(artifactContent); }); test('should successfully save artifact', async () => { @@ -142,7 +158,7 @@ describe('v8 Artifacts API', () => { `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); - expect(res.status).toBe(201); + expect(res.status).toBe(202); const artifactStream = await workerEnv.STORAGE_MANAGER.getActiveStorage().read( `${teamId}/${artifactId}` @@ -156,13 +172,13 @@ describe('v8 Artifacts API', () => { `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); - expect(res.status).toBe(201); + expect(res.status).toBe(202); const request2 = createArtifactPutRequest( `http://localhost/v8/artifacts/${artifactId}?slug=${teamId}` ); const res2 = await app.fetch(request2, workerEnv, ctx); - expect(res2.status).toBe(201); + expect(res2.status).toBe(202); }); test('should save artifact tag when provided', async () => { @@ -171,7 +187,7 @@ describe('v8 Artifacts API', () => { true ); const res = await app.fetch(request, workerEnv, ctx); - expect(res.status).toBe(201); + expect(res.status).toBe(202); const artifactWithMeta = await workerEnv.STORAGE_MANAGER.getActiveStorage().readWithMetadata( `${teamId}/${artifactId}` @@ -188,7 +204,6 @@ describe('v8 Artifacts API', () => { request.headers.delete('Content-Type'); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'EXPECTED_CONTENT_TYPE_OCTET_STREAM' }); }); }); @@ -198,7 +213,7 @@ describe('v8 Artifacts API', () => { workerEnv.STORAGE_MANAGER = new StorageManager(workerEnv); ctx = new ExecutionContext(); await workerEnv.STORAGE_MANAGER.getActiveStorage().write( - `${teamId}/existing-${artifactId}`, + `${teamId}/${artifactId}`, artifactContent, { artifactTag } ); @@ -211,15 +226,24 @@ describe('v8 Artifacts API', () => { }); } - test('should return 400 when teamId is missing', async () => { - const request = createArtifactHeadRequest(`http://localhost/v8/artifacts/${artifactId}`); - const res = await app.fetch(request, workerEnv, ctx); - expect(res.status).toBe(400); + test('should use the default team ID when none is provided', async () => { + let request = createArtifactHeadRequest(`http://localhost/v8/artifacts/${artifactId}`); + let res = await app.fetch(request, workerEnv, ctx); + expect(res.status).toBe(404); + + await workerEnv.STORAGE_MANAGER.getActiveStorage().write( + `${DEFAULT_TEAM_ID}/${artifactId}`, + artifactContent, + { artifactTag } + ); + request = createArtifactHeadRequest(`http://localhost/v8/artifacts/${artifactId}`); + res = await app.fetch(request, workerEnv, ctx); + expect(res.status).toBe(200); }); test('should return 404 when artifact does not exist', async () => { const request = createArtifactHeadRequest( - `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/missing-${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(404); @@ -227,7 +251,7 @@ describe('v8 Artifacts API', () => { test('should return 200 when artifact exists', async () => { const request = createArtifactHeadRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(200); @@ -235,13 +259,13 @@ describe('v8 Artifacts API', () => { test('should accept both teamId and slug as query params', async () => { const request = createArtifactHeadRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?teamId=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?teamId=${teamId}` ); const res = await app.fetch(request, workerEnv, ctx); expect(res.status).toBe(200); const request2 = createArtifactHeadRequest( - `http://localhost/v8/artifacts/existing-${artifactId}?slug=${teamId}` + `http://localhost/v8/artifacts/${artifactId}?slug=${teamId}` ); const res2 = await app.fetch(request2, workerEnv, ctx); expect(res2.status).toBe(200); @@ -257,11 +281,23 @@ describe('v8 Artifacts API', () => { test('it should return 200', async () => { const request = new Request('http://localhost/v8/artifacts/events', { - headers: { Authorization: 'Bearer ' + workerEnv.TURBO_TOKEN }, + headers: { + Authorization: 'Bearer ' + workerEnv.TURBO_TOKEN, + 'Content-Type': 'application/json', + }, method: 'POST', - body: JSON.stringify({}), + body: JSON.stringify([ + { + sessionId: '30fb7fdf-8124-4fce-8121-d525170942a0', + source: 'LOCAL', + event: 'HIT', + hash: '12HKQaOmR5t5Uy6vdcQsNIiZgHGB', + duration: 400, + }, + ]), }); const res = await app.fetch(request, workerEnv, ctx); + console.log(await res.text()); expect(res.status).toBe(200); }); });