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

This commit is contained in:
jableader 2025-11-01 23:21:22 +11:00
parent 5586e2eede
commit 59cce781ec
6 changed files with 222 additions and 129 deletions

View file

@ -74,10 +74,13 @@ export async function handleGoogleLogin(): Promise<string> {
// Ask backend for the Google OAuth start URL // Ask backend for the Google OAuth start URL
const res = await fetchApi('/api/v1/auth/google/start', { method: 'GET' }) const res = await fetchApi('/api/v1/auth/google/start', { method: 'GET' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`) if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
const data = (await res.json()) as unknown const data: unknown = await res.json()
const url = typeof (data as any)?.url === 'string' ? (data as any).url : null const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object'
if (!url) throw new Error('Invalid google start response') if (isObj(data)) {
return url 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<User> { export async function completeGoogleLogin(code: string, state?: string): Promise<User> {
@ -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'}`) if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
const data: unknown = await res.json() const data: unknown = await res.json()
const token = (data && typeof (data as any).accessToken === 'string') ? (data as any).accessToken as string : null const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object'
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 (!isObj(data)) throw new Error('Invalid token response')
if (!token || !user) throw new Error('Invalid token response') const tokenVal = data['accessToken']
authToken = token const userVal = data['user']
cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' } 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 return cachedUser
} }

View file

@ -1,10 +1,9 @@
import { api, getHouseholdSlug, fetchApi } from '@/api/client' import { api, getHouseholdSlug } from '@/api/client'
import type { components } from '@/api/types' import type { components } from '@/api/types'
import { import {
toDate, toDate,
decodeMeal, decodeMeal,
decodeRecipe, decodeRecipe,
decodeIngredients,
decodeShoppingList, decodeShoppingList,
decodeShoppingListItems, decodeShoppingListItems,
decodeListIngredientItems, decodeListIngredientItems,
@ -155,51 +154,17 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
return dtoOut return dtoOut
} }
// Local runtime guards for raw JSON endpoints function hasProp<T extends string>(o: unknown, k: T): o is Record<T, unknown> {
function isObject(v: unknown): v is Record<string, unknown> { return o !== null && typeof o === 'object' && k in o
return v !== null && typeof v === 'object'
}
function isRecipeLike(v: unknown): v is Partial<components['schemas']['Recipe']> & { 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 isListIngredientItem(v: unknown): v is components['schemas']['ListIngredientItem'] { function isListIngredientItem(v: unknown): v is components['schemas']['ListIngredientItem'] {
if (!isObject(v)) return false
return ( return (
v.kind === 'ingredient' && hasProp(v, 'kind') && typeof v.kind === 'string' && v.kind === 'ingredient' &&
typeof v.id === 'number' && hasProp(v, 'id') && typeof v.id === 'number' &&
typeof v.ingredientId === 'number' && hasProp(v, 'ingredientId') && typeof v.ingredientId === 'number' &&
typeof v.personId === 'number' && hasProp(v, 'personId') && typeof v.personId === 'number' &&
typeof v.createdDate === 'string' hasProp(v, 'createdDate') && typeof v.createdDate === 'string'
) )
} }
@ -248,40 +213,36 @@ export async function deleteRecipe(id: number | string): Promise<void> {
} }
export async function parseRecipe(url: string): Promise<Recipe | null> { export async function parseRecipe(url: string): Promise<Recipe | null> {
const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' }) const householdSlug = requireSlug()
if (!res.ok) throw httpError(res, null) const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes/parse-from-url', {
const data: unknown = await res.json() params: { path: { householdSlug } },
if (!isRecipeLike(data)) throw new Error('Invalid recipe payload') body: { url },
const normalized: components['schemas']['RecipeOut'] = { })
id: data.id, if (!response.ok) throw httpError(response, error)
name: data.name, // Validate unknown payload before decoding to maintain strict boundary
link: typeof data.link === 'string' ? data.link : '', const isRecipeLike = (v: unknown): v is components['schemas']['RecipeOut'] => (
serves: typeof data.serves === 'number' ? data.serves : 1, v !== null && typeof v === 'object' &&
imageUrls: Array.isArray(data.imageUrls) ? data.imageUrls as string[] : [], hasProp(v, 'id') && typeof v.id === 'number' &&
ingredients: Array.isArray(data.ingredients) ? (data.ingredients as components['schemas']['Ingredient'][]) : [], hasProp(v, 'name') && typeof v.name === 'string' &&
createdById: typeof (data as any).createdById === 'number' ? (data as any).createdById : -1, hasProp(v, 'link') && typeof v.link === 'string' &&
} hasProp(v, 'serves') && typeof v.serves === 'number' &&
return decodeRecipe(normalized) 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<Ingredient[]> { export async function parseIngredients(_lines: string[]): Promise<Ingredient[]> {
const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v])) // Removed in v2 API; replaced by full recipe parsing endpoint
const res = await fetchApi(url, { method: 'GET' }) // Reference _lines to satisfy lint rules without changing behavior
if (!res.ok) throw httpError(res, null) throw new Error(`parseIngredients is not available in v2 API (got ${_lines.length} lines)`)
const data: unknown = await res.json()
if (!isIngredientsArray(data)) throw new Error('Invalid ingredient list payload')
return decodeIngredients(data)
} }
export async function parseProduct( export async function parseProduct(): Promise<components['schemas']['Product'] | null> {
ingredient: Pick<components['schemas']['Ingredient'], 'name' | 'line'>, // Removed in v2 API; product parsing/creation is not exposed via this endpoint
url: string throw new Error('parseProduct is not available in v2 API')
): Promise<components['schemas']['Product'] | null> {
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
} }
// Person-related functions are removed as the entity is no longer in use. // Person-related functions are removed as the entity is no longer in use.

