DDD
Terminal Window
cd /Volumes/files/src
git clone pathways repo
cd pathways
func init data-access --language typescript --worker-runtime node
cd data-access
func new --template "Http Trigger" --name graphql
npm i -D typescript
npm i apollo-server-azure-functions graphql@15 apollo-datasource-mongodb apollo-server-plugin-response-cache
npm install @types/node
npm i @graphql-tools/graphql-file-loader @graphql-tools/load @graphql-tools/schema graphql-scalars
npm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers
npm i -D @graphql-codegen/introspection @graphql-codegen/typed-document-node @graphql-codegen/typescript-operations
npm i @graphql-tools/load-files @graphql-tools/schema @graphql-tools/stitch @graphql-tools/json-file-loader
npm i graphql-fields graphql-middleware graphql-mongo-fields graphql-shield
npm i -D mongodb-memory-server
npm i -D npm-run-all
npm i -D jest ts-jest @types/jest
npm i mongoose nanoid
npm i jose openid-client
npm i dayjs
npm i @lucaspaganini/value-objects
npm i @azure/arm-maps @azure/cognitiveservices-contentmoderator @azure/identity @azure/ms-rest-azure-js @azure/search-documents @azure/storage-blob
package.json
"scripts": {
"build": "tsc",
"build:production": "npm run prestart && npm prune --production",
"watch": "tsc --w",
"prestart": "npm run build && func extensions install",
"start:host": "func start",
"start": "npm-run-all --parallel start:host watch",
"test": "npx jest --watchAll=true",
"gen": "graphql-codegen --config codegen.yml",
"gen:watch": "graphql-codegen --config codegen.yml --watch --silent=false"
},
tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"outDir": "dist",
"rootDir": ".",
"sourceMap": true,
"strict": false,
"esModuleInterop": true,
"resolveJsonModule": true,
}
}
host.json
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[3.3.0, 4.0.0)"
}
}
.vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Node Functions",
"type": "node",
"request": "attach",
"port": 9229,
"preLaunchTask": "func: host start"
}
,
{
"type": "node",
"request": "attach",
"name": "Jest Tests",
"preLaunchTask": "test",
}
]
}
.vscode/setting.json
{
"azureFunctions.deploySubpath": ".",
"azureFunctions.postDeployTask": "npm install (functions)",
"azureFunctions.projectLanguage": "TypeScript",
"azureFunctions.projectRuntime": "~4",
"debug.internalConsoleOptions": "neverOpen",
"azureFunctions.preDeployTask": "npm prune (functions)",
"editor.tabSize": 2,
"cSpell.words": [
"datasource",
"lucaspaganini",
"maxlength"
]
}
.vscode/tasks.json
{
"version": "2.0.0",
"tasks": [
{
"type": "func",
"command": "host start",
"problemMatcher": "$func-node-watch",
"isBackground": true,
"dependsOn": "npm build (functions)"
},
{
"type": "shell",
"label": "npm build (functions)",
"command": "npm run build",
"dependsOn": "npm install (functions)",
"problemMatcher": "$tsc"
},
{
"type": "shell",
"label": "npm install (functions)",
"command": "npm install"
},
{
"type": "shell",
"label": "test",
"command": "npx jest --watchAll=true",
"dependsOn": "npm build (functions)"
},
{
"type": "shell",
"label": "npm prune (functions)",
"command": "npm prune --production",
"dependsOn": "npm build (functions)",
"problemMatcher": []
}
]
}
.prettierrc
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 200
}
codegen.yml
# @format
overwrite: true
schema:
- './graphql/resolvers/core/graphql-tools-scalars.ts'
- './graphql/resolvers/**/**.graphql'
documents: '../ui/src/components/**/**.graphql'
generates:
graphql/generated.ts:
config:
contextType: './context#Context'
useIndexSignature: true
scalars:
BigInt: any
Byte: any
Currency: any
Date: Date
DateTime: any
Duration: any
EmailAddress: string
GUID: string
HSL: any
HSLA: any
HexColorCode: any
Hexadecimal: any
IBAN: any
IPv4: any
IPv6: any
ISBN: any
ISO8601Duration: any
JSON: any
JSONObject: any
JWT: any
Latitude: any
LocalDate: any
LocalEndTime: any
LocalTime: any
Long: any
Longitude: any
MAC: any
NegativeFloat: any
NegativeInt: any
NonEmptyString: any
NonNegativeFloat: any
NonNegativeInt: any
NonPositiveFloat: any
NonPositiveInt: any
ObjectID: any
PhoneNumber: any
Port: any
PositiveFloat: any
PositiveInt: any
PostalCode: any
RGB: any
RGBA: any
SafeInt: any
Time: any
Timestamp: any
URL: any
USCurrency: any
UUID: any
UnsignedFloat: any
UnsignedInt: any
UtcOffset: any
Void: any
plugins:
- 'typescript'
- 'typescript-resolvers'
./graphql.schema.json:
plugins:
- 'introspection'
../ui/src/generated.tsx:
config:
withHooks: true
withHOC: false
withComponent: false
plugins:
- 'typescript'
- 'typescript-operations'
- 'typed-document-node'
hooks:
afterAllFileWrite:
- npx prettier --write
graphql/function.json
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"route": "graphql/{*segments}",
"methods": ["get", "post", "options", "put", "head", "delete", "patch"]
},
{
"type": "http",
"direction": "out",
"name": "$return"
}
],
"scriptFile": "../dist/graphql/index.js"
}
graphql/context.ts
import { Passport } from '../domain/contexts/iam/passport';
import { DataSourcesType } from './data-sources';
export type Context = {
verifiedUser: {
verifiedJWT: VerifiedJWT;
openIdConfigKey: string;
};
passport: Passport;
dataSources: DataSourcesType;
executionContext: any;
}
export type VerifiedJWT = {
oid: string;
accountType: string;
}
graphql/index.ts
import { ApolloServerRequestHandler } from './init/apollo';
import { HttpRequest, Context } from "@azure/functions";
let apolloServerRequestHandler = new ApolloServerRequestHandler(
new Map<string,string>([
["CaseManagementPortal","CASE_MANAGEMENT_PORTAL"]
])
);
// Exexute the following with every http request
export default (context: Context, req: HttpRequest) => {
return apolloServerRequestHandler.handleRequests(context, req);
}
In the above codeblock, edit the Map entries as per business needs
graphql/init/extensions/passport-context.ts
import { HttpRequest } from "@azure/functions";
import { PassportImpl, Passport, ReadOnlyPassport } from "../../../domain/contexts/iam/passport";
import { Context } from "../../context";
import {Account, AccountModel} from '../../../infrastructure/data-sources/cosmos-db/models/account'
import {AccountConverter} from '../../../domain/infrastructure/persistence/account.domain-adapter'
export const decorateContext = async (context: Partial<Context>, req:HttpRequest): Promise<void> => {
let oid = context.verifiedUser.verifiedJWT.oid;
let accountType = context.verifiedUser.verifiedJWT.accountType;
let readOnlyPassport = ReadOnlyPassport.GetInstance();
if(oid && accountType) {
let account: Account = await AccountModel.findOne({oid, accountType});
if(account) {
let accountDo = new AccountConverter().toDomain(account, {passport: readOnlyPassport});
context.passport = new PassportImpl(accountDo);
}
}
else {
context.passport = readOnlyPassport;
}
}
graphql/init/extensions/portal-token-validation.ts
import { OpenIdConfig, VerifiedTokenService } from './verified-token-service';
export class PortalTokenValidation {
private tokenVerifier: VerifiedTokenService;
private tokenSettings: Map<string,OpenIdConfig>;
/**
* @param refreshInterval The number of seconds to wait between refreshing the keystore, defaults to 5 minutes
* @param openIdConfigs A map of key and enviornment variable prefix, when a JWT is verified, the matching key is returned with the verified JWT
*
* Expects to have 3 environment variables set:
* - [prefix]_OIDC_ENDPOINT
* - [prefix]_OIDC_AUDIENCE
* - [prefix]_OIDC_ISSUER
**/
constructor(portal: Map<string, string>, refreshInterval: number = 1000*60*5) {
this.tokenSettings = new Map<string,OpenIdConfig>();
for(let [portalKey, envPrefix] of portal){
this.tokenSettings.set(
portalKey,
{
oidcEndpoint: this.tryGetConfigValue(envPrefix + '_OIDC_ENDPOINT'),
audience: this.tryGetConfigValue(envPrefix + '_OIDC_AUDIENCE'),
issuerUrl: this.tryGetConfigValue(envPrefix + '_OIDC_ISSUER')
} as OpenIdConfig
);
}
this.tokenVerifier = new VerifiedTokenService(this.tokenSettings,refreshInterval);
}
public tryGetConfigValue(configKey:string){
if(process.env.hasOwnProperty(configKey)){
return process.env[configKey];
}else{
throw new Error(`Environment variable ${configKey} not set`);
}
}
public Start(){
this.tokenVerifier.Start();
}
public async GetVerifiedUser (bearerToken:string): Promise<{verifiedJWT:any,openIdConfigKey:string}|null>{
for await(let [openIdConfigKey] of this.tokenSettings){
let verifedJWT = await this.tokenVerifier.GetVerifiedJwt(bearerToken,openIdConfigKey);
console.log(`for ${openIdConfigKey} with bearerToken: ${bearerToken} verifiedJWT: ${JSON.stringify(verifedJWT)}`)
if(verifedJWT){
return {
verifiedJWT:verifedJWT.payload,
openIdConfigKey:openIdConfigKey
}
}
}
return null;
}
}
graphql/init/extensions/verified-token-service.ts
import { jwtVerify, createRemoteJWKSet, JWSHeaderParameters, FlattenedJWSInput } from 'jose';
import { GetKeyFunction, JWTVerifyResult, ResolvedKey } from 'jose/dist/types/types';
import { Issuer } from 'openid-client';
export type OpenIdConfig = {
issuerUrl:string;
oidcEndpoint:string;
audience: any;
/**
* The number of seconds to allow the current time to be off from the token's,
* (defalts to 5 minutes if not specified)
*/
clockTolerance?: string;
ignoreNbf?: boolean;
}
export class VerifiedTokenService {
openIdConfigs: Map<string,OpenIdConfig>;
refreshInterval:number;
keyStoreCollection: Map<string,{keyStore: GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput>, issuerUrl: string}>;
timerInstance:NodeJS.Timer;
/**
* @param openIdConfigs A map of key to OpenIdConfig, when a JWT is verified, the matching key is returned with the verified JWT
* @param refreshInterval The number of seconds to wait between refreshing the keystore, defaults to 5 minutes
**/
constructor(openIdConfigs:Map<string,OpenIdConfig>, refreshInterval:number = 1000*60*5) {
if(!openIdConfigs) {throw new Error('openIdConfigs is required');}
this.keyStoreCollection = new Map<string,{keyStore: GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput>, issuerUrl: string}>();
this.openIdConfigs = openIdConfigs;
this.refreshInterval = refreshInterval;
}
/**
*
* Refresh the keystore collection periodically
*
**/
public Start() {
console.log('Starting VerifiedTokenService');
if(this.timerInstance) {
return; // already running
}
//need to run immediately...
(async () => {
await this.refreshCollection();
})();
//..as setInterval only runs after the timer runs out
this.timerInstance = setInterval(() => {
(async () => {
await this.refreshCollection();
})();
}, this.refreshInterval);
}
/**
* For each OIDC Endpoint, either create a new keystore or refresh the existing one.
* Keys in the keystore expire over time, so it is important to refresh the keystore periodically
*/
async refreshCollection() {
if(!this.openIdConfigs){return}
for(let configKey of [...this.openIdConfigs.keys()]) {
let newKeyStore = {
keyStore: createRemoteJWKSet(new URL(this.openIdConfigs.get(configKey).oidcEndpoint)),
issuerUrl: this.openIdConfigs.get(configKey).issuerUrl
}
if(newKeyStore) {
if(this.keyStoreCollection.has(configKey)) {
this.keyStoreCollection.delete(configKey); // remove old keystore if it exists
}
this.keyStoreCollection.set(configKey, newKeyStore); //Update keystore with new one or add it if it doesn't exist
}
}
}
public async GetVerifiedJwt(bearerToken:string, configKey:string) : Promise<JWTVerifyResult & ResolvedKey> {
if(!this.timerInstance) {
throw new Error('ContextUserFromMsal not started');
}
if(!this.keyStoreCollection.has(configKey)) {
throw new Error('Invalid OpenIdConfig Key');
}
let openIdConfig = this.openIdConfigs.get(configKey);
return jwtVerify(
bearerToken,
this.keyStoreCollection.get(configKey).keyStore,
// createRemoteJWKSet(new URL(this.openIdConfigs.get(configKey).oidcEndpoint)),
{
audience: openIdConfig.audience,
issuer: openIdConfig.issuerUrl,
//ignoreNbf: openIdConfig.ignoreNbf??true,
clockTolerance: openIdConfig.clockTolerance?? '5 minutes',
}
)
}
}
graphql/init/extensions/util.ts
import { HttpRequest } from '@azure/functions';
/**
* Extracts the bearer token from the request assuming it is in the Authorization header and starts with 'Bearer '
* @param request The request to extract the token from
* @returns The token or null if it could not be extracted
*/
export const ExtractBearerToken = (request: HttpRequest): string => {
let token = request.headers['authorization'];
if (!token || !(token.startsWith('Bearer '))) {
return null;
}
// Remove Bearer from string
token = token.slice(7, token.length).trimLeft();
return token;
}
graphql/init/extensions/schema-builder.ts
/**
* This file merges all the schemas together to create
* the overall Apollo schema
*/
import { loadSchemaSync } from '@graphql-tools/load';
import { addResolversToSchema, mergeSchemas, makeExecutableSchema } from '@graphql-tools/schema';
import { resolvers } from '../../schema';
import { JsonFileLoader } from '@graphql-tools/json-file-loader';
import * as Scalars from 'graphql-scalars';
const schema = loadSchemaSync('./graphql.schema.json', {
loaders: [new JsonFileLoader()],
});
const appSchema = addResolversToSchema(schema,resolvers)
const scalarSchema = makeExecutableSchema({
typeDefs:[
...Scalars.typeDefs,
],
resolvers:{
...Scalars.resolvers,
}
});
export const combinedSchema = mergeSchemas({
schemas: [
appSchema,
scalarSchema,
]
});
graphql/init/apollo.ts
import { ApolloServer, CreateHandlerOptions } from 'apollo-server-azure-functions';
import { HttpRequest, Context } from '@azure/functions';
import { DataSources } from '../data-sources';
import { connect } from '../../infrastructure/data-sources/cosmos-db/connect';
import { GraphQLServiceContext } from 'apollo-server-types';
import responseCachePlugin from 'apollo-server-plugin-response-cache';
import mongoose from 'mongoose';
import { PortalTokenValidation } from './extensions/portal-token-validation';
import { combinedSchema } from './extensions/schema-builder';
import * as util from './extensions/util';
import RegisterHandlers from '../../domain/infrastructure/event-handlers/index'
import { Context as ApolloContext } from '../context';
import { applyMiddleware } from 'graphql-middleware'
import { permissions } from '../schema';
import { GraphQLSchemaWithFragmentReplacements } from 'graphql-middleware/dist/types';
import {
GraphQLRequestContext,
} from 'apollo-server-plugin-base'
import { decorateContext } from './extensions/passport-context';
export class ApolloServerRequestHandler {
private readonly serverConfig = (
portalTokenExtractor:PortalTokenValidation,
securedSchema:GraphQLSchemaWithFragmentReplacements) => {
return {
schema:securedSchema,
context: async (req:any) => { //context loads before data sources
let bearerToken = util.ExtractBearerToken(req.request);
let context:Partial<ApolloContext> ={};
bearerToken = (process.env.DO_VERIFY_TOKEN === 'true') ? bearerToken : 'fake-token';
if(bearerToken){
// let verifiedUser = await portalTokenExtractor.GetVerifiedUser(bearerToken);
let verifiedUser = (process.env.DO_VERIFY_TOKEN === 'true') ? (
await portalTokenExtractor.GetVerifiedUser(bearerToken)) : {
verifiedJWT: {
oid: '123xyz',
accountType: 'staff'
},
openIdConfigKey:'ApplicantPortal'
};
console.log('Decorating context with verified user:',JSON.stringify(verifiedUser));
if(verifiedUser){
context.verifiedUser = verifiedUser
console.log('context value is now:', JSON.stringify(context));
}
}
await decorateContext(context,req.request);
return context;
},
dataSources: () => {
return DataSources
},
// playground: { endpoint: '/api/graphql/playground' },
plugins:[
{
async didEncounterErrors (requestContext: GraphQLRequestContext) {
console.error('Apollo Server encountered error:', requestContext.errors);
},
async serverWillStart(service: GraphQLServiceContext) {
console.log('Apollo Server Starting');
await connect();
portalTokenExtractor.Start();
RegisterHandlers();
},
},
responseCachePlugin()
]
}
};
public handleRequests(context: Context, req: HttpRequest){
req.headers['x-ms-privatelink-id'] = ''; // https://github.com/Azure/azure-functions-host/issues/6013
req.headers['server'] = null; //hide microsoft server header
return this.graphqlHandlerObj(context, req)
}
private readonly graphqlHandlerObj:any;
constructor(portals:Map<string,string>){
console.log(' -=-=-=-=-=-=-=-=-= INITIALIZING APOLLO -=-=-=-=-=-=-=-=-=')
const portalTokenExtractor:PortalTokenValidation = new PortalTokenValidation(portals);
const server = new ApolloServer({
...this.serverConfig(portalTokenExtractor,
combinedSchema)
});
this.graphqlHandlerObj = server.createHandler({
cors: {
origin: true,
credentials: true,
},
// health check endpoint is: https://<function-name>.azurewebsites.net/api/graphql/.well-known/apollo/server-health
onHealthCheck: async (): Promise<any> => {
// doesn't work yet
// https://github.com/apollographql/apollo-server/pull/5270
// https://github.com/apollographql/apollo-server/pull/5003
let mongoConnected = mongoose.connection.readyState === 1;
if(mongoConnected) {
return;
} else {
throw new Error('MongoDB is not connected');
}
},
} as CreateHandlerOptions)
}
}
graphql/data-sources/index.ts
/** @format */
import { CognitiveSearch } from "./cognitive-search";
import { CosmosDB } from "./cosmos-db";
import { Domain } from "./domain";
export const DataSources = {
...CosmosDB,
...Domain,
...CognitiveSearch,
};
export type DataSourcesType = typeof DataSources;
graphql/schema/core/base.graphql
""" Core schema """
schema {
query: Query
mutation: Mutation
}
""" Base Mutation Type definition - all mutations will be defined in separate files extending this type """
type Mutation {
"""
IGNORE: Dummy field necessary for the Mutation type to be valid
"""
_empty:String
}
""" Base Query Type definition - , all mutations will be defined in separate files extending this type """
type Query {
"""
IGNORE: Dummy field necessary for the Query type to be valid
"""
_empty:String
}
""" Required to enable Apollo Cache Control """
enum CacheControlScope {
PUBLIC
PRIVATE
}
""" Required to enable Apollo Cache Control """
directive @cacheControl22(
maxAge: Int
scope: CacheControlScope
inheritMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
graphql/schema/core/graphql-tools-scalars.ts
/* ensure these remain as require statements as they get called from graphql-code-generator */
const { typeDefs } = require('graphql-scalars');
const { buildSchema } = require('graphql');
const scalars = typeDefs.join('\n')
module.exports = buildSchema(scalars);
graphql/schema/interfaces/mutation-result.graphql
interface MutationResult {
status: MutationStatus!
}
graphql/schema/shared/blob-auth-header.graphql
type BlobAuthHeader {
authHeader: String
blobName: String
blobContainer: String
requestDate: String
}
graphql/schema/shared/mutation-status.graphql
type MutationStatus {
success: Boolean!
errorMessage: String
}
graphql/schema/index.ts
/**
* This file is used to traverse all the files in this directory
* and merge them together to create the application schema
*/
import { Resolvers } from '../generated';
import path from 'path';
import { mergeResolvers } from '@graphql-tools/merge';
import { loadFilesSync } from '@graphql-tools/load-files';
console.log(`Loading resolvers from ${path.join(__dirname, "./**/*.resolvers.*")}`);
const resolversArray = loadFilesSync(path.join(__dirname, "./**/*.resolvers.*"));
const permissionsArray = loadFilesSync(path.join(__dirname, "./**/*.permissions.*"));
export const resolvers: Resolvers = mergeResolvers(resolversArray);
export const permissions = mergeResolvers(permissionsArray);
create graphql/schema/types folder to keep all the graphql types and resolver files
graphql/data-sources/blob/blob-data-source.ts
import { DataSource,DataSourceConfig } from 'apollo-datasource';
import { Context as GraphQLContext } from '../../context';
import { Passport } from '../../../domain/contexts/iam/passport';
import { BlobStorage } from '../../../infrastructure/services/blob-storage';
export class BlobDataSource<Context extends GraphQLContext> extends DataSource<Context> {
private _context: Context;
private _blobStorage: BlobStorage;
public get context(): Context { return this._context;}
public async withStorage(func:(passport:Passport, blobStorage:BlobStorage) => Promise<void>): Promise<void> {
let passport = this.context.passport; //await getPassport(this.context);
await func(passport, this._blobStorage);
}
public initialize(config: DataSourceConfig<Context>): void {
this._context = config.context;
this._blobStorage = new BlobStorage();
}
}
graphql/data-sources/blob/index.ts
import { Communities } from "./communities";
import { Members } from "./members";
import { Properties } from "./properties";
export const Blob = {
communityBlobAPI: new Communities(),
memberBlobAPI: new Members(),
propertyBlobAPI: new Properties(),
}
Last updated