refactor(sdk): remove runtime guards; rely on strict OpenAPI types; update recipe create types; switch ingredient parsing to typed GET; keep decoders strict; lint/type/tests green

This commit is contained in:
jableader 2025-11-01 23:40:01 +11:00
parent 59cce781ec
commit d90ffddcd7
3 changed files with 51 additions and 44 deletions

View file

@ -154,19 +154,7 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
return dtoOut return dtoOut
} }
function hasProp<T extends string>(o: unknown, k: T): o is Record<T, unknown> { // With stricter OpenAPI types, we can rely on the typed responses and decoders.
return o !== null && typeof o === 'object' && k in o
}
function isListIngredientItem(v: unknown): v is components['schemas']['ListIngredientItem'] {
return (
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'
)
}
export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> { export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> {
const query: Record<string, unknown> = {} const query: Record<string, unknown> = {}
@ -191,10 +179,10 @@ export async function getRecipe(householdSlug: string, id: number | string): Pro
return mapped return mapped
} }
export async function saveRecipe(recipe: components['schemas']['RecipeCreate']): Promise<Recipe | null> { export async function saveRecipe(recipe: components['schemas']['RecipeCreate-Input']): Promise<Recipe | null> {
const householdSlug = requireSlug() const householdSlug = requireSlug()
// Map "Recipe-Input" to "RecipeCreate" // Map "Recipe-Input" to "RecipeCreate"
const body: components['schemas']['RecipeCreate'] = { const body: components['schemas']['RecipeCreate-Input'] = {
name: recipe.name, name: recipe.name,
link: recipe.link, link: recipe.link,
serves: recipe.serves, serves: recipe.serves,
@ -219,25 +207,28 @@ export async function parseRecipe(url: string): Promise<Recipe | null> {
body: { url }, body: { url },
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
// Validate unknown payload before decoding to maintain strict boundary // Response is RecipeCreate-Output; map to domain Recipe via decoder by augmenting required ids
const isRecipeLike = (v: unknown): v is components['schemas']['RecipeOut'] => ( const created: components['schemas']['RecipeCreate-Output'] | undefined = data
v !== null && typeof v === 'object' && if (!created) return null
hasProp(v, 'id') && typeof v.id === 'number' && const out: components['schemas']['RecipeOut'] = {
hasProp(v, 'name') && typeof v.name === 'string' && id: -1,
hasProp(v, 'link') && typeof v.link === 'string' && name: created.name,
hasProp(v, 'serves') && typeof v.serves === 'number' && link: created.link,
hasProp(v, 'imageUrls') && Array.isArray(v.imageUrls) && serves: created.serves,
hasProp(v, 'ingredients') && Array.isArray(v.ingredients) && imageUrls: created.imageUrls,
hasProp(v, 'createdById') && typeof v.createdById === 'number' ingredients: created.ingredients,
) createdById: -1,
if (!isRecipeLike(data)) throw new Error('Invalid parse response payload') }
return decodeRecipe(data) return decodeRecipe(out)
} }
export async function parseIngredients(_lines: string[]): Promise<Ingredient[]> { export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
// Removed in v2 API; replaced by full recipe parsing endpoint const householdSlug = requireSlug()
// Reference _lines to satisfy lint rules without changing behavior const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/ingredients/parse', {
throw new Error(`parseIngredients is not available in v2 API (got ${_lines.length} lines)`) params: { path: { householdSlug }, query: { lines } },
})
if (!response.ok) throw httpError(response, error)
return Array.isArray(data) ? data.map(decodeIngredient) : []
} }
export async function parseProduct(): Promise<components['schemas']['Product'] | null> { export async function parseProduct(): Promise<components['schemas']['Product'] | null> {
@ -388,7 +379,7 @@ export async function requestIngredient(ingredientId: number): Promise<import('@
body: { ingredientId }, body: { ingredientId },
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
if (!isListIngredientItem(data)) throw new Error('Failed to decode requested ingredient item') if (!data) throw new Error('Failed to decode requested ingredient item')
const [decoded] = decodeListIngredientItems([data]) const [decoded] = decodeListIngredientItems([data])
if (!decoded) throw new Error('Failed to decode requested ingredient item') if (!decoded) throw new Error('Failed to decode requested ingredient item')
return decoded return decoded

View file

@ -227,15 +227,15 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/recipes/ingredients/parse": { "/api/v1/households/{householdSlug}/ingredients/parse": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Parse Ingredients */ /** Parse an ingredient line from a string */
get: operations["parse_ingredients_api_v1_recipes_ingredients_parse_get"]; get: operations["parse_ingredient_api_v1_households__householdSlug__ingredients_parse_get"];
put?: never; put?: never;
post?: never; post?: never;
delete?: never; delete?: never;
@ -864,7 +864,20 @@ export interface components {
hiddenBy?: components["schemas"]["MemberRef"] | null; hiddenBy?: components["schemas"]["MemberRef"] | null;
}; };
/** RecipeCreate */ /** RecipeCreate */
RecipeCreate: { "RecipeCreate-Input": {
/** Name */
name: string;
/** Link */
link: string;
/** Serves */
serves: number;
/** Imageurls */
imageUrls: string[];
/** Ingredients */
ingredients: components["schemas"]["Ingredient"][];
};
/** RecipeCreate */
"RecipeCreate-Output": {
/** Name */ /** Name */
name: string; name: string;
/** Link */ /** Link */
@ -1407,7 +1420,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["RecipeCreate"]; "application/json": components["schemas"]["RecipeCreate-Input"];
}; };
}; };
responses: { responses: {
@ -1522,7 +1535,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": unknown; "application/json": components["schemas"]["RecipeCreate-Output"];
}; };
}; };
403: components["responses"]["Problem403"]; 403: components["responses"]["Problem403"];
@ -1537,14 +1550,16 @@ export interface operations {
}; };
}; };
}; };
parse_ingredients_api_v1_recipes_ingredients_parse_get: { parse_ingredient_api_v1_households__householdSlug__ingredients_parse_get: {
parameters: { parameters: {
query: { query: {
/** @description Array of ingredients to parse */ /** @description Multiple ingredient lines to parse */
ingredients: string[]; lines: string[];
}; };
header?: never; header?: never;
path?: never; path: {
householdSlug: string;
};
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody?: never;
@ -1558,6 +1573,7 @@ export interface operations {
"application/json": components["schemas"]["Ingredient"][]; "application/json": components["schemas"]["Ingredient"][];
}; };
}; };
403: components["responses"]["Problem403"];
/** @description Validation Error */ /** @description Validation Error */
422: { 422: {
headers: { headers: {

View file

@ -192,7 +192,7 @@ watch(
) )
// expose functions for template binding names (automatic in <script setup>) // expose functions for template binding names (automatic in <script setup>)
function toRecipeCreate(r: DomainRecipe): components['schemas']['RecipeCreate'] { function toRecipeCreate(r: DomainRecipe): components['schemas']['RecipeCreate-Input'] {
return { return {
name: r.name, name: r.name,
link: r.link, link: r.link,