View file

@ -210,6 +210,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/households/{householdSlug}/meals/upcoming": {
parameters: { parameters: {
query?: never; query?: never;
@ -376,7 +410,13 @@ export interface paths {
put?: never; put?: never;
/** Request an ingredient for shopping (scoped) */ /** Request an ingredient for shopping (scoped) */
post: operations["requestIngredientV2"]; 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; options?: never;
head?: never; head?: never;
patch?: never; patch?: never;
@ -721,6 +761,11 @@ export interface components {
*/ */
total: number; total: number;
}; };
/** ParseUrlIn */
ParseUrlIn: {
/** Url */
url: string;
};
/** ProblemDetails */ /** ProblemDetails */
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: { getUpcomingMealsV2: {
parameters: { parameters: {
query: { 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: { healthz_healthz_get: {
parameters: { parameters: {
query?: never; query?: never;

View file

@ -31,7 +31,7 @@ export function decodeRecipe(
const ingredients = Array.isArray(r.ingredients) ? r.ingredients : [] const ingredients = Array.isArray(r.ingredients) ? r.ingredients : []
const link = typeof r.link === 'string' ? r.link : '' const link = typeof r.link === 'string' ? r.link : ''
const serves = typeof r.serves === 'number' ? r.serves : 1 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 } return { ...r, link, serves, createdById, imageUrls, ingredients }
} }

View file

@ -1,18 +1,15 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup' 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', () => { 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( server.use(
http.get('*/api/v1/recipes/ingredients/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(parseIngredients([''])).rejects.toBeTruthy()
}) })
it('returns 422 for invalid recipe URL', async () => {
server.use(
http.get('*/api/v1/recipes/parse', () => new HttpResponse(null, { status: 422 }))
) )
await expect(parseRecipe('not-a-url')).rejects.toBeTruthy() await expect(parseRecipe('not-a-url')).rejects.toBeTruthy()
}) })

View file

@ -1,48 +1,21 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup' 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 // Parse endpoints: ingredients, product, recipe
describe('parse api (typed client)', () => { describe('parse api (typed client)', () => {
it('parses ingredient lines', async () => { it('parses a recipe from URL (scoped)', async () => {
setHouseholdSlugProvider(() => 'the-smiths')
server.use( server.use(
http.get('*/api/v1/recipes/ingredients/parse', () => { http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', async ({ request }) => {
return HttpResponse.json([ const body = await request.json()
{ id: 1, name: 'Eggs', line: '2 eggs', unit: 'Items', quantity: 2 }, expect(body).toEqual({ url: 'https://example.com/pancakes' })
]) return HttpResponse.json({ id: 10, name: 'Pancakes', link: '', serves: 1, imageUrls: [], ingredients: [], createdById: -1 })
})
)
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: [] })
}) })
) )
const recipe = await parseRecipe('https://example.com/pancakes') const recipe = await parseRecipe('https://example.com/pancakes')
expect(recipe?.name).toBe('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')
})
}) })