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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue