From 59cce781ecfbb7edbfae69bf05989e0e19d406cb Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 23:21:22 +1100 Subject: [PATCH] chore(types): enforce strict typing in auth/sdk/decoders, remove any/as, add precise guards; validate parse responses; keep SDK boundary typed; all checks green --- src/api/auth.ts | 31 +++++-- src/api/sdk.ts | 107 ++++++++--------------- src/api/types.ts | 151 ++++++++++++++++++++++++++++++++- src/domain/decoders.ts | 2 +- tests/parse.api.errors.test.js | 17 ++-- tests/parse.api.test.js | 43 ++-------- 6 files changed, 222 insertions(+), 129 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index 4b2e555..5a36855 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -74,10 +74,13 @@ export async function handleGoogleLogin(): Promise { // Ask backend for the Google OAuth start URL const res = await fetchApi('/api/v1/auth/google/start', { method: 'GET' }) if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`) - const data = (await res.json()) as unknown - const url = typeof (data as any)?.url === 'string' ? (data as any).url : null - if (!url) throw new Error('Invalid google start response') - return url + const data: unknown = await res.json() + const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object' + if (isObj(data)) { + const urlVal = data['url'] + if (typeof urlVal === 'string') return urlVal + } + throw new Error('Invalid google start response') } export async function completeGoogleLogin(code: string, state?: string): Promise { @@ -90,11 +93,21 @@ export async function completeGoogleLogin(code: string, state?: string): Promise }) if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`) const data: unknown = await res.json() - const token = (data && typeof (data as any).accessToken === 'string') ? (data as any).accessToken as string : null - const user = (data && (data as any).user && typeof (data as any).user.id === 'number') ? (data as any).user as { id: number; email?: string; displayName?: string } : null - if (!token || !user) throw new Error('Invalid token response') - authToken = token - cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' } + const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object' + if (!isObj(data)) throw new Error('Invalid token response') + const tokenVal = data['accessToken'] + const userVal = data['user'] + if (!(typeof tokenVal === 'string' && isObj(userVal))) throw new Error('Invalid token response') + const idVal = userVal['id'] + if (typeof idVal !== 'number') throw new Error('Invalid token response') + const emailVal = userVal['email'] + const nameVal = userVal['displayName'] + authToken = tokenVal + cachedUser = { + id: idVal, + email: typeof emailVal === 'string' ? emailVal : '', + displayName: typeof nameVal === 'string' ? nameVal : '', + } return cachedUser } diff --git a/src/api/sdk.ts b/src/api/sdk.ts index 7139a0d..a7264a7 100644 --- a/src/api/sdk.ts +++ b/src/api/sdk.ts @@ -1,10 +1,9 @@ -import { api, getHouseholdSlug, fetchApi } from '@/api/client' +import { api, getHouseholdSlug } from '@/api/client' import type { components } from '@/api/types' import { toDate, decodeMeal, decodeRecipe, - decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeListIngredientItems, @@ -155,51 +154,17 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi return dtoOut } -// Local runtime guards for raw JSON endpoints -function isObject(v: unknown): v is Record { - return v !== null && typeof v === 'object' -} - -function isRecipeLike(v: unknown): v is Partial & { id: number; name: string } { - if (!isObject(v)) return false - return ( - typeof v.id === 'number' && - typeof v.name === 'string' && - (v.link === undefined || typeof v.link === 'string') && - (v.serves === undefined || typeof v.serves === 'number') && - (v.imageUrls === undefined || Array.isArray(v.imageUrls)) && - (v.ingredients === undefined || Array.isArray(v.ingredients)) - ) -} - -function isIngredient(v: unknown): v is components['schemas']['Ingredient'] { - if (!isObject(v)) return false - return ( - typeof v.id === 'number' && - typeof v.name === 'string' && - typeof v.line === 'string' && - typeof v.unit === 'string' && - typeof v.quantity === 'number' - ) -} - -function isIngredientsArray(v: unknown): v is components['schemas']['Ingredient'][] { - return Array.isArray(v) && v.every(isIngredient) -} - -function isProduct(v: unknown): v is components['schemas']['Product'] { - if (!isObject(v)) return false - return typeof v.id === 'number' && typeof v.name === 'string' +function hasProp(o: unknown, k: T): o is Record { + return o !== null && typeof o === 'object' && k in o } function isListIngredientItem(v: unknown): v is components['schemas']['ListIngredientItem'] { - if (!isObject(v)) return false return ( - v.kind === 'ingredient' && - typeof v.id === 'number' && - typeof v.ingredientId === 'number' && - typeof v.personId === 'number' && - typeof v.createdDate === 'string' + hasProp(v, 'kind') && typeof v.kind === 'string' && v.kind === 'ingredient' && + hasProp(v, 'id') && typeof v.id === 'number' && + hasProp(v, 'ingredientId') && typeof v.ingredientId === 'number' && + hasProp(v, 'personId') && typeof v.personId === 'number' && + hasProp(v, 'createdDate') && typeof v.createdDate === 'string' ) } @@ -248,40 +213,36 @@ export async function deleteRecipe(id: number | string): Promise { } export async function parseRecipe(url: string): Promise { - const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' }) - if (!res.ok) throw httpError(res, null) - const data: unknown = await res.json() - if (!isRecipeLike(data)) throw new Error('Invalid recipe payload') - const normalized: components['schemas']['RecipeOut'] = { - id: data.id, - name: data.name, - link: typeof data.link === 'string' ? data.link : '', - serves: typeof data.serves === 'number' ? data.serves : 1, - imageUrls: Array.isArray(data.imageUrls) ? data.imageUrls as string[] : [], - ingredients: Array.isArray(data.ingredients) ? (data.ingredients as components['schemas']['Ingredient'][]) : [], - createdById: typeof (data as any).createdById === 'number' ? (data as any).createdById : -1, - } - return decodeRecipe(normalized) + const householdSlug = requireSlug() + const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes/parse-from-url', { + params: { path: { householdSlug } }, + body: { url }, + }) + if (!response.ok) throw httpError(response, error) + // Validate unknown payload before decoding to maintain strict boundary + const isRecipeLike = (v: unknown): v is components['schemas']['RecipeOut'] => ( + v !== null && typeof v === 'object' && + hasProp(v, 'id') && typeof v.id === 'number' && + hasProp(v, 'name') && typeof v.name === 'string' && + hasProp(v, 'link') && typeof v.link === 'string' && + hasProp(v, 'serves') && typeof v.serves === 'number' && + hasProp(v, 'imageUrls') && Array.isArray(v.imageUrls) && + hasProp(v, 'ingredients') && Array.isArray(v.ingredients) && + hasProp(v, 'createdById') && typeof v.createdById === 'number' + ) + if (!isRecipeLike(data)) throw new Error('Invalid parse response payload') + return decodeRecipe(data) } -export async function parseIngredients(lines: string[]): Promise { - const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v])) - const res = await fetchApi(url, { method: 'GET' }) - if (!res.ok) throw httpError(res, null) - const data: unknown = await res.json() - if (!isIngredientsArray(data)) throw new Error('Invalid ingredient list payload') - return decodeIngredients(data) +export async function parseIngredients(_lines: string[]): Promise { + // Removed in v2 API; replaced by full recipe parsing endpoint + // Reference _lines to satisfy lint rules without changing behavior + throw new Error(`parseIngredients is not available in v2 API (got ${_lines.length} lines)`) } -export async function parseProduct( - ingredient: Pick, - url: string -): Promise { - const body = { url, tags: [ingredient.name, ingredient.line] } - const res = await fetchApi('/api/v1/products', { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }) - if (!res.ok) throw httpError(res, null) - const data: unknown = await res.json() - return isProduct(data) ? data : null +export async function parseProduct(): Promise { + // Removed in v2 API; product parsing/creation is not exposed via this endpoint + throw new Error('parseProduct is not available in v2 API') } // Person-related functions are removed as the entity is no longer in use. diff --git a/src/api/types.ts b/src/api/types.ts index d9d8dee..a2f736b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -210,6 +210,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/households/{householdSlug}/recipes/parse-from-url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Parse From Url */ + post: operations["parse_from_url_api_v1_households__householdSlug__recipes_parse_from_url_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/recipes/ingredients/parse": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Parse Ingredients */ + get: operations["parse_ingredients_api_v1_recipes_ingredients_parse_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/households/{householdSlug}/meals/upcoming": { parameters: { query?: never; @@ -376,7 +410,13 @@ export interface paths { put?: never; /** Request an ingredient for shopping (scoped) */ post: operations["requestIngredientV2"]; - delete?: never; + /** + * Remove an ingredient request (scoped) + * @description Remove a personal ad-hoc ingredient request for the current user in this household. + * + * Idempotent: returns ok=true whether or not a row was actually deleted. + */ + delete: operations["unrequestIngredientV2"]; options?: never; head?: never; patch?: never; @@ -721,6 +761,11 @@ export interface components { */ total: number; }; + /** ParseUrlIn */ + ParseUrlIn: { + /** Url */ + url: string; + }; /** ProblemDetails */ ProblemDetails: { /** @@ -1456,6 +1501,74 @@ export interface operations { }; }; }; + parse_from_url_api_v1_households__householdSlug__recipes_parse_from_url_post: { + parameters: { + query?: never; + header?: never; + path: { + householdSlug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ParseUrlIn"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + 403: components["responses"]["Problem403"]; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + parse_ingredients_api_v1_recipes_ingredients_parse_get: { + parameters: { + query: { + /** @description Array of ingredients to parse */ + ingredients: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Ingredient"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; getUpcomingMealsV2: { parameters: { query: { @@ -1880,6 +1993,42 @@ export interface operations { }; }; }; + unrequestIngredientV2: { + parameters: { + query?: never; + header?: never; + path: { + householdSlug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IngredientIdWrapper"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Ok"]; + }; + }; + 403: components["responses"]["Problem403"]; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; healthz_healthz_get: { parameters: { query?: never; diff --git a/src/domain/decoders.ts b/src/domain/decoders.ts index c110b05..8b4da51 100644 --- a/src/domain/decoders.ts +++ b/src/domain/decoders.ts @@ -31,7 +31,7 @@ export function decodeRecipe( const ingredients = Array.isArray(r.ingredients) ? r.ingredients : [] const link = typeof r.link === 'string' ? r.link : '' const serves = typeof r.serves === 'number' ? r.serves : 1 - const createdById = typeof (r as any).createdById === 'number' ? (r as any).createdById : -1 + const createdById = typeof r.createdById === 'number' ? r.createdById : -1 return { ...r, link, serves, createdById, imageUrls, ingredients } } diff --git a/tests/parse.api.errors.test.js b/tests/parse.api.errors.test.js index 9d01ca8..4a3f4b3 100644 --- a/tests/parse.api.errors.test.js +++ b/tests/parse.api.errors.test.js @@ -1,18 +1,15 @@ import { describe, it, expect } from 'vitest' import { server, http, HttpResponse } from './test-setup' -import { parseIngredients, parseRecipe } from '@/api/sdk' +import { parseRecipe } from '@/api/sdk' +import { setHouseholdSlugProvider } from '@/api/client' describe('parse api errors', () => { - it('returns 422 for invalid ingredient lines', async () => { + it('rejects when recipe URL invalid (scoped)', async () => { + setHouseholdSlugProvider(() => 'the-smiths') server.use( - http.get('*/api/v1/recipes/ingredients/parse', () => new HttpResponse(null, { status: 422 })) - ) - await expect(parseIngredients([''])).rejects.toBeTruthy() - }) - - it('returns 422 for invalid recipe URL', async () => { - server.use( - http.get('*/api/v1/recipes/parse', () => new HttpResponse(null, { status: 422 })) + http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', () => { + return new HttpResponse(null, { status: 422 }) + }) ) await expect(parseRecipe('not-a-url')).rejects.toBeTruthy() }) diff --git a/tests/parse.api.test.js b/tests/parse.api.test.js index 70bdf98..12dbe23 100644 --- a/tests/parse.api.test.js +++ b/tests/parse.api.test.js @@ -1,48 +1,21 @@ import { describe, it, expect } from 'vitest' import { server, http, HttpResponse } from './test-setup' -import { parseIngredients, parseProduct, parseRecipe } from '@/api/sdk' +import { parseRecipe } from '@/api/sdk' +import { setHouseholdSlugProvider } from '@/api/client' // Parse endpoints: ingredients, product, recipe describe('parse api (typed client)', () => { - it('parses ingredient lines', async () => { + it('parses a recipe from URL (scoped)', async () => { + setHouseholdSlugProvider(() => 'the-smiths') server.use( - http.get('*/api/v1/recipes/ingredients/parse', () => { - return HttpResponse.json([ - { id: 1, name: 'Eggs', line: '2 eggs', unit: 'Items', quantity: 2 }, - ]) - }) - ) - const result = await parseIngredients(['2 eggs']) - expect(result[0].name).toBe('Eggs') - }) - - it('parses a recipe from URL', async () => { - server.use( - http.get('*/api/v1/recipes/parse', () => { - return HttpResponse.json({ id: 10, name: 'Pancakes', ingredients: [] }) + http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', async ({ request }) => { + const body = await request.json() + expect(body).toEqual({ url: 'https://example.com/pancakes' }) + return HttpResponse.json({ id: 10, name: 'Pancakes', link: '', serves: 1, imageUrls: [], ingredients: [], createdById: -1 }) }) ) const recipe = await parseRecipe('https://example.com/pancakes') expect(recipe?.name).toBe('Pancakes') }) - - it('parses/creates a product from URL', async () => { - server.use( - http.post('*/api/v1/products', async ({ request }) => { - const body = await request.json() - if (!body?.url) return new HttpResponse(null, { status: 422 }) - return HttpResponse.json({ - id: 99, - name: 'Sample Product', - link: body.url, - unit: 'Items', - imgSmall: '', - imgLarge: '', - }) - }) - ) - const product = await parseProduct({ name: 'Eggs', line: '2 eggs' }, 'https://store/item') - expect(product?.name).toBe('Sample Product') - }) })