diff --git a/package.json b/package.json index 2803f5a..dafc519 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "license": "MIT", "dependencies": { - "@helsingborg-stad/gdi-api-node": "^1.0.10", + "@graphql-tools/schema": "^10.0.4", "@koa/cors": "^3.4.1", "@sendgrid/mail": "^7.7.0", "@types/pdfkit": "^0.13.4", @@ -21,13 +21,14 @@ "@types/xlsx": "^0.0.36", "JSONStream": "^1.3.5", "ajv": "^8.12.0", - "debug": "^4.3.4", + "debug": "^4.3.5", "dotenv": "^16.0.2", "email-validator": "^2.0.4", "fastq": "^1.15.0", "graphql": "^16.6.0", "graphql-tools": "^8.3.6", "handlebars": "^4.7.8", + "http-errors": "^2.0.0", "http-status-codes": "^2.2.0", "jsonwebtoken": "^9.0.0", "koa": "^2.13.4", @@ -56,6 +57,7 @@ "node": "18" }, "devDependencies": { + "@types/debug": "^4.1.12", "@types/eslint": "^8.44.1", "@types/jest": "^29.0.3", "@types/jsonwebtoken": "^8.5.9", diff --git a/src/advert-field-config/config-gql-module.ts b/src/advert-field-config/config-gql-module.ts index 972e500..fd500eb 100644 --- a/src/advert-field-config/config-gql-module.ts +++ b/src/advert-field-config/config-gql-module.ts @@ -1,9 +1,9 @@ import HttpStatusCodes from 'http-status-codes' -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import { advertFieldConfigGqlSchema } from './config.gql.schema' import type { Services } from '../types' import { normalizeRoles } from '../login' import { advertFieldConfigAdapter } from './mappers' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createAdvertFieldConfigGqlModule = ({ settings, diff --git a/src/adverts/adverts-gql-module.ts b/src/adverts/adverts-gql-module.ts index a8426e7..bd19f85 100644 --- a/src/adverts/adverts-gql-module.ts +++ b/src/adverts/adverts-gql-module.ts @@ -1,4 +1,3 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import { advertsGqlSchema } from './adverts.gql.schema' import { mapAdvertMutationResultToAdvertWithMetaMutationResult, @@ -7,6 +6,7 @@ import { } from './mappers' import { createAdvertMutations } from './advert-mutations' import type { Services } from '../types' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createAdvertsGqlModule = ( services: Pick< diff --git a/src/adverts/repository/fs/index.ts b/src/adverts/repository/fs/index.ts index a70547c..df213b4 100644 --- a/src/adverts/repository/fs/index.ts +++ b/src/adverts/repository/fs/index.ts @@ -1,8 +1,8 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import { join } from 'path' import type { AdvertsRepository } from '../../types' import { createFsAdvertsRepository } from './fs-adverts-repository' import type { StartupLog } from '../../../types' +import { getEnv } from '../../../lib/gdi-api-node' export const tryCreateFsAdvertsRepositoryFromEnv = ( startupLog: StartupLog diff --git a/src/adverts/repository/mongo/index.ts b/src/adverts/repository/mongo/index.ts index 51c9cde..76ed0df 100644 --- a/src/adverts/repository/mongo/index.ts +++ b/src/adverts/repository/mongo/index.ts @@ -1,10 +1,10 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { AdvertsRepository } from '../../types' import type { MongoAdvert } from './types' import { createMongoAdvertsRepository } from './mongo-db-adverts-repository' import { createMongoConnection } from '../../../mongodb-utils' import type { MongoConnectionOptions } from '../../../mongodb-utils/types' import type { StartupLog } from '../../../types' +import { getEnv } from '../../../lib/gdi-api-node' export const createAndConfigureMongoAdvertsRepository = ({ uri, diff --git a/src/api-keys/api-key-user-module.ts b/src/api-keys/api-key-user-module.ts index 1fd9000..f2db528 100644 --- a/src/api-keys/api-key-user-module.ts +++ b/src/api-keys/api-key-user-module.ts @@ -1,8 +1,8 @@ -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import type { IncomingHttpHeaders } from 'http' import { apiKeysAdapter } from './api-keys-adapter' import type { SettingsService } from '../settings/types' import { makeUser } from '../login' +import type { ApplicationModule } from '../lib/gdi-api-node' /** Given (Koa) headers, extract bearer token */ const getApiKeySecretFromAuthorizationHeader = ( diff --git a/src/api-keys/api-keys-gql-module.ts b/src/api-keys/api-keys-gql-module.ts index 2ad194f..fc1e75c 100644 --- a/src/api-keys/api-keys-gql-module.ts +++ b/src/api-keys/api-keys-gql-module.ts @@ -1,9 +1,9 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { apiKeysGqlSchema } from './api-keys.gql.schema' import { normalizeRoles } from '../login' import { apiKeysAdapter } from '.' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createApiKeysGqlModule = ({ settings, diff --git a/src/api-keys/request-validation.spec.ts b/src/api-keys/request-validation.spec.ts index d1d769e..07d3ee9 100644 --- a/src/api-keys/request-validation.spec.ts +++ b/src/api-keys/request-validation.spec.ts @@ -1,6 +1,5 @@ import request from 'supertest' import HttpStatusCodes from 'http-status-codes' -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import type { ApiKey } from './types' import { apiKeysAdapter } from './api-keys-adapter' import type { LoginPolicy } from '../login-policies/types' @@ -8,6 +7,7 @@ import type { Services } from '../types' import { requireHaffaUser } from '../login/require-haffa-user' import type { End2EndTestConfig, End2EndTestContext } from '../test-utils' import { end2endTest } from '../test-utils' +import type { ApplicationModule } from '../lib/gdi-api-node' describe('user access validation', () => { interface TestCase { diff --git a/src/categories/categories-gql-module.ts b/src/categories/categories-gql-module.ts index c37f398..0f5dbb7 100644 --- a/src/categories/categories-gql-module.ts +++ b/src/categories/categories-gql-module.ts @@ -1,8 +1,8 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { categoriesGqlSchema } from './categories.gql.schema' import { normalizeRoles } from '../login' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createCategoriesGqlModule = ({ adverts, diff --git a/src/content/content-gql-module.ts b/src/content/content-gql-module.ts index 6dc692a..7b34035 100644 --- a/src/content/content-gql-module.ts +++ b/src/content/content-gql-module.ts @@ -1,8 +1,8 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { contentGqlSchema } from './content.gql.schema' import { normalizeRoles } from '../login' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createContentGqlModule = ({ content, diff --git a/src/content/mongo/index.ts b/src/content/mongo/index.ts index c4f9980..526eab9 100644 --- a/src/content/mongo/index.ts +++ b/src/content/mongo/index.ts @@ -1,4 +1,4 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' +import { getEnv } from '../../lib/gdi-api-node' import type { Services, StartupLog } from '../../types' import type { ContentRepository } from '../types' import { createMongoContentConnection } from './connection' diff --git a/src/create-app.ts b/src/create-app.ts index 6fcca3c..f40d36b 100644 --- a/src/create-app.ts +++ b/src/create-app.ts @@ -1,12 +1,5 @@ import cors from '@koa/cors' import bodyparser from 'koa-bodyparser' -import type { Application } from '@helsingborg-stad/gdi-api-node' -import { - createApplication, - healthCheckModule, - jwtUserModule, - swaggerModule, -} from '@helsingborg-stad/gdi-api-node' import type { Services } from './types' import { graphQLModule } from './haffa/haffa-module' import { loginModule } from './login/login-module' @@ -18,6 +11,13 @@ import { guestUserModule } from './guest' import { snapshotModule } from './snapshot/snapshot-module' import { socialPreviewModule } from './social-preview/social-preview-module' import { advertLabelModule } from './labels/advertLabelModule' +import type { Application } from './lib/gdi-api-node' +import { + createApplication, + healthCheckModule, + jwtUserModule, + swaggerModule, +} from './lib/gdi-api-node' /** Create fully packaged web application, given dependencies */ export const createApp = ({ diff --git a/src/events/events-gql-module.ts b/src/events/events-gql-module.ts index aa11451..466015d 100644 --- a/src/events/events-gql-module.ts +++ b/src/events/events-gql-module.ts @@ -1,8 +1,8 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { normalizeRoles } from '../login' import { eventsGqlSchema } from './events.gql.schema' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createEventsGqlModule = ({ eventLog, diff --git a/src/events/mongo-event-log/index.ts b/src/events/mongo-event-log/index.ts index c7ded77..46fc08e 100644 --- a/src/events/mongo-event-log/index.ts +++ b/src/events/mongo-event-log/index.ts @@ -1,8 +1,8 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { StartupLog } from '../../types' import { createMongoEventsConnection } from './connection' import type { EventLogService } from '../types' import { createMongoEventLogService } from './mongo-event-log-service' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateMongoEventLogFromEnv = ( startupLog: StartupLog diff --git a/src/files/fs-files-service/fs-files-service.ts b/src/files/fs-files-service/fs-files-service.ts index c99f119..a62968d 100644 --- a/src/files/fs-files-service/fs-files-service.ts +++ b/src/files/fs-files-service/fs-files-service.ts @@ -2,11 +2,11 @@ import { join, relative } from 'path' import { mkdirp } from 'mkdirp' import { readFile, writeFile, unlink } from 'fs/promises' import send from 'koa-send' -import type { ApplicationContext } from '@helsingborg-stad/gdi-api-node' import ms from 'ms' import type { FilesService } from '../types' import { generateFileId, tryConvertDataUriToImageBuffer } from '../utils' import { tryConvertUrlToDataUrlForLocalUrlsHelper } from '../utils/image-utils' +import type { ApplicationContext } from '../../lib/gdi-api-node' // max-age in ms header for transmitted files const SEND_MAX_AGE = ms('30 days') diff --git a/src/files/fs-files-service/index.ts b/src/files/fs-files-service/index.ts index d909a8f..9e17cb5 100644 --- a/src/files/fs-files-service/index.ts +++ b/src/files/fs-files-service/index.ts @@ -1,8 +1,8 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import { join } from 'path' import type { FilesService } from '../types' import { createFsFilesService } from './fs-files-service' import type { StartupLog } from '../../types' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateFsFilesServiceFromEnv = ( startupLog: StartupLog diff --git a/src/files/minio-files-service/index.ts b/src/files/minio-files-service/index.ts index bf47cee..a98516a 100644 --- a/src/files/minio-files-service/index.ts +++ b/src/files/minio-files-service/index.ts @@ -1,7 +1,7 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { FilesService } from '../types' import { createMinioFilesService } from './minio-files-service' import type { StartupLog } from '../../types' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateMinioFilesServiceFromEnv = ( startupLog: StartupLog diff --git a/src/files/minio-files-service/minio-files-service.ts b/src/files/minio-files-service/minio-files-service.ts index 04ec72f..0799eb2 100644 --- a/src/files/minio-files-service/minio-files-service.ts +++ b/src/files/minio-files-service/minio-files-service.ts @@ -1,10 +1,10 @@ import { Client } from 'minio' -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import mime from 'mime-types' import ms from 'ms' import type { FilesService } from '../types' import { generateFileId, tryConvertDataUriToImageBuffer } from '../utils' import { tryConvertUrlToDataUrlForLocalUrlsHelper } from '../utils/image-utils' +import type { ApplicationModule } from '../../lib/gdi-api-node' const SEND_MAX_AGE = ms('30 days') interface MinioConfig { diff --git a/src/files/types.ts b/src/files/types.ts index 09bcd9d..e9016b1 100644 --- a/src/files/types.ts +++ b/src/files/types.ts @@ -1,4 +1,4 @@ -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' +import type { ApplicationModule } from '../lib/gdi-api-node' export interface FilesService { tryConvertDataUrlToUrl: (dataUrl: string) => Promise diff --git a/src/git-revision-module.ts b/src/git-revision-module.ts index 0226376..dd1e0c9 100644 --- a/src/git-revision-module.ts +++ b/src/git-revision-module.ts @@ -1,6 +1,6 @@ -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import { readFile } from 'fs/promises' import { join } from 'path' +import type { ApplicationModule } from './lib/gdi-api-node' // Small module that exposes contents of 'git_revision.txt' to a response header export const gitRevisionModule = (): ApplicationModule => { diff --git a/src/guest/index.ts b/src/guest/index.ts index 475be2b..551e4c7 100644 --- a/src/guest/index.ts +++ b/src/guest/index.ts @@ -1,4 +1,4 @@ -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' +import type { ApplicationModule } from '../lib/gdi-api-node' import type { UserMapper } from '../users/types' export const guestUserModule = diff --git a/src/haffa/haffa-gql-module.ts b/src/haffa/haffa-gql-module.ts index 54c8a58..ac73cea 100644 --- a/src/haffa/haffa-gql-module.ts +++ b/src/haffa/haffa-gql-module.ts @@ -1,5 +1,3 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' -import type { EntityResolverMap } from '@helsingborg-stad/gdi-api-node/graphql' import type { Services } from '../types' import { createAdvertsGqlModule } from '../adverts/adverts-gql-module' import { createProfileGqlModule } from '../profile/profile-gql-module' @@ -19,6 +17,8 @@ import { createLocationsGqlModule } from '../locations/locations-gql-module' import { createUserMapperGqlModule } from '../users' import { createSmsTemplatesGqlModule } from '../notifications/templates/sms-templates/sms-templates-gql-module' import { createSyslogGqlModule } from '../syslog/syslog-gql-module' +import type { GraphQLModule } from '../lib/gdi-api-node' +import type { EntityResolverMap } from '../lib/gdi-api-node/graphql' export const createStandardGqlModule = (): GraphQLModule => ({ schema: haffaGqlSchema, diff --git a/src/haffa/haffa-module.ts b/src/haffa/haffa-module.ts index e27bd27..f88ffdb 100644 --- a/src/haffa/haffa-module.ts +++ b/src/haffa/haffa-module.ts @@ -1,11 +1,8 @@ -import type { ApplicationContext } from '@helsingborg-stad/gdi-api-node' -import { - makeGqlEndpoint, - makeGqlMiddleware, -} from '@helsingborg-stad/gdi-api-node' import { requireHaffaUser } from '../login/require-haffa-user' import type { Services } from '../types' import { createHaffaGqlModule } from './haffa-gql-module' +import type { ApplicationContext } from '../lib/gdi-api-node' +import { makeGqlEndpoint, makeGqlMiddleware } from '../lib/gdi-api-node' export const graphQLModule = (services: Services) => diff --git a/src/jobs/index.ts b/src/jobs/index.ts index fa25189..7e520c1 100644 --- a/src/jobs/index.ts +++ b/src/jobs/index.ts @@ -1,4 +1,3 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { JobExcecutorService, JobParameters, @@ -8,6 +7,7 @@ import type { import { tasks } from './tasks' import type { SyslogEntry, SyslogUserData } from '../syslog/types' import { Severity } from '../syslog/types' +import { getEnv } from '../lib/gdi-api-node' export const createJobExecutorService = ( taskRepository: TaskList, diff --git a/src/jobs/jobs-gql-module.ts b/src/jobs/jobs-gql-module.ts index 34c4aca..56c2340 100644 --- a/src/jobs/jobs-gql-module.ts +++ b/src/jobs/jobs-gql-module.ts @@ -1,8 +1,8 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { jobsGqlSchema } from './jobs.gql.schema' import { normalizeRoles } from '../login' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createJobsGqlModule = (services: Services): GraphQLModule => ({ schema: jobsGqlSchema, diff --git a/src/labels/advertLabelModule.ts b/src/labels/advertLabelModule.ts index 9188800..cc64277 100644 --- a/src/labels/advertLabelModule.ts +++ b/src/labels/advertLabelModule.ts @@ -1,5 +1,4 @@ import HttpStatusCodes from 'http-status-codes' -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import PDFDocument from 'pdfkit' import QRCode from 'qrcode' import type { Services } from '../types' @@ -8,6 +7,7 @@ import type { Advert } from '../adverts/types' import { optionsAdapter } from '../options' import { createLabelFooter, transformLabelOptions } from './mappers' import { requireHaffaUser } from '../login/require-haffa-user' +import type { ApplicationModule } from '../lib/gdi-api-node' export const advertLabelModule = ({ adverts, settings, userMapper }: Services): ApplicationModule => diff --git a/src/lib/gdi-api-node/__tests/404-not-found.test.ts b/src/lib/gdi-api-node/__tests/404-not-found.test.ts new file mode 100644 index 0000000..e310d2a --- /dev/null +++ b/src/lib/gdi-api-node/__tests/404-not-found.test.ts @@ -0,0 +1,13 @@ +import { StatusCodes } from 'http-status-codes' +import request from 'supertest' +import { createTestApp } from './test-utils' + + +describe('GET /missing/resource', () => { + it('gives 404 not found', () => createTestApp() + .run(async server => { + const { status } = await request(server) + .post('/missing/resource') + expect(status).toBe(StatusCodes.NOT_FOUND) + })) +}) \ No newline at end of file diff --git a/src/lib/gdi-api-node/__tests/api-operation.test.ts b/src/lib/gdi-api-node/__tests/api-operation.test.ts new file mode 100644 index 0000000..afe5b07 --- /dev/null +++ b/src/lib/gdi-api-node/__tests/api-operation.test.ts @@ -0,0 +1,26 @@ +import { StatusCodes } from 'http-status-codes' +import request from 'supertest' +import { createTestApp, registerTestApi } from './test-utils' + +describe('POST /api/v1/test-operation', () => { + it('works just fine', () => createTestApp() + .use(registerTestApi({ + testOperation: async (ctx) => { + const { request: { body: { query } } } = ctx as any + ctx.body = { + id: '1234', + answer: `Please google "${query}"`, + } + }, + })) + .run(async server => { + const { status, body } = await request(server) + .post('/api/v1/test-operation') + .send({ query: 'what time is it?' }) + expect(status).toBe(StatusCodes.OK) + expect(body).toMatchObject({ + id: '1234', + answer: 'Please google "what time is it?"', + }) + })) +}) \ No newline at end of file diff --git a/src/lib/gdi-api-node/__tests/extend.test.ts b/src/lib/gdi-api-node/__tests/extend.test.ts new file mode 100644 index 0000000..1a4f431 --- /dev/null +++ b/src/lib/gdi-api-node/__tests/extend.test.ts @@ -0,0 +1,34 @@ +import { StatusCodes } from 'http-status-codes' +import request from 'supertest' +import { createTestApp } from './test-utils' + +describe('POST /api/v1/test-operation', () => { + it('can be extended with downstream middleware', () => createTestApp() + .use(({ extend }) => { + extend({ + // we extend by composing in additional middleware that should be combined + // with subsequent koa api handlers + compose: mv => (ctx, next) => { + ctx.downstreamMessage = 'hello downstream' + return mv(ctx, next) + }, + }) + }) + .use(({ registerKoaApi }) => registerKoaApi({ + testOperation: async (ctx) => { + ctx.body = { + id: '1234', + answer: ctx.downstreamMessage, + } + }, + })) + .run(async server => { + const { status, body } = await request(server) + .post('/api/v1/test-operation') + .send({ query: '?' }) + expect(status).toBe(StatusCodes.OK) + expect(body).toMatchObject({ + answer: 'hello downstream', + }) + })) +}) \ No newline at end of file diff --git a/src/lib/gdi-api-node/__tests/get-env.test.ts b/src/lib/gdi-api-node/__tests/get-env.test.ts new file mode 100644 index 0000000..ed852c0 --- /dev/null +++ b/src/lib/gdi-api-node/__tests/get-env.test.ts @@ -0,0 +1,28 @@ +import { getEnv } from '../config' + +describe('get-env', () => { + it('defaults to trimmed process.ENV', () => { + process.env.TEST_VARIABLE = ' a value ' + expect(getEnv('TEST_VARIABLE')).toEqual('a value') + }) + it('can have default value specified', () => { + expect(getEnv('TEST_VARIABLE_THAT_DOES_NOT_EXIST', { fallback: 'a default value' })).toEqual('a default value') + }) + + it('throws on missing key if no fallback is specified', () => { + expect(() => getEnv('TEST_VARIABLE_THAT_DOES_NOT_EXIST')).toThrow(Error) + }) + + it('throws on validation', () => { + expect(() => getEnv('PATH', { validate: () => false })).toThrow(Error) + }) + + it('throws when validation throws', () => { + expect(() => getEnv('PATH', { validate: () => {throw new Error()} })).toThrow(Error) + }) + + it('returns validated values only', () => { + expect(getEnv('PATH', { fallback: 'a', validate: v => v === 'a' })).toBe('a') + }) + +}) \ No newline at end of file diff --git a/src/lib/gdi-api-node/__tests/response-validation-tests.test.ts b/src/lib/gdi-api-node/__tests/response-validation-tests.test.ts new file mode 100644 index 0000000..19a016b --- /dev/null +++ b/src/lib/gdi-api-node/__tests/response-validation-tests.test.ts @@ -0,0 +1,51 @@ +import { StatusCodes } from 'http-status-codes' +import request from 'supertest' +import { createTestApp, registerTestApi } from './test-utils' + +describe('POST /api/v1/test-operation', () => { + it('validates parameters', () => createTestApp() + .run(async server => { + const { status } = await request(server) + .post('/api/v1/test-operation') + .send({ this_body_is_missing_required_property_query: true }) + + // NOTE: validation occurs albeit we didn't + // define a handler for the actual API operation + expect(status).toBe(StatusCodes.BAD_REQUEST) + })) + it('can validate responses when {validateResponses: true}', () => createTestApp() + .use(registerTestApi({ + testOperation: async (ctx) => { + ctx.body = { this_response_is_missing_required_id_property: true } + }, + })) + .run(async server => { + const { status } = await request(server) + .post('/api/v1/test-operation') + .send({ query: 'a test query' }) + + // NOTE: validation occurs albeit we didn't + // define a handler for teh actual API operation + expect(status).toBe(StatusCodes.BAD_GATEWAY) + })) + it('works just fine', () => createTestApp() + .use(registerTestApi({ + testOperation: async (ctx) => { + const { request: { body: { query } } } = ctx as any + ctx.body = { + id: '1234', + answer: `Please google "${query}"`, + } + }, + })) + .run(async server => { + const { status, body } = await request(server) + .post('/api/v1/test-operation') + .send({ query: 'what time is it?' }) + expect(status).toBe(StatusCodes.OK) + expect(body).toMatchObject({ + id: '1234', + answer: 'Please google "what time is it?"', + }) + })) +}) \ No newline at end of file diff --git a/src/lib/gdi-api-node/__tests/test-app.openapi.yml b/src/lib/gdi-api-node/__tests/test-app.openapi.yml new file mode 100644 index 0000000..ae4e018 --- /dev/null +++ b/src/lib/gdi-api-node/__tests/test-app.openapi.yml @@ -0,0 +1,66 @@ +openapi: 3.0.0 +info: + title: Test App + description: GraphQL API for unit tests + version: 1.0.0 +paths: + /api/v1/test-operation: + post: + operationId: testOperation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestParams" + responses: + "200": + description: "successful operation" + content: + application/json: + schema: + $ref: "#/components/schemas/TestResponse" + /api/v1/{namespace}/healthcheck: + get: + operationId: healthCheck + summary: "Healthcheck of service" + tags: + - Status of Operations + parameters: + - name: namespace + in: path + required: true + schema: + type: string + responses: + 200: + description: "Service is healthy" + content: + application/json: + schema: + type: object + properties: + status: + type: + string + +components: + schemas: + TestParams: + type: object + required: + - query + properties: + query: + type: string + parameters: + type: object + TestResponse: + type: object + required: + - id + properties: + id: + type: string + answer: + type: string \ No newline at end of file diff --git a/src/lib/gdi-api-node/__tests/test-utils.ts b/src/lib/gdi-api-node/__tests/test-utils.ts new file mode 100644 index 0000000..247b58b --- /dev/null +++ b/src/lib/gdi-api-node/__tests/test-utils.ts @@ -0,0 +1,39 @@ +import * as path from 'path' +import type * as Koa from 'koa' +import { createApplication } from '../application' +import { swaggerModule } from '../modules/swagger' +import { webFrameworkModule } from '../modules/web-framework' +import type { + Application, + ApplicationContext, + ApplicationModule, +} from '../application/types' + +const noop = () => undefined + +/** register a handler for (Koa captured) errors to prevenmt default console.error logging */ +const silentErrorsModule = + (): ApplicationModule => + ({ app }) => + app.on('error', noop) + +export const getOpenApiDefinitionPathForTest = () => + path.join(__dirname, './test-app.openapi.yml') + +/** create full baked application configured for test */ +const createTestApp = (): Application => + createApplication({ + openApiDefinitionPath: getOpenApiDefinitionPathForTest(), + validateResponse: true, + }) + .use(silentErrorsModule()) + .use(webFrameworkModule()) + .use(swaggerModule()) + +/** Shorthand module for registering API operations in application */ +const registerTestApi = + (handlers: Record): ApplicationModule => + ({ registerKoaApi }: ApplicationContext) => + registerKoaApi(handlers) + +export { createTestApp, registerTestApi } diff --git a/src/lib/gdi-api-node/application/api-not-found-module.ts b/src/lib/gdi-api-node/application/api-not-found-module.ts new file mode 100644 index 0000000..5a88bcd --- /dev/null +++ b/src/lib/gdi-api-node/application/api-not-found-module.ts @@ -0,0 +1,8 @@ +import { ApplicationContext } from './types' + +export const apiNotFoundModule = () => ({ api }: ApplicationContext): void => + // setup reasonable defaults + api.register({ + // https://github.com/anttiviljami/openapi-backend#quick-start + notFound: (c, ctx) => ctx.throw(404), + }) diff --git a/src/lib/gdi-api-node/application/api-validation-module.ts b/src/lib/gdi-api-node/application/api-validation-module.ts new file mode 100644 index 0000000..6f1eb9c --- /dev/null +++ b/src/lib/gdi-api-node/application/api-validation-module.ts @@ -0,0 +1,56 @@ +import type Koa from 'koa' +import type { Context } from 'openapi-backend' +import Debug from 'debug' +import type { ApplicationContext } from './types' + +const debug = Debug('application') + +const performResponseValidation = (c: Context, ctx: Koa.Context) => { + /** + * Perform response validation + * + * Typically, this is done in tests only + * + * To make life simpler, we only validate 2xx results + * Also, header validation is probably not needed + */ + const { status } = ctx + if (!(status >= 200 && status < 300)) { + return + } + // https://github.com/anttiviljami/openapi-backend#response-validation + ;[ + c.api.validateResponse(ctx.body, c.operation), + /* + c.api.validateResponseHeaders(ctx.headers, c.operation, { + statusCode: ctx.status, + setMatchType: SetMatchType.Superset, + }), + */ + ] + .map(({ errors }) => errors) + .filter(errors => errors && errors.length) + .forEach(errors => { + debug({ + body: ctx.body, + operation: c.operation, + errors, + }) + ctx.throw(502) + }) +} + +export const apiValidationModule = + (validateResponse: boolean | undefined) => + ({ api }: ApplicationContext): void => + api.register({ + validationFail: (c, ctx) => { + ctx.body = { errors: c.validation.errors } + ctx.status = 400 + }, + postResponseHandler: async (c, ctx) => { + // turn off all caching of all API responses + ctx.set('Cache-Control', 'no-store') + return validateResponse && performResponseValidation(c, ctx) + }, + }) diff --git a/src/lib/gdi-api-node/application/application.ts b/src/lib/gdi-api-node/application/application.ts new file mode 100644 index 0000000..e9e4a12 --- /dev/null +++ b/src/lib/gdi-api-node/application/application.ts @@ -0,0 +1,187 @@ +import Debug from 'debug' +import Koa from 'koa' +import Router from 'koa-router' +import type { Handler, Request as OpenApiRequest } from 'openapi-backend' +import OpenAPIBackend from 'openapi-backend' + +import type { + Application, + ApplicationContext, + ApplicationExtension, + ApplicationModule, + ApplicationRunHandler, +} from './types' +import { mapValues } from '../util' +import { apiValidationModule } from './api-validation-module' +import { apiNotFoundModule } from './api-not-found-module' + +const debug = Debug('application') + +const TEST_PORT = 4444 + +interface CreateApplicationArgs { + openApiDefinitionPath: string + validateResponse?: boolean +} + +// simple memoization - at most one evaluation, evalauation as late as possible +const ensureOnce = (fn: () => Promise): (() => Promise) => { + let value: Promise | null = null + return async () => { + if (!value) { + value = fn() + } + return value + } +} + +const mapKoaRequestToApiRequest = ({ + method, + path, + headers, + query, + body, +}: Koa.Request): OpenApiRequest => + ({ + method, + path, + headers, + query, + body, + } as OpenApiRequest) + +const compineApplicationExtensions = ( + prev: ApplicationExtension, + next: Partial +): ApplicationExtension => ({ + compose: mv => + next.compose ? next.compose(prev.compose(mv)) : prev.compose(mv), + mapApi: api => + next.mapApi + ? prev.mapApi(api).then(mapped => next.mapApi!(mapped)) + : prev.mapApi(api), +}) + +/** + * ### createWebApplication + * create web application by wrapping with reasonable defaults + * - Koa web application + * - routing + * - openapi + * - with default request validation + * - with optional response validation + */ +export function createApplication({ + openApiDefinitionPath, + validateResponse, +}: CreateApplicationArgs): Application { + // create app + const app = new Koa() + // create API backend + const api = new OpenAPIBackend({ definition: openApiDefinitionPath }) + // create routes + const router = new Router() + + // register modules via .use(...) + const modules: ApplicationModule[] = [] + + // forward declation of this + let application: Application = null as unknown as Application + let koaApi: Record = {} + let applicationExtension: ApplicationExtension = { + compose: mv => mv, + mapApi: async _ => _, + } + + const mapKoaMiddlewareToHandler = + (middleware: Koa.Middleware): Handler => + (c, ctx, next) => { + // we announce the api context to handlers + ctx.apiContext = c + // we copy params manually to be compatible with + // libraries such as https://github.com/koajs/router/blob/master/API.md#url-parameters + // In short, openapi path parameters are parsed and made visible in koa context + ctx.params = c.request.params + return middleware(ctx, next) + } + + const getContext = (): ApplicationContext => ({ + app, + router, + api, + application, + registerKoaApi: handlers => { + koaApi = { ...koaApi, ...handlers } + }, + extend: extension => { + applicationExtension = compineApplicationExtensions( + applicationExtension, + extension + ) + }, + }) + + const init = ensureOnce(async () => { + // initialize all modules + await modules.reduce( + (prev, m) => prev.then(() => m(getContext())), + Promise.resolve() + ) + // build/compose and register all koa apis + const apis = mapValues( + mapValues(koaApi, mv => applicationExtension.compose(mv)), + mapKoaMiddlewareToHandler + ) + + const mappedApis = await applicationExtension.mapApi(apis) + api.register(mappedApis) + + // finalize api + await api.init() + return ( + app + // wire all custom routes + .use(router.routes()) + .use(router.allowedMethods()) + // wire in API endpoints + .use((ctx, next) => + api.handleRequest(mapKoaRequestToApiRequest(ctx.request), ctx, next) + ) + ) + }) + + const use = (module: ApplicationModule): Application => { + // we store the module but defer initialization until last responsible moment + // this intruduces additional complexity but allows for use of async modules while still + // having a fluent api as in .use(...).use(...) + modules.push(module) + return application + } + + const start = async (port: number | string) => + (await init()).listen(port, () => debug(`Server listening to port ${port}`)) + + const run = async ( + handler: ApplicationRunHandler, + port: number = TEST_PORT + ) => { + const server = await start(port) + try { + await handler(server) + } finally { + await new Promise((resolve, reject) => + server.close(err => (err ? reject(err) : resolve(null))) + ) + } + } + + application = { + getContext, + use, + start, + run, + } + return application + .use(apiValidationModule(validateResponse)) + .use(apiNotFoundModule()) +} diff --git a/src/lib/gdi-api-node/application/index.ts b/src/lib/gdi-api-node/application/index.ts new file mode 100644 index 0000000..1ade09d --- /dev/null +++ b/src/lib/gdi-api-node/application/index.ts @@ -0,0 +1,3 @@ +import { createApplication } from './application' + +export { createApplication } \ No newline at end of file diff --git a/src/lib/gdi-api-node/application/types.ts b/src/lib/gdi-api-node/application/types.ts new file mode 100644 index 0000000..2d53d20 --- /dev/null +++ b/src/lib/gdi-api-node/application/types.ts @@ -0,0 +1,33 @@ +import type { Handler } from 'openapi-backend' +import type OpenAPIBackend from 'openapi-backend' +import type Koa from 'koa' +import type Router from 'koa-router' +import type { Server } from 'node:http' + +// TODO: Document this +export interface ApplicationContext { + app: Koa + api: OpenAPIBackend + router: Router + application: Application + extend: (extension: Partial) => void + registerKoaApi: (handlers: Record) => void +} + +export type ApplicationModule = ( + context: ApplicationContext +) => any | Promise + +export type ApplicationRunHandler = (server: Server) => Promise + +export interface ApplicationExtension { + compose: (m: Koa.Middleware) => Koa.Middleware + mapApi: (api: Record) => Promise> +} + +export interface Application { + getContext(): ApplicationContext + use(module: ApplicationModule): Application + start(port: number | string): Promise + run(handler: ApplicationRunHandler, port?: number): Promise +} diff --git a/src/lib/gdi-api-node/config/get-env.ts b/src/lib/gdi-api-node/config/get-env.ts new file mode 100644 index 0000000..b08bf98 --- /dev/null +++ b/src/lib/gdi-api-node/config/get-env.ts @@ -0,0 +1,38 @@ +interface GetEnvArgs { + trim?: boolean + validate?: (v: string) => boolean + fallback?: string +} + +const notFound = (key: string) => (): string => { + throw Error( + `Failed to read and validate environment ${key}="${process.env[key]}"` + ) +} + +const tryValidate = ( + key: string, + value: string, + validate: (v: string) => boolean +) => { + try { + return validate(value) + } catch { + throw new Error( + `Failed to read and validate environment ${key}="${process.env[key]}"` + ) + } +} + +/** get named evironment variable with options for trimming, validation and default value */ +export const getEnv = ( + key: string, + { trim, validate, fallback }: GetEnvArgs = { trim: true } +): string => + [process.env[key], fallback] + .filter(v => typeof v === 'string') + .map(v => v!) + .map(v => (trim ? v.trim() : v)) + .filter(v => tryValidate(key, v, validate || (() => true))) + .map(value => () => value) + .concat(notFound(key))[0]() diff --git a/src/lib/gdi-api-node/config/index.ts b/src/lib/gdi-api-node/config/index.ts new file mode 100644 index 0000000..9852c2c --- /dev/null +++ b/src/lib/gdi-api-node/config/index.ts @@ -0,0 +1,3 @@ +import { getEnv } from './get-env' + +export { getEnv } \ No newline at end of file diff --git a/src/lib/gdi-api-node/graphql/README.md b/src/lib/gdi-api-node/graphql/README.md new file mode 100644 index 0000000..a092c38 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/README.md @@ -0,0 +1,3 @@ +# GraphQL Module for gdi api + +Allows for creation of gql endpoints for an api diff --git a/src/lib/gdi-api-node/graphql/__tests/gql.test.ts b/src/lib/gdi-api-node/graphql/__tests/gql.test.ts new file mode 100644 index 0000000..43cb851 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/__tests/gql.test.ts @@ -0,0 +1,111 @@ +import jwt from 'jsonwebtoken' +import request from 'supertest' +import { createTestApp } from './test-app' +import { StatusCodes } from 'http-status-codes' + +const TEST_SHARED_SECRET = 'shared secret for test' + +const createAuthorizationHeadersFor = (id: string, secret: string = TEST_SHARED_SECRET) => ({ + authorization: `Bearer ${jwt.sign({ id }, secret)}`, +}) + +describe('GraphQL', () => { + it('POST /api/v1/test/graphql handles combinations of sync/async/promise resolvers', () => createTestApp(TEST_SHARED_SECRET) + .run(async server => { + const { status, body: { data, errors } } = await request(server) + .post('/api/v1/test/graphql') + .set(createAuthorizationHeadersFor('abc-123')) + .send({ + variables: {}, + query: ` + query ASP { + combinationsOfSynAsyncPromise {syncId, asyncId, promiseId, asyncPromiseId} + } + `, + }) + + expect(status).toBe(StatusCodes.OK) + expect(errors).toBeFalsy() + expect(data).toMatchObject({ + combinationsOfSynAsyncPromise: { + syncId: 'abc-123', + asyncId: 'abc-123', + promiseId: 'abc-123', + asyncPromiseId: 'abc-123', + }, + }) + })) + + it('POST /api/v1/test/graphql validates parameters ({query, variables} = body)', () => createTestApp(TEST_SHARED_SECRET) + .run(async server => { + const { status, body: { data } } = await request(server) + .post('/api/v1/test/graphql') + .set(createAuthorizationHeadersFor('test-person-id-123')) + .send({ + query: ` + query TestQuery { + testData { + idFromToken + } + }`, + variables: {}, + }) + expect(status).toBe(StatusCodes.OK) + expect(data).toMatchObject({ + testData: { + idFromToken: 'test-person-id-123', + }, + }) + })) + + it('POST /api/v1/test/graphql validates parameters ({query, variables} = body)', () => createTestApp(TEST_SHARED_SECRET) + .run(async server => { + const { status } = await request(server) + .post('/api/v1/test/graphql') + .set(createAuthorizationHeadersFor('test-person-id-123')) + .send({ 'the-body-is': 'missing query and variables' }) + + expect(status).toBe(StatusCodes.BAD_REQUEST) + })) + + it('POST /api/v1/test/graphql requires valid authorization header', () => createTestApp(TEST_SHARED_SECRET) + .run(async server => { + const { status } = await request(server) + .post('/api/v1/test/graphql') + + .send({ query: 'a', variables: {} }) + + expect(status).toBe(StatusCodes.UNAUTHORIZED) + })) + + it('POST /api/v1/test/graphql can utilize cache in implementation', () => createTestApp(TEST_SHARED_SECRET) + .run(async server => { + const { status, body: { data } } = await request(server) + .post('/api/v1/test/graphql') + .set(createAuthorizationHeadersFor('test-person-id-123')) + .send({ + query: ` + query TestQuery($n: Int!, $idValue: String!) { + cachedComputedEntries(n: $n, idValue: $idValue) { + id + } + }`, + variables: { n: 3, idValue: 'testid' }, + }) + + expect(status).toBe(StatusCodes.OK) + expect(data).toMatchObject({ + cachedComputedEntries: [ { + id: 'testid', + },{ + id: 'testid', + },{ + id: 'testid', + } ], + }) + })) + +}) + + + \ No newline at end of file diff --git a/src/lib/gdi-api-node/graphql/__tests/make-gql-endpoint.test.ts b/src/lib/gdi-api-node/graphql/__tests/make-gql-endpoint.test.ts new file mode 100644 index 0000000..bb01d9b --- /dev/null +++ b/src/lib/gdi-api-node/graphql/__tests/make-gql-endpoint.test.ts @@ -0,0 +1,50 @@ +import { makeGqlEndpoint } from '../make-gql-endpoint' +import { GraphQLModule } from '../types' + +const TestModule: GraphQLModule = { + schema: ` + type TestData { + id: String, + } + type Query { + dataSync: TestData, + dataAsync: TestData, + dataPromise: TestData, + dataAsyncPromise: TestData + } + `, + resolvers: { + Query: { + dataSync: ({ ctx: { user: { id } } }) => ({ id }), + dataAsync: async ({ ctx: { user: { id } } }) => ({ id }), + dataPromise: ({ ctx: { user: { id } } }) => Promise.resolve({ id }), + dataAsyncPromise: ({ ctx: { user: { id } } }) => Promise.resolve({ id }), + }, + }, +} +describe('makeGqlEndpoint', () => { + it('can handle compination of sync/async/promise resolvers', async () => { + const ep = makeGqlEndpoint(TestModule) + const { data } = await ep({ + context: { + user: { + id: 'test-id-123', + }, + }, + query: ` + query TestQuery { + dataSync {id} + dataAsync {id} + dataPromise {id} + dataAsyncPromise {id} + }`, + variables: {}, + }) + expect(data).toMatchObject({ + dataSync: { id: 'test-id-123' }, + dataAsync: { id: 'test-id-123' }, + dataPromise: { id: 'test-id-123' }, + dataAsyncPromise: { id: 'test-id-123' }, + }) + }) +}) \ No newline at end of file diff --git a/src/lib/gdi-api-node/graphql/__tests/make-gql-middleware.test.ts b/src/lib/gdi-api-node/graphql/__tests/make-gql-middleware.test.ts new file mode 100644 index 0000000..b683ec6 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/__tests/make-gql-middleware.test.ts @@ -0,0 +1,32 @@ +import { makeGqlMiddleware } from '../make-gql-middleware' + +describe('makeGqlMiddleware', () => { + it('parses {query, parameters}, executes endpoint and returns result as json', async () => { + const query = 'query MyQuery {test}' + const variables = { a: 1, b:'two' } + const mv = makeGqlMiddleware(async ({ context, model, query, variables }) => ({ + 'I, who is a endpoint, got this stuff': { + query, variables, + }, + })) + + // create a fake context which can be mutated (since thats the Koa way) + const ctx = { + request: { + body: { + query, + variables, + }, + }, + body: null, + } + await mv(ctx as any) + expect(ctx).toMatchObject({ + body: { + 'I, who is a endpoint, got this stuff': { + query, variables, + }, + }, + }) + }) +}) \ No newline at end of file diff --git a/src/lib/gdi-api-node/graphql/__tests/test-app/index.ts b/src/lib/gdi-api-node/graphql/__tests/test-app/index.ts new file mode 100644 index 0000000..a201083 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/__tests/test-app/index.ts @@ -0,0 +1,20 @@ +import path from 'path' +import { createApplication } from '../../../application' +import { jwtUserModule } from '../../../modules/jwt-user' +import { swaggerModule } from '../../../modules/swagger' +import { webFrameworkModule } from '../../../modules/web-framework' +import { createAuthorizationService } from '../../../services/authorization-service' +import { Application } from '../../../application/types' +import testGqlModule from './test-gql-module' + +const createTestApp = (sharedSecret: string): Application => createApplication({ + openApiDefinitionPath: path.join(__dirname, './test-app.openapi.yml'), + validateResponse: true, +}) + .use(webFrameworkModule()) + .use(swaggerModule()) + .use(jwtUserModule(createAuthorizationService(sharedSecret))) + .use(testGqlModule()) + + +export { createTestApp } \ No newline at end of file diff --git a/src/lib/gdi-api-node/graphql/__tests/test-app/test-app.openapi.yml b/src/lib/gdi-api-node/graphql/__tests/test-app/test-app.openapi.yml new file mode 100644 index 0000000..2d355f4 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/__tests/test-app/test-app.openapi.yml @@ -0,0 +1,38 @@ +openapi: 3.0.0 +info: + title: Test App + description: GraphQL API for unit tests + version: 1.0.0 +paths: + /api/v1/test/graphql: + post: + operationId: testGql + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GraphQLQuery" + responses: + "200": + description: "successful operation" + content: + application/json: + schema: + $ref: "#/components/schemas/GraphQLResponse" +components: + schemas: + GraphQLQuery: + type: object + required: + - query + properties: + query: + type: string + parameters: + type: object + GraphQLResponse: + type: object + properties: + data: + type: object \ No newline at end of file diff --git a/src/lib/gdi-api-node/graphql/__tests/test-app/test-gql-module.ts b/src/lib/gdi-api-node/graphql/__tests/test-app/test-gql-module.ts new file mode 100644 index 0000000..4c0d157 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/__tests/test-app/test-gql-module.ts @@ -0,0 +1,62 @@ +import { requireJwtUser } from '../../../modules/jwt-user' +import { ApplicationContext, ApplicationModule } from '../../../application/types' +import { makeGqlEndpoint } from '../../make-gql-endpoint' +import { makeGqlMiddleware } from '../../make-gql-middleware' +import { GraphQLModule } from '../../types' + +const createTestGqlModule = (): GraphQLModule => ({ + schema: ` + type TestData { + idFromToken: String, + } + type SyncAsyncPromise { + syncId: String, + asyncId: String, + promiseId: String, + asyncPromiseId: String + } + type CacheComputedEntry { + id: String + } + type Query { + testData: TestData, + combinationsOfSynAsyncPromise: SyncAsyncPromise + cachedComputedEntries(n: Int!, idValue: String!): [CacheComputedEntry] + } + `, + // https://www.graphql-tools.com/docs/resolvers + resolvers: { + SyncAsyncPromise: { + syncId: ({ ctx: { user: { id } } }) => { + return id + }, + asyncId: async ({ ctx: { user: { id } } }) => id, + promiseId: ({ ctx: { user: { id } } }) => Promise.resolve(id), + asyncPromiseId: async ({ ctx: { user: { id } } }) => Promise.resolve(id), + }, + CacheComputedEntry: { + id: ({ cache }) => { + return cache.getOrCreateCachedValue('id', () => 'missing') + }, + }, + Query: { + testData: ({ ctx: { user: { id } } }) => ({ + idFromToken: id, + }), + combinationsOfSynAsyncPromise: () => { + return ({ dummy_object: true }) + }, + // return a sequnce of integers that the are mapped to computed values + cachedComputedEntries: ({ cache, args: { n, idValue } }) => { + cache.getOrCreateCachedValue('id', () => idValue) + return [...new Array(n)].map(() => ({})) + }, + }, + }, +}) + +const testGqlModule = (): ApplicationModule => ({ registerKoaApi }: ApplicationContext) => registerKoaApi({ + testGql: requireJwtUser(makeGqlMiddleware(makeGqlEndpoint(createTestGqlModule()))), +}) + +export default testGqlModule diff --git a/src/lib/gdi-api-node/graphql/index.ts b/src/lib/gdi-api-node/graphql/index.ts new file mode 100644 index 0000000..b445fc3 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/index.ts @@ -0,0 +1,11 @@ +// #Does stuff + +import { makeGqlEndpoint } from './make-gql-endpoint' +import { makeGqlMiddleware } from './make-gql-middleware' + +export { + makeGqlEndpoint, + makeGqlMiddleware, +} + +export * from './types' \ No newline at end of file diff --git a/src/lib/gdi-api-node/graphql/make-gql-endpoint.ts b/src/lib/gdi-api-node/graphql/make-gql-endpoint.ts new file mode 100644 index 0000000..a2fd7a7 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/make-gql-endpoint.ts @@ -0,0 +1,77 @@ +import { makeExecutableSchema } from '@graphql-tools/schema' +import { graphql } from 'graphql' +import { mapValues } from '../util' +import type { + GraphQLEndpoint, + GraphQLEndpointArgs, + GraphQLModule, + GraphQLPerRequestCache, +} from './types' + +const createPerRequestCache = (): GraphQLPerRequestCache => { + const cache: any = {} + return { + getOrCreateCachedValue: (name, factory) => { + if (!cache[name]) { + cache[name] = { v: factory() } + } + return cache[name].v + }, + } +} +const bindPerRequestCache = ( + ctx: TContext +): GraphQLPerRequestCache => { + const c = ctx as any + // fast return + const fc = c?.state?.gqlCache + if (fc) { + return fc + } + // construct cache + if (!c.state) { + c.state = {} + } + if (!c.state.gqlCache) { + c.state.gqlCache = createPerRequestCache() + } + return c.state.gqlCache +} + +/** create endpoint function that given parameters resolves against a GraphQL module */ +export function makeGqlEndpoint({ + schema, + resolvers, +}: GraphQLModule): GraphQLEndpoint { + const es = makeExecutableSchema({ + typeDefs: schema, + // wrap resolver going from indexed arguments to parameter object + resolvers: mapValues(resolvers, entity => + mapValues( + entity, + field => async (source: TModel, args: any, ctx: TContext, info: any) => + field({ + source, + ctx, + args, + info, + cache: bindPerRequestCache(ctx), + }) + ) + ), + }) + + return ({ + context, + model, + query, + variables, + }: GraphQLEndpointArgs) => + graphql({ + schema: es, + source: query, + rootValue: model, + contextValue: context, + variableValues: variables, + }) +} diff --git a/src/lib/gdi-api-node/graphql/make-gql-middleware.ts b/src/lib/gdi-api-node/graphql/make-gql-middleware.ts new file mode 100644 index 0000000..1ca4fef --- /dev/null +++ b/src/lib/gdi-api-node/graphql/make-gql-middleware.ts @@ -0,0 +1,26 @@ +import { GraphQLEndpoint } from './types' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const debug = require('debug')('application:gql-middleware') + +/** Create Koa middleware that executes given GraphQL endpoint, passing {query, variables} */ +export function makeGqlMiddleware( + endpoint: GraphQLEndpoint, + { + mapQuery = q => q, + mapVariables = v => v, + }: { + mapQuery?: (q: any) => any, + mapVariables?: (q: any) => any + } = {} +): (ctx: any) => Promise { + debug('creating middleware') + return async ctx => { + const { request: { body: { query, variables } } } = ctx + const result = await endpoint({ + context: ctx, + query: mapQuery(query), + variables: mapVariables(variables), + }) + ctx.body = result + } +} \ No newline at end of file diff --git a/src/lib/gdi-api-node/graphql/types.ts b/src/lib/gdi-api-node/graphql/types.ts new file mode 100644 index 0000000..26b1775 --- /dev/null +++ b/src/lib/gdi-api-node/graphql/types.ts @@ -0,0 +1,30 @@ +export interface GraphQLModule { + schema: string + resolvers: EntityResolverMap // | ResolverMap[] +} +// obj, args, context, info +// https://www.graphql-tools.com/docs/resolvers + +// We stray away from resolver functions as defined in https://www.graphql-tools.com/docs/resolvers +// and move from +// resolver(obj, args, context, info) +// to +// resolver({source, ctx, args, info}) + +// export type ResolverFn = (source?: TModel, args?: any, context?: TContext, info?: any) => any +export type ResolverFn = ({ source, ctx, args, info }: {source?: TModel, args?: any, ctx?: TContext, info?: any, cache: GraphQLPerRequestCache}) => any +export type FieldResolverMap = Record> +export type EntityResolverMap = Record> + +export type GraphQLEndpointArgs = { + context?: TContext, + model?: TModel, + query: string, + variables: Record, +} + +export interface GraphQLPerRequestCache { + getOrCreateCachedValue: (name: string, factory: (() => T)) => T +} + +export type GraphQLEndpoint = (args: GraphQLEndpointArgs) => Promise diff --git a/src/lib/gdi-api-node/index.ts b/src/lib/gdi-api-node/index.ts new file mode 100644 index 0000000..b18c8d3 --- /dev/null +++ b/src/lib/gdi-api-node/index.ts @@ -0,0 +1,26 @@ +export { createApplication } from './application' +export { + Application, + ApplicationContext, + ApplicationModule, + ApplicationRunHandler, +} from './application/types' + +export { getEnv } from './config' + +export { makeGqlEndpoint, makeGqlMiddleware } from './graphql' +export { GraphQLModule } from './graphql/types' + +export { healthCheckModule } from './modules/healthcheck' + +export { jwtUserModule, requireJwtUser } from './modules/jwt-user' + +export { swaggerModule } from './modules/swagger' + +export { webFrameworkModule } from './modules/web-framework' + +export { + AuthorizationService, + createAuthorizationService, + createAuthorizationServiceFromEnv, +} from './services/authorization-service' diff --git a/src/lib/gdi-api-node/modules/healthcheck/README.md b/src/lib/gdi-api-node/modules/healthcheck/README.md new file mode 100644 index 0000000..b78042f --- /dev/null +++ b/src/lib/gdi-api-node/modules/healthcheck/README.md @@ -0,0 +1,16 @@ +# Healthcheck module + +Module for the `healthCheck` operation + +## Usage: + +```ts +import { createApplication } from 'gdi-api-node' +import healthCheckModule from 'gdi-api-node/modules/healthcheck' + +createApplication({ + openApiDefinitionPath: 'openapi.yml', + validateResponse, + }) + .use(healthCheckModule) +``` \ No newline at end of file diff --git a/src/lib/gdi-api-node/modules/healthcheck/__tests/healthcheck-module.test.ts b/src/lib/gdi-api-node/modules/healthcheck/__tests/healthcheck-module.test.ts new file mode 100644 index 0000000..3f45d64 --- /dev/null +++ b/src/lib/gdi-api-node/modules/healthcheck/__tests/healthcheck-module.test.ts @@ -0,0 +1,41 @@ +import { StatusCodes } from 'http-status-codes' +import request from 'supertest' +import { healthCheckModule } from '..' +import { createApplication } from '../../../application' +import { swaggerModule } from '../../swagger' +import { webFrameworkModule } from '../../web-framework' +import { getOpenApiDefinitionPathForTest } from '../../../__tests/test-utils' + +const createTestApp = () => + createApplication({ + openApiDefinitionPath: getOpenApiDefinitionPathForTest(), + validateResponse: true, + }) + .use(webFrameworkModule()) + .use(swaggerModule()) + +describe('GET /api/v1/{api-namespace-name}/healthcheck', () => { + it('gives HTTP 200 OK by default', () => + createTestApp() + .use(healthCheckModule()) + .run(async server => { + const { status } = await request(server).get( + '/api/v1/my-actual-api-namespace/healthcheck' + ) + expect(status).toBe(StatusCodes.OK) + })) + it('can be configured with custom logic', () => + createTestApp() + .use(healthCheckModule(() => ({ message: "It's all good" }))) + .run(async server => { + const { status, body } = await request(server).get( + '/api/v1/my-actual-api-namespace/healthcheck' + ) + expect(status).toBe(StatusCodes.OK) + expect(body).toMatchObject({ + status: 'ok', + namespace: 'my-actual-api-namespace', + message: "It's all good", + }) + })) +}) diff --git a/src/lib/gdi-api-node/modules/healthcheck/index.ts b/src/lib/gdi-api-node/modules/healthcheck/index.ts new file mode 100644 index 0000000..68fcca8 --- /dev/null +++ b/src/lib/gdi-api-node/modules/healthcheck/index.ts @@ -0,0 +1,15 @@ +import { ApplicationContext } from '../../application/types' + +/** Module for the healthCheck operation (which should be described in our openapi spec) */ +export const healthCheckModule = (checkHealth?: ((namespace: string) => Promise | any )) => ({ registerKoaApi }: ApplicationContext): void => registerKoaApi({ + healthCheck: async ctx => { + const { params: { namespace } } = ctx + const hc = await checkHealth?.(namespace) + + ctx.body = { + status: 'ok', + namespace, + ...hc, + } + }, +}) diff --git a/src/lib/gdi-api-node/modules/jwt-user/README.md b/src/lib/gdi-api-node/modules/jwt-user/README.md new file mode 100644 index 0000000..22e6e0e --- /dev/null +++ b/src/lib/gdi-api-node/modules/jwt-user/README.md @@ -0,0 +1,23 @@ +# JWT-User module + +Updates __ctx.user__ with payload extracted from JWT bearer token, if present in request header. + +If JWT bearer token is missing, and `JWT_DEFAULT_USER` is set, the JSON parsing of `JWT_DEFAULT_USER` is used. + +```sh +JWT_DEFAULT_USER={id: 0, name: 'Unknown'} +``` + +## Usage: + +```ts +import { createApplication } from 'gdi-api-node' +import jwtUserModule from 'gdi-api-node/modules/jwt-user' +import { createAuthorizationServiceFromEnv } from 'gdi-api-node/services/authorization-service' + +createApplication({ + openApiDefinitionPath: 'openapi.yml', + validateResponse, + }) + .use(jwtUserModule(createAuthorizationServiceFromEnv())) +``` \ No newline at end of file diff --git a/src/lib/gdi-api-node/modules/jwt-user/__tests/get-token-from-authorization-header.test.ts b/src/lib/gdi-api-node/modules/jwt-user/__tests/get-token-from-authorization-header.test.ts new file mode 100644 index 0000000..e4f3a01 --- /dev/null +++ b/src/lib/gdi-api-node/modules/jwt-user/__tests/get-token-from-authorization-header.test.ts @@ -0,0 +1,33 @@ +import { getTokenFromAuthorizationHeader } from '../get-token-from-authorization-header' + +describe('getTokenFromAuthorizationHeader', () => { + it('understands Authorization: Bearer ', () => { + expect(getTokenFromAuthorizationHeader({ + authorization: 'Bearer test-token', + })) + .toBe('test-token') + }) + it('understands Authorization: bearer ', () => { + expect(getTokenFromAuthorizationHeader({ + authorization: 'bearer test-token', + })) + .toBe('test-token') + }) + it('will trim() the token', () => { + expect(getTokenFromAuthorizationHeader({ + authorization: 'bearer test-token ', + })) + .toBe('test-token') + }) + it('return null on missing Authorization header', () => { + expect(getTokenFromAuthorizationHeader({ + })) + .toBeNull() + }) + it('return null on malformed Authorization header', () => { + expect(getTokenFromAuthorizationHeader({ + authorization: 'beaver eager', + })) + .toBeNull() + }) +}) \ No newline at end of file diff --git a/src/lib/gdi-api-node/modules/jwt-user/__tests/jwt-user-module.test.ts b/src/lib/gdi-api-node/modules/jwt-user/__tests/jwt-user-module.test.ts new file mode 100644 index 0000000..d27b546 --- /dev/null +++ b/src/lib/gdi-api-node/modules/jwt-user/__tests/jwt-user-module.test.ts @@ -0,0 +1,25 @@ +import request from 'supertest' +import { StatusCodes } from 'http-status-codes' +import { createApplication } from '../../../application' +import type { AuthorizationService } from '../../../services/authorization-service' +import { createAuthorizationService } from '../../../services/authorization-service' +import { jwtUserModule } from '..' +import { getOpenApiDefinitionPathForTest } from '../../../__tests/test-utils' + +const createTestApp = (authorization: AuthorizationService) => + createApplication({ + openApiDefinitionPath: getOpenApiDefinitionPathForTest(), + validateResponse: true, + }).use(jwtUserModule(authorization)) + +describe('jwt-user-module', () => { + it('ignores apa', async () => + createTestApp(createAuthorizationService('test shared secret')).run( + async server => { + const { status } = await request(server) + .get('/some/page/it/can/be/anyone/actally') + .set('Authorization', 'Bearer apa') + expect(status).toBe(StatusCodes.UNAUTHORIZED) + } + )) +}) diff --git a/src/lib/gdi-api-node/modules/jwt-user/get-token-from-authorization-header.ts b/src/lib/gdi-api-node/modules/jwt-user/get-token-from-authorization-header.ts new file mode 100644 index 0000000..49633d3 --- /dev/null +++ b/src/lib/gdi-api-node/modules/jwt-user/get-token-from-authorization-header.ts @@ -0,0 +1,6 @@ +/** Given (Koa) headers, extract bearer token */ +export const getTokenFromAuthorizationHeader = ( + headers: Record +): string | null => + /^Bearer\s(.+)$/gim.exec(headers?.authorization as string)?.[1]?.trim() || + null diff --git a/src/lib/gdi-api-node/modules/jwt-user/index.ts b/src/lib/gdi-api-node/modules/jwt-user/index.ts new file mode 100644 index 0000000..1acb0b3 --- /dev/null +++ b/src/lib/gdi-api-node/modules/jwt-user/index.ts @@ -0,0 +1,26 @@ +import type Koa from 'koa' +import type { AuthorizationService } from '../../services/authorization-service' +import type { + ApplicationContext, + ApplicationModule, +} from '../../application/types' +import { getTokenFromAuthorizationHeader } from './get-token-from-authorization-header' + +/** Module that updates __ctx.user__ with payload extracted from JWT bearer token, if present in request headers */ +export const jwtUserModule = + (authorization: AuthorizationService): ApplicationModule => + ({ app }: ApplicationContext) => + app.use(async (ctx, next) => { + ctx.user = + ctx.user || + authorization.tryGetUserFromJwt( + getTokenFromAuthorizationHeader(ctx.headers) || '' + ) + return next() + }) + +/** Koa middleware that prevent futher processing if __user__ is not present in Koa context */ +export const requireJwtUser = + (mv: Koa.Middleware): Koa.Middleware => + (ctx, next) => + ctx.user ? mv(ctx, next) : ctx.throw(401) diff --git a/src/lib/gdi-api-node/modules/swagger/README.md b/src/lib/gdi-api-node/modules/swagger/README.md new file mode 100644 index 0000000..0b11aa2 --- /dev/null +++ b/src/lib/gdi-api-node/modules/swagger/README.md @@ -0,0 +1,18 @@ +# Swagger module + +Exposes `/swagger` and `/swagger.json` with contents derived from current openapi specification. + +Also redirects `/` to `/swagger` + +## Usage: + +```ts +import { createApplication } from 'gdi-api-node' +import swaggerModule from 'gdi-api-node/modules/swagger' + +createApplication({ + openApiDefinitionPath: 'openapi.yml', + validateResponse, + }) + .use(swaggerModule()) +``` \ No newline at end of file diff --git a/src/lib/gdi-api-node/modules/swagger/__tests/swagger.test.ts b/src/lib/gdi-api-node/modules/swagger/__tests/swagger.test.ts new file mode 100644 index 0000000..8f15a24 --- /dev/null +++ b/src/lib/gdi-api-node/modules/swagger/__tests/swagger.test.ts @@ -0,0 +1,58 @@ +import { StatusCodes } from 'http-status-codes' +import request from 'supertest' +import type { SwaggerModuleProps } from '..' +import { swaggerModule } from '..' +import { createApplication } from '../../../application' +import { getOpenApiDefinitionPathForTest } from '../../../__tests/test-utils' + +const createTestApp = (props?: SwaggerModuleProps) => + createApplication({ + openApiDefinitionPath: getOpenApiDefinitionPathForTest(), + validateResponse: true, + }).use(swaggerModule(props)) + +describe('swagger-module', () => { + it('GET /swagger.json responds with JSON', async () => + createTestApp().run(async server => { + const { headers, status } = await request(server) + .get('/swagger.json') + .set('Accept', 'application/json') + expect(headers['content-type']).toMatch(/json/) + expect(status).toEqual(StatusCodes.OK) + })) + it('GET / redirects to /swagger', async () => + createTestApp().run(async server => { + const { status, headers } = await request(server).get('/') + expect(status).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(headers.location).toBe('/swagger') + })) + it('GET /swagger responds with HTML', async () => + createTestApp().run(async server => { + const { status, headers } = await request(server).get('/swagger') + expect(status).toBe(StatusCodes.OK) + expect(headers['content-type']).toMatch(/html/) + })) +}) + +describe('swagger-module, routePrefix=/api/v1/test', () => { + it('GET /api/v1/test/swagger.json responds with JSON', async () => + createTestApp({ routePrefix: '/api/v1/test' }).run(async server => { + const { headers, status } = await request(server) + .get('/api/v1/test.json') + .set('Accept', 'application/json') + expect(headers['content-type']).toMatch(/json/) + expect(status).toEqual(StatusCodes.OK) + })) + it('GET / redirects to /api/v1/test', async () => + createTestApp({ routePrefix: '/api/v1/test' }).run(async server => { + const { status, headers } = await request(server).get('/') + expect(status).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(headers.location).toBe('/api/v1/test') + })) + it('GET /api/v1/test/swagger responds with HTML', async () => + createTestApp({ routePrefix: '/api/v1/test' }).run(async server => { + const { status, headers } = await request(server).get('/api/v1/test') + expect(status).toBe(StatusCodes.OK) + expect(headers['content-type']).toMatch(/html/) + })) +}) diff --git a/src/lib/gdi-api-node/modules/swagger/index.ts b/src/lib/gdi-api-node/modules/swagger/index.ts new file mode 100644 index 0000000..d53c9dc --- /dev/null +++ b/src/lib/gdi-api-node/modules/swagger/index.ts @@ -0,0 +1,24 @@ +import { koaSwagger } from 'koa2-swagger-ui' +import { ApplicationModule, ApplicationContext } from '../../application/types' + +export interface SwaggerModuleProps { + routePrefix: string, +} +/** + * Module that exposes __/swagger__ and __/swagger.json__ with contents derived from current openapi specification + */ +export const swaggerModule = ({ routePrefix }: SwaggerModuleProps = { routePrefix: '/swagger' }): ApplicationModule => ({ app, router, api }: ApplicationContext) => { + const jsonPath = `${routePrefix}.json` + app.use(koaSwagger({ + routePrefix: routePrefix, + swaggerOptions: { + url: jsonPath, + }, + })) + + router + .get(jsonPath, ctx => { + ctx.body = api.document + }) + .get('/', ctx => ctx.redirect(routePrefix)) +} diff --git a/src/lib/gdi-api-node/modules/web-framework/README.md b/src/lib/gdi-api-node/modules/web-framework/README.md new file mode 100644 index 0000000..19b3582 --- /dev/null +++ b/src/lib/gdi-api-node/modules/web-framework/README.md @@ -0,0 +1,16 @@ +# Web framwork module + +Module thet handles basic CORS and body parsing for your convenience 🍻 + +## Usage: + +```ts +import { createApplication } from 'gdi-api-node' +import webFrameworkModule from 'gdi-api-node/modules/web-framework' + +createApplication({ + openApiDefinitionPath: 'openapi.yml', + validateResponse, + }) + .use(webFrameworkModule()) +``` \ No newline at end of file diff --git a/src/lib/gdi-api-node/modules/web-framework/index.ts b/src/lib/gdi-api-node/modules/web-framework/index.ts new file mode 100644 index 0000000..e19c2ec --- /dev/null +++ b/src/lib/gdi-api-node/modules/web-framework/index.ts @@ -0,0 +1,8 @@ +import cors from '@koa/cors' +import bodyparser from 'koa-bodyparser' +import { ApplicationContext, ApplicationModule } from '../../application/types' + +/** Module thet handles basic CORS and body parsing for your convenience 🍻 */ +export const webFrameworkModule = (): ApplicationModule => ({ app }: ApplicationContext) => app + .use(cors()) + .use(bodyparser()) diff --git a/src/lib/gdi-api-node/readme.md b/src/lib/gdi-api-node/readme.md new file mode 100644 index 0000000..2228294 --- /dev/null +++ b/src/lib/gdi-api-node/readme.md @@ -0,0 +1,6 @@ +# gdi-api-node + +As per 20224-06-17, the dependency to _@helsingborg-stad/gdi-api-node_ was replaced with source code copy from said repository into this folder. + +- github package version: 1.0.10 +- https://github.com/helsingborg-stad/gdi-api-node/commit/e4ca4231c84d1fd7ee0695c8343c4b874a4f96d3 diff --git a/src/lib/gdi-api-node/services/__tests/authorization-service.test.ts b/src/lib/gdi-api-node/services/__tests/authorization-service.test.ts new file mode 100644 index 0000000..4c25278 --- /dev/null +++ b/src/lib/gdi-api-node/services/__tests/authorization-service.test.ts @@ -0,0 +1,52 @@ +import { StatusCodes } from 'http-status-codes' +import jwt from 'jsonwebtoken' +import { createAuthorizationService } from '../authorization-service' + +const wrapResultAndError = (fn: () => T): { result?: T; error?: Error } => { + try { + return { + result: fn(), + } + } catch (error) { + return { error: error as Error } + } +} + +describe('authorization-service', () => { + it('tryGetUserFromJwt throw error {status: 401 unauthorized} on invalid signature', () => { + const s = createAuthorizationService('shared secret') + const { error } = wrapResultAndError(() => + s.tryGetUserFromJwt(jwt.sign({}, 'wrong shared secret')) + ) + expect(error).toMatchObject({ + status: StatusCodes.UNAUTHORIZED, + message: 'invalid signature', + }) + }) + it('tryGetUserFromJwt throw error {status: 401 unauthorized} on malformed token', () => { + const s = createAuthorizationService('shared secret') + const { error } = wrapResultAndError(() => + s.tryGetUserFromJwt('a superbad token') + ) + expect(error).toMatchObject({ + status: StatusCodes.UNAUTHORIZED, + message: 'jwt malformed', + }) + }) + it('tryGetUserFromJwt() => null', () => { + const s = createAuthorizationService('shared secret') + expect(s.tryGetUserFromJwt('')).toBeNull() + expect(s.tryGetUserFromJwt(null as any as string)).toBeNull() + expect(s.tryGetUserFromJwt(undefined as any as string)).toBeNull() + }) + it('tryGetUserFromJwt() => ', () => { + const s = createAuthorizationService('shared secret') + const user = s.tryGetUserFromJwt( + jwt.sign({ id: 123, name: 'Gandalf' }, 'shared secret') + ) + expect(user).toMatchObject({ + id: 123, + name: 'Gandalf', + }) + }) +}) diff --git a/src/lib/gdi-api-node/services/__tests/default-user.test.ts b/src/lib/gdi-api-node/services/__tests/default-user.test.ts new file mode 100644 index 0000000..94c0849 --- /dev/null +++ b/src/lib/gdi-api-node/services/__tests/default-user.test.ts @@ -0,0 +1,60 @@ +import { StatusCodes } from 'http-status-codes' +import jwt from 'jsonwebtoken' +import { createAuthorizationService } from '../authorization-service' + +const wrapResultAndError = (fn: () => T): { result?: T; error?: Error } => { + try { + return { + result: fn(), + } + } catch (error) { + return { error: error as Error } + } +} + +const DefaultUser = { id: 'default', name: 'default user' } +const createAuthorizationServiceForTest = () => + createAuthorizationService('shared secret', () => DefaultUser) + +describe('authorization-service', () => { + it('tryGetUserFromJwt throw error {status: 401 unauthorized} on invalid signature', () => { + const s = createAuthorizationServiceForTest() + const { error } = wrapResultAndError(() => + s.tryGetUserFromJwt(jwt.sign({}, 'wrong shared secret')) + ) + expect(error).toMatchObject({ + status: StatusCodes.UNAUTHORIZED, + message: 'invalid signature', + }) + }) + it('tryGetUserFromJwt throw error {status: 401 unauthorized} on malformed token', () => { + const s = createAuthorizationServiceForTest() + const { error } = wrapResultAndError(() => + s.tryGetUserFromJwt('a superbad token') + ) + expect(error).toMatchObject({ + status: StatusCodes.UNAUTHORIZED, + message: 'jwt malformed', + }) + }) + it('tryGetUserFromJwt() => ', () => { + const s = createAuthorizationServiceForTest() + expect(s.tryGetUserFromJwt('')).toMatchObject(DefaultUser) + expect(s.tryGetUserFromJwt(null as any as string)).toMatchObject( + DefaultUser + ) + expect(s.tryGetUserFromJwt(undefined as any as string)).toMatchObject( + DefaultUser + ) + }) + it('tryGetUserFromJwt() => ', () => { + const s = createAuthorizationServiceForTest() + const user = s.tryGetUserFromJwt( + jwt.sign({ id: 123, name: 'Gandalf' }, 'shared secret') + ) + expect(user).toMatchObject({ + id: 123, + name: 'Gandalf', + }) + }) +}) diff --git a/src/lib/gdi-api-node/services/authorization-service.ts b/src/lib/gdi-api-node/services/authorization-service.ts new file mode 100644 index 0000000..e6f5cec --- /dev/null +++ b/src/lib/gdi-api-node/services/authorization-service.ts @@ -0,0 +1,49 @@ +import jwt from 'jsonwebtoken' +import createError from 'http-errors' +import { getEnv } from '../config' + +export interface AuthorizationService { + /** + * Given a JWT, if not null/empty, + * extract user from payload. + * + * Should throw if JWT validation fails + */ + tryGetUserFromJwt: (token: string) => any +} + +/** + * Recognize JWT exceptions and map them to http 401 exceptions + * + */ +const mapErrors = (fn: () => T): T => { + try { + return fn() + } catch (err) { + if (err instanceof jwt.JsonWebTokenError) { + throw createError(401, err) + } + throw err + } +} + +const createGetDefaultUser = (json: string): any | null => + json ? () => JSON.parse(json) : null + +export const createAuthorizationService = ( + sharedSecret: string, + getDefaultUser?: () => any | null +): AuthorizationService => ({ + tryGetUserFromJwt: token => + mapErrors(() => + token + ? jwt.verify(token, sharedSecret, { complete: true })?.payload + : getDefaultUser?.() || null + ), +}) + +export const createAuthorizationServiceFromEnv = (): AuthorizationService => + createAuthorizationService( + getEnv('JWT_SHARED_SECRET'), + createGetDefaultUser(getEnv('JWT_DEFAULT_USER', { fallback: '' })) + ) diff --git a/src/lib/gdi-api-node/util/index.ts b/src/lib/gdi-api-node/util/index.ts new file mode 100644 index 0000000..7a3463c --- /dev/null +++ b/src/lib/gdi-api-node/util/index.ts @@ -0,0 +1,5 @@ +export const mapValues = ( + obj: Record, + valueFn: (value: V) => U +): Record => + Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, valueFn(v)])) diff --git a/src/locations/locations-gql-module.ts b/src/locations/locations-gql-module.ts index 970117f..1578787 100644 --- a/src/locations/locations-gql-module.ts +++ b/src/locations/locations-gql-module.ts @@ -1,9 +1,9 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { normalizeRoles } from '../login' import { locationsGqlSchema } from './locations.gql.schema' import { locationsAdapter } from './locations-adapter' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createLocationsGqlModule = ({ settings, diff --git a/src/login-policies/login-policies-qgl-module.ts b/src/login-policies/login-policies-qgl-module.ts index 912fd60..6c6c12a 100644 --- a/src/login-policies/login-policies-qgl-module.ts +++ b/src/login-policies/login-policies-qgl-module.ts @@ -1,9 +1,9 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { loginPoliciesGqlSchema } from './login-policies.gql.schema' import { loginPolicyAdapter } from './login-policy-adapter' import { normalizeRoles } from '../login' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createLoginPoliciesGqlModule = ({ settings, diff --git a/src/login/cookies/cookie-request-valildation.spec.ts b/src/login/cookies/cookie-request-valildation.spec.ts index f17422a..8d256ca 100644 --- a/src/login/cookies/cookie-request-valildation.spec.ts +++ b/src/login/cookies/cookie-request-valildation.spec.ts @@ -1,6 +1,5 @@ import request from 'supertest' import HttpStatusCodes from 'http-status-codes' -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import type { Services } from '../../types' import { requireHaffaUser } from '../require-haffa-user' import { @@ -9,6 +8,7 @@ import { type End2EndTestContext, } from '../../test-utils' import type { LoginPolicy } from '../../login-policies/types' +import type { ApplicationModule } from '../../lib/gdi-api-node' describe('user access validation', () => { interface TestCase { diff --git a/src/login/cookies/cookie-user-module.ts b/src/login/cookies/cookie-user-module.ts index 5a51d03..e7c8fa4 100644 --- a/src/login/cookies/cookie-user-module.ts +++ b/src/login/cookies/cookie-user-module.ts @@ -1,5 +1,5 @@ -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' -import type { AuthorizationService } from '@helsingborg-stad/gdi-api-node/services/authorization-service' +import type { ApplicationModule } from '../../lib/gdi-api-node' +import type { AuthorizationService } from '../../lib/gdi-api-node/services/authorization-service' import type { CookieService } from '../types' // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/login/cookies/index.ts b/src/login/cookies/index.ts index a84d1eb..1cc62e3 100644 --- a/src/login/cookies/index.ts +++ b/src/login/cookies/index.ts @@ -1,4 +1,4 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' +import { getEnv } from '../../lib/gdi-api-node' import type { StartupLog } from '../../types' import type { CookieService } from '../types' import { createCookieService } from './cookie-service' diff --git a/src/login/in-memory-login-service/index.ts b/src/login/in-memory-login-service/index.ts index 9ff8bff..b5c63d8 100644 --- a/src/login/in-memory-login-service/index.ts +++ b/src/login/in-memory-login-service/index.ts @@ -1,9 +1,9 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import { createIssuePincode } from '../issue-pincode' import type { StartupLog } from '../../types' import type { UserMapper } from '../../users/types' import type { LoginService } from '../types' import { createInMemoryLoginService } from './in-memory-login-service' +import { getEnv } from '../../lib/gdi-api-node' export { createInMemoryLoginService } diff --git a/src/login/login-module.ts b/src/login/login-module.ts index 62f7f4d..da10beb 100644 --- a/src/login/login-module.ts +++ b/src/login/login-module.ts @@ -1,8 +1,4 @@ import type Koa from 'koa' -import type { - ApplicationContext, - ApplicationModule, -} from '@helsingborg-stad/gdi-api-node' import { RequestPincodeStatus } from './types' import type { CookieService, LoginService } from './types' import type { TokenService } from '../tokens/types' @@ -10,6 +6,7 @@ import type { NotificationService } from '../notifications/types' import { rolesToRolesArray } from '.' import { requireHaffaUserRole } from './require-haffa-user' import type { UserMapper } from '../users/types' +import type { ApplicationContext, ApplicationModule } from '../lib/gdi-api-node' export const loginModule = ( diff --git a/src/login/mongo-login-service/mongo-login-service.ts b/src/login/mongo-login-service/mongo-login-service.ts index d9aed93..99e8d02 100644 --- a/src/login/mongo-login-service/mongo-login-service.ts +++ b/src/login/mongo-login-service/mongo-login-service.ts @@ -1,4 +1,3 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import ms from 'ms' import type { LoginService } from '../types' import { RequestPincodeStatus } from '../types' @@ -12,6 +11,7 @@ import type { UserMapper } from '../../users/types' import type { StartupLog } from '../../types' import type { IssuePincode } from '../issue-pincode/types' import { createIssuePincode } from '../issue-pincode' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateMongoLoginServiceFromEnv = ( startupLog: StartupLog, diff --git a/src/notifications/brevo/brevo-notifications.ts b/src/notifications/brevo/brevo-notifications.ts index 3a5713f..0c9e493 100644 --- a/src/notifications/brevo/brevo-notifications.ts +++ b/src/notifications/brevo/brevo-notifications.ts @@ -1,9 +1,9 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { NotificationService } from '../types' import type { Advert } from '../../adverts/types' import { createClient } from './brevo-client' import type { BrevoConfig, Identity, TemplateName } from './types' import type { StartupLog } from '../../types' +import { getEnv } from '../../lib/gdi-api-node' export const createBrevoNotifications = ( config: BrevoConfig diff --git a/src/notifications/datatorget-helsingborg-se/index.ts b/src/notifications/datatorget-helsingborg-se/index.ts index 0f85190..a40dd18 100644 --- a/src/notifications/datatorget-helsingborg-se/index.ts +++ b/src/notifications/datatorget-helsingborg-se/index.ts @@ -1,10 +1,10 @@ import request from 'superagent' -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { StartupLog } from '../../types' import type { NotificationService } from '../types' import type { SettingsService } from '../../settings/types' import { userMapperConfigAdapter } from '../../users' import { smsTemplateMapper } from '../templates/sms-templates/sms-template-mapper' +import { getEnv } from '../../lib/gdi-api-node' const createDatatorgetSmsNotifications = ({ apiKey, diff --git a/src/notifications/sendgrid/sendgrid-notifications.ts b/src/notifications/sendgrid/sendgrid-notifications.ts index a82fe2f..9a6fd55 100644 --- a/src/notifications/sendgrid/sendgrid-notifications.ts +++ b/src/notifications/sendgrid/sendgrid-notifications.ts @@ -1,8 +1,8 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { NotificationService } from '../types' import { createSendGridMailSender } from './sendgrid-mail-sender' import type { SendGridConfig } from './types' import type { StartupLog } from '../../types' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateSendGridNofificationsFromEnv = ( startupLog: StartupLog diff --git a/src/notifications/templates/sms-templates/sms-templates-gql-module.ts b/src/notifications/templates/sms-templates/sms-templates-gql-module.ts index 7b4fb94..a031500 100644 --- a/src/notifications/templates/sms-templates/sms-templates-gql-module.ts +++ b/src/notifications/templates/sms-templates/sms-templates-gql-module.ts @@ -1,9 +1,9 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import { smsTemplatesGqlSchema } from './sms-templates.gql.schema' import type { Services } from '../../../types' import { normalizeRoles } from '../../../login' import { smsTemplateMapper } from './sms-template-mapper' +import type { GraphQLModule } from '../../../lib/gdi-api-node' export const createSmsTemplatesGqlModule = ({ settings, diff --git a/src/options/options-gql-module.ts b/src/options/options-gql-module.ts index ea581e6..0cb434b 100644 --- a/src/options/options-gql-module.ts +++ b/src/options/options-gql-module.ts @@ -1,9 +1,9 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { optionsGqlSchema } from './options.gql.schema' import { normalizeRoles } from '../login' import { optionsAdapter } from './options-adapter' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createOptionsGqlModule = ({ settings, diff --git a/src/options/options-user-module.ts b/src/options/options-user-module.ts index 86da720..b6d0519 100644 --- a/src/options/options-user-module.ts +++ b/src/options/options-user-module.ts @@ -1,8 +1,8 @@ -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import type { SettingsService } from '../settings/types' import { optionsAdapter } from './options-adapter' import { type Option } from './types' import { userMapperConfigAdapter } from '../users' +import type { ApplicationModule } from '../lib/gdi-api-node' const getSystemSettings = async ( settings: SettingsService diff --git a/src/profile/fs-profile-repository.ts b/src/profile/fs-profile-repository.ts index c46ca07..69a815d 100644 --- a/src/profile/fs-profile-repository.ts +++ b/src/profile/fs-profile-repository.ts @@ -1,10 +1,10 @@ -import { readFile, writeFile, unlink, rm } from 'fs/promises' +import { readFile, writeFile, rm } from 'fs/promises' import { join } from 'path' -import { getEnv } from '@helsingborg-stad/gdi-api-node' import { mkdirp } from 'mkdirp' import type { Profile, ProfileRepository } from './types' import { createEmptyProfile } from './mappers' import type { StartupLog } from '../types' +import { getEnv } from '../lib/gdi-api-node' export const createFsProfileRepository = ( dataFolder: string diff --git a/src/profile/mongo-profile-repository/index.ts b/src/profile/mongo-profile-repository/index.ts index 4f16eef..d6b9db9 100644 --- a/src/profile/mongo-profile-repository/index.ts +++ b/src/profile/mongo-profile-repository/index.ts @@ -1,8 +1,8 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import { createMongoProfileConnection } from './mongo-profile-connection' import { createMongoProfileRepository } from './mongo-profile-repository' import type { ProfileRepository } from '../types' import type { StartupLog } from '../../types' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateMongoDbProfileRepositoryFromEnv = ( startupLog: StartupLog diff --git a/src/profile/profile-gql-module.ts b/src/profile/profile-gql-module.ts index 5f280cf..2a50674 100644 --- a/src/profile/profile-gql-module.ts +++ b/src/profile/profile-gql-module.ts @@ -1,11 +1,11 @@ import HttpStatusCodes from 'http-status-codes' -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import { profileGqlSchema } from './profile.gql.schema' import type { Services } from '../types' import { elevateUser, normalizeRoles } from '../login' import { waitForAll } from '../lib' import { waitRepeat } from '../lib/wait' import type { RemoveProfileInput } from './types' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createProfileGqlModule = ({ profiles, diff --git a/src/settings/fs/index.ts b/src/settings/fs/index.ts index 4993b7e..5b2dbb1 100644 --- a/src/settings/fs/index.ts +++ b/src/settings/fs/index.ts @@ -1,8 +1,8 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import { join } from 'path' import type { SettingsService } from '../types' import { createFsSettingsService } from './fs-settings-service' import type { StartupLog } from '../../types' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateFsSettingsServiceFromEnv = ( startupLog: StartupLog diff --git a/src/settings/mongodb/index.ts b/src/settings/mongodb/index.ts index 469873d..7f80313 100644 --- a/src/settings/mongodb/index.ts +++ b/src/settings/mongodb/index.ts @@ -1,10 +1,10 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { SettingsService } from '../types' import { createMongoSettingsConnection, createMongoSettingsService, } from './mongodb-settings-service' import type { StartupLog } from '../../types' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateMongoDbSettingsServiceFromEnv = ( startupLog: StartupLog diff --git a/src/snapshot/snapshot-module.ts b/src/snapshot/snapshot-module.ts index 6426c07..73b30f1 100644 --- a/src/snapshot/snapshot-module.ts +++ b/src/snapshot/snapshot-module.ts @@ -1,5 +1,4 @@ import HttpStatusCodes from 'http-status-codes' -import { type ApplicationModule } from '@helsingborg-stad/gdi-api-node' import type { Services } from '../types' import { normalizeRoles } from '../login' import type { ImportSnapshotFunction, SnapshotFunction } from './types' @@ -12,6 +11,7 @@ import { categoriesSnapshot, importCategoriesSnapshot, } from './categories/categories-snapshot' +import type { ApplicationModule } from '../lib/gdi-api-node' const snapshotHandlers: Record = { adverts: advertsSnapshot, diff --git a/src/social-preview/social-preview-module.ts b/src/social-preview/social-preview-module.ts index d939149..c478038 100644 --- a/src/social-preview/social-preview-module.ts +++ b/src/social-preview/social-preview-module.ts @@ -1,7 +1,7 @@ -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import type { Services } from '../types' import { makeGuestUser } from '../login' import { optionsAdapter } from '../options' +import type { ApplicationModule } from '../lib/gdi-api-node' export const socialPreviewModule = ({ adverts, settings }: Services): ApplicationModule => diff --git a/src/stats/stats-gql-module.ts b/src/stats/stats-gql-module.ts index a774142..8369400 100644 --- a/src/stats/stats-gql-module.ts +++ b/src/stats/stats-gql-module.ts @@ -1,7 +1,7 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import type { Services } from '../types' import { statsGqlSchema } from './stats.gql.schema' import { statsAdapter } from './stats-adapter' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createStatsGqlModule = ({ adverts, diff --git a/src/subscriptions/mongo/index.ts b/src/subscriptions/mongo/index.ts index 4020b1e..b3d0ef2 100644 --- a/src/subscriptions/mongo/index.ts +++ b/src/subscriptions/mongo/index.ts @@ -1,4 +1,4 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' +import { getEnv } from '../../lib/gdi-api-node' import type { Services, StartupLog } from '../../types' import type { SubscriptionsRepository } from '../types' import { createMongoSubscriptionsConnection } from './connection' diff --git a/src/subscriptions/subscriptions-gql-module.ts b/src/subscriptions/subscriptions-gql-module.ts index 563ca50..ec28c3b 100644 --- a/src/subscriptions/subscriptions-gql-module.ts +++ b/src/subscriptions/subscriptions-gql-module.ts @@ -1,8 +1,8 @@ import HttpStatusCodes from 'http-status-codes' -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import { subscriptionsGqlSchema } from './subscriptions.gql.schema' import { normalizeRoles } from '../login' import type { Services } from '../types' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createSubscriptionsGqlModule = ({ subscriptions, diff --git a/src/syslog/mongodb/index.ts b/src/syslog/mongodb/index.ts index 2cfb219..72f6435 100644 --- a/src/syslog/mongodb/index.ts +++ b/src/syslog/mongodb/index.ts @@ -1,10 +1,10 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import type { StartupLog } from '../../types' import { createMongoSyslogConnection, createMongoSyslogService, } from './mongodb-syslog-service' import type { SyslogService } from '../types' +import { getEnv } from '../../lib/gdi-api-node' export const tryCreateMongoSyslogServiceFromEnv = ( startupLog: StartupLog diff --git a/src/syslog/syslog-gql-module.ts b/src/syslog/syslog-gql-module.ts index 045e075..cf36a67 100644 --- a/src/syslog/syslog-gql-module.ts +++ b/src/syslog/syslog-gql-module.ts @@ -1,8 +1,8 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { normalizeRoles } from '../login' import { syslogGqlSchema } from './syslog.gql.schema' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createSyslogGqlModule = (services: Services): GraphQLModule => ({ schema: syslogGqlSchema, diff --git a/src/terms/terms-gql-module.ts b/src/terms/terms-gql-module.ts index ed36eb1..f4fc865 100644 --- a/src/terms/terms-gql-module.ts +++ b/src/terms/terms-gql-module.ts @@ -1,9 +1,9 @@ import HttpStatusCodes from 'http-status-codes' -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import { termsGqlSchema } from './terms.gql.schema' import type { Services } from '../types' import { termsAdapter } from './mappers' import { normalizeRoles } from '../login' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createTermsGqlModule = ({ settings, diff --git a/src/test-utils/e2e.ts b/src/test-utils/e2e.ts index 9d78078..44a864b 100644 --- a/src/test-utils/e2e.ts +++ b/src/test-utils/e2e.ts @@ -1,10 +1,6 @@ import type { Test } from 'supertest' import request from 'supertest' import HttpStatusCodes from 'http-status-codes' -import type { - Application, - ApplicationRunHandler, -} from '@helsingborg-stad/gdi-api-node/application' import type { Services } from '../types' import type { Advert } from '../adverts/types' import type { LoginRequestEntry } from '../login/in-memory-login-service/in-memory-login-service' @@ -21,7 +17,7 @@ import { createUserMapper } from '../users' import { createInMemorySettingsService } from '../settings' import { loginPolicyAdapter } from '../login-policies/login-policy-adapter' import { createIssuePincode } from '../login' -import type { LoginPolicy } from '../login-policies/types' +import type { Application, ApplicationRunHandler } from '../lib/gdi-api-node' const createGqlRequest = ( diff --git a/src/test-utils/test-app.ts b/src/test-utils/test-app.ts index 81cb291..ad20e8b 100644 --- a/src/test-utils/test-app.ts +++ b/src/test-utils/test-app.ts @@ -1,5 +1,4 @@ import jwt from 'jsonwebtoken' -import type { Application } from '@helsingborg-stad/gdi-api-node' import type { Services } from '../types' import { createInMemoryLoginService } from '../login/in-memory-login-service/in-memory-login-service' import { createInMemoryAdvertsRepository } from '../adverts/repository/memory' @@ -19,6 +18,7 @@ import { createNullEventLogService } from '../events' import { createNullSubscriptionsRepository } from '../subscriptions' import { createNullContentRepository } from '../content' import { createNullSyslogService } from '../syslog/null-syslog-service' +import type { Application } from '../lib/gdi-api-node' export const TEST_SHARED_SECRET = 'shared scret used in tests' diff --git a/src/tokens/index.ts b/src/tokens/index.ts index 669a7fb..8545f8b 100644 --- a/src/tokens/index.ts +++ b/src/tokens/index.ts @@ -1,8 +1,8 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import { createTokenService } from './create-token-service' import type { TokenService } from './types' import type { UserMapper } from '../users/types' import type { StartupLog } from '../types' +import { getEnv } from '../lib/gdi-api-node' export { createTokenService } diff --git a/src/tokens/types.ts b/src/tokens/types.ts index da5ccb4..55d6511 100644 --- a/src/tokens/types.ts +++ b/src/tokens/types.ts @@ -1,4 +1,4 @@ -import type { AuthorizationService } from '@helsingborg-stad/gdi-api-node/services/authorization-service' +import type { AuthorizationService } from '../lib/gdi-api-node' import type { HaffaUser } from '../login/types' export interface TokenService extends AuthorizationService { diff --git a/src/users/index.ts b/src/users/index.ts index 5cca8e8..18bd538 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -1,10 +1,10 @@ -import { getEnv } from '@helsingborg-stad/gdi-api-node' import { createUserMapper, isValidEmail } from './user-mapper' import type { SettingsService } from '../settings/types' import type { StartupLog } from '../types' import type { UserMapper } from './types' import { userMapperConfigAdapter } from './user-mapper-config-adapter' import { createUserMapperGqlModule } from './user-mapper-gql-module' +import { getEnv } from '../lib/gdi-api-node' export { createUserMapper, diff --git a/src/users/tests/request-validation.spec.ts b/src/users/tests/request-validation.spec.ts index 996d3ea..58fa3f4 100644 --- a/src/users/tests/request-validation.spec.ts +++ b/src/users/tests/request-validation.spec.ts @@ -1,6 +1,5 @@ import request from 'supertest' import HttpStatusCodes from 'http-status-codes' -import type { ApplicationModule } from '@helsingborg-stad/gdi-api-node' import type { End2EndTestConfig, End2EndTestContext } from '../../test-utils' import { end2endTest } from '../../test-utils' import { requireHaffaUser } from '../../login/require-haffa-user' @@ -11,6 +10,7 @@ import { makeRoles, makeUser } from '../../login' import type { LoginPolicy } from '../../login-policies/types' import type { UserMapperConfig } from '../types' import { userMapperConfigAdapter } from '..' +import type { ApplicationModule } from '../../lib/gdi-api-node' describe('user access validation', () => { interface TestCase { diff --git a/src/users/user-mapper-gql-module.ts b/src/users/user-mapper-gql-module.ts index 54d95bd..318d2b8 100644 --- a/src/users/user-mapper-gql-module.ts +++ b/src/users/user-mapper-gql-module.ts @@ -1,9 +1,9 @@ -import type { GraphQLModule } from '@helsingborg-stad/gdi-api-node' import HttpStatusCodes from 'http-status-codes' import type { Services } from '../types' import { userMapperGqlSchema } from './user-mapper.gql.schema' import { userMapperConfigAdapter } from './user-mapper-config-adapter' import { normalizeRoles } from '../login' +import type { GraphQLModule } from '../lib/gdi-api-node' export const createUserMapperGqlModule = ({ settings, diff --git a/yarn.lock b/yarn.lock index eb169b2..8f13dd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -381,7 +381,25 @@ "@graphql-tools/utils" "^9.2.1" tslib "^2.4.0" -"@graphql-tools/schema@^9.0.18", "@graphql-tools/schema@^9.0.4": +"@graphql-tools/merge@^9.0.3": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-9.0.4.tgz#66c34cbc2b9a99801c0efca7b8134b2c9aecdb06" + integrity sha512-MivbDLUQ+4Q8G/Hp/9V72hbn810IJDEZQ57F01sHnlrrijyadibfVhaQfW/pNH+9T/l8ySZpaR/DpL5i+ruZ+g== + dependencies: + "@graphql-tools/utils" "^10.0.13" + tslib "^2.4.0" + +"@graphql-tools/schema@^10.0.4": + version "10.0.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-10.0.4.tgz#d4fc739a2cc07b4fc5f31a714178a561cba210cd" + integrity sha512-HuIwqbKxPaJujox25Ra4qwz0uQzlpsaBOzO6CVfzB/MemZdd+Gib8AIvfhQArK0YIN40aDran/yi+E5Xf0mQww== + dependencies: + "@graphql-tools/merge" "^9.0.3" + "@graphql-tools/utils" "^10.2.1" + tslib "^2.4.0" + value-or-promise "^1.0.12" + +"@graphql-tools/schema@^9.0.18": version "9.0.19" resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-9.0.19.tgz#c4ad373b5e1b8a0cf365163435b7d236ebdd06e7" integrity sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w== @@ -391,6 +409,16 @@ tslib "^2.4.0" value-or-promise "^1.0.12" +"@graphql-tools/utils@^10.0.13", "@graphql-tools/utils@^10.2.1": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-10.2.2.tgz#6477295fae051ffb5d6c28253aa6d8a449d4a820" + integrity sha512-ueoplzHIgFfxhFrF4Mf/niU/tYHuO6Uekm2nCYU72qpI+7Hn9dA2/o5XOBvFXDk27Lp5VSvQY5WfmRbqwVxaYQ== + dependencies: + "@graphql-typed-document-node/core" "^3.1.1" + cross-inspect "1.0.0" + dset "^3.1.2" + tslib "^2.4.0" + "@graphql-tools/utils@^9.2.1": version "9.2.1" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-9.2.1.tgz#1b3df0ef166cfa3eae706e3518b17d5922721c57" @@ -404,22 +432,6 @@ resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== -"@helsingborg-stad/gdi-api-node@^1.0.10": - version "1.0.10" - resolved "https://npm.pkg.github.com/download/@helsingborg-stad/gdi-api-node/1.0.10/7e89f031d409e2948d8b7361b69260ba46593c73#7e89f031d409e2948d8b7361b69260ba46593c73" - integrity sha512-ViHBhgyQH0/e7omJ87duzaxHJ72CtVEDalQAUcbu55cFXDBJCBflaYzMaANe16ZMS0xRkinNg8edIzsFM9ReWA== - dependencies: - "@graphql-tools/schema" "^9.0.4" - "@koa/cors" "^3.4.1" - graphql "^16.6.0" - http-errors "^2.0.0" - jsonwebtoken "^8.5.1" - koa "^2.13.4" - koa-bodyparser "^4.3.0" - koa-router "^12.0.0" - koa2-swagger-ui "^5.6.0" - openapi-backend "^5.5.0" - "@humanwhocodes/config-array@^0.11.10": version "0.11.10" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2" @@ -841,6 +853,13 @@ "@types/keygrip" "*" "@types/node" "*" +"@types/debug@^4.1.12": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + "@types/eslint@^8.44.1": version "8.44.1" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.44.1.tgz#d1811559bb6bcd1a76009e3f7883034b78a0415e" @@ -1016,6 +1035,11 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== +"@types/ms@*": + version "0.7.34" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" + integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== + "@types/ms@^0.7.31": version "0.7.31" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" @@ -2001,6 +2025,13 @@ crc-32@~1.2.0, crc-32@~1.2.1: resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== +cross-inspect@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cross-inspect/-/cross-inspect-1.0.0.tgz#5fda1af759a148594d2d58394a9e21364f6849af" + integrity sha512-4PFfn4b5ZN6FMNGSZlyb7wUhuN8wvj8t/VQHZdM4JsDcruGJ8L2kf9zao98QIrBPFCpdk27qst/AGTl7pL3ypQ== + dependencies: + tslib "^2.4.0" + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -2041,6 +2072,13 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: dependencies: ms "2.1.2" +debug@^4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -2261,6 +2299,11 @@ dotenv@^16.0.2: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== +dset@^3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.3.tgz#c194147f159841148e8e34ca41f638556d9542d2" + integrity sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ== + ecdsa-sig-formatter@1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" @@ -4178,22 +4221,6 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== -jsonwebtoken@^8.5.1: - version "8.5.1" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" - integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== - dependencies: - jws "^3.2.2" - lodash.includes "^4.3.0" - lodash.isboolean "^3.0.3" - lodash.isinteger "^4.0.4" - lodash.isnumber "^3.0.3" - lodash.isplainobject "^4.0.6" - lodash.isstring "^4.0.1" - lodash.once "^4.0.0" - ms "^2.1.1" - semver "^5.6.0" - jsonwebtoken@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d" @@ -4267,7 +4294,7 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -koa-bodyparser@^4.3.0, koa-bodyparser@^4.4.1: +koa-bodyparser@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/koa-bodyparser/-/koa-bodyparser-4.4.1.tgz#a908d848e142cc57d9eece478e932bf00dce3029" integrity sha512-kBH3IYPMb+iAXnrxIhXnW+gXV8OTzCu8VPDqvcDHW9SQrbkHmqPQtiZwrltNmSq6/lpipHnT7k7PsjlVD7kK0w== @@ -4414,36 +4441,6 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== -lodash.includes@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" - integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== - -lodash.isboolean@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" - integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== - -lodash.isinteger@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" - integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== - -lodash.isnumber@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" - integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== - -lodash.isplainobject@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" - integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== - -lodash.isstring@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" - integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== - lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -4454,11 +4451,6 @@ lodash.merge@^4.6.1, lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash.once@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" - integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== - lodash@^4.17.13, lodash@^4.17.15, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -5443,7 +5435,7 @@ sax@>=0.6.0: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== -"semver@2 || 3 || 4 || 5", semver@^5.6.0: +"semver@2 || 3 || 4 || 5": version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==