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
}
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'] {
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'
)
}
// With stricter OpenAPI types, we can rely on the typed responses and decoders.
export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> {
const query: Record<string, unknown> = {}
@ -191,10 +179,10 @@ export async function getRecipe(householdSlug: string, id: number | string): Pro
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()
// Map "Recipe-Input" to "RecipeCreate"
const body: components['schemas']['RecipeCreate'] = {
const body: components['schemas']['RecipeCreate-Input'] = {
name: recipe.name,
link: recipe.link,
serves: recipe.serves,
@ -219,25 +207,28 @@ export async function parseRecipe(url: string): Promise<Recipe | null> {
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)
// Response is RecipeCreate-Output; map to domain Recipe via decoder by augmenting required ids
const created: components['schemas']['RecipeCreate-Output'] | undefined = data
if (!created) return null
const out: components['schemas']['RecipeOut'] = {
id: -1,
name: created.name,
link: created.link,
serves: created.serves,
imageUrls: created.imageUrls,
ingredients: created.ingredients,
createdById: -1,
}
return decodeRecipe(out)
}
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 parseIngredients(lines: string[]): Promise<Ingredient[]> {
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/ingredients/parse', {
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> {
@ -388,7 +379,7 @@ export async function requestIngredient(ingredientId: number): Promise<import('@
body: { ingredientId },
})
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])
if (!decoded) throw new Error('Failed to decode requested ingredient item')
return decoded

View file

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

View file

@ -192,7 +192,7 @@ watch(
)
// 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 {
name: r.name,
link: r.link,