fronts-client/src/services/recipeQuery.ts (285 lines of code) (raw):

import url from '../constants/url'; import { Recipe, RecipePartialIndexContent } from '../types/Recipe'; import Raven from 'raven-js'; import { FeastKeyword, FeastKeywordResponse, FeastKeywordType, } from '../types/FeastKeyword'; interface KeyAndCount { key: string; doc_count: number; } export interface ChefSearchParams { query?: string; limit?: number; } export interface RecipeSearchFilters { diets?: string[]; contributors?: string[]; cuisines?: string[]; mealTypes?: string[]; celebrations?: string[]; filterType: 'During' | 'Post'; } export type DateParamField = | undefined | 'publishedDate' | 'firstPublishedDate' | 'lastModifiedDate'; export interface RecipeSearchParams { queryText: string; searchType?: 'Embedded' | 'Match' | 'Lucene'; fields?: string[]; kfactor?: number; limit?: number; filters?: RecipeSearchFilters; uprateByDate?: DateParamField; uprateConfig?: { originDate?: string; //should be ISO format date, defaults to today //take this and add it to `offsetDays`. Then, weights will be modified so that //at originDate +/- this many days results will be downweighted by `decay` dropoffScaleDays?: number; offsetDays?: number; decay?: number; }; format?: 'Full' | 'Titles'; allSponsors?: boolean; } export interface ChefSearchHit { contributorType: 'Profile' | 'Byline'; nameOrId: string; docCount: number; } export interface ChefSearchResponse { hits: number; results: ChefSearchHit[]; } export interface RecipeSearchResponse { hits: number; maxScore: number; recipes: RecipeSearchHit[]; } interface RecipeSearchTitlesResponse { score: number; href: string; } export type RecipeSearchHit = Recipe & { score: number; }; export interface DietSearchResponse { 'diet-ids': KeyAndCount[]; } const widthParam = /width=(\d+)/; export const updateImageScalingParams = (url: string) => { return url.replace(widthParam, 'width=83'); }; const setupRecipeThumbnails = (recep: Recipe) => { try { return { ...recep, previewImage: recep.previewImage ? { ...recep.previewImage, url: updateImageScalingParams(recep.previewImage.url), } : undefined, featuredImage: recep.featuredImage ? { ...recep.featuredImage, url: updateImageScalingParams(recep.featuredImage.url), } : undefined, }; } catch (err) { console.error(err); return recep; } }; const recipeQuery = (baseUrl: string) => { const captureErrors = (status: number, content: string, url: string) => { const existingContext = Raven.getContext(); Raven.setUserContext({ ...existingContext, recipeSearchResponse: content }); Raven.captureMessage(`${url} returned ${status}`); Raven.setUserContext(existingContext); console.error(content); }; const fetchOne = async (href: string): Promise<Recipe | undefined> => { const response = await fetch(`${baseUrl}${href}`); switch (response.status) { case 200: const content = await response.json(); return setupRecipeThumbnails(content as unknown as Recipe); case 404: case 403: console.warn( `Search response returned outdated recipe ${baseUrl}${href}`, ); return undefined; default: console.error(`Could not retrieve recipe ${href}: ${response.status}`); return undefined; } }; const fetchAllRecipes = async ( forRecipes: RecipeSearchTitlesResponse[], ): Promise<RecipeSearchHit[]> => { const results = await Promise.all( forRecipes.map((r) => fetchOne(r.href) .then((recep) => recep ? { ...recep, score: r.score, } : undefined, ) .catch(console.warn), ), ); return results.filter((r) => !!r) as RecipeSearchHit[]; }; const baseUrlForKwType = (kwType: FeastKeywordType) => { switch (kwType) { case 'celebration': return `${baseUrl}/keywords/celebrations`; case 'mealType': return `${baseUrl}/keywords/meal-types`; case 'cuisine': return `${baseUrl}/keywords/cuisines`; case 'diet': return `${baseUrl}/keywords/diet-ids`; } }; return { chefs: async (params: ChefSearchParams): Promise<ChefSearchResponse> => { const args = [ params.query ? `q=${encodeURIComponent(params.query)}` : undefined, params.limit ? `limit=${encodeURIComponent(params.limit)}` : undefined, ].filter((arg) => !!arg); const queryString = args.length > 0 ? '?' + args.join('&') : ''; const url = `${baseUrl}/keywords/contributors${queryString}`; const response = await fetch(url); const content = await response.text(); if (response.status == 200) { return JSON.parse(content) as ChefSearchResponse; } else { captureErrors(response.status, content, url); throw new Error(`Unable to contact recipe API: ${response.status}`); } }, diets: async (): Promise<DietSearchResponse> => { const response = await fetch(`${baseUrl}/keywords/diet-ids`); const content = await response.text(); if (response.status == 200) { return JSON.parse(content) as DietSearchResponse; } else { captureErrors(response.status, content, `${baseUrl}/keywords/diet-ids`); throw new Error(`Unable to contact recipe API: ${response.status}`); } }, recipes: async ( params: RecipeSearchParams, ): Promise<RecipeSearchResponse> => { const queryDoc = JSON.stringify({ ...params, noStats: true, //we are not reading stats, so no point slowing the query down by retrieving them. }); const response = await fetch(`${baseUrl}/search`, { method: 'POST', body: queryDoc, mode: 'cors', headers: new Headers({ 'Content-Type': 'application/json' }), }); const content = await response.text(); if (response.status == 200) { const searchResponse = JSON.parse(content); const recipes = await fetchAllRecipes(searchResponse.results); return { hits: searchResponse.hits, maxScore: searchResponse.maxScore, recipes, }; } else { const prevContext = Raven.getContext(); Raven.setUserContext({ ...prevContext, query: queryDoc }); captureErrors(response.status, content, `${baseUrl}/search`); Raven.setUserContext(prevContext); throw new Error(`Unable to contact recipe API: ${response.status}`); } }, recipesById: async (idList: string[]): Promise<Recipe[]> => { const doTheFetch = async (idsToFind: string[]) => { const indexResponse = await fetch( `/recipes/api/content/by-uid?ids=${idsToFind.join(',')}`, { credentials: 'include', }, ); if (indexResponse.status != 200) { const content = await indexResponse.text(); captureErrors( indexResponse.status, content, `/recipes/api/content/by-uid?ids=${idsToFind.join(',')}`, ); throw new Error( `Unable to retrieve partial index: server error ${indexResponse.status}`, ); } const content = (await indexResponse.json()) as RecipePartialIndexContent; const recipeResponses = await Promise.all( content.results.map((entry) => fetch(`${baseUrl}/content/${entry.checksum}`), ), ); const successes = recipeResponses.filter((_) => _.status === 200); return Promise.all(successes.map((_) => _.json())) as Promise<Recipe[]>; }; const recurseTheList = async ( idsToFind: string[], prevResults: Recipe[], ): Promise<Recipe[]> => { const thisBatch = idsToFind.slice(0, 50); //we need to avoid a 414 URI Too Long error so batch into 50s const results = (await doTheFetch(thisBatch)).concat(prevResults); if (thisBatch.length == idsToFind.length) { //we finished the list return results; } else { return recurseTheList(idsToFind.slice(50), results); } }; return recurseTheList(idList, []); }, keywords: async (kwType: FeastKeywordType): Promise<FeastKeyword[]> => { const url = baseUrlForKwType(kwType); const response = await fetch(url); if (response.status === 200) { const data = (await response.json()) as FeastKeywordResponse; const vals = Object.values(data); if (vals.length < 1) { console.error( `Recipe API response was invalid, no data for ${kwType} keyword`, ); throw new Error('Invalid API response'); } return vals[0].map((kw) => ({ keywordType: kwType, id: kw.key, doc_count: kw.doc_count, })); } else { const bodyContent = await response.text(); console.error( `Unable to communicate with recipe search: ${response.status} ${bodyContent}`, ); throw new Error(`Recipe API responded with ${response.status}`); } }, }; }; const isCode = () => window.location.hostname.includes('code.') || window.location.hostname.includes('local.'); export const liveRecipes = recipeQuery( isCode() ? url.codeRecipes : url.recipes, );