diff --git a/app/api_types.ts b/app/api_types.ts index 46c1901c3b20bae5bf71126e673ad6e615d37e55..2def0dd7b8b3dd2eb34e5e4ba1f7f9f4f1d15e87 100644 --- a/app/api_types.ts +++ b/app/api_types.ts @@ -1,77 +1,77 @@ -export type ApiProjectId = number; +export type ApiProjectId = number export type ApiLink = { - href: string; - rel: string; -}; + href: string + rel: string +} export type ApiPicture = { - links: Array<ApiLink>; -}; + links: Array<ApiLink> +} export type ApiCarrier = { - id: number; - name: string; - city: string | undefined | null; - country: string | undefined | null; - links: Array<ApiLink>; - picture: ApiPicture; -}; + id: number + name: string + city: string | undefined | null + country: string | undefined | null + links: Array<ApiLink> + picture: ApiPicture +} /** only covers the parts of the API response type that were using */ export type ApiProject = { - id: ApiProjectId; - title: string; - city: string | undefined | null; - country: string | undefined | null; - links: Array<ApiLink>; - carrier: ApiCarrier; - donations_count: number; - progress_percentage: number; - open_amount_in_cents: number; - profile_picture: ApiPicture | undefined; - summary: string | undefined | null; - description: string | undefined | null; - blog_post_count: number; -}; + id: ApiProjectId + title: string + city: string | undefined | null + country: string | undefined | null + links: Array<ApiLink> + carrier: ApiCarrier + donations_count: number + progress_percentage: number + open_amount_in_cents: number + profile_picture: ApiPicture | undefined + summary: string | undefined | null + description: string | undefined | null + blog_post_count: number +} /** only covers the parts of the API response type that were using */ export type ApiProjectsResponse = { - total_entries: number; - offset: number; - data: Array<ApiProject>; -}; + total_entries: number + offset: number + data: Array<ApiProject> +} -export type ApiProjectCategorySlug = string; +export type ApiProjectCategorySlug = string export type ApiProjectCategory = { - created_at: string; // ISO datetime - id: number; - links: Array<ApiLink>; - name: string; - slug: ApiProjectCategorySlug; - updated_at: string; // ISO datetime -}; + created_at: string // ISO datetime + id: number + links: Array<ApiLink> + name: string + slug: ApiProjectCategorySlug + updated_at: string // ISO datetime +} type ApiAuthor = { - name: string; - picture: ApiPicture; -}; + name: string + picture: ApiPicture +} export type ApiBlogPost = { - id: number; - title: string; - published_at: string; - author?: ApiAuthor; - body: string; - links: Array<ApiLink>; -}; + id: number + title: string + published_at: string + author?: ApiAuthor + body: string + links: Array<ApiLink> +} export type ApiBlogPostResponse = { - total_entries: number; - data: ApiBlogPost[]; -}; + total_entries: number + data: ApiBlogPost[] +} export type ApiOrganisation = { - links: Array<ApiLink>; -}; + links: Array<ApiLink> +} diff --git a/app/betterplace.ts b/app/betterplace.ts index edb72c7aa73aca6ce4ace4f95f5f93b54e67fd5e..189edf31c5e8f38906309a0f1f6fe2d4d0b48e8e 100644 --- a/app/betterplace.ts +++ b/app/betterplace.ts @@ -1,4 +1,4 @@ -import { Parser } from "./deps.ts"; +import { Parser } from './deps.ts' import { BetterPlaceLanguage, Initiative, @@ -9,7 +9,7 @@ import { ProjectParameters, ProjectsParameters, ProjectsResponse, -} from "./types.ts"; +} from './types.ts' import { ApiBlogPost, ApiCarrier, @@ -19,61 +19,61 @@ import { ApiProject, ApiProjectCategory, ApiProjectsResponse, -} from "./api_types.ts"; -import { logger } from "./logging.ts"; -import { GraphQLError } from "./deps.ts"; +} from './api_types.ts' +import { logger } from './logging.ts' +import { GraphQLError } from './deps.ts' -const BASE_URL = "https://api.betterplace.org"; -const PICTURE_TYPE_REGEX = /(.*)_(\d+)x(\d+)/; -export const SUPPORTED_LANGUAGES = ["en", "de"]; -export const DEFAULT_LANGUAGE: BetterPlaceLanguage = "en"; -const DEFAULT_PAGE_SIZE = 10; -const MAX_PAGE_FOR_RANDOMIZATION = 10; -const ERROR_CODE_NOT_FOUND = "NOT_FOUND"; +const BASE_URL = 'https://api.betterplace.org' +const PICTURE_TYPE_REGEX = /(.*)_(\d+)x(\d+)/ +export const SUPPORTED_LANGUAGES = ['en', 'de'] +export const DEFAULT_LANGUAGE: BetterPlaceLanguage = 'en' +const DEFAULT_PAGE_SIZE = 10 +const MAX_PAGE_FOR_RANDOMIZATION = 10 +const ERROR_CODE_NOT_FOUND = 'NOT_FOUND' const cleanUpHtml = (html: string): string => { - let result = ""; + let result = '' const parser = new Parser({ onopentag: (name) => { - if (name === "br") { - result += "\n"; + if (name === 'br') { + result += '\n' } }, ontext: (text) => { - result += text; + result += text }, - }); - parser.end(html); - return result; -}; + }) + parser.end(html) + return result +} const transformPicture = (picture: ApiPicture | undefined): Picture[] => { if (!picture) { - return []; + return [] } return picture.links.map((link: ApiLink) => { try { - const matches = PICTURE_TYPE_REGEX.exec(link.rel); + const matches = PICTURE_TYPE_REGEX.exec(link.rel) if (matches?.length !== 4) { return { type: link.rel, url: link.href, - }; + } } return { type: matches[1], url: link.href, width: parseInt(matches[2], 10), height: parseInt(matches[3], 10), - }; + } } catch { return { type: link.rel, url: link.href, - }; + } } - }); -}; + }) +} const transformCarrier = (carrier: ApiCarrier): Initiative => { return { @@ -81,10 +81,10 @@ const transformCarrier = (carrier: ApiCarrier): Initiative => { name: carrier.name, city: carrier.city, country: carrier.country, - url: carrier.links.find((l) => l.rel === "self")?.href, // will be replaced by separate resolver resp. used as fallback + url: carrier.links.find((l) => l.rel === 'self')?.href, // will be replaced by separate resolver resp. used as fallback picture: transformPicture(carrier.picture), - }; -}; + } +} const transformProject = (project: ApiProject): Project => { return { @@ -100,30 +100,30 @@ const transformProject = (project: ApiProject): Project => { picture: transformPicture(project.profile_picture), summary: project.summary, description: project.description && cleanUpHtml(project.description), - donationUrl: project.links.find((p) => p.rel === "new_donation")!.href, - newsUrl: project.links.find((p) => p.rel === "blog_posts")!.href, + donationUrl: project.links.find((p) => p.rel === 'new_donation')!.href, + newsUrl: project.links.find((p) => p.rel === 'blog_posts')!.href, news: [], // will be set by separate resolver newsCount: project.blog_post_count, - }; -}; + } +} const transformProjectsResponse = ( response: ApiProjectsResponse, ): ProjectsResponse => { if (!response.data) { - throw new Error(`Invalid response: ${JSON.stringify(response)}`); + throw new Error(`Invalid response: ${JSON.stringify(response)}`) } return { totalResults: response.total_entries, data: response.data.map(transformProject), - }; -}; + } +} // TODO consider returning an object consisting of at least id + slug so that original category URL can be reconstructed // which could be needed in later stories in which we fetch projects of a given category const transformProjectCategory = ( apiProjectCategory: ApiProjectCategory, -): string => apiProjectCategory.slug; +): string => apiProjectCategory.slug const transformNews = (blogPost: ApiBlogPost): News => ({ id: blogPost.id, @@ -136,178 +136,176 @@ const transformNews = (blogPost: ApiBlogPost): News => ({ } : undefined, body: cleanUpHtml(blogPost.body), - url: blogPost.links.find((p) => p.rel === "platform")!.href, -}); + url: blogPost.links.find((p) => p.rel === 'platform')!.href, +}) export const fetchCategories = async (projectId: number): Promise<string[]> => { // even though the original query might use a different locale, we are using "en" as locale deliberately // betterplace translates category slugs but we need stable identifiers, therefore we default to "en" - const href = - `https://api.betterplace.org/en/api_v4/projects/${projectId}/categories.json`; - logger.info(`fetching project categories from ${href}`); + const href = `https://api.betterplace.org/en/api_v4/projects/${projectId}/categories.json` + logger.info(`fetching project categories from ${href}`) try { - const response = await fetch(href); - const json = await response.json(); - const categories = json.data as Array<ApiProjectCategory>; - return categories.map(transformProjectCategory); + const response = await fetch(href) + const json = await response.json() + const categories = json.data as Array<ApiProjectCategory> + return categories.map(transformProjectCategory) } catch (e) { logger.warn( `Error while fetching project categories from '${href}', using empty array to handle this (somewhat) gracefully`, e, - ); - return []; + ) + return [] } -}; +} export const fetchNews = async ( project: Project, parameters: NewsParameters, ): Promise<News[]> => { const href = project.newsUrl + - `?per_page=${parameters.limit || 5}&order=published_at:desc`; - logger.info(`fetching blog posts from ${href}`); + `?per_page=${parameters.limit || 5}&order=published_at:desc` + logger.info(`fetching blog posts from ${href}`) try { - const response = await fetch(href); - const json = await response.json(); - const news = json.data as ApiBlogPost[]; - return news.map(transformNews); + const response = await fetch(href) + const json = await response.json() + const news = json.data as ApiBlogPost[] + return news.map(transformNews) } catch (e) { logger.warn( `Error while fetching blog posts from '${href}', using empty array to handle this (somewhat) gracefully`, e, - ); - return []; + ) + return [] } -}; +} export const fetchInitiativeUrl = async ( initiative: Initiative, ): Promise<string | undefined> => { if (!initiative.url) { - return Promise.resolve(initiative.url); + return Promise.resolve(initiative.url) } try { - const response = await fetch(initiative.url); - const organisation: ApiOrganisation = await response.json(); - return organisation.links.find((l) => l.rel === "platform")?.href; + const response = await fetch(initiative.url) + const organisation: ApiOrganisation = await response.json() + return organisation.links.find((l) => l.rel === 'platform')?.href } catch (e) { logger.warn( `Error while fetching organisation url from '${initiative.url}', using api url to handle this (somewhat) gracefully`, e, - ); - return initiative.url; + ) + return initiative.url } -}; +} function _debugP<In>(desc: string | undefined = undefined) { return async (elemP: In) => { - const elem = await elemP; - if (desc) console.debug(desc, elem); - else console.debug(elem); - return elem; - }; + const elem = await elemP + if (desc) console.debug(desc, elem) + else console.debug(elem) + return elem + } } const buildProjectsUrl = ( { limit = DEFAULT_PAGE_SIZE, offset = 0, location }: ProjectsParameters, language: string = DEFAULT_LANGUAGE, ): URL => { - const page = Math.floor(offset / limit) + 1; // page is NOT zero-based (!), i.e. first page = 1 - const url = new URL(`${BASE_URL}/${language}/api_v4/projects.json`); + const page = Math.floor(offset / limit) + 1 // page is NOT zero-based (!), i.e. first page = 1 + const url = new URL(`${BASE_URL}/${language}/api_v4/projects.json`) url.searchParams.append( - "facets", - "completed:false|closed:false|prohibit_donations:false", - ); - url.searchParams.append("order", "rank:DESC"); - url.searchParams.append("page", page.toString()); - url.searchParams.append("per_page", limit.toString()); + 'facets', + 'completed:false|closed:false|prohibit_donations:false', + ) + url.searchParams.append('order', 'rank:DESC') + url.searchParams.append('page', page.toString()) + url.searchParams.append('per_page', limit.toString()) if (location) { - url.searchParams.append("scope", "location"); - url.searchParams.append("q", location); + url.searchParams.append('scope', 'location') + url.searchParams.append('q', location) } - return url; -}; + return url +} const fetchPageOfProjects = async ( params: ProjectsParameters, language: BetterPlaceLanguage = DEFAULT_LANGUAGE, ): Promise<ProjectsResponse> => { - const url = buildProjectsUrl(params, language); - const start = Date.now(); - logger.info(`fetching projects from ${url}`); + const url = buildProjectsUrl(params, language) + const start = Date.now() + logger.info(`fetching projects from ${url}`) try { - const fetchResponse = await fetch(url); - const jsonResponse = await fetchResponse.json(); - return transformProjectsResponse(jsonResponse); + const fetchResponse = await fetch(url) + const jsonResponse = await fetchResponse.json() + return transformProjectsResponse(jsonResponse) } catch (e) { logger.error( `Error performing request to ${url}: ${e.message}`, - ); - throw e; + ) + throw e } finally { - const duration = Date.now() - start; - logger.debug(`fetching projects took ${duration} ms`); + const duration = Date.now() - start + logger.debug(`fetching projects took ${duration} ms`) } -}; +} // Do not select projects from too far back -const randomPageForToday = () => - (new Date().setUTCHours(0, 0, 0, 0) / 100000) % MAX_PAGE_FOR_RANDOMIZATION; +const randomPageForToday = () => (new Date().setUTCHours(0, 0, 0, 0) / 100000) % MAX_PAGE_FOR_RANDOMIZATION export const fetchProjects = async ( { offset = 0, limit = DEFAULT_PAGE_SIZE, location }: ProjectsParameters, language: BetterPlaceLanguage = DEFAULT_LANGUAGE, ): Promise<ProjectsResponse> => { // When not filtering: Each day a randomly selected page is sorted to the front - const randomPage = randomPageForToday(); - const isFiltering = !!location; - const isFirstPageOfUnfilteredList = offset < limit && !isFiltering; + const randomPage = randomPageForToday() + const isFiltering = !!location + const isFirstPageOfUnfilteredList = offset < limit && !isFiltering if (isFirstPageOfUnfilteredList) { - logger.info(`fetching random projects from page ${randomPage}`); + logger.info(`fetching random projects from page ${randomPage}`) const projects = await fetchPageOfProjects({ offset: randomPage * DEFAULT_PAGE_SIZE, limit: DEFAULT_PAGE_SIZE, - }, language); + }, language) return ({ ...projects, data: projects.data.slice(0, limit), - }); + }) } // After one randomly selected page, "normal" pagination starts with offset 0 and skips the random page that was already displayed - const afterRandomPage = offset > randomPage * DEFAULT_PAGE_SIZE; + const afterRandomPage = offset > randomPage * DEFAULT_PAGE_SIZE return fetchPageOfProjects({ offset: (isFiltering || afterRandomPage) ? offset : offset - limit, limit, location, - }, language); -}; + }, language) +} export const fetchProject = async ( { id }: ProjectParameters, language: BetterPlaceLanguage = DEFAULT_LANGUAGE, ): Promise<Project> => { - const url = `${BASE_URL}/${language}/api_v4/projects/${id}.json`; - logger.info(`fetching project from ${url}`); - const start = Date.now(); + const url = `${BASE_URL}/${language}/api_v4/projects/${id}.json` + logger.info(`fetching project from ${url}`) + const start = Date.now() try { - const response = await fetch(url); + const response = await fetch(url) if (response.status === 404) { - throw new GraphQLError("Not found", { - extensions: { "code": ERROR_CODE_NOT_FOUND }, - }); + throw new GraphQLError('Not found', { + extensions: { 'code': ERROR_CODE_NOT_FOUND }, + }) } - const json = await response.json(); - return transformProject(json); + const json = await response.json() + return transformProject(json) } catch (e) { if (e.extensions?.code !== ERROR_CODE_NOT_FOUND) { logger.error( `Error performing request to ${url}: ${e.message}`, - ); + ) } - throw e; + throw e } finally { - const duration = Date.now() - start; - logger.debug(`fetching project with ID ${id} took ${duration} ms`); + const duration = Date.now() - start + logger.debug(`fetching project with ID ${id} took ${duration} ms`) } -}; +} diff --git a/app/betterplace_test.ts b/app/betterplace_test.ts index 15cc0ffc83b56dc4305d86e9c945c046bfd1f391..7be2afedbb823eca9d796879eaa08166d03342c6 100644 --- a/app/betterplace_test.ts +++ b/app/betterplace_test.ts @@ -11,13 +11,9 @@ import { returnsNext, Stub, stub, -} from "./dev_deps.ts"; -import { - processGqlRequest, - ResponsePayload, - stubFetch, -} from "./common_test.ts"; -import { fetchProject, fetchProjects } from "./betterplace.ts"; +} from './dev_deps.ts' +import { processGqlRequest, ResponsePayload, stubFetch } from './common_test.ts' +import { fetchProject, fetchProjects } from './betterplace.ts' import { apiBlogPosts1, apiBlogPosts2, @@ -31,71 +27,69 @@ import { categoryPopular, expectedProjectsResponse, expectedResult, -} from "./betterplace_test_data.ts"; -import { Project, ProjectsResponse } from "./types.ts"; -import { createGraphQLServer, GraphQLServer } from "./server.ts"; -import { GraphQLError } from "./deps.ts"; +} from './betterplace_test_data.ts' +import { Project, ProjectsResponse } from './types.ts' +import { createGraphQLServer, GraphQLServer } from './server.ts' +import { GraphQLError } from './deps.ts' const emptyResponse = { total_entries: 0, offset: 0, data: [], -}; +} -type UrlPrefix = string; +type UrlPrefix = string const projectsUrlWithDefaultOptions = - "https://api.betterplace.org/de/api_v4/projects.json?facets=completed%3Afalse%7Cclosed%3Afalse%7Cprohibit_donations%3Afalse&order=rank%3ADESC"; + 'https://api.betterplace.org/de/api_v4/projects.json?facets=completed%3Afalse%7Cclosed%3Afalse%7Cprohibit_donations%3Afalse&order=rank%3ADESC' const noCacheServerConfig = { cacheEnabled: false, port: 0, cacheTtlMsBetterplace: 0, fake: false, -}; +} const stubFetchByUrlPrefix = ( responses: Record<UrlPrefix, ResponsePayload>, ) => { - return stub(globalThis, "fetch", (input) => { - const url = input.toString(); + return stub(globalThis, 'fetch', (input) => { + const url = input.toString() for (const [urlPrefix, response] of Object.entries(responses)) { if (url.startsWith(urlPrefix)) { if (response instanceof Error) { - return Promise.reject(response); + return Promise.reject(response) } else { - return Promise.resolve(new Response(JSON.stringify(response))); + return Promise.resolve(new Response(JSON.stringify(response))) } } } - throw Promise.reject(new Error(`Unexpected request URL "${url}"`)); - }); -}; + throw Promise.reject(new Error(`Unexpected request URL "${url}"`)) + }) +} const validApiResponses = { - "https://api.betterplace.org/en/api_v4/projects.json": apiProjectsResult, - "https://api.betterplace.org/en/api_v4/projects/1.json": apiProject1, - "https://api.betterplace.org/de/api_v4/projects/1.json": apiProject1, - "https://api.betterplace.org/en/api_v4/projects/1/categories.json": - categoriesResult([categoryEducation]), - "https://api.betterplace.org/en/api_v4/projects/2/categories.json": - categoriesResult([categoryPopular]), - "https://api.betterplace.org/en/api_v4/projects/3/categories.json": - categoriesResult([categoryEducation, categoryPopular]), - "https://api.betterplace.org/en/api_v4/projects/4/categories.json": - categoriesResult([categoryEducation, categoryPopular]), - "https://api.betterplace.org/de/api_v4/organisations/1.json": - apiOrganisation1, - "https://api.betterplace.org/de/api_v4/organisations/2.json": - apiOrganisation2, - "https://api.betterplace.org/de/api_v4/organisations/3.json": - apiOrganisation2, - "https://api.betterplace.org/de/api_v4/organisations/4.json": - apiOrganisation2, - "https://api.betterplace.org/de/api_v4/blog_posts/1.json": apiBlogPosts1, - "https://api.betterplace.org/de/api_v4/blog_posts/2.json": apiBlogPosts2, - "https://api.betterplace.org/de/api_v4/blog_posts/": apiBlogPostsEmpty, -}; + 'https://api.betterplace.org/en/api_v4/projects.json': apiProjectsResult, + 'https://api.betterplace.org/en/api_v4/projects/1.json': apiProject1, + 'https://api.betterplace.org/de/api_v4/projects/1.json': apiProject1, + 'https://api.betterplace.org/en/api_v4/projects/1/categories.json': categoriesResult([categoryEducation]), + 'https://api.betterplace.org/en/api_v4/projects/2/categories.json': categoriesResult([categoryPopular]), + 'https://api.betterplace.org/en/api_v4/projects/3/categories.json': categoriesResult([ + categoryEducation, + categoryPopular, + ]), + 'https://api.betterplace.org/en/api_v4/projects/4/categories.json': categoriesResult([ + categoryEducation, + categoryPopular, + ]), + 'https://api.betterplace.org/de/api_v4/organisations/1.json': apiOrganisation1, + 'https://api.betterplace.org/de/api_v4/organisations/2.json': apiOrganisation2, + 'https://api.betterplace.org/de/api_v4/organisations/3.json': apiOrganisation2, + 'https://api.betterplace.org/de/api_v4/organisations/4.json': apiOrganisation2, + 'https://api.betterplace.org/de/api_v4/blog_posts/1.json': apiBlogPosts1, + 'https://api.betterplace.org/de/api_v4/blog_posts/2.json': apiBlogPosts2, + 'https://api.betterplace.org/de/api_v4/blog_posts/': apiBlogPostsEmpty, +} const projectFragment = ` id @@ -146,7 +140,7 @@ const projectFragment = ` } newsUrl newsCount -`; +` const queryProjects = ( graphQLServer: GraphQLServer, @@ -160,11 +154,11 @@ const queryProjects = ( ${projectFragment} } } - }`; + }` return processGqlRequest(graphQLServer, query).then( (result) => result?.projects as ProjectsResponse, - ); -}; + ) +} const queryProject = async ( graphQLServer: GraphQLServer, @@ -180,336 +174,327 @@ const queryProject = async ( }`, { id: apiProject1.id }, headers, - ); + ) - return (await promise)?.project as Project; -}; + return (await promise)?.project as Project +} const urlForPage = (page = 1, pageSize = 10) => - new URL(`${projectsUrlWithDefaultOptions}&page=${page}&per_page=${pageSize}`); + new URL(`${projectsUrlWithDefaultOptions}&page=${page}&per_page=${pageSize}`) -describe("betterplace", () => { - let fetchStub: Stub; - let time: FakeTime; +describe('betterplace', () => { + let fetchStub: Stub + let time: FakeTime beforeEach(() => { - time = new FakeTime(new Date(2023, 8, 12, 12)); - }); + time = new FakeTime(new Date(2023, 8, 12, 12)) + }) afterEach(() => { - fetchStub?.restore(); - time?.restore(); - }); + fetchStub?.restore() + time?.restore() + }) - describe("fetching project list", () => { - it("calls api with correct parameters", async () => { - fetchStub = stubFetch(emptyResponse); + describe('fetching project list', () => { + it('calls api with correct parameters', async () => { + fetchStub = stubFetch(emptyResponse) - await fetchProjects({ limit: 10, offset: 30 }, "de"); + await fetchProjects({ limit: 10, offset: 30 }, 'de') - const expectedUrl = urlForPage(3); - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + const expectedUrl = urlForPage(3) + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("calls api with correct parameters for location search", async () => { - fetchStub = stubFetch(emptyResponse); + it('calls api with correct parameters for location search', async () => { + fetchStub = stubFetch(emptyResponse) - await fetchProjects({ limit: 10, offset: 20, location: "Hamburg" }, "de"); + await fetchProjects({ limit: 10, offset: 20, location: 'Hamburg' }, 'de') const expectedUrl = new URL( projectsUrlWithDefaultOptions + - "&page=3&per_page=10&scope=location&q=Hamburg", - ); - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + '&page=3&per_page=10&scope=location&q=Hamburg', + ) + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("properly encodes query for location search", async () => { - fetchStub = stubFetch(emptyResponse); + it('properly encodes query for location search', async () => { + fetchStub = stubFetch(emptyResponse) await fetchProjects( { limit: 10, offset: 20, - location: "Straße 123, 20535 Hamburg", + location: 'Straße 123, 20535 Hamburg', }, - "de", - ); + 'de', + ) const expectedUrl = new URL( projectsUrlWithDefaultOptions + - "&page=3&per_page=10&scope=location&q=Stra%C3%9Fe+123%2C+20535+Hamburg", - ); - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + '&page=3&per_page=10&scope=location&q=Stra%C3%9Fe+123%2C+20535+Hamburg', + ) + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("calls api with correct parameters for empty location search", async () => { - fetchStub = stubFetch(emptyResponse); + it('calls api with correct parameters for empty location search', async () => { + fetchStub = stubFetch(emptyResponse) - await fetchProjects({ limit: 10, offset: 30, location: "" }, "de"); + await fetchProjects({ limit: 10, offset: 30, location: '' }, 'de') - const expectedUrl = urlForPage(3); - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + const expectedUrl = urlForPage(3) + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("queries a random page for the first unfiltered page", async () => { - fetchStub = stubFetch(emptyResponse); + it('queries a random page for the first unfiltered page', async () => { + fetchStub = stubFetch(emptyResponse) - await fetchProjects({}, "de"); + await fetchProjects({}, 'de') - assertSpyCall(fetchStub, 0, { args: [urlForPage(9)] }); - }); + assertSpyCall(fetchStub, 0, { args: [urlForPage(9)] }) + }) - it("queries a different random page the next day", async () => { - time.tick(24 * 60 * 60 * 1000); - fetchStub = stubFetch(emptyResponse); + it('queries a different random page the next day', async () => { + time.tick(24 * 60 * 60 * 1000) + fetchStub = stubFetch(emptyResponse) - await fetchProjects({}, "de"); + await fetchProjects({}, 'de') - assertSpyCall(fetchStub, 0, { args: [urlForPage(3)] }); - }); + assertSpyCall(fetchStub, 0, { args: [urlForPage(3)] }) + }) - it("resumes normal pagination after the first page", async () => { + it('resumes normal pagination after the first page', async () => { fetchStub = stub( globalThis, - "fetch", + 'fetch', returnsNext( - Array(3).fill(emptyResponse).map((response) => - Promise.resolve(new Response(JSON.stringify(response))) - ), + Array(3).fill(emptyResponse).map((response) => Promise.resolve(new Response(JSON.stringify(response)))), ), - ); + ) - await fetchProjects({}, "de"); - await fetchProjects({ offset: 10 }, "de"); - await fetchProjects({ offset: 20 }, "de"); + await fetchProjects({}, 'de') + await fetchProjects({ offset: 10 }, 'de') + await fetchProjects({ offset: 20 }, 'de') - assertSpyCall(fetchStub, 0, { args: [urlForPage(9)] }); - assertSpyCall(fetchStub, 1, { args: [urlForPage(1)] }); - assertSpyCall(fetchStub, 2, { args: [urlForPage(2)] }); - }); + assertSpyCall(fetchStub, 0, { args: [urlForPage(9)] }) + assertSpyCall(fetchStub, 1, { args: [urlForPage(1)] }) + assertSpyCall(fetchStub, 2, { args: [urlForPage(2)] }) + }) - it("skips the randomly selected page in normal pagination", async () => { + it('skips the randomly selected page in normal pagination', async () => { fetchStub = stub( globalThis, - "fetch", + 'fetch', returnsNext( - Array(3).fill(emptyResponse).map((response) => - Promise.resolve(new Response(JSON.stringify(response))) - ), + Array(3).fill(emptyResponse).map((response) => Promise.resolve(new Response(JSON.stringify(response)))), ), - ); + ) - await fetchProjects({}, "de"); - await fetchProjects({ offset: 80 }, "de"); - await fetchProjects({ offset: 90 }, "de"); + await fetchProjects({}, 'de') + await fetchProjects({ offset: 80 }, 'de') + await fetchProjects({ offset: 90 }, 'de') - assertSpyCall(fetchStub, 0, { args: [urlForPage(9)] }); - assertSpyCall(fetchStub, 1, { args: [urlForPage(8)] }); - assertSpyCall(fetchStub, 2, { args: [urlForPage(10)] }); - }); + assertSpyCall(fetchStub, 0, { args: [urlForPage(9)] }) + assertSpyCall(fetchStub, 1, { args: [urlForPage(8)] }) + assertSpyCall(fetchStub, 2, { args: [urlForPage(10)] }) + }) - it("does not query random projects for the first page when filtering for location", async () => { - fetchStub = stubFetch(emptyResponse); + it('does not query random projects for the first page when filtering for location', async () => { + fetchStub = stubFetch(emptyResponse) - await fetchProjects({ limit: 10, offset: 0, location: "Hamburg" }, "de"); + await fetchProjects({ limit: 10, offset: 0, location: 'Hamburg' }, 'de') const expectedUrl = new URL( projectsUrlWithDefaultOptions + - "&page=1&per_page=10&scope=location&q=Hamburg", - ); - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + '&page=1&per_page=10&scope=location&q=Hamburg', + ) + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("correctly parses empty response", async () => { - fetchStub = stubFetch(emptyResponse); + it('correctly parses empty response', async () => { + fetchStub = stubFetch(emptyResponse) - const result = await fetchProjects({ offset: 10 }); + const result = await fetchProjects({ offset: 10 }) - assertEquals(result, { totalResults: 0, data: [] }); - }); + assertEquals(result, { totalResults: 0, data: [] }) + }) - it("correctly parses project list", async () => { - fetchStub = stubFetchByUrlPrefix(validApiResponses); + it('correctly parses project list', async () => { + fetchStub = stubFetchByUrlPrefix(validApiResponses) - const graphQLServer = createGraphQLServer(noCacheServerConfig); - const result = await queryProjects(graphQLServer); + const graphQLServer = createGraphQLServer(noCacheServerConfig) + const result = await queryProjects(graphQLServer) - assertEquals(result, expectedProjectsResponse); - }); + assertEquals(result, expectedProjectsResponse) + }) - it("throws error for invalid projects response", async () => { + it('throws error for invalid projects response', async () => { fetchStub = stub( globalThis, - "fetch", - returnsNext([Promise.resolve(new Response("foobar"))]), - ); + 'fetch', + returnsNext([Promise.resolve(new Response('foobar'))]), + ) - await assertRejects(() => fetchProjects({ offset: 10 })); - }); + await assertRejects(() => fetchProjects({ offset: 10 })) + }) - it("throws error for API error response", async () => { + it('throws error for API error response', async () => { fetchStub = stubFetch({ - status: "internal_server_error", - "status_code": 500, - }); + status: 'internal_server_error', + 'status_code': 500, + }) - await assertRejects(() => fetchProjects({ offset: 10 })); - }); + await assertRejects(() => fetchProjects({ offset: 10 })) + }) - it("returns empty categories for invalid category responses", async () => { + it('returns empty categories for invalid category responses', async () => { fetchStub = stubFetchByUrlPrefix({ ...validApiResponses, - "https://api.betterplace.org/en/api_v4/projects/1/categories.json": - new Error("Expected error"), - }); + 'https://api.betterplace.org/en/api_v4/projects/1/categories.json': new Error('Expected error'), + }) - const graphQLServer = createGraphQLServer(noCacheServerConfig); - const result = await queryProjects(graphQLServer); + const graphQLServer = createGraphQLServer(noCacheServerConfig) + const result = await queryProjects(graphQLServer) - const project1 = result.data.find((p) => p.id === 1); - assertExists(project1); - assertEquals(project1?.categories, []); - }); + const project1 = result.data.find((p) => p.id === 1) + assertExists(project1) + assertEquals(project1?.categories, []) + }) - it("returns api url for invalid organisation responses", async () => { + it('returns api url for invalid organisation responses', async () => { fetchStub = stubFetchByUrlPrefix({ ...validApiResponses, - "https://api.betterplace.org/de/api_v4/organisations/1.json": new Error( - "Expected error", + 'https://api.betterplace.org/de/api_v4/organisations/1.json': new Error( + 'Expected error', ), - }); + }) - const graphQLServer = createGraphQLServer(noCacheServerConfig); - const result = await queryProjects(graphQLServer); + const graphQLServer = createGraphQLServer(noCacheServerConfig) + const result = await queryProjects(graphQLServer) - const project1 = result.data.find((p) => p.id === 1); - assertExists(project1?.initiative); + const project1 = result.data.find((p) => p.id === 1) + assertExists(project1?.initiative) assertEquals( project1?.initiative.url, - "https://api.betterplace.org/de/api_v4/organisations/1.json", - ); - }); + 'https://api.betterplace.org/de/api_v4/organisations/1.json', + ) + }) - it("returns empty news for invalid blog post responses", async () => { + it('returns empty news for invalid blog post responses', async () => { fetchStub = stubFetchByUrlPrefix({ ...validApiResponses, - "https://api.betterplace.org/de/api_v4/blog_posts/1.json": new Error( - "Expected error", + 'https://api.betterplace.org/de/api_v4/blog_posts/1.json': new Error( + 'Expected error', ), - }); + }) - const graphQLServer = createGraphQLServer(noCacheServerConfig); - const result = await queryProjects(graphQLServer); - const project1 = result.data.find((p) => p.id === 1); - assertExists(project1); - assertEquals(project1?.news, []); - }); + const graphQLServer = createGraphQLServer(noCacheServerConfig) + const result = await queryProjects(graphQLServer) + const project1 = result.data.find((p) => p.id === 1) + assertExists(project1) + assertEquals(project1?.news, []) + }) - it("returns cached results on recurring requests", async () => { - fetchStub = stubFetchByUrlPrefix(validApiResponses); + it('returns cached results on recurring requests', async () => { + fetchStub = stubFetchByUrlPrefix(validApiResponses) const graphQLServer = createGraphQLServer({ cacheEnabled: true, port: 0, cacheTtlMsBetterplace: 1000, fake: false, - }); - const result = await queryProjects(graphQLServer); - assertEquals(result, expectedProjectsResponse); + }) + const result = await queryProjects(graphQLServer) + assertEquals(result, expectedProjectsResponse) - fetchStub?.restore(); + fetchStub?.restore() fetchStub = stubFetchByUrlPrefix({ - "https://api.betterplace.org/en/api_v4/projects.json": new Error( - "Expected error", + 'https://api.betterplace.org/en/api_v4/projects.json': new Error( + 'Expected error', ), - }); - const result2 = await queryProjects(graphQLServer); - assertEquals(result2, expectedProjectsResponse); + }) + const result2 = await queryProjects(graphQLServer) + assertEquals(result2, expectedProjectsResponse) // clean up cache timeout - await time.delay(1000); - }); - }); + await time.delay(1000) + }) + }) - describe("fetching single project", () => { - it("correctly retrieves a project by ID", async () => { - fetchStub = stubFetchByUrlPrefix(validApiResponses); + describe('fetching single project', () => { + it('correctly retrieves a project by ID', async () => { + fetchStub = stubFetchByUrlPrefix(validApiResponses) - const graphQLServer = createGraphQLServer(noCacheServerConfig); - const result = await queryProject(graphQLServer); + const graphQLServer = createGraphQLServer(noCacheServerConfig) + const result = await queryProject(graphQLServer) - assertEquals(result, expectedResult); - }); + assertEquals(result, expectedResult) + }) - it("correctly extracts language from locale", async () => { - fetchStub = stubFetchByUrlPrefix(validApiResponses); + it('correctly extracts language from locale', async () => { + fetchStub = stubFetchByUrlPrefix(validApiResponses) - const graphQLServer = createGraphQLServer(noCacheServerConfig); + const graphQLServer = createGraphQLServer(noCacheServerConfig) const result = await queryProject(graphQLServer, { - "accept-language": "de-DE", - }); + 'accept-language': 'de-DE', + }) - assertEquals(result, expectedResult); - const expectedUrl = - "https://api.betterplace.org/de/api_v4/projects/1.json"; - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + assertEquals(result, expectedResult) + const expectedUrl = 'https://api.betterplace.org/de/api_v4/projects/1.json' + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("uses correct language as default if requested language is not supported", async () => { - fetchStub = stubFetchByUrlPrefix(validApiResponses); + it('uses correct language as default if requested language is not supported', async () => { + fetchStub = stubFetchByUrlPrefix(validApiResponses) - const graphQLServer = createGraphQLServer(noCacheServerConfig); + const graphQLServer = createGraphQLServer(noCacheServerConfig) const result = await queryProject(graphQLServer, { - "accept-language": "es", - }); + 'accept-language': 'es', + }) - assertEquals(result, expectedResult); - const expectedUrl = - "https://api.betterplace.org/en/api_v4/projects/1.json"; - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + assertEquals(result, expectedResult) + const expectedUrl = 'https://api.betterplace.org/en/api_v4/projects/1.json' + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("calls api with correct parameters for fetching a project by ID", async () => { - fetchStub = stubFetch(apiProject1); + it('calls api with correct parameters for fetching a project by ID', async () => { + fetchStub = stubFetch(apiProject1) - await fetchProject({ id: 1 }, "de"); + await fetchProject({ id: 1 }, 'de') - const expectedUrl = - "https://api.betterplace.org/de/api_v4/projects/1.json"; - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + const expectedUrl = 'https://api.betterplace.org/de/api_v4/projects/1.json' + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("uses default locale if missing for fetching a project by ID", async () => { - fetchStub = stubFetch(apiProject1); + it('uses default locale if missing for fetching a project by ID', async () => { + fetchStub = stubFetch(apiProject1) - await fetchProject({ id: 1 }); + await fetchProject({ id: 1 }) - const expectedUrl = - "https://api.betterplace.org/en/api_v4/projects/1.json"; - assertSpyCall(fetchStub, 0, { args: [expectedUrl] }); - }); + const expectedUrl = 'https://api.betterplace.org/en/api_v4/projects/1.json' + assertSpyCall(fetchStub, 0, { args: [expectedUrl] }) + }) - it("throws error for invalid project response", async () => { + it('throws error for invalid project response', async () => { fetchStub = stub( globalThis, - "fetch", - returnsNext([Promise.resolve(new Response("foobar"))]), - ); + 'fetch', + returnsNext([Promise.resolve(new Response('foobar'))]), + ) - await assertRejects(() => fetchProject({ id: 123 })); - }); + await assertRejects(() => fetchProject({ id: 123 })) + }) - it("throws NOT_FOUND error for 404 response", async () => { + it('throws NOT_FOUND error for 404 response', async () => { fetchStub = stub( globalThis, - "fetch", + 'fetch', returnsNext([Promise.resolve({ status: 404 } as Response)]), - ); + ) await assertRejects( () => fetchProject({ id: 123 }), GraphQLError, - "Not found", - ); - }); - }); -}); + 'Not found', + ) + }) + }) +}) diff --git a/app/betterplace_test_data.ts b/app/betterplace_test_data.ts index ece93d9eb566f2d3b8f384322a86f441ecd7c0cc..3c3f38d0260eec542c0dfbf74b6b1af0e920b8e9 100644 --- a/app/betterplace_test_data.ts +++ b/app/betterplace_test_data.ts @@ -6,89 +6,88 @@ import { ApiProject, ApiProjectCategory, ApiProjectsResponse, -} from "./api_types.ts"; -import { Initiative, News, Project, ProjectsResponse } from "./types.ts"; +} from './api_types.ts' +import { Initiative, News, Project, ProjectsResponse } from './types.ts' const apiProjectCarrier1: ApiCarrier = { id: 1, - name: "Carrier 1", - city: "Berlin", - country: "Deutschland", + name: 'Carrier 1', + city: 'Berlin', + country: 'Deutschland', picture: { links: [ { - "rel": "fill_100x100", - "href": - "https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/000/045/fill_100x100_original_ARCHE_Logo_rgb_pos.jpg", + 'rel': 'fill_100x100', + 'href': + 'https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/000/045/fill_100x100_original_ARCHE_Logo_rgb_pos.jpg', }, { - "rel": "original", - "href": - "https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/000/045/crop_original_original_ARCHE_Logo_rgb_pos.jpg", + 'rel': 'original', + 'href': + 'https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/000/045/crop_original_original_ARCHE_Logo_rgb_pos.jpg', }, ], }, links: [ { - "rel": "self", - "href": "https://api.betterplace.org/de/api_v4/organisations/1.json", + 'rel': 'self', + 'href': 'https://api.betterplace.org/de/api_v4/organisations/1.json', }, ], -}; +} const apiProjectCarrier2: ApiCarrier = { id: 2, - name: "Carrier 2", - city: "Berlin", - country: "Deutschland", + name: 'Carrier 2', + city: 'Berlin', + country: 'Deutschland', picture: { links: [ { - "rel": "fill_100x100", - "href": - "https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/025/417/fill_100x100_sea_watch_logo.png", + 'rel': 'fill_100x100', + 'href': + 'https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/025/417/fill_100x100_sea_watch_logo.png', }, { - "rel": "original", - "href": - "https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/025/417/crop_original_sea_watch_logo.png", + 'rel': 'original', + 'href': + 'https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/025/417/crop_original_sea_watch_logo.png', }, ], }, links: [ { - "rel": "self", - "href": "https://api.betterplace.org/de/api_v4/organisations/2.json", + 'rel': 'self', + 'href': 'https://api.betterplace.org/de/api_v4/organisations/2.json', }, ], -}; +} export const apiOrganisation1: ApiOrganisation = { links: [ { - "rel": "platform", - "href": - "https://www.betterplace.org/de/organisations/5147-berliner-obdachlosenhilfe-e-v", + 'rel': 'platform', + 'href': 'https://www.betterplace.org/de/organisations/5147-berliner-obdachlosenhilfe-e-v', }, ], -}; +} export const apiOrganisation2: ApiOrganisation = { links: [ { - "rel": "platform", - "href": "https://www.betterplace.org/de/organisations/25435-sea-eye-e-v", + 'rel': 'platform', + 'href': 'https://www.betterplace.org/de/organisations/25435-sea-eye-e-v', }, ], -}; +} export const apiProject1: ApiProject = { id: 1, - title: "Project 1", - summary: "Summary Project 1", - description: "<div>Description Project 1</div>", - city: "Kiew", - country: "Ukraine", + title: 'Project 1', + summary: 'Summary Project 1', + description: '<div>Description Project 1</div>', + city: 'Kiew', + country: 'Ukraine', donations_count: 42, progress_percentage: 33, open_amount_in_cents: 421234, @@ -96,44 +95,43 @@ export const apiProject1: ApiProject = { profile_picture: { links: [ { - "rel": "fill_100x100", - "href": "https://www.example.com/image1_resized2.jpg", + 'rel': 'fill_100x100', + 'href': 'https://www.example.com/image1_resized2.jpg', }, { - "rel": "fill_410x214", - "href": "https://www.example.com/image1_resized1.jpg", + 'rel': 'fill_410x214', + 'href': 'https://www.example.com/image1_resized1.jpg', }, { - "rel": "original", - "href": "https://www.example.com/image1.jpg", + 'rel': 'original', + 'href': 'https://www.example.com/image1.jpg', }, ], }, links: [ { - "rel": "categories", - "href": - "https://api.betterplace.org/en/api_v4/projects/1/categories.json", + 'rel': 'categories', + 'href': 'https://api.betterplace.org/en/api_v4/projects/1/categories.json', }, { - "rel": "new_donation", - "href": "https://www.betterplace.org/de/donate/platform/projects/1", + 'rel': 'new_donation', + 'href': 'https://www.betterplace.org/de/donate/platform/projects/1', }, { - "rel": "blog_posts", - "href": "https://api.betterplace.org/de/api_v4/blog_posts/1.json", + 'rel': 'blog_posts', + 'href': 'https://api.betterplace.org/de/api_v4/blog_posts/1.json', }, ], carrier: apiProjectCarrier1, -}; +} const apiProject2: ApiProject = { id: 2, - title: "Project 2", - summary: "Summary Project 2", - description: "Description <b>Project 2</b>", - city: "Kiew", - country: "Ukraine", + title: 'Project 2', + summary: 'Summary Project 2', + description: 'Description <b>Project 2</b>', + city: 'Kiew', + country: 'Ukraine', donations_count: 13, progress_percentage: 10, open_amount_in_cents: 1234567, @@ -142,25 +140,24 @@ const apiProject2: ApiProject = { links: [], }, links: [{ - "rel": "categories", - "href": "https://api.betterplace.org/en/api_v4/projects/2/categories.json", + 'rel': 'categories', + 'href': 'https://api.betterplace.org/en/api_v4/projects/2/categories.json', }, { - "rel": "new_donation", - "href": "https://www.betterplace.org/de/donate/platform/projects/2", + 'rel': 'new_donation', + 'href': 'https://www.betterplace.org/de/donate/platform/projects/2', }, { - "rel": "blog_posts", - "href": "https://api.betterplace.org/de/api_v4/blog_posts/2.json", + 'rel': 'blog_posts', + 'href': 'https://api.betterplace.org/de/api_v4/blog_posts/2.json', }], carrier: apiProjectCarrier1, -}; +} const apiProject3: ApiProject = { id: 3, - title: "Project 3", - summary: "Summary Project 3", - description: - '<p>Description <a href="#">Project 3</a><br><img src="#" /></p>', - city: "Kiew", + title: 'Project 3', + summary: 'Summary Project 3', + description: '<p>Description <a href="#">Project 3</a><br><img src="#" /></p>', + city: 'Kiew', country: null, donations_count: 0, progress_percentage: 0, @@ -170,25 +167,25 @@ const apiProject3: ApiProject = { links: [], }, links: [{ - "rel": "categories", - "href": "https://api.betterplace.org/en/api_v4/projects/3/categories.json", + 'rel': 'categories', + 'href': 'https://api.betterplace.org/en/api_v4/projects/3/categories.json', }, { - "rel": "new_donation", - "href": "https://www.betterplace.org/de/donate/platform/projects/3", + 'rel': 'new_donation', + 'href': 'https://www.betterplace.org/de/donate/platform/projects/3', }, { - "rel": "blog_posts", - "href": "https://api.betterplace.org/de/api_v4/blog_posts/3.json", + 'rel': 'blog_posts', + 'href': 'https://api.betterplace.org/de/api_v4/blog_posts/3.json', }], carrier: apiProjectCarrier2, -}; +} const apiProject4: ApiProject = { id: 4, - title: "Project 4", + title: 'Project 4', summary: null, description: null, city: null, - country: "Ukraine", + country: 'Ukraine', donations_count: 0, progress_percentage: 0, open_amount_in_cents: 1200000, @@ -197,17 +194,17 @@ const apiProject4: ApiProject = { links: [], }, links: [{ - "rel": "categories", - "href": "https://api.betterplace.org/en/api_v4/projects/4/categories.json", + 'rel': 'categories', + 'href': 'https://api.betterplace.org/en/api_v4/projects/4/categories.json', }, { - "rel": "new_donation", - "href": "https://www.betterplace.org/de/donate/platform/projects/4", + 'rel': 'new_donation', + 'href': 'https://www.betterplace.org/de/donate/platform/projects/4', }, { - "rel": "blog_posts", - "href": "https://api.betterplace.org/de/api_v4/blog_posts/4.json", + 'rel': 'blog_posts', + 'href': 'https://api.betterplace.org/de/api_v4/blog_posts/4.json', }], carrier: apiProjectCarrier2, -}; +} export const apiProjectsResult: ApiProjectsResponse = { total_entries: 123, @@ -218,25 +215,25 @@ export const apiProjectsResult: ApiProjectsResponse = { apiProject3, apiProject4, ], -}; +} export const categoryEducation: ApiProjectCategory = { id: 3, - created_at: "2016-12-13T16:18:14+01:00", - updated_at: "2018-12-03T17:26:13+01:00", - name: "Education", - slug: "education", + created_at: '2016-12-13T16:18:14+01:00', + updated_at: '2018-12-03T17:26:13+01:00', + name: 'Education', + slug: 'education', links: [], -}; +} export const categoryPopular: ApiProjectCategory = { id: 44, - created_at: "2018-08-13T17:01:27+02:00", - updated_at: "2021-07-19T11:40:13+02:00", - name: "Popular", - slug: "popular", + created_at: '2018-08-13T17:01:27+02:00', + updated_at: '2021-07-19T11:40:13+02:00', + name: 'Popular', + slug: 'popular', links: [], -}; +} export const categoriesResult = ( categories: Array<ApiProjectCategory>, @@ -248,289 +245,288 @@ export const categoriesResult = ( current_page: 1, per_page: 20, data: categories, - }; -}; + } +} const blogPost1: ApiBlogPost = { id: 1, - "title": "Mirwais", - "published_at": "2009-03-11T20:46:27+01:00", - "body": "<p>Lorem ipsum</p>", - "author": { - "name": "Miray Heinrich", - "picture": { - "links": [ + 'title': 'Mirwais', + 'published_at': '2009-03-11T20:46:27+01:00', + 'body': '<p>Lorem ipsum</p>', + 'author': { + 'name': 'Miray Heinrich', + 'picture': { + 'links': [ { - "rel": "fill_100x100", - "href": - "https://betterplace-assets.betterplace.org/uploads/user/profile_picture/000/009/238/fill_100x100_bp1584570257_original_maxn_skate.jpg", + 'rel': 'fill_100x100', + 'href': + 'https://betterplace-assets.betterplace.org/uploads/user/profile_picture/000/009/238/fill_100x100_bp1584570257_original_maxn_skate.jpg', }, { - "rel": "original", - "href": - "https://betterplace-assets.betterplace.org/uploads/user/profile_picture/000/009/238/crop_original_bp1584570257_original_maxn_skate.jpg", + 'rel': 'original', + 'href': + 'https://betterplace-assets.betterplace.org/uploads/user/profile_picture/000/009/238/crop_original_bp1584570257_original_maxn_skate.jpg', }, ], }, }, - "links": [ + 'links': [ { - "rel": "platform", - "href": - "https://www.betterplace.org/de/projects/1114-unterstuetze-skateistan-sport-bildung-fuer-kinder/news/3917#ppp-sticky-anchor", + 'rel': 'platform', + 'href': + 'https://www.betterplace.org/de/projects/1114-unterstuetze-skateistan-sport-bildung-fuer-kinder/news/3917#ppp-sticky-anchor', }, ], -}; +} const blogPost2: ApiBlogPost = { id: 1, - "title": "Blog post 2", - "published_at": "2009-03-11T20:46:27+01:00", - "body": '<p>Lorem ipsum</p><img src="www.example.com/test.jpg"', - "links": [ + 'title': 'Blog post 2', + 'published_at': '2009-03-11T20:46:27+01:00', + 'body': '<p>Lorem ipsum</p><img src="www.example.com/test.jpg"', + 'links': [ { - "rel": "platform", - "href": - "https://www.betterplace.org/de/projects/1114-unterstuetze-skateistan-sport-bildung-fuer-kinder/news/3917#ppp-sticky-anchor", + 'rel': 'platform', + 'href': + 'https://www.betterplace.org/de/projects/1114-unterstuetze-skateistan-sport-bildung-fuer-kinder/news/3917#ppp-sticky-anchor', }, ], -}; +} export const apiBlogPosts1: ApiBlogPostResponse = { total_entries: 2, data: [blogPost1, blogPost2], -}; +} export const apiBlogPosts2: ApiBlogPostResponse = { total_entries: 1, data: [blogPost1], -}; +} export const apiBlogPostsEmpty: ApiBlogPostResponse = { total_entries: 0, data: [], -}; +} const expectedNews1: News = { author: { - name: "Miray Heinrich", + name: 'Miray Heinrich', picture: [ { height: 100, - type: "fill", + type: 'fill', url: - "https://betterplace-assets.betterplace.org/uploads/user/profile_picture/000/009/238/fill_100x100_bp1584570257_original_maxn_skate.jpg", + 'https://betterplace-assets.betterplace.org/uploads/user/profile_picture/000/009/238/fill_100x100_bp1584570257_original_maxn_skate.jpg', width: 100, }, { height: null, - type: "original", + type: 'original', url: - "https://betterplace-assets.betterplace.org/uploads/user/profile_picture/000/009/238/crop_original_bp1584570257_original_maxn_skate.jpg", + 'https://betterplace-assets.betterplace.org/uploads/user/profile_picture/000/009/238/crop_original_bp1584570257_original_maxn_skate.jpg', width: null, }, ], }, - body: "Lorem ipsum", + body: 'Lorem ipsum', id: 1, - publishDate: "2009-03-11T20:46:27+01:00", - title: "Mirwais", + publishDate: '2009-03-11T20:46:27+01:00', + title: 'Mirwais', url: - "https://www.betterplace.org/de/projects/1114-unterstuetze-skateistan-sport-bildung-fuer-kinder/news/3917#ppp-sticky-anchor", -}; + 'https://www.betterplace.org/de/projects/1114-unterstuetze-skateistan-sport-bildung-fuer-kinder/news/3917#ppp-sticky-anchor', +} const expectedNews2: News = { author: null, - body: "Lorem ipsum", + body: 'Lorem ipsum', id: 1, - publishDate: "2009-03-11T20:46:27+01:00", - title: "Blog post 2", + publishDate: '2009-03-11T20:46:27+01:00', + title: 'Blog post 2', url: - "https://www.betterplace.org/de/projects/1114-unterstuetze-skateistan-sport-bildung-fuer-kinder/news/3917#ppp-sticky-anchor", -}; + 'https://www.betterplace.org/de/projects/1114-unterstuetze-skateistan-sport-bildung-fuer-kinder/news/3917#ppp-sticky-anchor', +} const expectedInitiative1: Initiative = { - city: "Berlin", - country: "Deutschland", + city: 'Berlin', + country: 'Deutschland', id: 1, - name: "Carrier 1", + name: 'Carrier 1', picture: [ { - type: "fill", + type: 'fill', width: 100, height: 100, url: - "https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/000/045/fill_100x100_original_ARCHE_Logo_rgb_pos.jpg", + 'https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/000/045/fill_100x100_original_ARCHE_Logo_rgb_pos.jpg', }, { - type: "original", + type: 'original', url: - "https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/000/045/crop_original_original_ARCHE_Logo_rgb_pos.jpg", + 'https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/000/045/crop_original_original_ARCHE_Logo_rgb_pos.jpg', width: null, height: null, }, ], - url: - "https://www.betterplace.org/de/organisations/5147-berliner-obdachlosenhilfe-e-v", -}; + url: 'https://www.betterplace.org/de/organisations/5147-berliner-obdachlosenhilfe-e-v', +} const expectedInitiative2: Initiative = { - city: "Berlin", - country: "Deutschland", + city: 'Berlin', + country: 'Deutschland', id: 2, - name: "Carrier 2", + name: 'Carrier 2', picture: [ { - type: "fill", + type: 'fill', width: 100, height: 100, url: - "https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/025/417/fill_100x100_sea_watch_logo.png", + 'https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/025/417/fill_100x100_sea_watch_logo.png', }, { - type: "original", + type: 'original', url: - "https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/025/417/crop_original_sea_watch_logo.png", + 'https://betterplace-assets.betterplace.org/uploads/organisation/profile_picture/000/025/417/crop_original_sea_watch_logo.png', width: null, height: null, }, ], - url: "https://www.betterplace.org/de/organisations/25435-sea-eye-e-v", -}; + url: 'https://www.betterplace.org/de/organisations/25435-sea-eye-e-v', +} const expectedProjects: Project[] = [ { id: 1, - title: "Project 1", - summary: "Summary Project 1", - description: "Description Project 1", - city: "Kiew", - country: "Ukraine", - categories: ["education"], + title: 'Project 1', + summary: 'Summary Project 1', + description: 'Description Project 1', + city: 'Kiew', + country: 'Ukraine', + categories: ['education'], donationsCount: 42, progressPercentage: 33, openAmountInCents: 421234, picture: [ { - type: "fill", - url: "https://www.example.com/image1_resized2.jpg", + type: 'fill', + url: 'https://www.example.com/image1_resized2.jpg', width: 100, height: 100, }, { - type: "fill", + type: 'fill', width: 410, height: 214, - url: "https://www.example.com/image1_resized1.jpg", + url: 'https://www.example.com/image1_resized1.jpg', }, { - type: "original", - url: "https://www.example.com/image1.jpg", + type: 'original', + url: 'https://www.example.com/image1.jpg', width: null, height: null, }, ], initiative: expectedInitiative1, - donationUrl: "https://www.betterplace.org/de/donate/platform/projects/1", - newsUrl: "https://api.betterplace.org/de/api_v4/blog_posts/1.json", + donationUrl: 'https://www.betterplace.org/de/donate/platform/projects/1', + newsUrl: 'https://api.betterplace.org/de/api_v4/blog_posts/1.json', news: [expectedNews1, expectedNews2], newsCount: 2, }, { id: 2, - title: "Project 2", - summary: "Summary Project 2", - description: "Description Project 2", - city: "Kiew", - country: "Ukraine", - categories: ["popular"], + title: 'Project 2', + summary: 'Summary Project 2', + description: 'Description Project 2', + city: 'Kiew', + country: 'Ukraine', + categories: ['popular'], donationsCount: 13, progressPercentage: 10, openAmountInCents: 1234567, picture: [], initiative: expectedInitiative1, - donationUrl: "https://www.betterplace.org/de/donate/platform/projects/2", - newsUrl: "https://api.betterplace.org/de/api_v4/blog_posts/2.json", + donationUrl: 'https://www.betterplace.org/de/donate/platform/projects/2', + newsUrl: 'https://api.betterplace.org/de/api_v4/blog_posts/2.json', news: [expectedNews1], newsCount: 1, }, { id: 3, - title: "Project 3", - summary: "Summary Project 3", - description: "Description Project 3\n", - city: "Kiew", + title: 'Project 3', + summary: 'Summary Project 3', + description: 'Description Project 3\n', + city: 'Kiew', country: null, - categories: ["education", "popular"], + categories: ['education', 'popular'], donationsCount: 0, progressPercentage: 0, openAmountInCents: 4200000, picture: [], initiative: expectedInitiative2, - donationUrl: "https://www.betterplace.org/de/donate/platform/projects/3", - newsUrl: "https://api.betterplace.org/de/api_v4/blog_posts/3.json", + donationUrl: 'https://www.betterplace.org/de/donate/platform/projects/3', + newsUrl: 'https://api.betterplace.org/de/api_v4/blog_posts/3.json', news: [], newsCount: 0, }, { id: 4, - title: "Project 4", + title: 'Project 4', summary: null, description: null, city: null, - country: "Ukraine", - categories: ["education", "popular"], + country: 'Ukraine', + categories: ['education', 'popular'], donationsCount: 0, progressPercentage: 0, openAmountInCents: 1200000, picture: [], initiative: expectedInitiative2, - donationUrl: "https://www.betterplace.org/de/donate/platform/projects/4", - newsUrl: "https://api.betterplace.org/de/api_v4/blog_posts/4.json", + donationUrl: 'https://www.betterplace.org/de/donate/platform/projects/4', + newsUrl: 'https://api.betterplace.org/de/api_v4/blog_posts/4.json', news: [], newsCount: 0, }, -]; +] export const expectedProjectsResponse: ProjectsResponse = { totalResults: 123, data: expectedProjects, -}; +} export const expectedResult: Project = { id: 1, - title: "Project 1", - summary: "Summary Project 1", - description: "Description Project 1", - city: "Kiew", - country: "Ukraine", - categories: ["education"], + title: 'Project 1', + summary: 'Summary Project 1', + description: 'Description Project 1', + city: 'Kiew', + country: 'Ukraine', + categories: ['education'], donationsCount: 42, progressPercentage: 33, openAmountInCents: 421234, picture: [ { - type: "fill", - url: "https://www.example.com/image1_resized2.jpg", + type: 'fill', + url: 'https://www.example.com/image1_resized2.jpg', width: 100, height: 100, }, { - type: "fill", + type: 'fill', width: 410, height: 214, - url: "https://www.example.com/image1_resized1.jpg", + url: 'https://www.example.com/image1_resized1.jpg', }, { - type: "original", - url: "https://www.example.com/image1.jpg", + type: 'original', + url: 'https://www.example.com/image1.jpg', width: null, height: null, }, ], initiative: expectedInitiative1, - donationUrl: "https://www.betterplace.org/de/donate/platform/projects/1", - newsUrl: "https://api.betterplace.org/de/api_v4/blog_posts/1.json", + donationUrl: 'https://www.betterplace.org/de/donate/platform/projects/1', + newsUrl: 'https://api.betterplace.org/de/api_v4/blog_posts/1.json', news: [expectedNews1, expectedNews2], newsCount: 2, -}; +} diff --git a/app/common_test.ts b/app/common_test.ts index 479fce8e98c033ec2224f46cc464f33538f3c7dc..ee4507da713d769430ffe200d6135eb8d08ce3e0 100644 --- a/app/common_test.ts +++ b/app/common_test.ts @@ -1,15 +1,15 @@ -import { returnsNext, stub } from "./dev_deps.ts"; -import { GraphQLServer } from "./server.ts"; +import { returnsNext, stub } from './dev_deps.ts' +import { GraphQLServer } from './server.ts' -export type ResponsePayload = Record<string, unknown> | Error; +export type ResponsePayload = Record<string, unknown> | Error export const stubFetch = (response: ResponsePayload) => { return stub( globalThis, - "fetch", + 'fetch', returnsNext([Promise.resolve(new Response(JSON.stringify(response)))]), - ); -}; + ) +} export const processGqlRequest = ( graphQLServer: GraphQLServer, @@ -17,11 +17,11 @@ export const processGqlRequest = ( variables: Record<string, unknown> = {}, headers: Record<string, unknown> = {}, ): Promise<Record<string, unknown> | undefined | null> => { - const request = new Request("http://localhost:8001/graphql", { - method: "POST", + const request = new Request('http://localhost:8001/graphql', { + method: 'POST', headers: { - "content-type": "application/json", - "accept": "*/*", + 'content-type': 'application/json', + 'accept': '*/*', ...headers, }, body: JSON.stringify({ @@ -30,10 +30,10 @@ export const processGqlRequest = ( query: query, extensions: { headers }, }), - }); + }) return graphQLServer.handleRequest(request) // deno-lint-ignore no-explicit-any .then((response: any) => response.json()) // deno-lint-ignore no-explicit-any - .then((response: any) => response.data); -}; + .then((response: any) => response.data) +} diff --git a/app/deps.ts b/app/deps.ts index c41463b83ddbfbb2f20533698812e8a75ef59674..94270d824ada056f1fec3e1e95e32b52746fc158 100644 --- a/app/deps.ts +++ b/app/deps.ts @@ -1,5 +1,5 @@ -export { serve } from "https://deno.land/std@0.156.0/http/server.ts"; -export { createSchema, createYoga } from "npm:graphql-yoga@5.0.1"; -export { useResponseCache } from "npm:@graphql-yoga/plugin-response-cache@1.0.0"; -export { GraphQLError } from "npm:graphql@16.8.1"; -export { Parser } from "https://deno.land/x/html_parser@v0.1.3/src/mod.ts"; +export { serve } from 'https://deno.land/std@0.156.0/http/server.ts' +export { createSchema, createYoga } from 'npm:graphql-yoga@5.0.1' +export { useResponseCache } from 'npm:@graphql-yoga/plugin-response-cache@1.0.0' +export { GraphQLError } from 'npm:graphql@16.8.1' +export { Parser } from 'https://deno.land/x/html_parser@v0.1.3/src/mod.ts' diff --git a/app/dev_deps.ts b/app/dev_deps.ts index c3f570c4b1e7d7b0cabfc39a70bd95db3ea55cd9..cd82e58119121edb6283f04dad8164f79317c27a 100644 --- a/app/dev_deps.ts +++ b/app/dev_deps.ts @@ -1,18 +1,5 @@ -export { - assertSpyCall, - returnsNext, - stub, -} from "https://deno.land/std@0.155.0/testing/mock.ts"; -export type { Stub } from "https://deno.land/std@0.155.0/testing/mock.ts"; -export { - assertEquals, - assertExists, - assertRejects, -} from "https://deno.land/std@0.155.0/testing/asserts.ts"; -export { - afterEach, - beforeEach, - describe, - it, -} from "https://deno.land/std@0.155.0/testing/bdd.ts"; -export { FakeTime } from "https://deno.land/std@0.198.0/testing/time.ts"; +export { assertSpyCall, returnsNext, stub } from 'https://deno.land/std@0.155.0/testing/mock.ts' +export type { Stub } from 'https://deno.land/std@0.155.0/testing/mock.ts' +export { assertEquals, assertExists, assertRejects } from 'https://deno.land/std@0.155.0/testing/asserts.ts' +export { afterEach, beforeEach, describe, it } from 'https://deno.land/std@0.155.0/testing/bdd.ts' +export { FakeTime } from 'https://deno.land/std@0.198.0/testing/time.ts' diff --git a/app/logging.ts b/app/logging.ts index 06779e533e520e78df05e7bb026dba44f13d747e..d9b19f3ef3958091c157f394bad4da9e8744f9d0 100644 --- a/app/logging.ts +++ b/app/logging.ts @@ -1,14 +1,14 @@ // Google Cloud LogSeverity levels https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity export enum LogSeverity { - DEFAULT = "DEFAULT", - DEBUG = "DEBUG", - INFO = "INFO", - NOTICE = "NOTICE", - WARNING = "WARNING", - ERROR = "ERROR", - CRITICAL = "CRITICAL", - ALERT = "ALERT", - EMERGENCY = "EMERGENCY", + DEFAULT = 'DEFAULT', + DEBUG = 'DEBUG', + INFO = 'INFO', + NOTICE = 'NOTICE', + WARNING = 'WARNING', + ERROR = 'ERROR', + CRITICAL = 'CRITICAL', + ALERT = 'ALERT', + EMERGENCY = 'EMERGENCY', } const sortedLevels = [ @@ -21,20 +21,20 @@ const sortedLevels = [ LogSeverity.CRITICAL, LogSeverity.ALERT, LogSeverity.EMERGENCY, -]; +] /** * The Logger class requires a setUpLogger method to be run during the app's initialization in order get the current environment. */ class Logger { - environment = "development"; + environment = 'development' // minimum level to log - level = LogSeverity.DEFAULT; + level = LogSeverity.DEFAULT setUpLogger(environment: string, level: LogSeverity) { - this.environment = environment; - this.level = level; + this.environment = environment + this.level = level } /** @@ -46,54 +46,52 @@ class Logger { * In other environments it should follow Google Cloud's Structured Logging formatting: https://cloud.google.com/logging/docs/structured-logging */ log(severity: LogSeverity, message: string, options = {}) { - if (!shouldLogLevel(this.level, severity)) return; + if (!shouldLogLevel(this.level, severity)) return - const date = new Date().toISOString(); - const input = this.environment === "development" - ? `${date} ${severity} ${message}` - : JSON.stringify({ - environment: this.environment, - severity, - message, - ...options, - }); + const date = new Date().toISOString() + const input = this.environment === 'development' ? `${date} ${severity} ${message}` : JSON.stringify({ + environment: this.environment, + severity, + message, + ...options, + }) switch (severity) { case LogSeverity.DEFAULT: - console.log(input); - break; + console.log(input) + break case LogSeverity.DEBUG: - console.debug(input); - break; + console.debug(input) + break case LogSeverity.INFO: case LogSeverity.NOTICE: - console.info(input); - break; + console.info(input) + break case LogSeverity.WARNING: - console.warn(input); - break; + console.warn(input) + break case LogSeverity.ERROR: case LogSeverity.CRITICAL: case LogSeverity.ALERT: case LogSeverity.EMERGENCY: - console.error(input); + console.error(input) } } debug(message: string, options = {}) { - this.log(LogSeverity.DEBUG, message, options); + this.log(LogSeverity.DEBUG, message, options) } info(message: string, options = {}) { - this.log(LogSeverity.INFO, message, options); + this.log(LogSeverity.INFO, message, options) } warn(message: string, options = {}) { - this.log(LogSeverity.WARNING, message, options); + this.log(LogSeverity.WARNING, message, options) } error(message: string, options = {}) { - this.log(LogSeverity.ERROR, message, options); + this.log(LogSeverity.ERROR, message, options) } } @@ -103,9 +101,9 @@ class Logger { * @returns Boolean, true if the current level is accepted and false if it is rejected by the minLevel */ const shouldLogLevel = (minLevel: LogSeverity, level: LogSeverity) => { - const minLevelIndex = sortedLevels.indexOf(minLevel); - const currentLevelIndex = sortedLevels.indexOf(level); - return currentLevelIndex >= minLevelIndex; -}; + const minLevelIndex = sortedLevels.indexOf(minLevel) + const currentLevelIndex = sortedLevels.indexOf(level) + return currentLevelIndex >= minLevelIndex +} -export const logger = new Logger(); +export const logger = new Logger() diff --git a/app/main.ts b/app/main.ts index e1292f81b7425faf050d9283d188283aec1e7a82..857fc522d0339d622e360baf3a4880f660611747 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1,48 +1,43 @@ -import { logger, LogSeverity } from "./logging.ts"; -import { - DEFAULT_CACHE_ENABLED, - DEFAULT_CACHE_TTL_MS_BETTERPLACE, - DEFAULT_PORT, - startServer, -} from "./server.ts"; +import { logger, LogSeverity } from './logging.ts' +import { DEFAULT_CACHE_ENABLED, DEFAULT_CACHE_TTL_MS_BETTERPLACE, DEFAULT_PORT, startServer } from './server.ts' -const environment = Deno.env.get("ENVIRONMENT") || "development"; +const environment = Deno.env.get('ENVIRONMENT') || 'development' logger.setUpLogger( environment, - environment === "development" ? LogSeverity.DEFAULT : LogSeverity.INFO, -); + environment === 'development' ? LogSeverity.DEFAULT : LogSeverity.INFO, +) const requiredEnv = <T>( name: string, typeFn: (s: string) => T, fallback?: T, ): T => { - const env = Deno.env.get(name); + const env = Deno.env.get(name) if (env === undefined && fallback === undefined) { - throw Error(`Environment variable "${name}" is required`); + throw Error(`Environment variable "${name}" is required`) } else { - return env !== undefined ? typeFn(env) : fallback!; + return env !== undefined ? typeFn(env) : fallback! } -}; +} -const asBoolean = (str: string) => /^true$/i.test(str); +const asBoolean = (str: string) => /^true$/i.test(str) const serverConfigFromEnv = () => { return { - port: requiredEnv("PORT", Number, DEFAULT_PORT), + port: requiredEnv('PORT', Number, DEFAULT_PORT), cacheEnabled: requiredEnv( - "CACHE_ENABLED", + 'CACHE_ENABLED', asBoolean, DEFAULT_CACHE_ENABLED, ), cacheTtlMsBetterplace: requiredEnv( - "CACHE_TTL_MS_BETTERPLACE", + 'CACHE_TTL_MS_BETTERPLACE', Number, DEFAULT_CACHE_TTL_MS_BETTERPLACE, ), - fake: requiredEnv("FAKE", asBoolean, false), // For local development. If set, the API returns dummy data - }; -}; + fake: requiredEnv('FAKE', asBoolean, false), // For local development. If set, the API returns dummy data + } +} -await startServer(serverConfigFromEnv()); +await startServer(serverConfigFromEnv()) diff --git a/app/server.ts b/app/server.ts index ebc6f8470faad5bb3b8f5997e2a7cf76227a9edf..9f558d32471010fb05d36f8661701a72ba8434ff 100644 --- a/app/server.ts +++ b/app/server.ts @@ -7,7 +7,7 @@ import { ProjectParameters, ProjectsParameters, ProjectsResponse, -} from "./types.ts"; +} from './types.ts' import { DEFAULT_LANGUAGE, fetchCategories, @@ -16,9 +16,9 @@ import { fetchProject, fetchProjects, SUPPORTED_LANGUAGES, -} from "./betterplace.ts"; -import { createSchema, createYoga, serve, useResponseCache } from "./deps.ts"; -import { logger } from "./logging.ts"; +} from './betterplace.ts' +import { createSchema, createYoga, serve, useResponseCache } from './deps.ts' +import { logger } from './logging.ts' const typeDefs = ` type Picture { @@ -80,17 +80,17 @@ const typeDefs = ` projects(offset: Int = 0, limit: Int = 10, location: String): ProjectsResponse! project(id: Int!): Project! } -`; +` const fakeProject: Project = { id: 10, - title: "fake", + title: 'fake', categories: [], city: undefined, country: undefined, initiative: { id: 0, - name: "fake", + name: 'fake', city: undefined, country: undefined, picture: [], @@ -101,11 +101,11 @@ const fakeProject: Project = { picture: [], summary: undefined, description: undefined, - donationUrl: "", - newsUrl: "", + donationUrl: '', + newsUrl: '', news: [], newsCount: 0, -}; +} const createResolvers = (_config: ServerConfig) => ({ Query: { @@ -115,12 +115,10 @@ const createResolvers = (_config: ServerConfig) => ({ parameters: ProjectsParameters = {}, context: GraphQLContext, ): Promise<ProjectsResponse> => - _config.fake - ? Promise.resolve({ totalResults: 0, data: [] }) - : fetchProjects( - parameters, - getLanguage(context?.request?.headers), - ), + _config.fake ? Promise.resolve({ totalResults: 0, data: [] }) : fetchProjects( + parameters, + getLanguage(context?.request?.headers), + ), project: ( // deno-lint-ignore no-explicit-any _parent: any, @@ -133,8 +131,7 @@ const createResolvers = (_config: ServerConfig) => ({ ), }, Project: { - categories: (args: Project): Promise<string[]> => - _config.fake ? Promise.resolve([]) : fetchCategories(args.id), + categories: (args: Project): Promise<string[]> => _config.fake ? Promise.resolve([]) : fetchCategories(args.id), news: (args: Project, parameters: NewsParameters = {}): Promise<News[]> => _config.fake ? Promise.resolve([]) : fetchNews(args, parameters), }, @@ -142,29 +139,29 @@ const createResolvers = (_config: ServerConfig) => ({ url: (args: Initiative): Promise<string | undefined> => _config.fake ? Promise.resolve(undefined) : fetchInitiativeUrl(args), }, -}); +}) -export const DEFAULT_PORT = 8001; -export const DEFAULT_CACHE_ENABLED = true; -export const DEFAULT_CACHE_TTL_MS_BETTERPLACE = 60_000; +export const DEFAULT_PORT = 8001 +export const DEFAULT_CACHE_ENABLED = true +export const DEFAULT_CACHE_TTL_MS_BETTERPLACE = 60_000 export interface ServerConfig { - port: number; // default: 8001 - cacheEnabled: boolean; // default: true - cacheTtlMsBetterplace: number; // default: 60 seconds - fake: boolean; // For local development. If set, the API returns dummy data + port: number // default: 8001 + cacheEnabled: boolean // default: true + cacheTtlMsBetterplace: number // default: 60 seconds + fake: boolean // For local development. If set, the API returns dummy data } const getLanguage = (headers?: Headers) => { - const languages: string = headers?.get("accept-language") || DEFAULT_LANGUAGE; + const languages: string = headers?.get('accept-language') || DEFAULT_LANGUAGE return (languages - .split(",") // languages are comma separated - .map((language) => language.split("-")[0]) // languages may include country code 'de-DE' e.g. - .map((language) => language.split(";")[0]) // languages may include a 'de;q=0.6' e.g. + .split(',') // languages are comma separated + .map((language) => language.split('-')[0]) // languages may include country code 'de-DE' e.g. + .map((language) => language.split(';')[0]) // languages may include a 'de;q=0.6' e.g. .map((language) => language.trim()) .find((language) => SUPPORTED_LANGUAGES.includes(language)) || - DEFAULT_LANGUAGE) as BetterPlaceLanguage; -}; + DEFAULT_LANGUAGE) as BetterPlaceLanguage +} export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { const plugins = config.cacheEnabled @@ -173,49 +170,47 @@ export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { // global cache per language, shared by all users session: (request: Request) => getLanguage(request.headers), ttlPerSchemaCoordinate: { - "Query.projects": config.cacheTtlMsBetterplace, - "Query.project": config.cacheTtlMsBetterplace, + 'Query.projects': config.cacheTtlMsBetterplace, + 'Query.project': config.cacheTtlMsBetterplace, }, }), ] - : []; - const resolvers = createResolvers(config); + : [] + const resolvers = createResolvers(config) return createYoga({ schema: createSchema({ resolvers, typeDefs }), graphiql: true, plugins, - }); -}; + }) +} // deno-lint-ignore no-explicit-any -export type GraphQLServer = any; +export type GraphQLServer = any type GraphQLContext = { - request?: { headers: Headers }; + request?: { headers: Headers } params?: { extensions?: { headers?: { - [key: string]: string; - }; - }; - }; -}; + [key: string]: string + } + } + } +} export const startServer = (config: ServerConfig): Promise<void> => { - const graphQLServer: GraphQLServer = createGraphQLServer(config); + const graphQLServer: GraphQLServer = createGraphQLServer(config) return serve(graphQLServer, { port: config.port, onListen({ port, hostname }) { logger.info( - `Server started at http://${ - hostname === "0.0.0.0" ? "localhost" : hostname - }:${port}/graphql`, - ); + `Server started at http://${hostname === '0.0.0.0' ? 'localhost' : hostname}:${port}/graphql`, + ) if (config.fake) { logger.info( `Server is serving fake data due to FAKE env var set to true`, - ); + ) } }, - }); -}; + }) +} diff --git a/app/types.ts b/app/types.ts index c333f6baf8133823bccd167bcb1ef847acbce05b..6d065810d51716f6736d0733e76b939b081cbebf 100644 --- a/app/types.ts +++ b/app/types.ts @@ -1,71 +1,71 @@ export interface ProjectsParameters { - limit?: number; - offset?: number; - location?: string; + limit?: number + offset?: number + location?: string } export interface ProjectParameters { - id: number; + id: number } export interface Picture { - url: string; - type: string; - width?: number | null; - height?: number | null; + url: string + type: string + width?: number | null + height?: number | null } export interface Initiative { - id: number; - name: string; - city: string | undefined | null; - country: string | undefined | null; - picture: Picture[]; - url?: string; + id: number + name: string + city: string | undefined | null + country: string | undefined | null + picture: Picture[] + url?: string } export interface Author { - name: string; - picture: Picture[]; + name: string + picture: Picture[] } export interface News { - id: number; - title: string; - publishDate: string; - author?: Author | null; - body: string; - url: string; + id: number + title: string + publishDate: string + author?: Author | null + body: string + url: string } export interface NewsParameters { - limit?: number; + limit?: number } export interface Project { - id: number; - title: string; - categories: string[]; - city: string | undefined | null; - country: string | undefined | null; - initiative: Initiative; - donationsCount: number; + id: number + title: string + categories: string[] + city: string | undefined | null + country: string | undefined | null + initiative: Initiative + donationsCount: number /** Number between 0 and 100 */ - progressPercentage: number; - openAmountInCents: number; + progressPercentage: number + openAmountInCents: number /** The project's "profile picture" in different variants */ - picture: Picture[]; - summary: string | undefined | null; - description: string | undefined | null; - donationUrl: string; - newsUrl: string; - news: News[]; - newsCount: number; + picture: Picture[] + summary: string | undefined | null + description: string | undefined | null + donationUrl: string + newsUrl: string + news: News[] + newsCount: number } export interface ProjectsResponse { - totalResults: number; - data: Project[]; + totalResults: number + data: Project[] } -export type BetterPlaceLanguage = "de" | "en"; +export type BetterPlaceLanguage = 'de' | 'en' diff --git a/deno.json b/deno.json index 23a254d9873989eb74c5c4c5b8501024f6451208..4ffff98c81635a268f7ae97151213f6335534e3a 100644 --- a/deno.json +++ b/deno.json @@ -19,6 +19,9 @@ } }, "fmt": { + "lineWidth": 120, + "singleQuote": true, + "semiColons": false, "exclude": [ "*.md" ] diff --git a/terraform/environments/scripts/destroy-env.sh b/terraform/environments/scripts/destroy-env.sh index d8137694745e9a6bcded13142bf6a5e666b55e6c..aac6dea64eafc13c326e5b4819351c32365e9b3c 100755 --- a/terraform/environments/scripts/destroy-env.sh +++ b/terraform/environments/scripts/destroy-env.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh # exit when any command fails set -ex @@ -12,7 +12,7 @@ export TF_LOG=DEBUG # * GCP stuff not allowing our resources to be deleted. # Most of the time, retrying a destroy fixes these causes. retry() { - for i in {1..3}; do + for i in $(seq 1 3); do set +e "$@" retval=$?