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:
parent
59cce781ec
commit
d90ffddcd7
3 changed files with 51 additions and 44 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue