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
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<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'}`)
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
}

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 {
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<string, unknown> {
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 hasProp<T extends string>(o: unknown, k: T): o is Record<T, unknown> {
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<void> {
}
export async function parseRecipe(url: string): Promise<Recipe | null> {
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<Ingredient[]> {
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<Ingredient[]> {
// 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<components['schemas']['Ingredient'], 'name' | 'line'>,
url: string
): 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
export async function parseProduct(): Promise<components['schemas']['Product'] | null> {
// 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.

View file

@ -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;

View file

@ -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 }
}

View file

@ -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()
http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', () => {
return new HttpResponse(null, { status: 422 })
})
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()
})

View file

@ -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')
})
})