diff --git a/.envrc b/.envrc index 4f0af3a80ef9b7d25eaa8177ee64bcd7310af8b4..3b5bb1416c85df12ee45836120a60c4dcab29a79 100644 --- a/.envrc +++ b/.envrc @@ -7,6 +7,3 @@ fi # loads personal (secret) data from separate env file (not checked in) source_env_if_exists .envrc.local - -type yarn >/dev/null 2>&1 && PATH="$PATH:$(yarn global bin)" -export PATH \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2d206274a49c870537cdb702758bdf88b416a0d8..0fd733088730ec925aa81515df5920c850a79061 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,7 +4,7 @@ default: - env interruptible: true tags: - - holi-small # build on smaller machine + - 1cpu-4gb # build on smaller machine variables: API_DOMAIN_PATH: "$CI_PROJECT_DIR/api_domain" diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index c1d699695f34ed0a0d789b795412ef67013e7458..0000000000000000000000000000000000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -gitleaks protect --staged -v -c ../.gitleaks.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2f34ebc58adb3df395e52bedee0d670532274122 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: +- repo: local + hooks: + - id: gitleaks + name: gitleaks + language: system + entry: gitleaks protect --staged -v -c ../.gitleaks.toml + pass_filenames: false + always_run: true diff --git a/app/betterplace.ts b/app/betterplace.ts index 96bd037d2ac9a78e7f876daeaf8dae5160896530..c76686e6451e6055faa92d1f2ae2b08f5986ad6b 100644 --- a/app/betterplace.ts +++ b/app/betterplace.ts @@ -138,26 +138,27 @@ const transformNews = (blogPost: ApiBlogPost): News => ({ url: blogPost.links.find((p) => p.rel === "platform")!.href, }); -export const fetchCategories = (projectId: number): Promise<string[]> => { +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}`); - return fetch(href) - .then((result) => result.json()) - .then((json) => json.data as Array<ApiProjectCategory>) - .then((categories) => categories.map(transformProjectCategory)) - .catch((e) => { - logger.error( - `Error while fetching project categories from '${href}', using empty array to handle this (somewhat) gracefully`, - e, - ); - return []; - }); + try { + const response = await fetch(href); + const json = await response.json(); + const categories = json.data as Array<ApiProjectCategory>; + return categories.map(transformProjectCategory); + } catch (e) { + logger.error( + `Error while fetching project categories from '${href}', using empty array to handle this (somewhat) gracefully`, + e, + ); + return []; + } }; -export const fetchNews = ( +export const fetchNews = async ( project: Project, parameters: NewsParameters, ): Promise<News[]> => { @@ -165,38 +166,37 @@ export const fetchNews = ( `?per_page=${parameters.limit || 5}&order=published_at:desc`; logger.info(`fetching blog posts from ${href}`); - return fetch(href) - .then((result) => result.json()) - .then((json) => json.data as ApiBlogPost[]) - .then((news) => news.map(transformNews)) - .catch((e) => { - logger.error( - `Error while fetching blog posts from '${href}', using empty array to handle this (somewhat) gracefully`, - e, - ); - return []; - }); + try { + const response = await fetch(href); + const json = await response.json(); + const news = json.data as ApiBlogPost[]; + return news.map(transformNews); + } catch (e) { + logger.error( + `Error while fetching blog posts from '${href}', using empty array to handle this (somewhat) gracefully`, + e, + ); + return []; + } }; -export const fetchInitiativeUrl = ( +export const fetchInitiativeUrl = async ( initiative: Initiative, ): Promise<string | undefined> => { if (!initiative.url) { return Promise.resolve(initiative.url); } - return fetch(initiative.url) - .then((result) => result.json()) - .then( - (organisation: ApiOrganisation) => - organisation.links.find((l) => l.rel === "platform")?.href, - ) - .catch((e) => { - logger.error( - `Error while fetching organisation url from '${initiative.url}', using api url to handle this (somewhat) gracefully`, - e, - ); - return initiative.url; - }); + try { + const response = await fetch(initiative.url); + const organisation: ApiOrganisation = await response.json(); + return organisation.links.find((l) => l.rel === "platform")?.href; + } catch (e) { + logger.error( + `Error while fetching organisation url from '${initiative.url}', using api url to handle this (somewhat) gracefully`, + e, + ); + return initiative.url; + } }; function _debugP<In>(desc: string | undefined = undefined) { @@ -228,35 +228,33 @@ const buildProjectsUrl = ( return url; }; -const fetchPageOfProjects = ( +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}`); - return fetch(url) - .then((result) => result.json()) - .then(transformProjectsResponse) - .then((result) => { - const duration = Date.now() - start; - logger.debug(`fetching projects took ${duration} ms`); - return result; - }) - .catch((e) => { - const duration = Date.now() - start; - logger.error( - `Error performing request to ${url} after ${duration} ms: ${e.message}`, - ); - throw e; - }); + try { + 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; + } finally { + 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; -export const fetchProjects = ( +export const fetchProjects = async ( { offset = 0, limit = DEFAULT_PAGE_SIZE, location }: ProjectsParameters, language: BetterPlaceLanguage = DEFAULT_LANGUAGE, ): Promise<ProjectsResponse> => { @@ -266,13 +264,14 @@ export const fetchProjects = ( const isFirstPageOfUnfilteredList = offset < limit && !isFiltering; if (isFirstPageOfUnfilteredList) { logger.info(`fetching random projects from page ${randomPage}`); - return fetchPageOfProjects({ + const projects = await fetchPageOfProjects({ offset: randomPage * DEFAULT_PAGE_SIZE, limit: DEFAULT_PAGE_SIZE, - }, language).then((result) => ({ - ...result, - data: result.data.slice(0, limit), - })); + }, 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; @@ -283,33 +282,29 @@ export const fetchProjects = ( }, language); }; -export const fetchProject = ( +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(); - return fetch(url) - .then((response) => { - if (response.status === 404) { - throw new GraphQLError("Not found", { - extensions: { "code": "NOT_FOUND" }, - }); - } - return response.json(); - }) - .then(transformProject) - .then((result) => { - const duration = Date.now() - start; - logger.debug(`fetching project with ID ${id} took ${duration} ms`); - return result; - }) - .catch((e) => { - const duration = Date.now() - start; - logger.error( - `Error performing request to ${url} after ${duration} ms: ${e.message}`, - ); - throw e; - }); + try { + const response = await fetch(url); + if (response.status === 404) { + throw new GraphQLError("Not found", { + extensions: { "code": "NOT_FOUND" }, + }); + } + const json = await response.json(); + return transformProject(json); + } catch (e) { + logger.error( + `Error performing request to ${url}: ${e.message}`, + ); + throw e; + } finally { + const duration = Date.now() - start; + logger.debug(`fetching project with ID ${id} took ${duration} ms`); + } }; diff --git a/app/common_test.ts b/app/common_test.ts index cc23dcd7df1e0ea7a56332001639669df0aad7e5..479fce8e98c033ec2224f46cc464f33538f3c7dc 100644 --- a/app/common_test.ts +++ b/app/common_test.ts @@ -22,6 +22,7 @@ export const processGqlRequest = ( headers: { "content-type": "application/json", "accept": "*/*", + ...headers, }, body: JSON.stringify({ operationName: null, diff --git a/app/server.ts b/app/server.ts index 7f830504cbc61cf49f5d7417fc2bdebf62c2abdb..ebc6f8470faad5bb3b8f5997e2a7cf76227a9edf 100644 --- a/app/server.ts +++ b/app/server.ts @@ -117,16 +117,20 @@ const createResolvers = (_config: ServerConfig) => ({ ): Promise<ProjectsResponse> => _config.fake ? Promise.resolve({ totalResults: 0, data: [] }) - : fetchProjects(parameters, context.language), + : fetchProjects( + parameters, + getLanguage(context?.request?.headers), + ), project: ( // deno-lint-ignore no-explicit-any _parent: any, parameters: ProjectParameters, context: GraphQLContext, ): Promise<Project> => - _config.fake - ? Promise.resolve(fakeProject) - : fetchProject(parameters, context.language), + _config.fake ? Promise.resolve(fakeProject) : fetchProject( + parameters, + getLanguage(context?.request?.headers), + ), }, Project: { categories: (args: Project): Promise<string[]> => @@ -151,23 +155,23 @@ export interface ServerConfig { fake: boolean; // For local development. If set, the API returns dummy data } -const getLanguage = (languages = "") => - languages +const getLanguage = (headers?: Headers) => { + 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. + .map((language) => language.trim()) .find((language) => SUPPORTED_LANGUAGES.includes(language)) || - DEFAULT_LANGUAGE; + DEFAULT_LANGUAGE) as BetterPlaceLanguage; +}; export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { const plugins = config.cacheEnabled ? [ useResponseCache({ // global cache per language, shared by all users - session: (request: Request) => - getLanguage( - request.headers.get("accept-language") || DEFAULT_LANGUAGE, - ), + session: (request: Request) => getLanguage(request.headers), ttlPerSchemaCoordinate: { "Query.projects": config.cacheTtlMsBetterplace, "Query.project": config.cacheTtlMsBetterplace, @@ -180,14 +184,6 @@ export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { schema: createSchema({ resolvers, typeDefs }), graphiql: true, plugins, - context: (context: GraphQLContext) => { - const headers = new Headers(context.params?.extensions?.headers); - const languages = headers.get("accept-language") || DEFAULT_LANGUAGE; - return { - ...context, - language: getLanguage(languages), - }; - }, }); }; @@ -195,6 +191,7 @@ export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { export type GraphQLServer = any; type GraphQLContext = { + request?: { headers: Headers }; params?: { extensions?: { headers?: { @@ -202,12 +199,11 @@ type GraphQLContext = { }; }; }; - language: BetterPlaceLanguage; }; export const startServer = (config: ServerConfig): Promise<void> => { const graphQLServer: GraphQLServer = createGraphQLServer(config); - return serve(graphQLServer.handleRequest, { + return serve(graphQLServer, { port: config.port, onListen({ port, hostname }) { logger.info( diff --git a/terraform/environments/deployment.tf b/terraform/environments/deployment.tf index 3266c54b928e5a159f4e59af960710ccd80c0d90..bb0ce296880f262d63753e6d70b0aee5747c56bd 100644 --- a/terraform/environments/deployment.tf +++ b/terraform/environments/deployment.tf @@ -54,7 +54,7 @@ resource "google_cloud_run_service" "donations_api" { } env { name = "CACHE_TTL_MS_BETTERPLACE" - value = local.environment == "production" ? "3600000" : "60000" + value = local.environment == "production" ? "3600000" : "3600000" # 1 hour for production, otherwise 1 minute } @@ -65,7 +65,7 @@ resource "google_cloud_run_service" "donations_api" { memory = local.environment == "production" ? "512Mi" : "256Mi" } requests = { - cpu = local.environment == "production" ? "1000m" : "10m" + cpu = local.environment == "production" ? "1000m" : "1000m" memory = local.environment == "production" ? "512Mi" : "256Mi" } }