зеркало из https://github.com/mozilla/fxa.git
Merge pull request #16417 from mozilla/firestore-cacheable
feat(auth): type-cacheable firestore and networkfirst adapter
This commit is contained in:
Коммит
9d2b26eecc
|
@ -40,14 +40,14 @@ export class CapabilityManager {
|
|||
): Promise<Record<string, string[]>> {
|
||||
if (!subscribedPrices.length) return {};
|
||||
|
||||
const purchaseDetails =
|
||||
await this.contentfulManager.getPurchaseDetailsForCapabilityServiceByPlanIds(
|
||||
[...subscribedPrices]
|
||||
);
|
||||
|
||||
const result: Record<string, string[]> = {};
|
||||
|
||||
for (const subscribedPrice of subscribedPrices) {
|
||||
const purchaseDetails =
|
||||
await this.contentfulManager.getPurchaseDetailsForCapabilityServiceByPlanIds(
|
||||
[subscribedPrice]
|
||||
);
|
||||
|
||||
const capabilityOffering =
|
||||
purchaseDetails.capabilityOfferingForPlanId(subscribedPrice);
|
||||
|
||||
|
|
|
@ -20,6 +20,12 @@ export class ContentfulClientConfig {
|
|||
@IsString()
|
||||
public readonly graphqlEnvironment!: string;
|
||||
|
||||
@IsString()
|
||||
public readonly firestoreCacheCollectionName!: string;
|
||||
|
||||
@IsNumber()
|
||||
public readonly cacheTTL?: number;
|
||||
public readonly memCacheTTL?: number;
|
||||
|
||||
@IsNumber()
|
||||
public readonly firestoreCacheTTL?: number;
|
||||
}
|
||||
|
|
|
@ -22,6 +22,15 @@ jest.mock('graphql-request', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock('@fxa/shared/db/type-cacheable', () => ({
|
||||
FirestoreCacheable: () => {
|
||||
return (target: any, propertyKey: any, descriptor: any) => {
|
||||
return descriptor;
|
||||
};
|
||||
},
|
||||
NetworkFirstStrategy: function () {},
|
||||
}));
|
||||
|
||||
describe('ContentfulClient', () => {
|
||||
let contentfulClient: ContentfulClient;
|
||||
const onCallback = jest.fn();
|
||||
|
@ -33,6 +42,7 @@ describe('ContentfulClient', () => {
|
|||
graphqlApiUri: faker.string.uuid(),
|
||||
graphqlSpaceId: faker.string.uuid(),
|
||||
graphqlEnvironment: faker.string.uuid(),
|
||||
firestoreCacheCollectionName: faker.string.uuid(),
|
||||
});
|
||||
contentfulClient.on('response', onCallback);
|
||||
});
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { OperationVariables } from '@apollo/client';
|
||||
import type { OperationVariables } from '@apollo/client';
|
||||
import { GraphQLClient } from 'graphql-request';
|
||||
import { TypedDocumentNode } from '@graphql-typed-document-node/core';
|
||||
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { determineLocale } from '@fxa/shared/l10n';
|
||||
import { DEFAULT_LOCALE } from './constants';
|
||||
|
@ -16,8 +16,14 @@ import {
|
|||
} from './contentful.error';
|
||||
import { ContentfulErrorResponse } from './types';
|
||||
import EventEmitter from 'events';
|
||||
import {
|
||||
FirestoreCacheable,
|
||||
NetworkFirstStrategy,
|
||||
} from '@fxa/shared/db/type-cacheable';
|
||||
import { CONTENTFUL_QUERY_CACHE_KEY, cacheKeyForQuery } from './util';
|
||||
|
||||
const DEFAULT_CACHE_TTL = 300000; // Milliseconds
|
||||
const DEFAULT_FIRESTORE_CACHE_TTL = 604800; // Seconds. 604800 is 7 days.
|
||||
const DEFAULT_MEM_CACHE_TTL = 300; // Seconds
|
||||
|
||||
interface EventResponse {
|
||||
method: string;
|
||||
|
@ -36,12 +42,12 @@ export class ContentfulClient {
|
|||
`${this.contentfulClientConfig.graphqlApiUri}/spaces/${this.contentfulClientConfig.graphqlSpaceId}/environments/${this.contentfulClientConfig.graphqlEnvironment}?access_token=${this.contentfulClientConfig.graphqlApiKey}`
|
||||
);
|
||||
private locales: string[] = [];
|
||||
private graphqlResultCache: Record<string, unknown> = {};
|
||||
private emitter: EventEmitter;
|
||||
public on: (
|
||||
event: 'response',
|
||||
listener: (response: EventResponse) => void
|
||||
) => EventEmitter;
|
||||
private graphqlMemCache: Record<string, unknown> = {};
|
||||
|
||||
constructor(private contentfulClientConfig: ContentfulClientConfig) {
|
||||
this.setupCacheBust();
|
||||
|
@ -58,33 +64,42 @@ export class ContentfulClient {
|
|||
return result;
|
||||
}
|
||||
|
||||
@FirestoreCacheable(
|
||||
{
|
||||
cacheKey: (args: any) => cacheKeyForQuery(args[0], args[1]),
|
||||
strategy: new NetworkFirstStrategy(),
|
||||
ttlSeconds: (_, context) =>
|
||||
context.contentfulClientConfig.firestoreCacheTTL ||
|
||||
DEFAULT_FIRESTORE_CACHE_TTL,
|
||||
},
|
||||
{
|
||||
collectionName: (_, context) =>
|
||||
context.contentfulClientConfig.firestoreCacheCollectionName ||
|
||||
CONTENTFUL_QUERY_CACHE_KEY,
|
||||
}
|
||||
)
|
||||
async query<Result, Variables extends OperationVariables>(
|
||||
query: TypedDocumentNode<Result, Variables>,
|
||||
variables: Variables
|
||||
): Promise<Result> {
|
||||
// Sort variables prior to stringifying to not be caller order dependent
|
||||
const variablesString = JSON.stringify(
|
||||
variables,
|
||||
Object.keys(variables as Record<string, unknown>).sort()
|
||||
);
|
||||
const cacheKey = variablesString + query;
|
||||
const cacheKey = cacheKeyForQuery(query, variables);
|
||||
|
||||
const emitterResponse = {
|
||||
method: 'query',
|
||||
query,
|
||||
variables: variablesString,
|
||||
variables: JSON.stringify(variables),
|
||||
requestStartTime: Date.now(),
|
||||
cache: false,
|
||||
};
|
||||
|
||||
if (this.graphqlResultCache[cacheKey]) {
|
||||
if (this.graphqlMemCache[cacheKey]) {
|
||||
this.emitter.emit('response', {
|
||||
...emitterResponse,
|
||||
requestEndTime: emitterResponse.requestStartTime,
|
||||
elapsed: 0,
|
||||
cache: true,
|
||||
});
|
||||
return this.graphqlResultCache[cacheKey] as Result;
|
||||
return this.graphqlMemCache[cacheKey] as Result;
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -100,7 +115,7 @@ export class ContentfulClient {
|
|||
requestEndTime,
|
||||
});
|
||||
|
||||
this.graphqlResultCache[cacheKey] = response;
|
||||
this.graphqlMemCache[cacheKey] = response;
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
@ -178,11 +193,12 @@ export class ContentfulClient {
|
|||
}
|
||||
|
||||
private setupCacheBust() {
|
||||
const cacheTTL = this.contentfulClientConfig.cacheTTL || DEFAULT_CACHE_TTL;
|
||||
const cacheTTL =
|
||||
this.contentfulClientConfig.memCacheTTL || DEFAULT_MEM_CACHE_TTL * 1000;
|
||||
|
||||
setInterval(() => {
|
||||
this.locales = [];
|
||||
this.graphqlResultCache = {};
|
||||
this.graphqlMemCache = {};
|
||||
}, cacheTTL);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,15 @@ import { PurchaseWithDetailsOfferingContentUtil } from './queries/purchase-with-
|
|||
import { PurchaseWithDetailsOfferingContentByPlanIdsResultFactory } from './queries/purchase-with-details-offering-content/factories';
|
||||
import { StatsD } from 'hot-shots';
|
||||
|
||||
jest.mock('@fxa/shared/db/type-cacheable', () => ({
|
||||
FirestoreCacheable: () => {
|
||||
return (target: any, propertyKey: any, descriptor: any) => {
|
||||
return descriptor;
|
||||
};
|
||||
},
|
||||
NetworkFirstStrategy: function () {},
|
||||
}));
|
||||
|
||||
describe('ContentfulManager', () => {
|
||||
let manager: ContentfulManager;
|
||||
let mockClient: ContentfulClient;
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import type { OperationVariables, TypedDocumentNode } from '@apollo/client';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export const CONTENTFUL_QUERY_CACHE_KEY = 'contentfulQueryCache';
|
||||
|
||||
export const cacheKeyForQuery = function <
|
||||
Result,
|
||||
Variables extends OperationVariables
|
||||
>(query: TypedDocumentNode<Result, Variables>, variables: Variables): string {
|
||||
// Sort variables prior to stringifying to not be caller order dependent
|
||||
const variablesString = JSON.stringify(
|
||||
variables,
|
||||
Object.keys(variables as Record<string, unknown>).sort()
|
||||
);
|
||||
const queryHash = createHash('sha256')
|
||||
.update(JSON.stringify(query))
|
||||
.digest('hex');
|
||||
const variableHash = createHash('sha256')
|
||||
.update(variablesString)
|
||||
.digest('hex');
|
||||
return `${CONTENTFUL_QUERY_CACHE_KEY}:${queryHash}:${variableHash}`;
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": ["../../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
# shared-db-firestore
|
||||
|
||||
Instantiates a NestJS injectable instance of firestore
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test shared-db-firestore` to execute the unit tests via [Jest](https://jestjs.io).
|
|
@ -0,0 +1,11 @@
|
|||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'shared-db-firestore',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/shared/db/firestore',
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "@fxa/shared/db/firestore",
|
||||
"version": "0.0.0"
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "shared-db-firestore",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/db/firestore/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/shared/db/firestore",
|
||||
"tsConfig": "libs/shared/db/firestore/tsconfig.lib.json",
|
||||
"packageJson": "libs/shared/db/firestore/package.json",
|
||||
"main": "libs/shared/db/firestore/src/index.ts",
|
||||
"assets": ["libs/shared/db/firestore/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/shared/db/firestore/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test-unit": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/shared/db/firestore/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"codeCoverage": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["scope:shared:lib"]
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
export * from './lib/firestore.provider';
|
||||
export * from './lib/firestore-typedi-token';
|
|
@ -0,0 +1,8 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
import { Token } from 'typedi';
|
||||
|
||||
export const AuthFirestore = new Token<Firestore>('AUTH_FIRESTORE');
|
|
@ -0,0 +1,57 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* Creates a firestore instance from a settings object.
|
||||
* @param config
|
||||
* @returns A firestore instance
|
||||
*/
|
||||
export function setupFirestore(config: FirebaseFirestore.Settings) {
|
||||
const fsConfig = Object.assign({}, config);
|
||||
// keyFilename takes precedence over credentials
|
||||
if (fsConfig.keyFilename) {
|
||||
delete fsConfig.credentials;
|
||||
}
|
||||
|
||||
const testing = !(fsConfig.keyFilename || fsConfig.credentials);
|
||||
|
||||
// Utilize the local firestore emulator when the env indicates
|
||||
if (process.env.FIRESTORE_EMULATOR_HOST || testing) {
|
||||
return new Firestore({
|
||||
customHeaders: {
|
||||
Authorization: 'Bearer owner',
|
||||
},
|
||||
port: 9090,
|
||||
projectId: 'demo-fxa',
|
||||
servicePath: 'localhost',
|
||||
sslCreds: grpc.credentials.createInsecure(),
|
||||
});
|
||||
} else {
|
||||
return new Firestore(fsConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for providing access to firestore
|
||||
*/
|
||||
export const FirestoreService = Symbol('FIRESTORE');
|
||||
export const FirestoreFactory: Provider<Firestore> = {
|
||||
provide: FirestoreService,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const firestoreConfig = configService.get('authFirestore');
|
||||
if (firestoreConfig == null) {
|
||||
throw new Error(
|
||||
"Could not locate config for firestore missing 'authFirestore';"
|
||||
);
|
||||
}
|
||||
const firestore = setupFirestore(firestoreConfig);
|
||||
return firestore;
|
||||
},
|
||||
inject: [ConfigService],
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": ["../../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
# shared-db-type-cacheable
|
||||
|
||||
Customized implementations for the type-cacheable library.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test shared-db-type-cacheable` to execute the unit tests via [Jest](https://jestjs.io).
|
|
@ -0,0 +1,11 @@
|
|||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'shared-db-type-cacheable',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/shared/db/type-cacheable',
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "@fxa/shared/db/type-cacheable",
|
||||
"version": "0.0.0"
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "shared-db-type-cacheable",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/db/type-cacheable/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/shared/db/type-cacheable",
|
||||
"tsConfig": "libs/shared/db/type-cacheable/tsconfig.lib.json",
|
||||
"packageJson": "libs/shared/db/type-cacheable/package.json",
|
||||
"main": "libs/shared/db/type-cacheable/src/index.ts",
|
||||
"assets": ["libs/shared/db/type-cacheable/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/shared/db/type-cacheable/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test-unit": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/shared/db/type-cacheable/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"codeCoverage": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["scope:shared:lib"]
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
export * from './lib/firestore-cacheable';
|
||||
export * from './lib/type-cachable-network-first';
|
||||
export * from './lib/type-cachable-firestore-adapter';
|
|
@ -0,0 +1,65 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Cacheable, CacheOptions } from '@type-cacheable/core';
|
||||
import { AuthFirestore, FirestoreService } from '@fxa/shared/db/firestore';
|
||||
import { FirestoreAdapter } from './type-cachable-firestore-adapter';
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
import Container from 'typedi';
|
||||
|
||||
export interface FirestoreCacheableOptions {
|
||||
collectionName: string | ((args: any[], context: any) => string);
|
||||
}
|
||||
|
||||
export function FirestoreCacheable(
|
||||
cacheableOptions: CacheOptions,
|
||||
firestoreOptions: FirestoreCacheableOptions
|
||||
) {
|
||||
// We try to fetch Firestore here with Nest DI, but need typedi compatibility where there's no Nest for now.
|
||||
let injectFirestore: ReturnType<typeof Inject<Firestore>> | undefined;
|
||||
try {
|
||||
injectFirestore = Inject(FirestoreService);
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
propertyDescriptor: PropertyDescriptor
|
||||
) => {
|
||||
injectFirestore?.(target, 'firestore');
|
||||
|
||||
const originalMethod = propertyDescriptor.value;
|
||||
|
||||
propertyDescriptor.value = async function (...args: any[]) {
|
||||
// We try to fetch typedi Firestore if available in case this was loaded in a non-Nest environment
|
||||
let typediFirestore: Firestore | undefined;
|
||||
try {
|
||||
typediFirestore = Container.get<Firestore>(AuthFirestore);
|
||||
} catch (e) {}
|
||||
|
||||
const firestore = typediFirestore || (this as any).firestore;
|
||||
if (!firestore) {
|
||||
throw new Error('Could not load Firestore from Nest or typedi');
|
||||
}
|
||||
|
||||
const collectionName =
|
||||
typeof firestoreOptions.collectionName === 'string'
|
||||
? firestoreOptions.collectionName
|
||||
: firestoreOptions.collectionName(args, this);
|
||||
|
||||
const newDescriptor = Cacheable({
|
||||
...cacheableOptions,
|
||||
client: new FirestoreAdapter(firestore, collectionName),
|
||||
})(target, propertyKey, {
|
||||
...propertyDescriptor,
|
||||
value: originalMethod,
|
||||
});
|
||||
|
||||
return await newDescriptor.value.apply(this, args);
|
||||
};
|
||||
|
||||
return propertyDescriptor;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
import cacheManager, {
|
||||
CacheClient,
|
||||
CacheManagerOptions,
|
||||
} from '@type-cacheable/core';
|
||||
|
||||
export class FirestoreAdapter implements CacheClient {
|
||||
constructor(firestoreClient: Firestore, collectionName: string) {
|
||||
this.firestoreClient = firestoreClient;
|
||||
this.collectionName = collectionName;
|
||||
|
||||
this.get = this.get.bind(this);
|
||||
this.del = this.del.bind(this);
|
||||
this.delHash = this.delHash.bind(this);
|
||||
this.getClientTTL = this.getClientTTL.bind(this);
|
||||
this.keys = this.keys.bind(this);
|
||||
this.set = this.set.bind(this);
|
||||
}
|
||||
|
||||
private readonly firestoreClient: Firestore;
|
||||
private readonly collectionName: string;
|
||||
|
||||
public async get(cacheKey: string): Promise<any> {
|
||||
const result = await this.firestoreClient
|
||||
.collection(this.collectionName)
|
||||
.doc(cacheKey)
|
||||
.get();
|
||||
|
||||
const data = result?.data();
|
||||
const cachedValue = data?.value;
|
||||
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
public async set(cacheKey: string, value: any, ttl?: number): Promise<any> {
|
||||
await this.firestoreClient
|
||||
.collection(this.collectionName)
|
||||
.doc(cacheKey)
|
||||
.set({
|
||||
value,
|
||||
ttl: ttl || 0,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
public getClientTTL(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async del(keyOrKeys: string | string[]): Promise<any> {
|
||||
if (Array.isArray(keyOrKeys) && !keyOrKeys.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const keysToDelete = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
|
||||
|
||||
for (const cacheKey of keysToDelete) {
|
||||
await this.firestoreClient
|
||||
.collection(this.collectionName)
|
||||
.doc(cacheKey)
|
||||
.delete();
|
||||
}
|
||||
}
|
||||
|
||||
public async keys(_: string): Promise<string[]> {
|
||||
throw new Error('keys() not supported for cachable FirestoreAdapter');
|
||||
}
|
||||
|
||||
public async delHash(_: string | string[]): Promise<any> {
|
||||
throw new Error('delHash() not supported for cachable FirestoreAdapter');
|
||||
}
|
||||
}
|
||||
|
||||
export const useAdapter = (
|
||||
client: Firestore,
|
||||
collectionName: string,
|
||||
asFallback?: boolean,
|
||||
options?: CacheManagerOptions
|
||||
): FirestoreAdapter => {
|
||||
const firestoreAdapter = new FirestoreAdapter(client, collectionName);
|
||||
|
||||
if (asFallback) {
|
||||
cacheManager.setFallbackClient(firestoreAdapter);
|
||||
} else {
|
||||
cacheManager.setClient(firestoreAdapter);
|
||||
}
|
||||
|
||||
if (options) {
|
||||
cacheManager.setOptions(options);
|
||||
}
|
||||
|
||||
return firestoreAdapter;
|
||||
};
|
|
@ -0,0 +1,140 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import {
|
||||
CacheClient,
|
||||
CacheStrategy,
|
||||
CacheStrategyContext,
|
||||
} from '@type-cacheable/core';
|
||||
|
||||
export class NetworkFirstStrategy implements CacheStrategy {
|
||||
private pendingCacheRequestMap = new Map<string, Promise<any>>();
|
||||
private pendingMethodCallMap = new Map<string, Promise<any>>();
|
||||
|
||||
private findCachedValue = async (client: CacheClient, key: string) => {
|
||||
let cachedValue: any;
|
||||
|
||||
try {
|
||||
if (this.pendingCacheRequestMap.has(key)) {
|
||||
cachedValue = await this.pendingCacheRequestMap.get(key);
|
||||
} else {
|
||||
const cachePromise = client.get(key);
|
||||
this.pendingCacheRequestMap.set(key, cachePromise);
|
||||
cachedValue = await cachePromise;
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
this.pendingCacheRequestMap.delete(key);
|
||||
}
|
||||
|
||||
return cachedValue;
|
||||
};
|
||||
|
||||
async handle(context: CacheStrategyContext): Promise<any> {
|
||||
let result: any;
|
||||
const pendingMethodRun = this.pendingMethodCallMap.get(context.key);
|
||||
|
||||
if (pendingMethodRun) {
|
||||
result = await pendingMethodRun;
|
||||
} else {
|
||||
const methodPromise = new Promise(async (resolve, reject) => {
|
||||
let returnValue;
|
||||
let isCacheable = false;
|
||||
try {
|
||||
returnValue = await context.originalMethod.apply(
|
||||
context.originalMethodScope,
|
||||
context.originalMethodArgs
|
||||
);
|
||||
isCacheable = context.isCacheable(returnValue);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCacheable) {
|
||||
resolve(returnValue);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await context.client.set(context.key, returnValue, context.ttl);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (context.fallbackClient) {
|
||||
try {
|
||||
await context.fallbackClient.set(
|
||||
context.key,
|
||||
returnValue,
|
||||
context.ttl
|
||||
);
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (context.debug) {
|
||||
console.warn(
|
||||
`type-cacheable Cacheable set cache failure on method ${
|
||||
context.originalMethod.name
|
||||
} due to client error: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(returnValue);
|
||||
});
|
||||
|
||||
try {
|
||||
this.pendingMethodCallMap.set(context.key, methodPromise);
|
||||
result = await methodPromise;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
this.pendingMethodCallMap.delete(context.key);
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const cachedValue = await this.findCachedValue(
|
||||
context.client,
|
||||
context.key
|
||||
);
|
||||
|
||||
// If a value for the cacheKey was found in cache, simply return that.
|
||||
if (cachedValue !== undefined && cachedValue !== null) {
|
||||
return cachedValue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (context.fallbackClient) {
|
||||
try {
|
||||
const cachedValue = await this.findCachedValue(
|
||||
context.fallbackClient,
|
||||
context.key
|
||||
);
|
||||
|
||||
// If a value for the cacheKey was found in cache, simply return that.
|
||||
if (cachedValue !== undefined && cachedValue !== null) {
|
||||
return cachedValue;
|
||||
}
|
||||
} catch (fallbackErr) {}
|
||||
}
|
||||
|
||||
if (context.debug) {
|
||||
console.warn(
|
||||
`type-cacheable Cacheable cache miss on method ${
|
||||
context.originalMethod.name
|
||||
} due to client error: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
|
@ -120,7 +120,8 @@ async function run(config) {
|
|||
config.contentful.graphqlUrl &&
|
||||
config.contentful.apiKey &&
|
||||
config.contentful.spaceId &&
|
||||
config.contentful.environment
|
||||
config.contentful.environment &&
|
||||
config.contentful.firestoreCacheCollectionName
|
||||
) {
|
||||
const contentfulClient = new ContentfulClient({
|
||||
cdnApiUri: config.contentful.cdnUrl,
|
||||
|
@ -128,6 +129,8 @@ async function run(config) {
|
|||
graphqlApiKey: config.contentful.apiKey,
|
||||
graphqlSpaceId: config.contentful.spaceId,
|
||||
graphqlEnvironment: config.contentful.environment,
|
||||
firestoreCacheCollectionName:
|
||||
config.contentful.firestoreCacheCollectionName,
|
||||
});
|
||||
const contentfulManager = new ContentfulManager(contentfulClient, statsd);
|
||||
Container.set(ContentfulManager, contentfulManager);
|
||||
|
|
|
@ -2011,6 +2011,12 @@ const convictConf = convict({
|
|||
env: 'CONTENTFUL_GRAPHQL_ENVIRONMENT',
|
||||
format: String,
|
||||
},
|
||||
firestoreCacheCollectionName: {
|
||||
default: '',
|
||||
doc: 'Firestore collection name to store Contentful query cache',
|
||||
env: 'CONTENTFUL_FIRESTORE_CACHE_COLLECTION_NAME',
|
||||
format: String,
|
||||
},
|
||||
},
|
||||
|
||||
cloudTasks: {
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
import { Request, RequestApplicationState } from '@hapi/hapi';
|
||||
import { Token } from 'typedi';
|
||||
import { Logger } from 'mozlog';
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
import { ConfigType } from '../config';
|
||||
|
||||
/**
|
||||
|
@ -87,7 +86,7 @@ export interface AuthLogger extends Logger {
|
|||
// Container token types
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const AuthLogger = new Token<AuthLogger>('AUTH_LOGGER');
|
||||
export const AuthFirestore = new Token<Firestore>('AUTH_FIRESTORE');
|
||||
export const AppConfig = new Token<ConfigType>('APP_CONFIG');
|
||||
export { AuthFirestore } from '@fxa/shared/db/firestore';
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const ProfileClient = new Token<ProfileClient>('PROFILE_CLIENT');
|
||||
|
|
|
@ -72,6 +72,12 @@ const conf = convict({
|
|||
env: 'CONTENTFUL_GRAPHQL_ENVIRONMENT',
|
||||
default: '',
|
||||
},
|
||||
firestoreCacheCollectionName: {
|
||||
doc: 'Firestore collection name to store Contentful query cache',
|
||||
format: String,
|
||||
env: 'CONTENTFUL_FIRESTORE_CACHE_COLLECTION_NAME',
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
corsOrigin: {
|
||||
doc: 'Value for the Access-Control-Allow-Origin response header',
|
||||
|
|
|
@ -36,6 +36,8 @@
|
|||
"libs/shared/account/account/src/index.ts"
|
||||
],
|
||||
"@fxa/shared/contentful": ["libs/shared/contentful/src/index.ts"],
|
||||
"@fxa/shared/db/type-cacheable": ["libs/shared/db/type-cacheable/src/index.ts"],
|
||||
"@fxa/shared/db/firestore": ["libs/shared/db/firestore/src/index.ts"],
|
||||
"@fxa/shared/db/mysql/account": [
|
||||
"libs/shared/db/mysql/account/src/index.ts"
|
||||
],
|
||||
|
|
Загрузка…
Ссылка в новой задаче