diff --git a/package-lock.json b/package-lock.json index fd51a24..af08f87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240208.0", + "@cloudflare/workers-types": "^4.20260526.1", "typescript": "^5.4.5", "vitest": "^4.0.17", "wrangler": "^4.82.2" @@ -141,9 +141,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260413.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260413.1.tgz", - "integrity": "sha512-4FFHIVIk645Wf20eCVfe0eM3ERsEw98DFng76QZf1C1JMgIVlfSV2gZF1EyXxNVwOG0RM/CBlu07+u/Z/0Oq9Q==", + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", "dev": true, "license": "MIT OR Apache-2.0" }, diff --git a/src/__tests__/schemas/openapi.test.ts b/src/__tests__/schemas/openapi.test.ts new file mode 100644 index 0000000..53dba7c --- /dev/null +++ b/src/__tests__/schemas/openapi.test.ts @@ -0,0 +1,562 @@ +import { describe, expect, it } from "vitest"; +import "../../index"; +import { getOpenApiDocument } from "../../schemas/openapi"; + +type SchemaObject = { + $ref?: string; + type?: string; + nullable?: boolean; + required?: string[]; + enum?: unknown[]; + properties?: Record; + items?: SchemaObject; + additionalProperties?: boolean | SchemaObject; + anyOf?: SchemaObject[]; + oneOf?: SchemaObject[]; +}; + +type DocumentShape = { + paths: Record< + string, + { + get?: { + responses?: Record< + string, + { + content?: { + "application/json"?: { schema?: SchemaObject }; + }; + } + >; + }; + } + >; + components?: { + schemas?: Record; + }; +}; + +const document = JSON.parse( + JSON.stringify(getOpenApiDocument("test")) +) as DocumentShape; + +function resolveSchema(schema: SchemaObject): SchemaObject { + if (!schema.$ref) return schema; + + const name = schema.$ref.split("/").at(-1); + const resolved = name ? document.components?.schemas?.[name] : undefined; + if (!resolved) throw new Error(`Unresolved schema reference: ${schema.$ref}`); + return resolveSchema(resolved); +} + +function responseSchema(path: string): SchemaObject { + const schema = document.paths[path]?.get?.responses?.["200"]?.content?.[ + "application/json" + ]?.schema; + if (!schema) throw new Error(`Missing GET 200 application/json schema: ${path}`); + return resolveSchema(schema); +} + +function itemOrObjectSchema(path: string): SchemaObject { + const schema = responseSchema(path); + return schema.type === "array" && schema.items + ? resolveSchema(schema.items) + : schema; +} + +function property(schema: SchemaObject, name: string): SchemaObject { + const value = resolveSchema(schema).properties?.[name]; + if (!value) throw new Error(`Missing property: ${name}`); + return resolveSchema(value); +} + +function additionalProperties(schema: SchemaObject): SchemaObject { + const value = resolveSchema(schema).additionalProperties; + if (!value || typeof value === "boolean") { + throw new Error("Missing schema-valued additionalProperties"); + } + return resolveSchema(value); +} + +function invalidNullablePaths(value: unknown, path: string): string[] { + if (Array.isArray(value)) { + return value.flatMap((item, index) => + invalidNullablePaths(item, `${path}[${index}]`) + ); + } + if (typeof value !== "object" || value === null) return []; + + const node = value as Record; + const invalid = node.nullable === true && !("type" in node) ? [path] : []; + return invalid.concat( + Object.entries(node).flatMap(([key, child]) => + invalidNullablePaths(child, `${path}.${key}`) + ) + ); +} + +describe("website-consumed OpenAPI response contracts", () => { + it.each([ + [ + "/v1/profile", + [ + "id", + "name", + "bio", + "handles", + "contact", + "timezone", + "avatar_url", + "location", + "email", + "website", + "image_url", + "image_alt", + "summary", + "updated_at", + ], + ], + [ + "/v1/projects", + [ + "id", + "title", + "description", + "links", + "tags", + "status", + "sort_order", + "published", + "created_at", + "updated_at", + ], + ], + [ + "/v1/posts", + [ + "id", + "slug", + "title", + "summary", + "content", + "tags", + "published_at", + "pinned", + "published", + "created_at", + "updated_at", + ], + ], + [ + "/v1/photos", + [ + "id", + "title", + "description", + "url", + "thumb_url", + "width", + "height", + "shot_at", + "camera", + "lens", + "settings", + "location", + "tags", + "published", + "created_at", + "updated_at", + ], + ], + [ + "/v1/experience", + [ + "id", + "company", + "role", + "location", + "start_date", + "end_date", + "employment_type", + "description", + "published", + "created_at", + "updated_at", + ], + ], + [ + "/v1/education", + [ + "id", + "institution", + "degree", + "field", + "start_date", + "end_date", + "description", + "published", + "created_at", + "updated_at", + ], + ], + [ + "/v1/skills", + ["id", "category", "items", "published", "created_at", "updated_at"], + ], + [ + "/v1/now", + [ + "id", + "focus", + "status", + "availability", + "mood", + "current_song", + "learning", + "projects", + "life", + "reading_goal", + "last_updated", + "updated_at", + ], + ], + [ + "/v1/uses", + [ + "id", + "category", + "name", + "url", + "note", + "published", + "created_at", + "updated_at", + ], + ], + [ + "/v1/settings", + ["id", "public_fields", "theme", "flags", "shelf_config", "updated_at"], + ], + [ + "/v1/shelf", + [ + "id", + "type", + "title", + "quote", + "author", + "source", + "url", + "note", + "image_url", + "drawer", + "tags", + "date_added", + "published", + "status", + "rating", + "rating_scale", + "started_at", + "completed_at", + "last_watched_at", + "progress_current", + "progress_total", + "progress_unit", + "favorite_rank", + "showcase", + "metadata", + "shelf_group", + "display_order", + "cover_override_url", + "spine_image_url", + "goodreads_id", + "isbn", + "apple_books_id", + "created_at", + "updated_at", + ], + ], + ["/v1/github", ["start", "end", "daily", "repos"]], + [ + "/v1/wakatime", + ["start", "end", "days", "languages", "projects", "editors"], + ], + ["/v1/wakatime/hourly", ["start", "end", "hours"]], + ])("declares the existing fields for GET %s", (path, fields) => { + const schema = itemOrObjectSchema(path); + expect(schema.properties).toBeDefined(); + for (const field of fields) { + expect(schema.properties).toHaveProperty(field); + } + }); + + it.each([ + ["/v1/profile", "object"], + ["/v1/projects", "array"], + ["/v1/posts", "array"], + ["/v1/photos", "array"], + ["/v1/experience", "array"], + ["/v1/education", "array"], + ["/v1/skills", "array"], + ["/v1/now", "object"], + ["/v1/uses", "array"], + ["/v1/settings", "object"], + ["/v1/shelf", "array"], + ["/v1/github", "object"], + ["/v1/wakatime", "object"], + ["/v1/wakatime/hourly", "object"], + ])("documents GET %s as an %s response", (path, type) => { + expect(responseSchema(path).type).toBe(type); + }); + + it("declares nested fields consumed by the website", () => { + const profile = responseSchema("/v1/profile"); + expect(property(profile, "handles").properties).toEqual( + expect.objectContaining({ + github: expect.any(Object), + linkedin: expect.any(Object), + twitter: expect.any(Object), + }) + ); + expect(property(profile, "contact").properties).toEqual( + expect.objectContaining({ email: expect.any(Object) }) + ); + + const now = responseSchema("/v1/now"); + const nowProject = resolveSchema(property(now, "projects").items!); + expect(nowProject.properties).toEqual( + expect.objectContaining({ + name: expect.any(Object), + status: expect.any(Object), + description: expect.any(Object), + }) + ); + + const settings = responseSchema("/v1/settings"); + const sections = property(property(settings, "shelf_config"), "sections"); + for (const section of [ + "links", + "quotes", + "visuals", + "wallpapers", + "books", + "movies", + "shows", + ]) { + expect(property(sections, section).properties).toHaveProperty("visible"); + } + + const githubDaily = resolveSchema(property(responseSchema("/v1/github"), "daily").items!); + expect(githubDaily.properties).toEqual( + expect.objectContaining({ + date: expect.any(Object), + count: expect.any(Object), + personal_count: expect.any(Object), + work_count: expect.any(Object), + }) + ); + + const wakatime = responseSchema("/v1/wakatime"); + expect( + resolveSchema(property(wakatime, "languages").items!).properties + ).toEqual( + expect.objectContaining({ + date: expect.any(Object), + name: expect.any(Object), + total_seconds: expect.any(Object), + total_minutes: expect.any(Object), + percent: expect.any(Object), + }) + ); + + const hour = resolveSchema( + property(responseSchema("/v1/wakatime/hourly"), "hours").items! + ); + expect(hour.properties).toEqual( + expect.objectContaining({ + date: expect.any(Object), + hour: expect.any(Object), + seconds: expect.any(Object), + languages: expect.any(Object), + }) + ); + expect(property(hour, "languages").additionalProperties).toEqual({ + type: "number", + }); + }); + + it.each([ + [ + "profile handles.github", + () => property(property(responseSchema("/v1/profile"), "handles"), "github"), + ], + [ + "profile handles.linkedin", + () => property(property(responseSchema("/v1/profile"), "handles"), "linkedin"), + ], + [ + "profile handles.twitter", + () => property(property(responseSchema("/v1/profile"), "handles"), "twitter"), + ], + [ + "profile contact.email", + () => property(property(responseSchema("/v1/profile"), "contact"), "email"), + ], + [ + "now projects[].name", + () => + property( + resolveSchema(property(responseSchema("/v1/now"), "projects").items!), + "name" + ), + ], + [ + "now projects[].status", + () => + property( + resolveSchema(property(responseSchema("/v1/now"), "projects").items!), + "status" + ), + ], + [ + "now projects[].description", + () => + property( + resolveSchema(property(responseSchema("/v1/now"), "projects").items!), + "description" + ), + ], + ])("emits open JSON value %s as a null-accepting empty schema", (_label, getSchema) => { + expect(getSchema()).toEqual({}); + }); + + it.each([ + [ + "profile handles", + () => additionalProperties(property(responseSchema("/v1/profile"), "handles")), + ], + [ + "profile contact", + () => additionalProperties(property(responseSchema("/v1/profile"), "contact")), + ], + [ + "now projects[]", + () => + additionalProperties( + resolveSchema(property(responseSchema("/v1/now"), "projects").items!) + ), + ], + [ + "settings flags", + () => additionalProperties(property(responseSchema("/v1/settings"), "flags")), + ], + [ + "shelf metadata", + () => additionalProperties(property(itemOrObjectSchema("/v1/shelf"), "metadata")), + ], + [ + "shelf tags record variant", + () => { + const tags = property(itemOrObjectSchema("/v1/shelf"), "tags"); + const recordVariant = [...(tags.anyOf ?? []), ...(tags.oneOf ?? [])] + .map(resolveSchema) + .find((schema) => schema.type === "object"); + if (!recordVariant) throw new Error("Missing shelf tags record variant"); + return additionalProperties(recordVariant); + }, + ], + ])("emits open JSON catchall %s as a null-accepting empty schema", (_label, getSchema) => { + expect(getSchema()).toEqual({}); + }); + + it("includes null in the nullable shelf rating scale enum", () => { + expect( + property(itemOrObjectSchema("/v1/shelf"), "rating_scale").enum + ).toEqual([5, 10, null]); + }); + + it("never emits OpenAPI 3.0 nullable without a sibling type", () => { + const invalid = Object.entries(document.components?.schemas ?? {}).flatMap( + ([name, schema]) => invalidNullablePaths(schema, `components.schemas.${name}`) + ); + expect(invalid).toEqual([]); + }); + + it("allows every JSON number in settings shelf_config.hiddenItems", () => { + const settings = responseSchema("/v1/settings"); + const shelfConfig = property(settings, "shelf_config"); + const hiddenItems = property(shelfConfig, "hiddenItems"); + expect(resolveSchema(hiddenItems.items!)).toMatchObject({ type: "number" }); + }); + + it("distinguishes normalized booleans from raw D1 integer flags", () => { + expect(property(itemOrObjectSchema("/v1/posts"), "published")).toMatchObject({ + type: "boolean", + }); + expect(property(itemOrObjectSchema("/v1/shelf"), "showcase")).toMatchObject({ + type: "boolean", + }); + expect(property(itemOrObjectSchema("/v1/projects"), "published")).toMatchObject({ + type: "integer", + enum: [0, 1], + }); + }); + + it.each([ + [ + "/v1/profile", + [ + "id", + "name", + "bio", + "handles", + "contact", + "timezone", + "avatar_url", + "location", + "email", + "website", + "image_url", + "image_alt", + "summary", + "updated_at", + ], + ], + [ + "/v1/now", + [ + "id", + "focus", + "status", + "availability", + "mood", + "current_song", + "learning", + "projects", + "life", + "reading_goal", + "last_updated", + "updated_at", + ], + ], + ])("keeps all GET %s properties optional for the empty fallback", (path, fields) => { + const required = responseSchema(path).required ?? []; + for (const field of fields) { + expect(required).not.toContain(field); + } + }); + + it("documents optional settings default fields and nullable database values", () => { + const settings = responseSchema("/v1/settings"); + for (const field of ["id", "updated_at"]) { + expect(settings.required ?? []).not.toContain(field); + } + + const project = itemOrObjectSchema("/v1/projects"); + expect(project.required).toEqual(expect.arrayContaining(["id", "title", "description"])); + expect(property(project, "description")).toMatchObject({ + type: "string", + nullable: true, + }); + }); +}); diff --git a/src/routes/education.ts b/src/routes/education.ts index e0a1ffa..d1b8f3a 100644 --- a/src/routes/education.ts +++ b/src/routes/education.ts @@ -4,10 +4,10 @@ import { nowIso } from "../utils/date"; import { parseJson } from "../utils/json"; import { validateBody } from "../utils/validation"; import { educationSchema } from "../schemas/profile"; +import { educationListResponseSchema } from "../schemas/responses"; import { listQuerySchema } from "../schemas/common"; import { openApiRegistry, - genericArraySchema, genericObjectSchema, okCreatedSchema, okUpdatedSchema, @@ -43,7 +43,7 @@ openApiRegistry.registerPath({ summary: "List education", security: authSecurity, request: { query: listQuerySchema }, - responses: okResponses(genericArraySchema), + responses: okResponses(educationListResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/experience.ts b/src/routes/experience.ts index a2575e7..2bfa859 100644 --- a/src/routes/experience.ts +++ b/src/routes/experience.ts @@ -4,10 +4,10 @@ import { nowIso } from "../utils/date"; import { parseJson } from "../utils/json"; import { validateBody } from "../utils/validation"; import { experienceSchema } from "../schemas/profile"; +import { experiencesResponseSchema } from "../schemas/responses"; import { listQuerySchema } from "../schemas/common"; import { openApiRegistry, - genericArraySchema, genericObjectSchema, okCreatedSchema, okUpdatedSchema, @@ -43,7 +43,7 @@ openApiRegistry.registerPath({ summary: "List experience", security: authSecurity, request: { query: listQuerySchema }, - responses: okResponses(genericArraySchema), + responses: okResponses(experiencesResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/github.ts b/src/routes/github.ts index d52f743..cdb94c4 100644 --- a/src/routes/github.ts +++ b/src/routes/github.ts @@ -4,11 +4,11 @@ import { dateOnly, addDays, daysBetween } from "../utils/date"; import { parseJson } from "../utils/json"; import { validateBody } from "../utils/validation"; import { backfillSchema } from "../schemas/common"; +import { githubResponseSchema } from "../schemas/responses"; import { refreshGitHub } from "../services/github"; import { markRefreshed } from "../services/wakatime"; import { openApiRegistry, - genericObjectSchema, okDateRangeSchema, openApiJsonRequestBody, okResponses, @@ -24,7 +24,7 @@ openApiRegistry.registerPath({ path: "/v1/github", summary: "Get GitHub data", security: authSecurity, - responses: okResponses(genericObjectSchema), + responses: okResponses(githubResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/now.ts b/src/routes/now.ts index 13f1ac7..bec6070 100644 --- a/src/routes/now.ts +++ b/src/routes/now.ts @@ -5,10 +5,10 @@ import { nowIso } from "../utils/date"; import { parseJson, mapJsonField } from "../utils/json"; import { validateBody } from "../utils/validation"; import { nowSchema } from "../schemas/profile"; +import { nowResponseSchema } from "../schemas/responses"; import { normalizeNow } from "../utils/normalizers"; import { openApiRegistry, - genericObjectSchema, okUpdatedSchema, openApiJsonRequestBody, okResponses, @@ -23,7 +23,7 @@ openApiRegistry.registerPath({ path: "/v1/now", summary: "Fetch current status", security: authSecurity, - responses: okResponses(genericObjectSchema), + responses: okResponses(nowResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/photos.ts b/src/routes/photos.ts index d5238c4..45dd5be 100644 --- a/src/routes/photos.ts +++ b/src/routes/photos.ts @@ -7,10 +7,10 @@ import { validateBody } from "../utils/validation"; import { normalizePhoto } from "../utils/normalizers"; import { fileExtensionForContentType } from "../utils/response"; import { photoSchema } from "../schemas/content"; +import { photosResponseSchema } from "../schemas/responses"; import { listQuerySchema } from "../schemas/common"; import { openApiRegistry, - genericArraySchema, genericObjectSchema, okCreatedSchema, okUpdatedSchema, @@ -92,7 +92,7 @@ openApiRegistry.registerPath({ summary: "List photos", security: authSecurity, request: { query: listQuerySchema }, - responses: okResponses(genericArraySchema), + responses: okResponses(photosResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/posts.ts b/src/routes/posts.ts index 33b13f5..5210ad5 100644 --- a/src/routes/posts.ts +++ b/src/routes/posts.ts @@ -6,10 +6,10 @@ import { parseJson, mapJsonField } from "../utils/json"; import { validateBody } from "../utils/validation"; import { normalizePost } from "../utils/normalizers"; import { postSchema } from "../schemas/content"; +import { postsResponseSchema } from "../schemas/responses"; import { listQuerySchema } from "../schemas/common"; import { openApiRegistry, - genericArraySchema, genericObjectSchema, okCreatedSchema, okUpdatedSchema, @@ -17,9 +17,7 @@ import { openApiJsonRequestBody, okResponses, createdResponses, - openApiResponseWithExample, authSecurity, - errorResponses, } from "../schemas/openapi"; import { parseListQuery, @@ -45,23 +43,7 @@ openApiRegistry.registerPath({ summary: "List posts", security: authSecurity, request: { query: listQuerySchema }, - responses: { - 200: openApiResponseWithExample(genericArraySchema, "OK", [ - { - id: 1, - slug: "hello-world", - title: "Hello World", - summary: "My first post", - content: "Lorem ipsum...", - tags: ["meta"], - published_at: "2025-01-01T00:00:00.000Z", - pinned: false, - created_at: "2025-01-01T00:00:00.000Z", - updated_at: "2025-01-01T00:00:00.000Z", - }, - ]), - ...errorResponses, - }, + responses: okResponses(postsResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/profile.ts b/src/routes/profile.ts index fde544d..3fba930 100644 --- a/src/routes/profile.ts +++ b/src/routes/profile.ts @@ -6,9 +6,9 @@ import { parseJson, mapJsonField } from "../utils/json"; import { validateBody } from "../utils/validation"; import { normalizeProfile } from "../utils/normalizers"; import { profileSchema } from "../schemas/profile"; +import { profileResponseSchema } from "../schemas/responses"; import { openApiRegistry, - genericObjectSchema, okUpdatedSchema, openApiJsonRequestBody, okResponses, @@ -23,7 +23,7 @@ openApiRegistry.registerPath({ path: "/v1/profile", summary: "Fetch profile", security: authSecurity, - responses: okResponses(genericObjectSchema), + responses: okResponses(profileResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/projects.ts b/src/routes/projects.ts index a490b20..f0381d5 100644 --- a/src/routes/projects.ts +++ b/src/routes/projects.ts @@ -6,10 +6,10 @@ import { parseJson, mapJsonField } from "../utils/json"; import { validateBody } from "../utils/validation"; import { normalizeProject } from "../utils/normalizers"; import { projectSchema } from "../schemas/content"; +import { projectsResponseSchema } from "../schemas/responses"; import { listQuerySchema } from "../schemas/common"; import { openApiRegistry, - genericArraySchema, genericObjectSchema, okCreatedSchema, okUpdatedSchema, @@ -17,9 +17,7 @@ import { openApiJsonRequestBody, okResponses, createdResponses, - openApiResponseWithExample, authSecurity, - errorResponses, } from "../schemas/openapi"; import { parseListQuery, @@ -46,21 +44,7 @@ openApiRegistry.registerPath({ summary: "List projects", security: authSecurity, request: { query: listQuerySchema }, - responses: { - 200: openApiResponseWithExample(genericArraySchema, "OK", [ - { - id: 1, - title: "Personal API", - description: "Cloudflare Worker + D1.", - links: ["https://api.example.com", "https://github.com/user/repo"], - tags: ["cloudflare", "typescript"], - status: "active", - created_at: "2025-01-05T12:34:56.000Z", - updated_at: "2025-01-05T12:34:56.000Z", - }, - ]), - ...errorResponses, - }, + responses: okResponses(projectsResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/settings.ts b/src/routes/settings.ts index b5d9419..07f0e4b 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -6,9 +6,9 @@ import { parseJson, mapJsonField } from "../utils/json"; import { validateBody } from "../utils/validation"; import { normalizeSettings } from "../utils/normalizers"; import { settingsSchema } from "../schemas/profile"; +import { settingsResponseSchema } from "../schemas/responses"; import { openApiRegistry, - genericObjectSchema, okUpdatedSchema, openApiJsonRequestBody, okResponses, @@ -23,7 +23,7 @@ openApiRegistry.registerPath({ path: "/v1/settings", summary: "Fetch settings", security: authSecurity, - responses: okResponses(genericObjectSchema), + responses: okResponses(settingsResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/shelf.ts b/src/routes/shelf.ts index f7f956f..a515f88 100644 --- a/src/routes/shelf.ts +++ b/src/routes/shelf.ts @@ -7,12 +7,12 @@ import { parseJson, mapJsonField } from "../utils/json"; import { validateBody } from "../utils/validation"; import { normalizeShelfItem } from "../utils/normalizers"; import { shelfItemBaseSchema, shelfItemSchema, statusMatchesShelfType } from "../schemas/content"; +import { shelfResponseSchema } from "../schemas/responses"; import { listQuerySchema } from "../schemas/common"; import { getTmdbMedia, normalizeTmdbMediaType, searchTmdbMedia, type TmdbCandidate } from "../services/tmdb"; import { getTag, mergeTags } from "../utils/tags"; import { openApiRegistry, - genericArraySchema, genericObjectSchema, okCreatedSchema, okUpdatedSchema, @@ -120,7 +120,7 @@ openApiRegistry.registerPath({ summary: "List shelf items", security: authSecurity, request: { query: shelfQuerySchema }, - responses: okResponses(genericArraySchema), + responses: okResponses(shelfResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/skills.ts b/src/routes/skills.ts index 563f7a4..5409eac 100644 --- a/src/routes/skills.ts +++ b/src/routes/skills.ts @@ -6,10 +6,10 @@ import { parseJson, mapJsonField } from "../utils/json"; import { validateBody } from "../utils/validation"; import { normalizeSkill } from "../utils/normalizers"; import { skillSchema } from "../schemas/profile"; +import { skillsResponseSchema } from "../schemas/responses"; import { listQuerySchema } from "../schemas/common"; import { openApiRegistry, - genericArraySchema, genericObjectSchema, okCreatedSchema, okUpdatedSchema, @@ -43,7 +43,7 @@ openApiRegistry.registerPath({ summary: "List skills", security: authSecurity, request: { query: listQuerySchema }, - responses: okResponses(genericArraySchema), + responses: okResponses(skillsResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/uses.ts b/src/routes/uses.ts index 05bf04b..46ce9a3 100644 --- a/src/routes/uses.ts +++ b/src/routes/uses.ts @@ -4,10 +4,10 @@ import { nowIso } from "../utils/date"; import { parseJson } from "../utils/json"; import { validateBody } from "../utils/validation"; import { usesItemSchema } from "../schemas/content"; +import { usesResponseSchema } from "../schemas/responses"; import { listQuerySchema } from "../schemas/common"; import { openApiRegistry, - genericArraySchema, genericObjectSchema, okCreatedSchema, okUpdatedSchema, @@ -41,7 +41,7 @@ openApiRegistry.registerPath({ summary: "List uses items", security: authSecurity, request: { query: listQuerySchema }, - responses: okResponses(genericArraySchema), + responses: okResponses(usesResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/routes/wakatime.ts b/src/routes/wakatime.ts index 9d16891..8fee24a 100644 --- a/src/routes/wakatime.ts +++ b/src/routes/wakatime.ts @@ -6,6 +6,10 @@ import { parseJson } from "../utils/json"; import { validateBody } from "../utils/validation"; import { normalizeWakaTimeHourly } from "../utils/normalizers"; import { backfillSchema } from "../schemas/common"; +import { + wakatimeHourlyResponseSchema, + wakatimeResponseSchema, +} from "../schemas/responses"; import { refreshWakaTime, refreshWakaTimeHourly, @@ -13,7 +17,6 @@ import { } from "../services/wakatime"; import { openApiRegistry, - genericObjectSchema, okDateRangeSchema, openApiJsonRequestBody, okResponses, @@ -29,7 +32,7 @@ openApiRegistry.registerPath({ path: "/v1/wakatime", summary: "Get WakaTime data", security: authSecurity, - responses: okResponses(genericObjectSchema), + responses: okResponses(wakatimeResponseSchema), }); openApiRegistry.registerPath({ @@ -37,7 +40,7 @@ openApiRegistry.registerPath({ path: "/v1/wakatime/hourly", summary: "Get WakaTime hourly data", security: authSecurity, - responses: okResponses(genericObjectSchema), + responses: okResponses(wakatimeHourlyResponseSchema), }); openApiRegistry.registerPath({ diff --git a/src/schemas/responses.ts b/src/schemas/responses.ts new file mode 100644 index 0000000..f9ef8d3 --- /dev/null +++ b/src/schemas/responses.ts @@ -0,0 +1,357 @@ +import { z } from "zod"; +import { + postSchema, + photoSchema, + projectSchema, + SHELF_STATUSES, + shelfItemBaseSchema, + usesItemSchema, +} from "./content"; +import { + educationSchema, + experienceSchema, + nowSchema, + profileSchema, + skillSchema, +} from "./profile"; +import { openApiRegistry } from "./openapi"; + +const nullableStringSchema = z.string().nullable(); +const nullableIntegerSchema = z.number().int().nullable(); + +function openApi30NullAcceptingSchema(schema: T): T { + // OpenAPI 3.0's empty schema already accepts null. Prevent the generator from + // adding its invalid `{ nullable: true }` representation when no type exists. + schema.isNullable = () => false; + return schema; +} + +const arbitraryJsonValueSchema = openApi30NullAcceptingSchema(z.unknown()); +const optionalArbitraryJsonValueSchema = openApi30NullAcceptingSchema( + arbitraryJsonValueSchema.optional() +); +const shelfTagsResponseSchema = openApi30NullAcceptingSchema( + z.union([ + z.array(z.string()), + z.record(arbitraryJsonValueSchema), + z.literal(null), + ]) +); +const d1BooleanSchema = z + .union([z.literal(0), z.literal(1)]) + .openapi({ type: "integer", enum: [0, 1] }); + +const profileHandlesResponseSchema = z + .object({ + github: optionalArbitraryJsonValueSchema, + linkedin: optionalArbitraryJsonValueSchema, + twitter: optionalArbitraryJsonValueSchema, + }) + .catchall(arbitraryJsonValueSchema); + +const profileContactResponseSchema = z + .object({ + email: optionalArbitraryJsonValueSchema, + }) + .catchall(arbitraryJsonValueSchema); + +const nowProjectResponseSchema = z + .object({ + name: optionalArbitraryJsonValueSchema, + status: optionalArbitraryJsonValueSchema, + description: optionalArbitraryJsonValueSchema, + }) + .catchall(arbitraryJsonValueSchema); + +const shelfSectionVisibilityResponseSchema = z.object({ + visible: z.boolean(), +}); + +const shelfConfigResponseSchema = z.object({ + sections: z + .object({ + links: shelfSectionVisibilityResponseSchema.optional(), + quotes: shelfSectionVisibilityResponseSchema.optional(), + visuals: shelfSectionVisibilityResponseSchema.optional(), + wallpapers: shelfSectionVisibilityResponseSchema.optional(), + books: shelfSectionVisibilityResponseSchema.optional(), + movies: shelfSectionVisibilityResponseSchema.optional(), + shows: shelfSectionVisibilityResponseSchema.optional(), + }) + .optional(), + hiddenItems: z.array(z.number()).optional(), +}); + +export const profileResponseSchema = openApiRegistry.register( + "ProfileResponse", + profileSchema.extend({ + id: z.number().int().optional(), + name: nullableStringSchema.optional(), + bio: nullableStringSchema.optional(), + handles: profileHandlesResponseSchema.nullable().optional(), + contact: profileContactResponseSchema.nullable().optional(), + timezone: nullableStringSchema.optional(), + avatar_url: nullableStringSchema.optional(), + location: nullableStringSchema.optional(), + email: nullableStringSchema.optional(), + website: nullableStringSchema.optional(), + image_url: nullableStringSchema.optional(), + image_alt: nullableStringSchema.optional(), + summary: z.array(z.string()).nullable().optional(), + updated_at: nullableStringSchema.optional(), + }) +); + +export const projectResponseSchema = openApiRegistry.register( + "ProjectResponse", + projectSchema.extend({ + id: z.number().int(), + description: nullableStringSchema, + links: z.array(z.string()).nullable(), + tags: z.array(z.string()).nullable(), + status: nullableStringSchema, + sort_order: nullableIntegerSchema, + published: d1BooleanSchema, + created_at: nullableStringSchema, + updated_at: nullableStringSchema, + }) +); +export const projectsResponseSchema = z.array(projectResponseSchema); + +export const postResponseSchema = openApiRegistry.register( + "PostResponse", + postSchema.extend({ + id: z.number().int(), + summary: nullableStringSchema, + content: nullableStringSchema, + tags: z.array(z.string()).nullable(), + published_at: nullableStringSchema, + pinned: z.boolean(), + published: z.boolean(), + created_at: nullableStringSchema, + updated_at: nullableStringSchema, + }) +); +export const postsResponseSchema = z.array(postResponseSchema); + +export const photoResponseSchema = openApiRegistry.register( + "PhotoResponse", + photoSchema.extend({ + id: z.number().int(), + title: nullableStringSchema, + description: nullableStringSchema, + thumb_url: nullableStringSchema, + width: nullableIntegerSchema, + height: nullableIntegerSchema, + shot_at: nullableStringSchema, + camera: nullableStringSchema, + lens: nullableStringSchema, + settings: nullableStringSchema, + location: nullableStringSchema, + tags: z.array(z.string()).nullable(), + published: d1BooleanSchema, + created_at: nullableStringSchema, + updated_at: nullableStringSchema, + }) +); +export const photosResponseSchema = z.array(photoResponseSchema); + +export const experienceResponseSchema = openApiRegistry.register( + "ExperienceResponse", + experienceSchema.extend({ + id: z.number().int(), + location: nullableStringSchema, + start_date: nullableStringSchema, + end_date: nullableStringSchema, + employment_type: nullableStringSchema, + description: nullableStringSchema, + published: d1BooleanSchema, + created_at: nullableStringSchema, + updated_at: nullableStringSchema, + }) +); +export const experiencesResponseSchema = z.array(experienceResponseSchema); + +export const educationResponseSchema = openApiRegistry.register( + "EducationResponse", + educationSchema.extend({ + id: z.number().int(), + degree: nullableStringSchema, + field: nullableStringSchema, + start_date: nullableStringSchema, + end_date: nullableStringSchema, + description: nullableStringSchema, + published: d1BooleanSchema, + created_at: nullableStringSchema, + updated_at: nullableStringSchema, + }) +); +export const educationListResponseSchema = z.array(educationResponseSchema); + +export const skillResponseSchema = openApiRegistry.register( + "SkillResponse", + skillSchema.extend({ + id: z.number().int(), + items: z.array(z.string()).nullable(), + published: d1BooleanSchema, + created_at: nullableStringSchema, + updated_at: nullableStringSchema, + }) +); +export const skillsResponseSchema = z.array(skillResponseSchema); + +export const nowResponseSchema = openApiRegistry.register( + "NowResponse", + nowSchema.extend({ + id: z.number().int().optional(), + focus: nullableStringSchema.optional(), + status: nullableStringSchema.optional(), + availability: nullableStringSchema.optional(), + mood: nullableStringSchema.optional(), + current_song: nullableStringSchema.optional(), + learning: z.array(z.string()).nullable().optional(), + projects: z.array(nowProjectResponseSchema).nullable().optional(), + life: z.array(z.string()).nullable().optional(), + reading_goal: nullableStringSchema.optional(), + last_updated: nullableStringSchema.optional(), + updated_at: nullableStringSchema.optional(), + }) +); + +export const usesItemResponseSchema = openApiRegistry.register( + "UsesItemResponse", + usesItemSchema.extend({ + id: z.number().int(), + url: nullableStringSchema, + note: nullableStringSchema, + published: d1BooleanSchema, + created_at: nullableStringSchema, + updated_at: nullableStringSchema, + }) +); +export const usesResponseSchema = z.array(usesItemResponseSchema); + +export const settingsResponseSchema = openApiRegistry.register( + "SettingsResponse", + z.object({ + id: z.number().int().optional(), + public_fields: z.array(z.string()).nullable(), + theme: nullableStringSchema, + flags: z.record(arbitraryJsonValueSchema).nullable(), + shelf_config: shelfConfigResponseSchema.nullable(), + updated_at: nullableStringSchema.optional(), + }) +); + +export const shelfItemResponseSchema = openApiRegistry.register( + "ShelfItemResponse", + shelfItemBaseSchema.extend({ + id: z.number().int(), + title: nullableStringSchema, + quote: nullableStringSchema, + author: nullableStringSchema, + source: nullableStringSchema, + url: nullableStringSchema, + note: nullableStringSchema, + image_url: nullableStringSchema, + drawer: nullableStringSchema, + tags: shelfTagsResponseSchema, + date_added: nullableStringSchema, + published: z.boolean(), + status: z.enum(SHELF_STATUSES).nullable(), + rating: z.number().nullable(), + rating_scale: z + .number() + .int() + .nullable() + .openapi({ enum: [5, 10, null] }), + started_at: nullableStringSchema, + completed_at: nullableStringSchema, + last_watched_at: nullableStringSchema, + progress_current: z.number().nullable(), + progress_total: z.number().nullable(), + progress_unit: nullableStringSchema, + favorite_rank: nullableIntegerSchema, + showcase: z.boolean(), + metadata: z.record(arbitraryJsonValueSchema).nullable(), + shelf_group: nullableStringSchema, + display_order: nullableIntegerSchema, + cover_override_url: nullableStringSchema, + spine_image_url: nullableStringSchema, + goodreads_id: nullableStringSchema, + isbn: nullableStringSchema, + apple_books_id: nullableStringSchema, + created_at: nullableStringSchema, + updated_at: nullableStringSchema, + }) +); +export const shelfResponseSchema = z.array(shelfItemResponseSchema); + +const githubDailyResponseSchema = z.object({ + date: z.string(), + count: nullableIntegerSchema, + created_at: nullableStringSchema, + personal_count: z.number().int(), + work_count: z.number().int(), +}); + +const githubRepoResponseSchema = z.object({ + range_start: z.string(), + range_end: z.string(), + repo: z.string(), + count: nullableIntegerSchema, +}); + +export const githubResponseSchema = openApiRegistry.register( + "GitHubResponse", + z.object({ + start: z.string(), + end: z.string(), + daily: z.array(githubDailyResponseSchema), + repos: z.array(githubRepoResponseSchema), + }) +); + +const wakatimeDayResponseSchema = z.object({ + date: z.string(), + total_seconds: z.number().nullable(), + total_minutes: nullableIntegerSchema, + timezone: nullableStringSchema, + created_at: nullableStringSchema, +}); + +const wakatimeBreakdownResponseSchema = z.object({ + date: z.string(), + name: z.string(), + total_seconds: z.number().nullable(), + total_minutes: nullableIntegerSchema, + percent: z.number().nullable(), +}); + +export const wakatimeResponseSchema = openApiRegistry.register( + "WakaTimeResponse", + z.object({ + start: z.string(), + end: z.string(), + days: z.array(wakatimeDayResponseSchema), + languages: z.array(wakatimeBreakdownResponseSchema), + projects: z.array(wakatimeBreakdownResponseSchema), + editors: z.array(wakatimeBreakdownResponseSchema), + }) +); + +const wakatimeHourResponseSchema = z.object({ + date: z.string(), + hour: z.number().int(), + seconds: z.number().nullable(), + languages: z.record(z.number()), +}); + +export const wakatimeHourlyResponseSchema = openApiRegistry.register( + "WakaTimeHourlyResponse", + z.object({ + start: z.string(), + end: z.string(), + hours: z.array(wakatimeHourResponseSchema), + }) +);