diff --git a/app/betterplace_test.ts b/app/betterplace_test.ts index dcff85e323e70a6a6f774c6cf5c2d9d335814f66..8d4450f0b61ee6cfaafb6dde0149ea86569531fc 100644 --- a/app/betterplace_test.ts +++ b/app/betterplace_test.ts @@ -46,6 +46,13 @@ 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"; +const noCacheServerConfig = { + cacheEnabled: false, + port: 0, + cacheTtlMsBetterplace: 0, + fake: false, +}; + const stubFetchByUrlPrefix = ( responses: Record<UrlPrefix, ResponsePayload>, ) => { @@ -323,7 +330,7 @@ describe("betterplace", () => { it("correctly parses project list", async () => { fetchStub = stubFetchByUrlPrefix(validApiResponses); - const graphQLServer = createGraphQLServer({ cacheEnabled: false }); + const graphQLServer = createGraphQLServer(noCacheServerConfig); const result = await queryProjects(graphQLServer); assertEquals(result, expectedProjectsResponse); @@ -355,7 +362,7 @@ describe("betterplace", () => { new Error("Expected error"), }); - const graphQLServer = createGraphQLServer({ cacheEnabled: false }); + const graphQLServer = createGraphQLServer(noCacheServerConfig); const result = await queryProjects(graphQLServer); const project1 = result.data.find((p) => p.id === 1); @@ -371,7 +378,7 @@ describe("betterplace", () => { ), }); - const graphQLServer = createGraphQLServer({ cacheEnabled: false }); + const graphQLServer = createGraphQLServer(noCacheServerConfig); const result = await queryProjects(graphQLServer); const project1 = result.data.find((p) => p.id === 1); @@ -390,7 +397,7 @@ describe("betterplace", () => { ), }); - const graphQLServer = createGraphQLServer({ cacheEnabled: false }); + const graphQLServer = createGraphQLServer(noCacheServerConfig); const result = await queryProjects(graphQLServer); const project1 = result.data.find((p) => p.id === 1); assertExists(project1); @@ -399,7 +406,12 @@ describe("betterplace", () => { it("returns cached results on recurring requests", async () => { fetchStub = stubFetchByUrlPrefix(validApiResponses); - const graphQLServer = createGraphQLServer({ cacheEnabled: true }); + const graphQLServer = createGraphQLServer({ + cacheEnabled: true, + port: 0, + cacheTtlMsBetterplace: 1000, + fake: false, + }); const result = await queryProjects(graphQLServer); assertEquals(result, expectedProjectsResponse); @@ -418,7 +430,7 @@ describe("betterplace", () => { it("correctly retrieves a project by ID", async () => { fetchStub = stubFetchByUrlPrefix(validApiResponses); - const graphQLServer = createGraphQLServer({ cacheEnabled: false }); + const graphQLServer = createGraphQLServer(noCacheServerConfig); const result = await queryProject(graphQLServer); assertEquals(result, expectedResult); @@ -427,7 +439,7 @@ describe("betterplace", () => { it("correctly extracts language from locale", async () => { fetchStub = stubFetchByUrlPrefix(validApiResponses); - const graphQLServer = createGraphQLServer({ cacheEnabled: false }); + const graphQLServer = createGraphQLServer(noCacheServerConfig); const result = await queryProject(graphQLServer, { "accept-language": "de-DE", }); @@ -441,7 +453,7 @@ describe("betterplace", () => { it("uses correct language as default if requested language is not supported", async () => { fetchStub = stubFetchByUrlPrefix(validApiResponses); - const graphQLServer = createGraphQLServer({ cacheEnabled: false }); + const graphQLServer = createGraphQLServer(noCacheServerConfig); const result = await queryProject(graphQLServer, { "accept-language": "es", }); diff --git a/app/main.ts b/app/main.ts index 7e31c805390d883dfaaa537dd58da4ce4104ee02..e1292f81b7425faf050d9283d188283aec1e7a82 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1,5 +1,10 @@ import { logger, LogSeverity } from "./logging.ts"; -import { startServer } from "./server.ts"; +import { + DEFAULT_CACHE_ENABLED, + DEFAULT_CACHE_TTL_MS_BETTERPLACE, + DEFAULT_PORT, + startServer, +} from "./server.ts"; const environment = Deno.env.get("ENVIRONMENT") || "development"; @@ -8,13 +13,35 @@ logger.setUpLogger( environment === "development" ? LogSeverity.DEFAULT : LogSeverity.INFO, ); +const requiredEnv = <T>( + name: string, + typeFn: (s: string) => T, + fallback?: T, +): T => { + const env = Deno.env.get(name); + if (env === undefined && fallback === undefined) { + throw Error(`Environment variable "${name}" is required`); + } else { + return env !== undefined ? typeFn(env) : fallback!; + } +}; + +const asBoolean = (str: string) => /^true$/i.test(str); + const serverConfigFromEnv = () => { - const asNumber = (str?: string) => (str ? Number(str) : undefined); - const asBoolean = (str?: string) => (str ? Boolean(str) : undefined); return { - port: asNumber(Deno.env.get("PORT")), - cacheEnabled: asBoolean(Deno.env.get("CACHE_ENABLED")), - cacheTtlMsBetterplace: asNumber(Deno.env.get("CACHE_TTL_MS_BETTERPLACE")), + port: requiredEnv("PORT", Number, DEFAULT_PORT), + cacheEnabled: requiredEnv( + "CACHE_ENABLED", + asBoolean, + DEFAULT_CACHE_ENABLED, + ), + cacheTtlMsBetterplace: requiredEnv( + "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 }; }; diff --git a/app/server.ts b/app/server.ts index 99695972bf8b0ff818548bd6efd4dc00dc942987..75207fbb30133c65d35835712d14a6a694e704b0 100644 --- a/app/server.ts +++ b/app/server.ts @@ -1,10 +1,12 @@ import { BetterPlaceLanguage, Initiative, + News, NewsParameters, Project, ProjectParameters, ProjectsParameters, + ProjectsResponse, } from "./types.ts"; import { DEFAULT_LANGUAGE, @@ -80,6 +82,31 @@ const typeDefs = ` } `; +const fakeProject: Project = { + id: 10, + title: "fake", + categories: [], + city: undefined, + country: undefined, + initiative: { + id: 0, + name: "fake", + city: undefined, + country: undefined, + picture: [], + }, + donationsCount: 0, + progressPercentage: 0, + openAmountInCents: 0, + picture: [], + summary: undefined, + description: undefined, + donationUrl: "", + newsUrl: "", + news: [], + newsCount: 0, +}; + const createResolvers = (_config: ServerConfig) => ({ Query: { projects: ( @@ -87,21 +114,29 @@ const createResolvers = (_config: ServerConfig) => ({ _parent: any, parameters: ProjectsParameters = {}, context: GraphQLContext, - ) => fetchProjects(parameters, context.language), + ): Promise<ProjectsResponse> => + _config.fake + ? Promise.resolve({ totalResults: 0, data: [] }) + : fetchProjects(parameters, context.language), project: ( // deno-lint-ignore no-explicit-any _parent: any, parameters: ProjectParameters, context: GraphQLContext, - ) => fetchProject(parameters, context.language), + ): Promise<Project> => + _config.fake + ? Promise.resolve(fakeProject) + : fetchProject(parameters, context.language), }, Project: { - categories: (args: Project) => fetchCategories(args.id), - news: (args: Project, parameters: NewsParameters = {}) => - fetchNews(args, parameters), + 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), }, Initiative: { - url: (args: Initiative) => fetchInitiativeUrl(args), + url: (args: Initiative): Promise<string | undefined> => + _config.fake ? Promise.resolve(undefined) : fetchInitiativeUrl(args), }, }); @@ -110,9 +145,10 @@ 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 + 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 = (languages = "") => @@ -123,7 +159,7 @@ const getLanguage = (languages = "") => DEFAULT_LANGUAGE; export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { - const plugins = config.cacheEnabled || DEFAULT_CACHE_ENABLED + const plugins = config.cacheEnabled ? [ useResponseCache({ // global cache per language, shared by all users @@ -132,10 +168,8 @@ export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { request.headers.get("accept-language") || DEFAULT_LANGUAGE, ), ttlPerSchemaCoordinate: { - "Query.projects": config.cacheTtlMsBetterplace || - DEFAULT_CACHE_TTL_MS_BETTERPLACE, - "Query.project": config.cacheTtlMsBetterplace || - DEFAULT_CACHE_TTL_MS_BETTERPLACE, + "Query.projects": config.cacheTtlMsBetterplace, + "Query.project": config.cacheTtlMsBetterplace, }, }), ] @@ -174,13 +208,18 @@ type GraphQLContext = { export const startServer = (config: ServerConfig): Promise<void> => { const graphQLServer: GraphQLServer = createGraphQLServer(config); return serve(graphQLServer.handleRequest, { - port: config.port || DEFAULT_PORT, + port: config.port, onListen({ port, hostname }) { logger.info( `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`, + ); + } }, }); };