From 802bfeed455294bb0c01c675370eda36099e2ce9 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Wed, 5 Aug 2020 11:56:46 +0200 Subject: [PATCH 01/21] Initial network implementation --- packages/datx-jsonapi/tsconfig.build.json | 16 +- packages/datx-network/.npmignore | 10 + packages/datx-network/LICENSE | 21 ++ packages/datx-network/README.md | 0 packages/datx-network/package.json | 69 +++++ packages/datx-network/rollup.config.js | 57 ++++ packages/datx-network/src/NetworkPipeline.ts | 167 ++++++++++++ packages/datx-network/src/Response.ts | 255 ++++++++++++++++++ packages/datx-network/src/cache.ts | 228 ++++++++++++++++ packages/datx-network/src/defaults.ts | 118 ++++++++ packages/datx-network/src/enums/BodyType.ts | 6 + .../datx-network/src/enums/CachingStrategy.ts | 10 + packages/datx-network/src/enums/HttpMethod.ts | 9 + .../datx-network/src/enums/ParamArrayType.ts | 6 + packages/datx-network/src/helpers/utils.ts | 109 ++++++++ packages/datx-network/src/index.ts | 29 ++ .../datx-network/src/interfaces/FetchType.ts | 8 + .../src/interfaces/IConfigType.ts | 16 ++ .../src/interfaces/IFetchOptions.ts | 14 + .../datx-network/src/interfaces/IHeaders.ts | 1 + .../src/interfaces/IInterceptor.ts | 9 + .../src/interfaces/INetworkHandler.ts | 8 + .../src/interfaces/IPipeOperator.ts | 3 + .../src/interfaces/IRequestOptions.ts | 14 + .../src/interfaces/IResponseHeaders.ts | 4 + .../src/interfaces/IResponseInternal.ts | 16 ++ .../src/interfaces/IResponseObject.ts | 12 + .../src/interfaces/IResponseSnapshot.ts | 6 + packages/datx-network/src/operators.ts | 92 +++++++ packages/datx-network/src/withNetwork.ts | 1 + .../test/mock/MockNetworkPipeline.ts | 5 + packages/datx-network/test/request.test.ts | 89 ++++++ packages/datx-network/tsconfig.build.json | 24 ++ packages/datx-network/tsconfig.json | 9 + packages/datx-utils/package.json | 4 +- 35 files changed, 1431 insertions(+), 14 deletions(-) create mode 100644 packages/datx-network/.npmignore create mode 100644 packages/datx-network/LICENSE create mode 100644 packages/datx-network/README.md create mode 100644 packages/datx-network/package.json create mode 100644 packages/datx-network/rollup.config.js create mode 100644 packages/datx-network/src/NetworkPipeline.ts create mode 100644 packages/datx-network/src/Response.ts create mode 100644 packages/datx-network/src/cache.ts create mode 100644 packages/datx-network/src/defaults.ts create mode 100644 packages/datx-network/src/enums/BodyType.ts create mode 100644 packages/datx-network/src/enums/CachingStrategy.ts create mode 100644 packages/datx-network/src/enums/HttpMethod.ts create mode 100644 packages/datx-network/src/enums/ParamArrayType.ts create mode 100644 packages/datx-network/src/helpers/utils.ts create mode 100644 packages/datx-network/src/index.ts create mode 100644 packages/datx-network/src/interfaces/FetchType.ts create mode 100644 packages/datx-network/src/interfaces/IConfigType.ts create mode 100644 packages/datx-network/src/interfaces/IFetchOptions.ts create mode 100644 packages/datx-network/src/interfaces/IHeaders.ts create mode 100644 packages/datx-network/src/interfaces/IInterceptor.ts create mode 100644 packages/datx-network/src/interfaces/INetworkHandler.ts create mode 100644 packages/datx-network/src/interfaces/IPipeOperator.ts create mode 100644 packages/datx-network/src/interfaces/IRequestOptions.ts create mode 100644 packages/datx-network/src/interfaces/IResponseHeaders.ts create mode 100644 packages/datx-network/src/interfaces/IResponseInternal.ts create mode 100644 packages/datx-network/src/interfaces/IResponseObject.ts create mode 100644 packages/datx-network/src/interfaces/IResponseSnapshot.ts create mode 100644 packages/datx-network/src/operators.ts create mode 100644 packages/datx-network/src/withNetwork.ts create mode 100644 packages/datx-network/test/mock/MockNetworkPipeline.ts create mode 100644 packages/datx-network/test/request.test.ts create mode 100644 packages/datx-network/tsconfig.build.json create mode 100644 packages/datx-network/tsconfig.json diff --git a/packages/datx-jsonapi/tsconfig.build.json b/packages/datx-jsonapi/tsconfig.build.json index 84d9cd68b..4a5f84a25 100644 --- a/packages/datx-jsonapi/tsconfig.build.json +++ b/packages/datx-jsonapi/tsconfig.build.json @@ -9,12 +9,7 @@ "forceConsistentCasingInFileNames": true, "sourceMap": true, "inlineSources": true, - "lib": [ - "es2015", - "es2016", - "es2017", - "dom" - ], + "lib": ["es2015", "es2016", "es2017", "dom"], "module": "esnext", "noImplicitAny": false, "noImplicitReturns": true, @@ -24,11 +19,6 @@ "strictFunctionTypes": false, "target": "es5" }, - "exclude": [ - "node_modules", - "examples" - ], - "include": [ - "src/**/*" - ] + "exclude": ["node_modules", "examples"], + "include": ["src/**/*"] } diff --git a/packages/datx-network/.npmignore b/packages/datx-network/.npmignore new file mode 100644 index 000000000..efc2c91c7 --- /dev/null +++ b/packages/datx-network/.npmignore @@ -0,0 +1,10 @@ +docs +src +test +tsconfig.json +tsconfig.build.json +tslint.json +coverage +.rpt2_cache +node_modules +rollup.config.js \ No newline at end of file diff --git a/packages/datx-network/LICENSE b/packages/datx-network/LICENSE new file mode 100644 index 000000000..a8fe07005 --- /dev/null +++ b/packages/datx-network/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Infinum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/datx-network/README.md b/packages/datx-network/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/datx-network/package.json b/packages/datx-network/package.json new file mode 100644 index 000000000..c7458754c --- /dev/null +++ b/packages/datx-network/package.json @@ -0,0 +1,69 @@ +{ + "name": "datx-network", + "version": "2.0.0-beta.4", + "description": "DatX network layer", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/infinum/datx.git" + }, + "bugs": { + "url": "https://github.com/infinum/datx/issues" + }, + "homepage": "https://github.com/infinum/datx#readme", + "author": "Infinum JavaScript Team ", + "license": "MIT", + "keywords": [ + "datx", + "mobx" + ], + "devDependencies": { + "@rollup/plugin-commonjs": "^13.0.0", + "@rollup/plugin-node-resolve": "^8.1.0", + "@types/jest": "^26.0.3", + "@types/node": "^14.0.14", + "jest": "^26.1.0", + "mobx": "^5.15.4", + "rollup": "^2.18.1", + "rollup-plugin-exclude-dependencies-from-bundle": "^1.1.10", + "rollup-plugin-terser": "^6.1.0", + "rollup-plugin-typescript2": "^0.27.1", + "ts-jest": "^26.1.1", + "typescript": "^3.9.5" + }, + "peerDependencies": { + "mobx": "^4.2.0 || ^5.5.0" + }, + "scripts": { + "test": "jest --coverage", + "watch": "jest --watch --coverage", + "prepublish": "npm run build", + "build": "rollup -c" + }, + "jest": { + "coveragePathIgnorePatterns": [ + "/test/", + "/node_modules/" + ], + "moduleFileExtensions": [ + "ts", + "js" + ], + "testRegex": "test/(.*).test.ts$", + "globals": { + "ts-jest": { + "diagnostics": { + "warnOnly": true + } + } + }, + "preset": "ts-jest", + "testMatch": null + }, + "dependencies": { + "datx": "^2.0.0-beta.4" + } +} diff --git a/packages/datx-network/rollup.config.js b/packages/datx-network/rollup.config.js new file mode 100644 index 000000000..c74990dd2 --- /dev/null +++ b/packages/datx-network/rollup.config.js @@ -0,0 +1,57 @@ +import typescript from 'rollup-plugin-typescript2'; +import { terser } from 'rollup-plugin-terser'; +import commonjs from '@rollup/plugin-commonjs'; +import resolve from '@rollup/plugin-node-resolve'; +import excludeDependenciesFromBundle from 'rollup-plugin-exclude-dependencies-from-bundle'; + +import pkg from './package.json'; + +export default [ + { + input: './src/index.ts', + output: [{ file: pkg.main, format: 'cjs' }], + plugins: [ + resolve(), + commonjs(), + excludeDependenciesFromBundle(), + typescript({ + check: true, + typescript: require('typescript'), + tsconfig: './tsconfig.build.json', + }), + terser({ + toplevel: true, + compress: { + passes: 3, + }, + output: { + comments: false, + }, + }), + ], + onwarn(warning, rollupWarn) { + if (warning.code !== 'CIRCULAR_DEPENDENCY') { + rollupWarn(warning); + } + }, + }, + { + input: './src/index.ts', + output: [{ file: pkg.module, format: 'es' }], + plugins: [ + resolve(), + commonjs(), + excludeDependenciesFromBundle(), + typescript({ + check: true, + typescript: require('typescript'), + tsconfig: './tsconfig.build.json', + }), + ], + onwarn(warning, rollupWarn) { + if (warning.code !== 'CIRCULAR_DEPENDENCY') { + rollupWarn(warning); + } + }, + }, +]; diff --git a/packages/datx-network/src/NetworkPipeline.ts b/packages/datx-network/src/NetworkPipeline.ts new file mode 100644 index 000000000..4c43d5f73 --- /dev/null +++ b/packages/datx-network/src/NetworkPipeline.ts @@ -0,0 +1,167 @@ +import { baseFetch, getDefaultConfig } from './defaults'; +import { IConfigType } from './interfaces/IConfigType'; +import { IHeaders } from './interfaces/IHeaders'; +import { IInterceptor } from './interfaces/IInterceptor'; +import { IPipeOperator } from './interfaces/IPipeOperator'; +import { Response } from './Response'; +import { PureModel } from 'datx'; +import { IFetchOptions } from './interfaces/IFetchOptions'; +import { IResponseObject } from './interfaces/IResponseObject'; +import { deepCopy, interpolateParams, appendQueryParams } from './helpers/utils'; +import { INetworkHandler } from './interfaces/INetworkHandler'; +import { HttpMethod } from './enums/HttpMethod'; +import { cacheInterceptor } from './cache'; +import { BodyType } from './enums/BodyType'; + +interface IHookOptions { + suspense?: boolean; +} + +interface IRequestOptions { + method: HttpMethod; + url?: string; + params: Record; + query: Record | object>; + headers: IHeaders; + body?: any; + bodyType: BodyType; +} + +// TODO: A response generic +// TODO: A params generic + +export class NetworkPipeline { + private _config: IConfigType = getDefaultConfig(); + private _options: IRequestOptions = { + method: HttpMethod.Get, + headers: {}, + query: {}, + params: {}, + bodyType: BodyType.Json, + }; + private _interceptors: Array = []; + + public get config(): IConfigType { + return this._config; + } + + public get options(): IRequestOptions { + return this._options; + } + + constructor(baseUrl: string) { + this._config.baseUrl = baseUrl; + } + + protected baseFetch( + method: string, + url: string, + body?: string | FormData, + requestHeaders?: IHeaders, + ): Promise { + return baseFetch(this, method, url, body, requestHeaders); + } + + public pipe(...operators: Array): NetworkPipeline { + const destinationPipeline = this.clone(); + operators.forEach((operator) => operator(destinationPipeline)); + + return destinationPipeline as NetworkPipeline; + } + + private doRequest(options: IFetchOptions): Promise { + return this.baseFetch(options.method, options.url, options.data, this.options.headers).then( + (resp) => { + return { + data: resp.data, + status: resp.status, + headers: resp.headers, + requestHeaders: resp.requestHeaders, + error: resp.error, + collection: resp.collection, + }; + }, + ); + } + + private processBody(): string | FormData { + if (this._options.bodyType === BodyType.Json) { + this._options.headers['Content-Type'] = 'application/json'; + return typeof this._options.body === 'string' + ? this._options.body + : JSON.stringify(this._options.body); + } else if (this._options.bodyType === BodyType.Urlencoded) { + this._options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + return typeof this._options.body === 'string' + ? this._options.body + : appendQueryParams( + '', + this._options.body, + this._config.paramArrayType, + this._config.encodeQueryString, + ).slice(1); + } else if (this._options.bodyType === BodyType.Multipart) { + return this._options.body instanceof FormData + ? this._options.body + : new FormData(this._options.body); + } else { + return typeof this._options.body === 'string' + ? this._options.body + : JSON.stringify(this._options.body); + } + } + + public fetch(params: Partial = {}): Promise> { + if (!this.options.url) { + throw new Error('URL should be defined'); + } + const urlParams = Object.assign({}, this.options.params, params); + const url = interpolateParams(this.options.url, urlParams); + const processedUrl = appendQueryParams( + url, + this.options.query, + this.config.paramArrayType, + this.config.encodeQueryString, + ); + const initialCallback = this._interceptors.reverse().reduce( + (callback: INetworkHandler, interceptor: IInterceptor) => { + return (options: IFetchOptions): Promise> => + interceptor(options, callback); + }, + (options: IFetchOptions) => { + return cacheInterceptor( + this._config.cache, + this._config.maxCacheAge, + this, + )(options, this.doRequest.bind(this)); + }, + ); + + // The error is not handled on purpose so UnhandledPromiseRejectionWarning is triggered if the client doesn't handle the error + return initialCallback({ + url: processedUrl, + method: this._options.method, + data: this.processBody(), + }); + } + + public useHook( + _params: object, + _options?: IHookOptions, + ): [Response, boolean, string | Error] { + // TODO useHook + throw new Error('Not yet implemented'); + } + + public clone( + NetworkPipelineConstructor: typeof NetworkPipeline = this.constructor as typeof NetworkPipeline, + ): NetworkPipeline { + // Can't use `new NetworkPipeline`, because we would lose the overridden methods + const clone = new NetworkPipelineConstructor(this._config.baseUrl); + clone._config = deepCopy(this._config); + clone._interceptors = this._interceptors.slice(); + clone._options = deepCopy(this._options); + + return clone as NetworkPipeline; + } +} diff --git a/packages/datx-network/src/Response.ts b/packages/datx-network/src/Response.ts new file mode 100644 index 000000000..856ae12bb --- /dev/null +++ b/packages/datx-network/src/Response.ts @@ -0,0 +1,255 @@ +import { + PureModel, + PureCollection, + View, + Bucket, + getModelId, + getModelType, + updateModel, + modelToJSON, + updateModelId, +} from 'datx'; +import { IResponseHeaders } from './interfaces/IResponseHeaders'; +import { IHeaders } from './interfaces/IHeaders'; +import { IRequestOptions } from './interfaces/IRequestOptions'; +import { IResponseInternal } from './interfaces/IResponseInternal'; +import { IResponseSnapshot } from './interfaces/IResponseSnapshot'; +import { action } from 'mobx'; +import { IResponseObject } from './interfaces/IResponseObject'; + +function serializeHeaders( + headers: Array<[string, string]> | IResponseHeaders, +): Array<[string, string]> { + if (headers instanceof Array) { + return headers; + } + + const list: Array<[string, string]> = []; + + headers.forEach((value: string, key: string) => { + list.push([key, value]); + }); + + return list; +} + +function initHeaders(headers: Array<[string, string]> | IResponseHeaders): IResponseHeaders { + if (headers instanceof Array) { + return new Headers(headers); + } + + return headers; +} + +function initData( + response: IResponseObject, + collection?: PureCollection, + overrideData?: T | Array, +): any { + if (collection && response.data) { + const data: any = overrideData || collection.add(response.data); + + return new Bucket.ToOneOrMany(data, collection as any, true); + } + + if (response.data) { + // The case when a record is not in a store and save/remove are used + if (response.data) { + if (response.data instanceof Array) { + throw new Error('A save/remove operation should not return an array of results'); + } + + return { + value: new PureModel(response.data), // TODO: Make a Generic model + // value: overrideData || (new GenericModel(flattenModel(undefined, resp.data)) as T), + }; + } + } + + return new Bucket.ToOneOrMany(null, collection as any, true); +} + +export class Response { + private __data; + + private __internal: IResponseInternal = { + response: {}, + views: [], + }; + + /** + * Headers received from the API call + * + * @type {IResponseHeaders} + * @memberOf Response + */ + public get headers(): IResponseHeaders | undefined { + return this.__internal.headers; + } + + /** + * Headers sent to the server + * + * @type {IHeaders} + * @memberOf Response + */ + public get requestHeaders(): IHeaders | undefined { + return this.__internal.requestHeaders; + } + + /** + * Request error + * + * @type {(Array|Error)} + * @memberOf Response + */ + public get error(): Array | Error | undefined { + return this.__internal.error; + } + + /** + * Received HTTP status + * + * @type {number} + * @memberOf Response + */ + public get status(): number | undefined { + return this.__internal.status; + } + + public get views(): Array { + return this.__internal.views; + } + + /** + * Related Store + * + * @type {PureCollection} + * @memberOf Response + */ + public readonly collection?: PureCollection; + + public get isSuccess(): boolean { + return !this.error; + } + + public get data(): T | Array | null { + return this.__data.value; + } + + constructor( + response: IResponseObject, + collection?: PureCollection, + options?: IRequestOptions, + overrideData?: T | Array, + views?: Array, + ) { + this.collection = collection; + this.__updateInternal(response, options, views); + this.__data = initData(response, collection, overrideData); + + this.views.forEach((view) => { + if (this.__data.value) { + view.add(this.__data.value); + } + }); + + Object.freeze(this); + + if (this.error) { + throw this; + } + } + + private __updateInternal( + response: IResponseObject, + options?: IRequestOptions, + views?: Array, + ): void { + if (options) { + this.__internal.options = options; + } + + this.__internal.response = response; + this.__internal.headers = response.headers && initHeaders(response.headers); + this.__internal.requestHeaders = response.requestHeaders; + this.__internal.error = response.error; + this.__internal.status = response.status; + + if (views) { + this.__internal.views = views; + } + + if (!this.error && !this.status) { + this.__internal.error = new Error('Network not available'); + } + } + + /** + * Replace the response record with a different record. Used to replace a record while keeping the same reference + * + * @param {PureModel} data New data + * @returns {Response} + * + * @memberOf Response + */ + @action + public replaceData(data: T): Response { + const record: PureModel = this.data as PureModel; + + if (record === data) { + return this; + } + + const newId = getModelId(record).toString(); + const type = getModelType(record); + + const viewIndexes = this.views.map((view) => view.list.indexOf(record)); + + if (this.collection) { + this.collection.removeOne(type, newId); + this.collection.add(data); + } + + updateModel(data, modelToJSON(record)); + updateModelId(data, newId); + + this.views.forEach((view, index) => { + if (viewIndexes[index] !== -1) { + view.list[viewIndexes[index]] = data; + } + }); + + return new Response(this.__internal.response, this.collection, this.__internal.options, data); + } + + public clone(): Response { + return new Response( + this.__internal.response, + this.collection, + this.__internal.options, + this.data || undefined, + ); + } + + public get snapshot(): IResponseSnapshot { + return { + response: Object.assign({}, this.__internal.response, { + headers: + this.__internal.response.headers && serializeHeaders(this.__internal.response.headers), + collection: undefined, + }), + options: this.__internal.options, + }; + } + + @action + public update(response: IResponseObject, views?: Array): Response { + this.__updateInternal(response, undefined, views); + const newData = initData(response, this.collection); + + this.__data.__readonlyValue = newData.value; + + return this; + } +} diff --git a/packages/datx-network/src/cache.ts b/packages/datx-network/src/cache.ts new file mode 100644 index 000000000..6c3002355 --- /dev/null +++ b/packages/datx-network/src/cache.ts @@ -0,0 +1,228 @@ +import { getModelType, IType, PureModel, PureCollection } from 'datx'; +import { mapItems } from 'datx-utils'; + +import { Response } from './Response'; +import { IResponseSnapshot } from './interfaces/IResponseSnapshot'; +import { CachingStrategy } from './enums/CachingStrategy'; +import { IFetchOptions } from './interfaces/IFetchOptions'; +import { IResponseObject } from './interfaces/IResponseObject'; +import { HttpMethod } from './enums/HttpMethod'; +import { NetworkPipeline } from './NetworkPipeline'; + +export type INextHandler = (request: IFetchOptions) => Promise; + +export interface ICache { + response: Response; + time: number; + types: Array; + url: string; +} + +export interface ICacheInternal { + response: IResponseSnapshot; + collection?: PureCollection; + time: number; + types: Array; + url: string; +} + +let cacheStorage: Array = []; + +export function saveCache(url: string, response: Response): void { + if (response && response.isSuccess && (response.data || response.data === null)) { + const types = mapItems(response.data || [], getModelType) as IType | Array; + + cacheStorage = cacheStorage.filter((item) => item.url !== url); + + cacheStorage.unshift({ + response: response.snapshot, + collection: response.collection, + time: Date.now(), + types: ([] as Array).concat(types), + url, + }); + } +} + +export function getCache(url: string, maxAge: number): ICache | undefined { + const ageLimit = Date.now() - maxAge * 1000; + const cache = cacheStorage.find((item) => item.url === url && item.time > ageLimit); + + if (cache) { + const data = cache.response; + + return { + // @ts-ignore Array headers that are supported but shouldn't be exposed in types + response: new Response(data.response, cache.collection, data.options), + time: cache.time, + types: cache.types, + url: cache.url, + }; + } + + return undefined; +} + +export function clearAllCache(): void { + cacheStorage.length = 0; +} + +export function clearCacheByType(type: IType): void { + cacheStorage = cacheStorage.filter((item) => !item.types.includes(type)); +} + +export function getCacheByCollection( + collection: PureCollection, +): Array> { + return cacheStorage + .filter((item) => item.collection === collection) + .map((item) => Object.assign({}, item, { collection: undefined })); +} + +export function saveCacheForCollection( + cacheItems: Array>, + collection: PureCollection, +): void { + // eslint-disable-next-line prefer-spread + cacheStorage.push.apply( + cacheStorage, + cacheItems.map((item) => Object.assign({ collection }, item)), + ); +} + +function makeNetworkCall( + params: IFetchOptions, + next: INextHandler, + networkPipeline: NetworkPipeline, + doCacheResponse = false, + existingResponse?: Response, +): Promise> { + return next(params) + .then((response: IResponseObject) => { + const collectionResponse = Object.assign({}, response, { collection: params.collection }); + + if (existingResponse) { + existingResponse.update(collectionResponse, params.views); + return existingResponse; + } + + return new Response( + networkPipeline.config.parse(collectionResponse), + params.collection, + params.options, + undefined, + params.views, + ); + }) + .then((response: Response) => { + if (doCacheResponse) { + saveCache(params.url, response); + } + return response; + }); +} + +function getLocalNetworkError( + message: string, + reqOptions: IFetchOptions, + collection?: PureCollection, +): Response { + return new Response( + { + error: new Error(message), + // collection, + requestHeaders: reqOptions.options?.networkConfig?.headers, + }, + collection, + reqOptions.options, + ); +} + +export function cacheInterceptor( + cache: CachingStrategy, + maxCacheAge: number, + networkPipeline: NetworkPipeline, +) { + return (request: IFetchOptions, next: INextHandler): Promise> => { + const isCacheSupported = request.method === HttpMethod.Get; + + const cacheStrategy = + request.options?.cacheOptions?.skipCache || !isCacheSupported + ? CachingStrategy.NETWORK_ONLY + : request.options?.cacheOptions?.cachingStrategy || cache; + + // NETWORK_ONLY - Ignore cache + if (cacheStrategy === CachingStrategy.NETWORK_ONLY) { + return makeNetworkCall(request, next, networkPipeline); + } + + const cacheContent: { response: Response } | undefined = (getCache( + request.url, + maxCacheAge, + ) as unknown) as { response: Response } | undefined; + + // NETWORK_FIRST - Fallback to cache only on network error + if (cacheStrategy === CachingStrategy.NETWORK_FIRST) { + return makeNetworkCall(request, next, networkPipeline, true).catch((errorResponse) => { + if (cacheContent) { + return cacheContent.response; + } + throw errorResponse; + }); + } + + // STALE_WHILE_REVALIDATE - Use cache and update it in background + if (cacheStrategy === CachingStrategy.STALE_WHILE_REVALIDATE) { + const network = makeNetworkCall(request, next, networkPipeline, true); + + if (cacheContent) { + network.catch(() => { + // Ignore the failure + }); + return Promise.resolve(cacheContent.response); + } + + return network; + } + + // CACHE_ONLY - Fail if nothing in cache + if (cacheStrategy === CachingStrategy.CACHE_ONLY) { + if (cacheContent) { + return Promise.resolve(cacheContent.response); + } + + return Promise.reject( + getLocalNetworkError('No cache for this request', request, request?.collection), + ); + } + + // PREFER_CACHE - Use cache if available + if (cacheStrategy === CachingStrategy.CACHE_FIRST) { + return cacheContent + ? Promise.resolve(cacheContent.response) + : makeNetworkCall(request, next, networkPipeline, true); + } + + // STALE_AND_UPDATE - Use cache and update response once network is complete + if (cacheStrategy === CachingStrategy.STALE_AND_UPDATE) { + const existingResponse = cacheContent?.response?.clone(); + + const network = makeNetworkCall(request, next, networkPipeline, true, existingResponse); + + if (existingResponse) { + network.catch(() => { + // Ignore the failure + }); + return Promise.resolve(existingResponse); + } + + return network; + } + + return Promise.reject( + getLocalNetworkError('Invalid caching strategy', request, request?.collection), + ); + }; +} + +export type IInterceptor = (request: IFetchOptions, next: INextHandler) => Promise; diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts new file mode 100644 index 000000000..731f74541 --- /dev/null +++ b/packages/datx-network/src/defaults.ts @@ -0,0 +1,118 @@ +import { IConfigType } from './interfaces/IConfigType'; +import { IHeaders } from './interfaces/IHeaders'; +import { CachingStrategy } from './enums/CachingStrategy'; +import { isBrowser } from './helpers/utils'; +import { ParamArrayType } from './enums/ParamArrayType'; +import { NetworkPipeline } from './NetworkPipeline'; +import { IResponseHeaders } from './interfaces/IResponseHeaders'; +import { PureModel } from 'datx'; +import { IResponseObject } from './interfaces/IResponseObject'; +import { BodyType } from './enums/BodyType'; + +/** + * Base implementation of the fetch function (can be overridden) + * + * @param {IConfigType} config The request config + * @param {string} method API call method + * @param {string} url API call URL + * @param {object} [body] API call body + * @param {IHeaders} [requestHeaders] Headers that will be sent + * @returns {Promise} Resolves with a raw response object + */ +export function baseFetch( + requestObj: NetworkPipeline, + method: string, + url: string, + body?: string | FormData, + requestHeaders: IHeaders = {}, +): Promise { + let data: object; + let status: number; + let headers: IResponseHeaders; + + const request: Promise = Promise.resolve(); + + const uppercaseMethod = method.toUpperCase(); + const isBodySupported = uppercaseMethod !== 'GET' && uppercaseMethod !== 'HEAD'; + + return request + .then(() => { + const defaultHeaders = requestObj.config.defaultFetchOptions.headers || {}; + const reqHeaders: IHeaders = Object.assign({}, defaultHeaders, requestHeaders) as IHeaders; + const options = Object.assign({}, requestObj.config.defaultFetchOptions, { + body: (isBodySupported && body) || undefined, + headers: reqHeaders, + method, + }); + + if (requestObj.config.fetchReference) { + return requestObj.config.fetchReference(url, options); + } + throw new Error('Fetch reference needs to be defined before using the network'); + }) + .then((response: Response) => { + status = response.status; + headers = response.headers; + + return response.json(); + }) + .catch((error: Error) => { + if (status === 204) { + return null; + } + throw error; + }) + .then((responseData: object) => { + data = responseData; + if (status >= 400) { + throw { + message: `Invalid HTTP status: ${status}`, + status, + }; + } + + return { data, headers, requestHeaders, status }; + }) + .catch((error) => { + throw { data, error, headers, requestHeaders, status }; + }); +} + +export function getDefaultConfig(): IConfigType { + return { + // Base URL for all API calls + baseUrl: '/', + + // Enable caching by default in the browser + cache: isBrowser ? CachingStrategy.CACHE_FIRST : CachingStrategy.NETWORK_ONLY, + maxCacheAge: Infinity, + + // Default options that will be passed to the fetch function + defaultFetchOptions: { + headers: { + 'content-type': 'application/vnd.api+json', + }, + }, + + encodeQueryString: true, + + // Reference of the fetch method that should be used + fetchReference: + (isBrowser && + 'fetch' in window && + typeof window.fetch === 'function' && + window.fetch.bind(window)) || + undefined, + + // Determines how will the request param arrays be stringified + paramArrayType: ParamArrayType.PARAM_ARRAY, + + serialize(data: object, _type: BodyType): object { + return data; + }, + + parse(data: object): object { + return data; + }, + }; +} diff --git a/packages/datx-network/src/enums/BodyType.ts b/packages/datx-network/src/enums/BodyType.ts new file mode 100644 index 000000000..64ab90f09 --- /dev/null +++ b/packages/datx-network/src/enums/BodyType.ts @@ -0,0 +1,6 @@ +export enum BodyType { + Raw, + Json, + Multipart, + Urlencoded, +} diff --git a/packages/datx-network/src/enums/CachingStrategy.ts b/packages/datx-network/src/enums/CachingStrategy.ts new file mode 100644 index 000000000..1fa75ad92 --- /dev/null +++ b/packages/datx-network/src/enums/CachingStrategy.ts @@ -0,0 +1,10 @@ +// Based on service worker strategies https://developers.google.com/web/tools/workbox/modules/workbox-strategies + +export enum CachingStrategy { + NETWORK_ONLY = 1, // Ignore cache + NETWORK_FIRST = 2, // Fallback to cache only on network error + STALE_WHILE_REVALIDATE = 3, // Use cache and update it in background + CACHE_ONLY = 4, // Fail if nothing in cache + CACHE_FIRST = 5, // Use cache if available + STALE_AND_UPDATE = 6, // Use cache and update response once network is complete +} diff --git a/packages/datx-network/src/enums/HttpMethod.ts b/packages/datx-network/src/enums/HttpMethod.ts new file mode 100644 index 000000000..365fe62fa --- /dev/null +++ b/packages/datx-network/src/enums/HttpMethod.ts @@ -0,0 +1,9 @@ +export enum HttpMethod { + Get = 'GET', + Post = 'POST', + Put = 'PUT', + Patch = 'Patch', + Delete = 'Delete', + Options = 'Options', + Head = 'Head', +} diff --git a/packages/datx-network/src/enums/ParamArrayType.ts b/packages/datx-network/src/enums/ParamArrayType.ts new file mode 100644 index 000000000..dbd3898e5 --- /dev/null +++ b/packages/datx-network/src/enums/ParamArrayType.ts @@ -0,0 +1,6 @@ +export enum ParamArrayType { + MULTIPLE_PARAMS, // filter[a]=1&filter[a]=2 + COMMA_SEPARATED, // filter[a]=1,2 + PARAM_ARRAY, // filter[a][]=1&filter[a][]=2 + OBJECT_PATH, // filter[a.0]=1&filter[a.1]=2 +} diff --git a/packages/datx-network/src/helpers/utils.ts b/packages/datx-network/src/helpers/utils.ts new file mode 100644 index 000000000..b093f74de --- /dev/null +++ b/packages/datx-network/src/helpers/utils.ts @@ -0,0 +1,109 @@ +import { ParamArrayType } from '../enums/ParamArrayType'; + +export const isBrowser: boolean = typeof window !== 'undefined'; + +export function deepCopy(inObject: T): T { + let value: any; + let key: string | number | symbol; + + if (typeof inObject !== 'object' || inObject === null) { + return inObject; // Return the value if inObject is not an object + } + + // Create an array or object to hold the values + const outObject: object = Array.isArray(inObject) ? [] : {}; + + for (key in inObject) { + value = inObject[key]; + + // Recursively (deep) copy for nested objects, including arrays + outObject[key] = deepCopy(value); + } + + return outObject as T; +} + +const interpolationRegex = /\{\s*([a-zA-Z0-9\-_]+)\s*\}/g; + +export function interpolateParams(url: string, params: Record): string { + let newUrl = url; + let match = interpolationRegex.exec(newUrl); + let lastIndex = 0; + while (match) { + let param = params[match[1]]; + if (param === undefined) { + param = match[0]; + lastIndex = interpolationRegex.lastIndex; + } + newUrl = newUrl.replace(match[0], param); + interpolationRegex.lastIndex = lastIndex; + match = interpolationRegex.exec(newUrl); + } + return newUrl; +} + +function parametrize( + paramArrayType: ParamArrayType, + params: object, + scope = '', +): Array<{ key: string; value: string }> { + const list: Array<{ key: string; value: string }> = []; + + Object.keys(params).forEach((key) => { + if (params[key] instanceof Array) { + if (paramArrayType === ParamArrayType.OBJECT_PATH) { + // eslint-disable-next-line prefer-spread + list.push.apply(list, parametrize(paramArrayType, params[key], `${key}.`)); + } else if (paramArrayType === ParamArrayType.COMMA_SEPARATED) { + list.push({ key: `${scope}${key}`, value: params[key].join(',') }); + } else if (paramArrayType === ParamArrayType.MULTIPLE_PARAMS) { + // eslint-disable-next-line prefer-spread + list.push.apply( + list, + params[key].map((param) => ({ key: `${scope}${key}`, value: param })), + ); + } else if (paramArrayType === ParamArrayType.PARAM_ARRAY) { + // eslint-disable-next-line prefer-spread + list.push.apply( + list, + params[key].map((param) => ({ key: `${scope}${key}][`, value: param })), + ); + } + } else if (typeof params[key] === 'object') { + // eslint-disable-next-line prefer-spread + list.push.apply(list, parametrize(paramArrayType, params[key], `${key}.`)); + } else { + list.push({ key: `${scope}${key}`, value: params[key] }); + } + }); + + return list; +} + +function appendParams(url: string, params: Array): string { + let newUrl = url; + + if (params.length) { + const separator = newUrl.indexOf('?') === -1 ? '?' : '&'; + + newUrl += separator + params.join('&'); + } + + return newUrl; +} + +export function appendQueryParams( + url: string, + query: Record | object>, + paramArrayType: ParamArrayType, + encodeQueryString: boolean, +): string { + const processedParams = parametrize(paramArrayType, query) + .map(({ key, value }) => ({ + key, + value: encodeQueryString ? encodeURIComponent(value) : value, + })) + .map(({ key, value }) => `${key}=${value}`); + + return appendParams(url, processedParams); +} diff --git a/packages/datx-network/src/index.ts b/packages/datx-network/src/index.ts new file mode 100644 index 000000000..dd18b0385 --- /dev/null +++ b/packages/datx-network/src/index.ts @@ -0,0 +1,29 @@ +export { NetworkPipeline } from './NetworkPipeline'; +export { Response } from './Response'; + +export { + addInterceptor, + cache, + method, + setUrl, + body, + query, + header, + params, + fetchReference, + encodeQueryString, + paramArrayType, + serializer, + parser, +} from './operators'; + +export { CachingStrategy } from './enums/CachingStrategy'; +export { HttpMethod } from './enums/HttpMethod'; +export { ParamArrayType } from './enums/ParamArrayType'; + +export { IFetchOptions } from './interfaces/IFetchOptions'; +export { IHeaders } from './interfaces/IHeaders'; +export { IInterceptor } from './interfaces/IInterceptor'; +export { INetworkHandler } from './interfaces/INetworkHandler'; +export { IPipeOperator } from './interfaces/IPipeOperator'; +export { IResponseObject } from './interfaces/IResponseObject'; diff --git a/packages/datx-network/src/interfaces/FetchType.ts b/packages/datx-network/src/interfaces/FetchType.ts new file mode 100644 index 000000000..d66cca65c --- /dev/null +++ b/packages/datx-network/src/interfaces/FetchType.ts @@ -0,0 +1,8 @@ +import { IHeaders } from './IHeaders'; + +export type FetchType = ( + method: string, + url: string, + body?: object, + requestHeaders?: IHeaders, +) => Promise; diff --git a/packages/datx-network/src/interfaces/IConfigType.ts b/packages/datx-network/src/interfaces/IConfigType.ts new file mode 100644 index 000000000..7ef414de0 --- /dev/null +++ b/packages/datx-network/src/interfaces/IConfigType.ts @@ -0,0 +1,16 @@ +import { CachingStrategy } from '../enums/CachingStrategy'; +import { ParamArrayType } from '../enums/ParamArrayType'; +import { IResponseObject } from './IResponseObject'; +import { BodyType } from '../enums/BodyType'; + +export interface IConfigType { + baseUrl: string; + cache: CachingStrategy; + maxCacheAge: number; + defaultFetchOptions: Record; + fetchReference?: typeof fetch; + paramArrayType: ParamArrayType; + encodeQueryString: boolean; + serialize(data: IResponseObject, type: BodyType): IResponseObject; + parse(data: IResponseObject): IResponseObject; +} diff --git a/packages/datx-network/src/interfaces/IFetchOptions.ts b/packages/datx-network/src/interfaces/IFetchOptions.ts new file mode 100644 index 000000000..48a6305dd --- /dev/null +++ b/packages/datx-network/src/interfaces/IFetchOptions.ts @@ -0,0 +1,14 @@ +import { View, PureCollection } from 'datx'; + +import { IRequestOptions } from './IRequestOptions'; +import { HttpMethod } from '../enums/HttpMethod'; + +export interface IFetchOptions { + url: string; + options?: IRequestOptions; + data?: string | FormData; + method: HttpMethod; + collection?: PureCollection; + skipCache?: boolean; + views?: Array; +} diff --git a/packages/datx-network/src/interfaces/IHeaders.ts b/packages/datx-network/src/interfaces/IHeaders.ts new file mode 100644 index 000000000..f3723bef0 --- /dev/null +++ b/packages/datx-network/src/interfaces/IHeaders.ts @@ -0,0 +1 @@ +export type IHeaders = Record; diff --git a/packages/datx-network/src/interfaces/IInterceptor.ts b/packages/datx-network/src/interfaces/IInterceptor.ts new file mode 100644 index 000000000..55991f578 --- /dev/null +++ b/packages/datx-network/src/interfaces/IInterceptor.ts @@ -0,0 +1,9 @@ +import { IFetchOptions } from './IFetchOptions'; +import { Response } from '../Response'; +import { INetworkHandler } from './INetworkHandler'; +import { PureModel } from 'datx'; + +export type IInterceptor = ( + request: IFetchOptions, + next: INetworkHandler, +) => Promise>; diff --git a/packages/datx-network/src/interfaces/INetworkHandler.ts b/packages/datx-network/src/interfaces/INetworkHandler.ts new file mode 100644 index 000000000..ecd6f1cc2 --- /dev/null +++ b/packages/datx-network/src/interfaces/INetworkHandler.ts @@ -0,0 +1,8 @@ +import { IFetchOptions } from './IFetchOptions'; +import { PureModel } from 'datx'; + +import { Response } from '../Response'; + +export type INetworkHandler = ( + request: IFetchOptions, +) => Promise>; diff --git a/packages/datx-network/src/interfaces/IPipeOperator.ts b/packages/datx-network/src/interfaces/IPipeOperator.ts new file mode 100644 index 000000000..a2179e685 --- /dev/null +++ b/packages/datx-network/src/interfaces/IPipeOperator.ts @@ -0,0 +1,3 @@ +import { NetworkPipeline } from '../NetworkPipeline'; + +export type IPipeOperator = (request: NetworkPipeline) => void; diff --git a/packages/datx-network/src/interfaces/IRequestOptions.ts b/packages/datx-network/src/interfaces/IRequestOptions.ts new file mode 100644 index 000000000..4b4914e97 --- /dev/null +++ b/packages/datx-network/src/interfaces/IRequestOptions.ts @@ -0,0 +1,14 @@ +import { IHeaders } from './IHeaders'; +import { CachingStrategy } from '../enums/CachingStrategy'; + +export interface IRequestOptions { + query?: Array<{ key: string; value: string } | string>; + cacheOptions?: { + cachingStrategy?: CachingStrategy; + maxAge?: number; + skipCache?: boolean; + }; + networkConfig?: { + headers?: IHeaders; + }; +} diff --git a/packages/datx-network/src/interfaces/IResponseHeaders.ts b/packages/datx-network/src/interfaces/IResponseHeaders.ts new file mode 100644 index 000000000..6ccb81efa --- /dev/null +++ b/packages/datx-network/src/interfaces/IResponseHeaders.ts @@ -0,0 +1,4 @@ +export interface IResponseHeaders { + get(name: string): string | null; + forEach(cb: (value: string, key: string) => void): void; +} diff --git a/packages/datx-network/src/interfaces/IResponseInternal.ts b/packages/datx-network/src/interfaces/IResponseInternal.ts new file mode 100644 index 000000000..848cfd5ae --- /dev/null +++ b/packages/datx-network/src/interfaces/IResponseInternal.ts @@ -0,0 +1,16 @@ +import { View } from 'datx'; + +import { IHeaders } from './IHeaders'; +import { IRequestOptions } from './IRequestOptions'; +import { IResponseHeaders } from './IResponseHeaders'; +import { IResponseObject } from './IResponseObject'; + +export interface IResponseInternal { + headers?: IResponseHeaders; + requestHeaders?: IHeaders; + error?: Array | Error; + status?: number; + options?: IRequestOptions; + response: IResponseObject; + views: Array; +} diff --git a/packages/datx-network/src/interfaces/IResponseObject.ts b/packages/datx-network/src/interfaces/IResponseObject.ts new file mode 100644 index 000000000..c392f55a1 --- /dev/null +++ b/packages/datx-network/src/interfaces/IResponseObject.ts @@ -0,0 +1,12 @@ +import { IResponseHeaders } from './IResponseHeaders'; +import { IHeaders } from './IHeaders'; +import { PureCollection } from 'datx'; + +export interface IResponseObject { + data?: object; + error?: Error; + headers?: IResponseHeaders; + requestHeaders?: IHeaders; + status?: number; + collection?: PureCollection; +} diff --git a/packages/datx-network/src/interfaces/IResponseSnapshot.ts b/packages/datx-network/src/interfaces/IResponseSnapshot.ts new file mode 100644 index 000000000..e98d33616 --- /dev/null +++ b/packages/datx-network/src/interfaces/IResponseSnapshot.ts @@ -0,0 +1,6 @@ +import { IRequestOptions } from './IRequestOptions'; + +export interface IResponseSnapshot { + response: Omit & { headers?: Array<[string, string]> }; + options?: IRequestOptions; +} diff --git a/packages/datx-network/src/operators.ts b/packages/datx-network/src/operators.ts new file mode 100644 index 000000000..11476023c --- /dev/null +++ b/packages/datx-network/src/operators.ts @@ -0,0 +1,92 @@ +import { NetworkPipeline } from './NetworkPipeline'; +import { IInterceptor } from './interfaces/IInterceptor'; +import { CachingStrategy } from './enums/CachingStrategy'; +import { HttpMethod } from './enums/HttpMethod'; +import { BodyType } from './enums/BodyType'; +import { ParamArrayType } from './enums/ParamArrayType'; + +export function setUrl(url: string) { + return (pipeline: NetworkPipeline): void => { + pipeline.options.url = url; + }; +} + +export function addInterceptor(interceptor: IInterceptor) { + return (pipeline: NetworkPipeline): void => { + pipeline['_interceptors'].push(interceptor); + }; +} + +export function cache(strategy: CachingStrategy, maxAge = Infinity) { + return (pipeline: NetworkPipeline): void => { + pipeline.config.cache = strategy; + pipeline.config.maxCacheAge = maxAge; + }; +} + +export function method(method: HttpMethod) { + return (pipeline: NetworkPipeline): void => { + pipeline.options.method = method; + }; +} + +export function body(body: any, bodyType: BodyType = BodyType.Json) { + return (pipeline: NetworkPipeline): void => { + pipeline.options.body = pipeline.config.serialize(body, bodyType); + pipeline.options.bodyType = bodyType; + }; +} + +export function query(name: string, value: string | Array | object) { + return (pipeline: NetworkPipeline): void => { + pipeline.options.query[name] = value; + }; +} + +export function header(name: string, value: string) { + return (pipeline: NetworkPipeline): void => { + pipeline.options.headers[name] = value; + }; +} + +export function params(name: string, value: string): (pipeline: NetworkPipeline) => void; +export function params(params: Record): (pipeline: NetworkPipeline) => void; +export function params(name: string | Record, value?: string) { + return (pipeline: NetworkPipeline): void => { + if (typeof name === 'string') { + pipeline.options.params[name] = value as string; + } else { + Object.assign(pipeline.options.params, name); + } + }; +} + +export function fetchReference(fetchReference: typeof fetch) { + return (pipeline: NetworkPipeline): void => { + pipeline.config.fetchReference = fetchReference; + }; +} + +export function encodeQueryString(encodeQueryString: boolean) { + return (pipeline: NetworkPipeline): void => { + pipeline.config.encodeQueryString = encodeQueryString; + }; +} + +export function paramArrayType(paramArrayType: ParamArrayType) { + return (pipeline: NetworkPipeline): void => { + pipeline.config.paramArrayType = paramArrayType; + }; +} + +export function serializer(serialize: (data: object, _type: BodyType) => object) { + return (pipeline: NetworkPipeline): void => { + pipeline.config.serialize = serialize; + }; +} + +export function parser(parse: (data: object) => object) { + return (pipeline: NetworkPipeline): void => { + pipeline.config.parse = parse; + }; +} diff --git a/packages/datx-network/src/withNetwork.ts b/packages/datx-network/src/withNetwork.ts new file mode 100644 index 000000000..6d82cae47 --- /dev/null +++ b/packages/datx-network/src/withNetwork.ts @@ -0,0 +1 @@ +// TODO: withNetwork diff --git a/packages/datx-network/test/mock/MockNetworkPipeline.ts b/packages/datx-network/test/mock/MockNetworkPipeline.ts new file mode 100644 index 000000000..fcda26af3 --- /dev/null +++ b/packages/datx-network/test/mock/MockNetworkPipeline.ts @@ -0,0 +1,5 @@ +import { NetworkPipeline } from '../../src'; + +export class MockNetworkPipeline extends NetworkPipeline { + protected baseFetch = jest.fn().mockResolvedValue(Promise.resolve({ status: 200 })); +} diff --git a/packages/datx-network/test/request.test.ts b/packages/datx-network/test/request.test.ts new file mode 100644 index 000000000..434718885 --- /dev/null +++ b/packages/datx-network/test/request.test.ts @@ -0,0 +1,89 @@ +import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { addInterceptor, cache, CachingStrategy, setUrl, Response } from '../src'; + +describe('Request', () => { + it('should initialize', () => { + const request = new MockNetworkPipeline('foobar'); + expect(request).toBeTruthy(); + expect(request.config.baseUrl).toBe('foobar'); + expect(request.config.maxCacheAge).toBe(Infinity); + expect(request).toBeInstanceOf(MockNetworkPipeline); + }); + + it('should clone the request', () => { + class FooRequest extends MockNetworkPipeline {} + const request1 = new MockNetworkPipeline('foobar'); + const request2 = request1.clone(FooRequest as any); + const request3 = request1.pipe(); + + expect(request1).not.toBe(request2); + expect(request1.config).not.toBe(request2.config); + + expect(request1).not.toBe(request3); + expect(request1.config).not.toBe(request3.config); + + expect(request3).not.toBe(request2); + expect(request3.config).not.toBe(request2.config); + + expect(request1).toBeInstanceOf(MockNetworkPipeline); + expect(request1).not.toBeInstanceOf(FooRequest); + + expect(request2).toBeInstanceOf(MockNetworkPipeline); + expect(request2).toBeInstanceOf(FooRequest); + + expect(request3).toBeInstanceOf(MockNetworkPipeline); + expect(request3).not.toBeInstanceOf(FooRequest); + }); + + it('should run the pipes in the right order', () => { + const request1 = new MockNetworkPipeline('foobar'); + + const request2 = request1.pipe(setUrl('foo'), setUrl('bar')); + + expect(request1.options.url).toBe(undefined); + expect(request2.options.url).toBe('bar'); + }); + + it('should call interceptors in the correct order', async () => { + let counter = 0; + + function mockInterceptor(expected: number) { + return async (options: any, next: any): Promise => { + expect(counter).toBe(expected); + counter++; + return next(options); + }; + } + + const request1 = new MockNetworkPipeline('foobar'); + + const request2 = request1.pipe( + setUrl('foobar'), + addInterceptor(mockInterceptor(0)), + addInterceptor(mockInterceptor(1)), + addInterceptor(mockInterceptor(2)), + ); + + await request2.fetch(); + + expect(request2['baseFetch']).toHaveBeenCalledTimes(1); + expect(request1['baseFetch']).toHaveBeenCalledTimes(0); + }); + + describe('caching', () => { + it('should fail if no cache with CACHE_ONLY strategy', async () => { + const request1 = new MockNetworkPipeline('foobar'); + + const request2 = request1.pipe(cache(CachingStrategy.CACHE_ONLY)); + + try { + await request2.fetch(); + expect(true).toBe(false); + } catch (resp) { + expect(resp).toBeInstanceOf(Response); + } + + expect(request2['baseFetch']).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/datx-network/tsconfig.build.json b/packages/datx-network/tsconfig.build.json new file mode 100644 index 000000000..4a5f84a25 --- /dev/null +++ b/packages/datx-network/tsconfig.build.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowJs": false, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "alwaysStrict": true, + "declaration": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "inlineSources": true, + "lib": ["es2015", "es2016", "es2017", "dom"], + "module": "esnext", + "noImplicitAny": false, + "noImplicitReturns": true, + "noUnusedParameters": true, + "outDir": "./dist", + "strict": true, + "strictFunctionTypes": false, + "target": "es5" + }, + "exclude": ["node_modules", "examples"], + "include": ["src/**/*"] +} diff --git a/packages/datx-network/tsconfig.json b/packages/datx-network/tsconfig.json new file mode 100644 index 000000000..672dc912a --- /dev/null +++ b/packages/datx-network/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "include": ["src/**/*", "test/**/*"], + "compilerOptions": { + "moduleResolution": "node", + "noUnusedLocals": true, + "esModuleInterop": true + } +} diff --git a/packages/datx-utils/package.json b/packages/datx-utils/package.json index 99e67b89c..58578dfba 100644 --- a/packages/datx-utils/package.json +++ b/packages/datx-utils/package.json @@ -63,5 +63,7 @@ "preset": "ts-jest", "testMatch": null }, - "dependencies": {} + "dependencies": { + "datx": "^2.0.0-beta.4" + } } From 365166b5ffc865351f3741c441feaa7577d95f37 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Wed, 5 Aug 2020 12:02:34 +0200 Subject: [PATCH 02/21] Update enum names --- packages/datx-network/src/cache.ts | 26 +++++++++---------- packages/datx-network/src/defaults.ts | 4 +-- .../datx-network/src/enums/CachingStrategy.ts | 12 ++++----- .../datx-network/src/enums/ParamArrayType.ts | 8 +++--- packages/datx-network/src/helpers/utils.ts | 8 +++--- packages/datx-network/test/request.test.ts | 2 +- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/datx-network/src/cache.ts b/packages/datx-network/src/cache.ts index 6c3002355..1c7833924 100644 --- a/packages/datx-network/src/cache.ts +++ b/packages/datx-network/src/cache.ts @@ -148,11 +148,11 @@ export function cacheInterceptor( const cacheStrategy = request.options?.cacheOptions?.skipCache || !isCacheSupported - ? CachingStrategy.NETWORK_ONLY + ? CachingStrategy.NetworkOnly : request.options?.cacheOptions?.cachingStrategy || cache; - // NETWORK_ONLY - Ignore cache - if (cacheStrategy === CachingStrategy.NETWORK_ONLY) { + // NetworkOnly - Ignore cache + if (cacheStrategy === CachingStrategy.NetworkOnly) { return makeNetworkCall(request, next, networkPipeline); } @@ -161,8 +161,8 @@ export function cacheInterceptor( maxCacheAge, ) as unknown) as { response: Response } | undefined; - // NETWORK_FIRST - Fallback to cache only on network error - if (cacheStrategy === CachingStrategy.NETWORK_FIRST) { + // NetworkFirst - Fallback to cache only on network error + if (cacheStrategy === CachingStrategy.NetworkFirst) { return makeNetworkCall(request, next, networkPipeline, true).catch((errorResponse) => { if (cacheContent) { return cacheContent.response; @@ -171,8 +171,8 @@ export function cacheInterceptor( }); } - // STALE_WHILE_REVALIDATE - Use cache and update it in background - if (cacheStrategy === CachingStrategy.STALE_WHILE_REVALIDATE) { + // StaleWhileRevalidate - Use cache and update it in background + if (cacheStrategy === CachingStrategy.StaleWhileRevalidate) { const network = makeNetworkCall(request, next, networkPipeline, true); if (cacheContent) { @@ -185,8 +185,8 @@ export function cacheInterceptor( return network; } - // CACHE_ONLY - Fail if nothing in cache - if (cacheStrategy === CachingStrategy.CACHE_ONLY) { + // CacheOnly - Fail if nothing in cache + if (cacheStrategy === CachingStrategy.CacheOnly) { if (cacheContent) { return Promise.resolve(cacheContent.response); } @@ -196,15 +196,15 @@ export function cacheInterceptor( ); } - // PREFER_CACHE - Use cache if available - if (cacheStrategy === CachingStrategy.CACHE_FIRST) { + // CacheFirst - Use cache if available + if (cacheStrategy === CachingStrategy.CacheFirst) { return cacheContent ? Promise.resolve(cacheContent.response) : makeNetworkCall(request, next, networkPipeline, true); } - // STALE_AND_UPDATE - Use cache and update response once network is complete - if (cacheStrategy === CachingStrategy.STALE_AND_UPDATE) { + // StaleAndUpdate - Use cache and update response once network is complete + if (cacheStrategy === CachingStrategy.StaleAndUpdate) { const existingResponse = cacheContent?.response?.clone(); const network = makeNetworkCall(request, next, networkPipeline, true, existingResponse); diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts index 731f74541..6699bdfef 100644 --- a/packages/datx-network/src/defaults.ts +++ b/packages/datx-network/src/defaults.ts @@ -84,7 +84,7 @@ export function getDefaultConfig(): IConfigType { baseUrl: '/', // Enable caching by default in the browser - cache: isBrowser ? CachingStrategy.CACHE_FIRST : CachingStrategy.NETWORK_ONLY, + cache: isBrowser ? CachingStrategy.CacheFirst : CachingStrategy.NetworkOnly, maxCacheAge: Infinity, // Default options that will be passed to the fetch function @@ -105,7 +105,7 @@ export function getDefaultConfig(): IConfigType { undefined, // Determines how will the request param arrays be stringified - paramArrayType: ParamArrayType.PARAM_ARRAY, + paramArrayType: ParamArrayType.ParamArray, serialize(data: object, _type: BodyType): object { return data; diff --git a/packages/datx-network/src/enums/CachingStrategy.ts b/packages/datx-network/src/enums/CachingStrategy.ts index 1fa75ad92..4352b8e43 100644 --- a/packages/datx-network/src/enums/CachingStrategy.ts +++ b/packages/datx-network/src/enums/CachingStrategy.ts @@ -1,10 +1,10 @@ // Based on service worker strategies https://developers.google.com/web/tools/workbox/modules/workbox-strategies export enum CachingStrategy { - NETWORK_ONLY = 1, // Ignore cache - NETWORK_FIRST = 2, // Fallback to cache only on network error - STALE_WHILE_REVALIDATE = 3, // Use cache and update it in background - CACHE_ONLY = 4, // Fail if nothing in cache - CACHE_FIRST = 5, // Use cache if available - STALE_AND_UPDATE = 6, // Use cache and update response once network is complete + NetworkOnly = 1, // Ignore cache + NetworkFirst = 2, // Fallback to cache only on network error + StaleWhileRevalidate = 3, // Use cache and update it in background + CacheOnly = 4, // Fail if nothing in cache + CacheFirst = 5, // Use cache if available + StaleAndUpdate = 6, // Use cache and update response once network is complete } diff --git a/packages/datx-network/src/enums/ParamArrayType.ts b/packages/datx-network/src/enums/ParamArrayType.ts index dbd3898e5..b7cb23ce1 100644 --- a/packages/datx-network/src/enums/ParamArrayType.ts +++ b/packages/datx-network/src/enums/ParamArrayType.ts @@ -1,6 +1,6 @@ export enum ParamArrayType { - MULTIPLE_PARAMS, // filter[a]=1&filter[a]=2 - COMMA_SEPARATED, // filter[a]=1,2 - PARAM_ARRAY, // filter[a][]=1&filter[a][]=2 - OBJECT_PATH, // filter[a.0]=1&filter[a.1]=2 + MultipleParams, // filter[a]=1&filter[a]=2 + CommaSeparated, // filter[a]=1,2 + ParamArray, // filter[a][]=1&filter[a][]=2 + ObjectPath, // filter[a.0]=1&filter[a.1]=2 } diff --git a/packages/datx-network/src/helpers/utils.ts b/packages/datx-network/src/helpers/utils.ts index b093f74de..02eaa3d02 100644 --- a/packages/datx-network/src/helpers/utils.ts +++ b/packages/datx-network/src/helpers/utils.ts @@ -51,18 +51,18 @@ function parametrize( Object.keys(params).forEach((key) => { if (params[key] instanceof Array) { - if (paramArrayType === ParamArrayType.OBJECT_PATH) { + if (paramArrayType === ParamArrayType.ObjectPath) { // eslint-disable-next-line prefer-spread list.push.apply(list, parametrize(paramArrayType, params[key], `${key}.`)); - } else if (paramArrayType === ParamArrayType.COMMA_SEPARATED) { + } else if (paramArrayType === ParamArrayType.CommaSeparated) { list.push({ key: `${scope}${key}`, value: params[key].join(',') }); - } else if (paramArrayType === ParamArrayType.MULTIPLE_PARAMS) { + } else if (paramArrayType === ParamArrayType.MultipleParams) { // eslint-disable-next-line prefer-spread list.push.apply( list, params[key].map((param) => ({ key: `${scope}${key}`, value: param })), ); - } else if (paramArrayType === ParamArrayType.PARAM_ARRAY) { + } else if (paramArrayType === ParamArrayType.ParamArray) { // eslint-disable-next-line prefer-spread list.push.apply( list, diff --git a/packages/datx-network/test/request.test.ts b/packages/datx-network/test/request.test.ts index 434718885..8225cdb47 100644 --- a/packages/datx-network/test/request.test.ts +++ b/packages/datx-network/test/request.test.ts @@ -74,7 +74,7 @@ describe('Request', () => { it('should fail if no cache with CACHE_ONLY strategy', async () => { const request1 = new MockNetworkPipeline('foobar'); - const request2 = request1.pipe(cache(CachingStrategy.CACHE_ONLY)); + const request2 = request1.pipe(cache(CachingStrategy.CacheOnly)); try { await request2.fetch(); From 5f8808f83372f5b198fb92146a1e19435ea97bc6 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Thu, 6 Aug 2020 09:54:13 +0200 Subject: [PATCH 03/21] Started work on the mixin --- packages/datx-network/package.json | 5 +- packages/datx-network/src/NetworkPipeline.ts | 42 ++++++++++-- packages/datx-network/src/decorateModel.ts | 67 +++++++++++++++++++ packages/datx-network/src/helpers/utils.ts | 17 +++++ .../src/interfaces/INetworkCollection.ts | 32 +++++++++ .../src/interfaces/INetworkModel.ts | 9 +++ .../src/interfaces/INetworkView.ts | 12 ++++ packages/datx-network/src/withNetwork.ts | 45 ++++++++++++- yarn.lock | 52 +++++++++++++- 9 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 packages/datx-network/src/decorateModel.ts create mode 100644 packages/datx-network/src/interfaces/INetworkCollection.ts create mode 100644 packages/datx-network/src/interfaces/INetworkModel.ts create mode 100644 packages/datx-network/src/interfaces/INetworkView.ts diff --git a/packages/datx-network/package.json b/packages/datx-network/package.json index c7458754c..315e06708 100644 --- a/packages/datx-network/package.json +++ b/packages/datx-network/package.json @@ -25,8 +25,10 @@ "@rollup/plugin-node-resolve": "^8.1.0", "@types/jest": "^26.0.3", "@types/node": "^14.0.14", + "@types/react": "^16.9.44", "jest": "^26.1.0", "mobx": "^5.15.4", + "react": "^16.13.1", "rollup": "^2.18.1", "rollup-plugin-exclude-dependencies-from-bundle": "^1.1.10", "rollup-plugin-terser": "^6.1.0", @@ -35,7 +37,8 @@ "typescript": "^3.9.5" }, "peerDependencies": { - "mobx": "^4.2.0 || ^5.5.0" + "mobx": "^4.2.0 || ^5.5.0", + "react": "^16.8.0" }, "scripts": { "test": "jest --coverage", diff --git a/packages/datx-network/src/NetworkPipeline.ts b/packages/datx-network/src/NetworkPipeline.ts index 4c43d5f73..1031fde6e 100644 --- a/packages/datx-network/src/NetworkPipeline.ts +++ b/packages/datx-network/src/NetworkPipeline.ts @@ -1,10 +1,12 @@ +import { PureModel } from 'datx'; +import { useCallback, useEffect, useState } from 'react'; + import { baseFetch, getDefaultConfig } from './defaults'; import { IConfigType } from './interfaces/IConfigType'; import { IHeaders } from './interfaces/IHeaders'; import { IInterceptor } from './interfaces/IInterceptor'; import { IPipeOperator } from './interfaces/IPipeOperator'; import { Response } from './Response'; -import { PureModel } from 'datx'; import { IFetchOptions } from './interfaces/IFetchOptions'; import { IResponseObject } from './interfaces/IResponseObject'; import { deepCopy, interpolateParams, appendQueryParams } from './helpers/utils'; @@ -146,11 +148,39 @@ export class NetworkPipeline, boolean, string | Error] { - // TODO useHook - throw new Error('Not yet implemented'); + params?: Partial, + options?: IHookOptions, + ): [Response | null, boolean, string | Error | null] { + const [loader, setLoader] = useState> | null>(null); + const [value, setValue] = useState | null>(null); + const [error, setError] = useState(null); + + const execute = useCallback(() => { + const loaderPromise = this.fetch(params); + setLoader(loaderPromise); + setValue(null); + setError(null); + + return loaderPromise + .then((response) => { + setValue(response); + setLoader(null); + }) + .catch((error) => { + setError(error); + setLoader(null); + }); + }, [this.fetch, params]); + + useEffect(() => { + execute(); + }, [execute]); + + if (options?.suspense && loader) { + throw loader; + } + + return [value, Boolean(loader), error]; } public clone( diff --git a/packages/datx-network/src/decorateModel.ts b/packages/datx-network/src/decorateModel.ts new file mode 100644 index 000000000..5c72569c8 --- /dev/null +++ b/packages/datx-network/src/decorateModel.ts @@ -0,0 +1,67 @@ +import { PureCollection, PureModel } from 'datx'; +import { IRawModel, META_FIELD, setMeta } from 'datx-utils'; + +// import { flattenModel, removeModel, saveModel } from './helpers/model'; +import { getModelClassRefs } from './helpers/utils'; +import { IRequestOptions } from './interfaces/IRequestOptions'; +import { INetworkModel } from './interfaces/INetworkModel'; +import { NetworkPipeline } from './NetworkPipeline'; + +const HYDRATIZATION_KEYS = ['networkPersisted']; + +export function decorateModel(BaseClass: typeof PureModel): typeof PureModel { + class NetworkModel extends BaseClass { + /** + * Should the autogenerated ID be sent to the server when creating a record + * + * @static + * @type {boolean} + * @memberOf Record + */ + public static useAutogeneratedIds: boolean = BaseClass['useAutogeneratedIds'] || false; + + public static network?: NetworkPipeline; + + /** + * Endpoint for API requests if there is no self link + * + * @static + * @type {string|() => string} + * @memberOf Record + */ + public static endpoint: string | (() => string); + + public static getAutoId(): string { + return super.getAutoId().toString(); + } + + constructor(rawData: IRawModel | IRecord = {}, collection?: PureCollection) { + let data = rawData; + + if (rawData && 'type' in rawData && ('attributes' in rawData || 'relationships' in rawData)) { + const classRefs = getModelClassRefs(BaseClass); + + data = flattenModel(classRefs, rawData as IRecord); + } + super(data, collection); + + const modelMeta = data?.[META_FIELD] || {}; + + HYDRATIZATION_KEYS.forEach((key) => { + if (key in modelMeta) { + setMeta(this, key, modelMeta[key]); + } + }); + } + + public save(options?: IRequestOptions): Promise { + return saveModel((this as unknown) as INetworkModel, options); + } + + public destroy(options?: IRequestOptions): Promise { + return removeModel(this, options); + } + } + + return NetworkModel as typeof PureModel; +} diff --git a/packages/datx-network/src/helpers/utils.ts b/packages/datx-network/src/helpers/utils.ts index 02eaa3d02..99e3ebeb8 100644 --- a/packages/datx-network/src/helpers/utils.ts +++ b/packages/datx-network/src/helpers/utils.ts @@ -1,4 +1,6 @@ import { ParamArrayType } from '../enums/ParamArrayType'; +import { PureModel, IFieldDefinition, IReferenceDefinition } from 'datx'; +import { getMeta } from 'datx-utils'; export const isBrowser: boolean = typeof window !== 'undefined'; @@ -107,3 +109,18 @@ export function appendQueryParams( return appendParams(url, processedParams); } + +export function getModelClassRefs( + type: typeof PureModel | PureModel, +): Record { + const fields: Record = getMeta(type, 'fields', {}, true, true); + const refs: Record = {}; + + Object.keys(fields).forEach((key) => { + if (fields[key].referenceDef) { + refs[key] = fields[key].referenceDef as IReferenceDefinition; + } + }); + + return refs; +} diff --git a/packages/datx-network/src/interfaces/INetworkCollection.ts b/packages/datx-network/src/interfaces/INetworkCollection.ts new file mode 100644 index 000000000..f0fb55054 --- /dev/null +++ b/packages/datx-network/src/interfaces/INetworkCollection.ts @@ -0,0 +1,32 @@ +import { PureCollection, IType, PureModel, IModelConstructor } from 'datx'; + +import { IRequestOptions } from './IRequestOptions'; +import { Response } from '../Response'; +import { INetworkModel } from './INetworkModel'; + +export interface INetworkCollection extends PureCollection { + getOne( + type: IType | IModelConstructor, + id: string, + options?: IRequestOptions, + ): Promise>; + + getMany( + type: IType | IModelConstructor, + options?: IRequestOptions, + ): Promise>; + + request( + url: string, + method?: string, + data?: object, + options?: IRequestOptions, + ): Promise>; + + removeOne( + type: IType | typeof PureModel, + id: string, + options?: boolean | IRequestOptions, + ): Promise; + removeOne(model: PureModel, options?: boolean | IRequestOptions): Promise; +} diff --git a/packages/datx-network/src/interfaces/INetworkModel.ts b/packages/datx-network/src/interfaces/INetworkModel.ts new file mode 100644 index 000000000..8672f1819 --- /dev/null +++ b/packages/datx-network/src/interfaces/INetworkModel.ts @@ -0,0 +1,9 @@ +import { PureModel } from 'datx'; + +import { IRequestOptions } from './IRequestOptions'; + +export interface INetworkModel extends PureModel { + save(options?: IRequestOptions): Promise; + + destroy(options?: IRequestOptions): Promise; +} diff --git a/packages/datx-network/src/interfaces/INetworkView.ts b/packages/datx-network/src/interfaces/INetworkView.ts new file mode 100644 index 000000000..e61d9ac5a --- /dev/null +++ b/packages/datx-network/src/interfaces/INetworkView.ts @@ -0,0 +1,12 @@ +import { View } from 'datx'; + +import { IRequestOptions } from './IRequestOptions'; +import { Response } from '../Response'; +import { INetworkModel } from './INetworkModel'; + +export interface INetworkView extends View { + sync(body?: Response): T | Array | null; + + getOne(id: string, options?: IRequestOptions): Promise>; + getMany(options?: IRequestOptions): Promise>; +} diff --git a/packages/datx-network/src/withNetwork.ts b/packages/datx-network/src/withNetwork.ts index 6d82cae47..194224f78 100644 --- a/packages/datx-network/src/withNetwork.ts +++ b/packages/datx-network/src/withNetwork.ts @@ -1 +1,44 @@ -// TODO: withNetwork +import { isCollection, isModel, isView, PureCollection, PureModel, View } from 'datx'; + +import { decorateCollection } from './decorateCollection'; +import { decorateModel } from './decorateModel'; +import { decorateView } from './decorateView'; +import { INetworkCollection } from './interfaces/INetworkCollection'; +import { INetworkModel } from './interfaces/INetworkModel'; +import { INetworkView } from './interfaces/INetworkView'; +import { NetworkPipeline } from './NetworkPipeline'; + +interface INetwork { + network?: NetworkPipeline; + + new (): T; +} + +export function withNetwork(Base: T): T & INetwork; + +export function withNetwork( + Base: T, +): T & INetwork; + +export function withNetwork(Base: T): T & INetwork; + +export function withNetwork( + Base: T, +): (T & INetworkModel) | (T & INetworkCollection) | (T & INetworkView) { + if (isModel(Base)) { + // @ts-ignore + return decorateModel(Base); + } + + if (isCollection(Base)) { + // @ts-ignore + return decorateCollection(Base); + } + + if (isView(Base)) { + // @ts-ignore + return decorateView(Base); + } + + throw new Error('The instance needs to be a model, collection or a view'); +} diff --git a/yarn.lock b/yarn.lock index 64e4a46ac..e35010ba3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1593,6 +1593,19 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.0.1.tgz#b6e98083f13faa1e5231bfa3bdb1b0feff536b6d" integrity sha512-boy4xPNEtiw6N3abRhBi/e7hNvy3Tt8E9ZRAQrwAGzoCGZS/1wjo9KY7JHhnfnEsG5wSjDbymCozUM9a3ea7OQ== +"@types/prop-types@*": + version "15.7.3" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" + integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== + +"@types/react@^16.9.44": + version "16.9.44" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.44.tgz#da84b179c031aef67dc92c33bd3401f1da2fa3bc" + integrity sha512-BtLoJrXdW8DVZauKP+bY4Kmiq7ubcJq+H/aCpRfvPF7RAT3RwR73Sg8szdc2YasbAlWBDrQ6Q+AFM0KwtQY+WQ== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/resolve@0.0.8": version "0.0.8" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194" @@ -2635,6 +2648,11 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.2.tgz#ee5ff8f208c8cd613b389f7b222c9801ca62b3f7" + integrity sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -4723,7 +4741,7 @@ jest@^26.1.0: import-local "^3.0.2" jest-cli "^26.1.0" -js-tokens@^4.0.0: +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== @@ -5026,6 +5044,13 @@ lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.2. resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +loose-envify@^1.1.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + loud-rejection@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" @@ -5608,7 +5633,7 @@ oauth-sign@~0.9.0: resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== -object-assign@^4.0.1, object-assign@^4.1.0: +object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -6106,6 +6131,15 @@ promzard@^0.3.0: dependencies: read "1" +prop-types@^15.6.2: + version "15.7.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" + integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.8.1" + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -6185,6 +6219,20 @@ react-is@^16.12.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== +react-is@^16.8.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react@^16.13.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" + integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + read-cmd-shim@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-1.0.5.tgz#87e43eba50098ba5a32d0ceb583ab8e43b961c16" From 87d8ebb6ae95a426a6913f97bb8354d0b6f925de Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 8 Aug 2020 15:02:49 +0200 Subject: [PATCH 04/21] Rename BaseRequest, better typings --- .../{NetworkPipeline.ts => BaseRequest.ts} | 25 +++++++-------- packages/datx-network/src/cache.ts | 6 ++-- packages/datx-network/src/decorateModel.ts | 4 +-- packages/datx-network/src/defaults.ts | 4 +-- packages/datx-network/src/index.ts | 2 +- .../src/interfaces/IPipeOperator.ts | 4 +-- packages/datx-network/src/operators.ts | 32 +++++++++---------- packages/datx-network/src/withNetwork.ts | 4 +-- 8 files changed, 40 insertions(+), 41 deletions(-) rename packages/datx-network/src/{NetworkPipeline.ts => BaseRequest.ts} (88%) diff --git a/packages/datx-network/src/NetworkPipeline.ts b/packages/datx-network/src/BaseRequest.ts similarity index 88% rename from packages/datx-network/src/NetworkPipeline.ts rename to packages/datx-network/src/BaseRequest.ts index 1031fde6e..3ca45683e 100644 --- a/packages/datx-network/src/NetworkPipeline.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -29,10 +29,7 @@ interface IRequestOptions { bodyType: BodyType; } -// TODO: A response generic -// TODO: A params generic - -export class NetworkPipeline { +export class BaseRequest { private _config: IConfigType = getDefaultConfig(); private _options: IRequestOptions = { method: HttpMethod.Get, @@ -64,11 +61,13 @@ export class NetworkPipeline): NetworkPipeline { - const destinationPipeline = this.clone(); + public pipe( + ...operators: Array + ): BaseRequest { + const destinationPipeline = this.clone(); operators.forEach((operator) => operator(destinationPipeline)); - return destinationPipeline as NetworkPipeline; + return destinationPipeline as BaseRequest; } private doRequest(options: IFetchOptions): Promise { @@ -113,7 +112,7 @@ export class NetworkPipeline = {}): Promise> { + public fetch(params?: TParams): Promise> { if (!this.options.url) { throw new Error('URL should be defined'); } @@ -148,7 +147,7 @@ export class NetworkPipeline, + params?: TParams, options?: IHookOptions, ): [Response | null, boolean, string | Error | null] { const [loader, setLoader] = useState> | null>(null); @@ -183,15 +182,15 @@ export class NetworkPipeline { + public clone( + NetworkPipelineConstructor: typeof BaseRequest = this.constructor as typeof BaseRequest, + ): BaseRequest { // Can't use `new NetworkPipeline`, because we would lose the overridden methods const clone = new NetworkPipelineConstructor(this._config.baseUrl); clone._config = deepCopy(this._config); clone._interceptors = this._interceptors.slice(); clone._options = deepCopy(this._options); - return clone as NetworkPipeline; + return clone as BaseRequest; } } diff --git a/packages/datx-network/src/cache.ts b/packages/datx-network/src/cache.ts index 1c7833924..c2d78d705 100644 --- a/packages/datx-network/src/cache.ts +++ b/packages/datx-network/src/cache.ts @@ -7,7 +7,7 @@ import { CachingStrategy } from './enums/CachingStrategy'; import { IFetchOptions } from './interfaces/IFetchOptions'; import { IResponseObject } from './interfaces/IResponseObject'; import { HttpMethod } from './enums/HttpMethod'; -import { NetworkPipeline } from './NetworkPipeline'; +import { BaseRequest } from './BaseRequest'; export type INextHandler = (request: IFetchOptions) => Promise; @@ -93,7 +93,7 @@ export function saveCacheForCollection( function makeNetworkCall( params: IFetchOptions, next: INextHandler, - networkPipeline: NetworkPipeline, + networkPipeline: BaseRequest, doCacheResponse = false, existingResponse?: Response, ): Promise> { @@ -141,7 +141,7 @@ function getLocalNetworkError( export function cacheInterceptor( cache: CachingStrategy, maxCacheAge: number, - networkPipeline: NetworkPipeline, + networkPipeline: BaseRequest, ) { return (request: IFetchOptions, next: INextHandler): Promise> => { const isCacheSupported = request.method === HttpMethod.Get; diff --git a/packages/datx-network/src/decorateModel.ts b/packages/datx-network/src/decorateModel.ts index 5c72569c8..383ad7b72 100644 --- a/packages/datx-network/src/decorateModel.ts +++ b/packages/datx-network/src/decorateModel.ts @@ -5,7 +5,7 @@ import { IRawModel, META_FIELD, setMeta } from 'datx-utils'; import { getModelClassRefs } from './helpers/utils'; import { IRequestOptions } from './interfaces/IRequestOptions'; import { INetworkModel } from './interfaces/INetworkModel'; -import { NetworkPipeline } from './NetworkPipeline'; +import { BaseRequest } from './BaseRequest'; const HYDRATIZATION_KEYS = ['networkPersisted']; @@ -20,7 +20,7 @@ export function decorateModel(BaseClass: typeof PureModel): typeof PureModel { */ public static useAutogeneratedIds: boolean = BaseClass['useAutogeneratedIds'] || false; - public static network?: NetworkPipeline; + public static network?: BaseRequest; /** * Endpoint for API requests if there is no self link diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts index 6699bdfef..ca8b127ba 100644 --- a/packages/datx-network/src/defaults.ts +++ b/packages/datx-network/src/defaults.ts @@ -3,7 +3,7 @@ import { IHeaders } from './interfaces/IHeaders'; import { CachingStrategy } from './enums/CachingStrategy'; import { isBrowser } from './helpers/utils'; import { ParamArrayType } from './enums/ParamArrayType'; -import { NetworkPipeline } from './NetworkPipeline'; +import { BaseRequest } from './BaseRequest'; import { IResponseHeaders } from './interfaces/IResponseHeaders'; import { PureModel } from 'datx'; import { IResponseObject } from './interfaces/IResponseObject'; @@ -20,7 +20,7 @@ import { BodyType } from './enums/BodyType'; * @returns {Promise} Resolves with a raw response object */ export function baseFetch( - requestObj: NetworkPipeline, + requestObj: BaseRequest, method: string, url: string, body?: string | FormData, diff --git a/packages/datx-network/src/index.ts b/packages/datx-network/src/index.ts index dd18b0385..f51b6b546 100644 --- a/packages/datx-network/src/index.ts +++ b/packages/datx-network/src/index.ts @@ -1,4 +1,4 @@ -export { NetworkPipeline } from './NetworkPipeline'; +export { BaseRequest as NetworkPipeline } from './BaseRequest'; export { Response } from './Response'; export { diff --git a/packages/datx-network/src/interfaces/IPipeOperator.ts b/packages/datx-network/src/interfaces/IPipeOperator.ts index a2179e685..89cb2a2e8 100644 --- a/packages/datx-network/src/interfaces/IPipeOperator.ts +++ b/packages/datx-network/src/interfaces/IPipeOperator.ts @@ -1,3 +1,3 @@ -import { NetworkPipeline } from '../NetworkPipeline'; +import { BaseRequest } from '../BaseRequest'; -export type IPipeOperator = (request: NetworkPipeline) => void; +export type IPipeOperator = (request: BaseRequest) => void; diff --git a/packages/datx-network/src/operators.ts b/packages/datx-network/src/operators.ts index 11476023c..87b5bb7b4 100644 --- a/packages/datx-network/src/operators.ts +++ b/packages/datx-network/src/operators.ts @@ -1,4 +1,4 @@ -import { NetworkPipeline } from './NetworkPipeline'; +import { BaseRequest } from './BaseRequest'; import { IInterceptor } from './interfaces/IInterceptor'; import { CachingStrategy } from './enums/CachingStrategy'; import { HttpMethod } from './enums/HttpMethod'; @@ -6,53 +6,53 @@ import { BodyType } from './enums/BodyType'; import { ParamArrayType } from './enums/ParamArrayType'; export function setUrl(url: string) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.options.url = url; }; } export function addInterceptor(interceptor: IInterceptor) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline['_interceptors'].push(interceptor); }; } export function cache(strategy: CachingStrategy, maxAge = Infinity) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.config.cache = strategy; pipeline.config.maxCacheAge = maxAge; }; } export function method(method: HttpMethod) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.options.method = method; }; } export function body(body: any, bodyType: BodyType = BodyType.Json) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.options.body = pipeline.config.serialize(body, bodyType); pipeline.options.bodyType = bodyType; }; } export function query(name: string, value: string | Array | object) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.options.query[name] = value; }; } export function header(name: string, value: string) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.options.headers[name] = value; }; } -export function params(name: string, value: string): (pipeline: NetworkPipeline) => void; -export function params(params: Record): (pipeline: NetworkPipeline) => void; +export function params(name: string, value: string): (pipeline: BaseRequest) => void; +export function params(params: Record): (pipeline: BaseRequest) => void; export function params(name: string | Record, value?: string) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { if (typeof name === 'string') { pipeline.options.params[name] = value as string; } else { @@ -62,31 +62,31 @@ export function params(name: string | Record, value?: string) { } export function fetchReference(fetchReference: typeof fetch) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.config.fetchReference = fetchReference; }; } export function encodeQueryString(encodeQueryString: boolean) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.config.encodeQueryString = encodeQueryString; }; } export function paramArrayType(paramArrayType: ParamArrayType) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.config.paramArrayType = paramArrayType; }; } export function serializer(serialize: (data: object, _type: BodyType) => object) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.config.serialize = serialize; }; } export function parser(parse: (data: object) => object) { - return (pipeline: NetworkPipeline): void => { + return (pipeline: BaseRequest): void => { pipeline.config.parse = parse; }; } diff --git a/packages/datx-network/src/withNetwork.ts b/packages/datx-network/src/withNetwork.ts index 194224f78..605dd36f7 100644 --- a/packages/datx-network/src/withNetwork.ts +++ b/packages/datx-network/src/withNetwork.ts @@ -6,10 +6,10 @@ import { decorateView } from './decorateView'; import { INetworkCollection } from './interfaces/INetworkCollection'; import { INetworkModel } from './interfaces/INetworkModel'; import { INetworkView } from './interfaces/INetworkView'; -import { NetworkPipeline } from './NetworkPipeline'; +import { BaseRequest } from './BaseRequest'; interface INetwork { - network?: NetworkPipeline; + network?: BaseRequest; new (): T; } From 134b4907280a6fd05220f2cc0b08ef4c574bb6bf Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 8 Aug 2020 15:36:40 +0200 Subject: [PATCH 05/21] Fix existing test --- packages/datx-network/package.json | 5 ++++- packages/datx-network/src/Response.ts | 1 + packages/datx-network/test/request.test.ts | 7 ++++++- packages/datx-network/test/setup.ts | 6 ++++++ 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 packages/datx-network/test/setup.ts diff --git a/packages/datx-network/package.json b/packages/datx-network/package.json index 315e06708..a3d5932d6 100644 --- a/packages/datx-network/package.json +++ b/packages/datx-network/package.json @@ -64,7 +64,10 @@ } }, "preset": "ts-jest", - "testMatch": null + "testMatch": null, + "setupFiles": [ + "./test/setup.ts" + ] }, "dependencies": { "datx": "^2.0.0-beta.4" diff --git a/packages/datx-network/src/Response.ts b/packages/datx-network/src/Response.ts index 856ae12bb..6ebd8bf5c 100644 --- a/packages/datx-network/src/Response.ts +++ b/packages/datx-network/src/Response.ts @@ -41,6 +41,7 @@ function initHeaders(headers: Array<[string, string]> | IResponseHeaders): IResp return headers; } +@action function initData( response: IResponseObject, collection?: PureCollection, diff --git a/packages/datx-network/test/request.test.ts b/packages/datx-network/test/request.test.ts index 8225cdb47..a68af58e8 100644 --- a/packages/datx-network/test/request.test.ts +++ b/packages/datx-network/test/request.test.ts @@ -1,7 +1,12 @@ import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; import { addInterceptor, cache, CachingStrategy, setUrl, Response } from '../src'; +import { clearAllCache } from '../src/cache'; describe('Request', () => { + beforeEach(() => { + clearAllCache(); + }); + it('should initialize', () => { const request = new MockNetworkPipeline('foobar'); expect(request).toBeTruthy(); @@ -74,7 +79,7 @@ describe('Request', () => { it('should fail if no cache with CACHE_ONLY strategy', async () => { const request1 = new MockNetworkPipeline('foobar'); - const request2 = request1.pipe(cache(CachingStrategy.CacheOnly)); + const request2 = request1.pipe(setUrl('foobar'), cache(CachingStrategy.CacheOnly)); try { await request2.fetch(); diff --git a/packages/datx-network/test/setup.ts b/packages/datx-network/test/setup.ts new file mode 100644 index 000000000..f3a231554 --- /dev/null +++ b/packages/datx-network/test/setup.ts @@ -0,0 +1,6 @@ +import { configure } from 'mobx'; + +configure({ + enforceActions: 'observed', + // computedRequiresReaction: true, +}); From ff88527f94df9ca4b6d0ac0707dfd715516a6f58 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 8 Aug 2020 15:37:44 +0200 Subject: [PATCH 06/21] Fix the action --- packages/datx-network/src/Response.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/datx-network/src/Response.ts b/packages/datx-network/src/Response.ts index 6ebd8bf5c..a06748ec4 100644 --- a/packages/datx-network/src/Response.ts +++ b/packages/datx-network/src/Response.ts @@ -14,7 +14,7 @@ import { IHeaders } from './interfaces/IHeaders'; import { IRequestOptions } from './interfaces/IRequestOptions'; import { IResponseInternal } from './interfaces/IResponseInternal'; import { IResponseSnapshot } from './interfaces/IResponseSnapshot'; -import { action } from 'mobx'; +import { action, runInAction } from 'mobx'; import { IResponseObject } from './interfaces/IResponseObject'; function serializeHeaders( @@ -41,7 +41,6 @@ function initHeaders(headers: Array<[string, string]> | IResponseHeaders): IResp return headers; } -@action function initData( response: IResponseObject, collection?: PureCollection, @@ -146,20 +145,22 @@ export class Response { views?: Array, ) { this.collection = collection; - this.__updateInternal(response, options, views); - this.__data = initData(response, collection, overrideData); + runInAction(() => { + this.__updateInternal(response, options, views); + this.__data = initData(response, collection, overrideData); - this.views.forEach((view) => { - if (this.__data.value) { - view.add(this.__data.value); - } - }); + this.views.forEach((view) => { + if (this.__data.value) { + view.add(this.__data.value); + } + }); - Object.freeze(this); + Object.freeze(this); - if (this.error) { - throw this; - } + if (this.error) { + throw this; + } + }); } private __updateInternal( From 6fccf58b546d9e239daaa715dbcdfd1d27bf2f83 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 8 Aug 2020 16:49:06 +0200 Subject: [PATCH 07/21] Add multiple tests, bugfixes --- packages/datx-network/package.json | 2 +- packages/datx-network/src/BaseRequest.ts | 5 +- .../datx-network/src/enums/ParamArrayType.ts | 1 - packages/datx-network/src/helpers/utils.ts | 35 +++-- .../test/{request.test.ts => basic.test.ts} | 24 +--- packages/datx-network/test/caching.test.ts | 19 +++ packages/datx-network/test/headers.test.ts | 16 +++ .../test/mock/MockNetworkPipeline.ts | 20 +++ packages/datx-network/test/params.test.ts | 77 +++++++++++ .../datx-network/test/query-params.test.ts | 121 ++++++++++++++++++ packages/datx-network/test/setup.ts | 5 + 11 files changed, 281 insertions(+), 44 deletions(-) rename packages/datx-network/test/{request.test.ts => basic.test.ts} (77%) create mode 100644 packages/datx-network/test/caching.test.ts create mode 100644 packages/datx-network/test/headers.test.ts create mode 100644 packages/datx-network/test/params.test.ts create mode 100644 packages/datx-network/test/query-params.test.ts diff --git a/packages/datx-network/package.json b/packages/datx-network/package.json index a3d5932d6..9fb12bfe8 100644 --- a/packages/datx-network/package.json +++ b/packages/datx-network/package.json @@ -65,7 +65,7 @@ }, "preset": "ts-jest", "testMatch": null, - "setupFiles": [ + "setupFilesAfterEnv": [ "./test/setup.ts" ] }, diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index 3ca45683e..85d3946bb 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -85,7 +85,10 @@ export class BaseRequest = []; Object.keys(params).forEach((key) => { + const scoped = `${scope}${scope ? `[${key}]` : key}`; if (params[key] instanceof Array) { - if (paramArrayType === ParamArrayType.ObjectPath) { - // eslint-disable-next-line prefer-spread - list.push.apply(list, parametrize(paramArrayType, params[key], `${key}.`)); - } else if (paramArrayType === ParamArrayType.CommaSeparated) { - list.push({ key: `${scope}${key}`, value: params[key].join(',') }); + if (paramArrayType === ParamArrayType.CommaSeparated) { + list.push({ key: scoped, value: params[key].join(',') }); } else if (paramArrayType === ParamArrayType.MultipleParams) { - // eslint-disable-next-line prefer-spread - list.push.apply( - list, - params[key].map((param) => ({ key: `${scope}${key}`, value: param })), - ); + list.push(...params[key].map((param: string) => ({ key: scoped, value: param }))); } else if (paramArrayType === ParamArrayType.ParamArray) { - // eslint-disable-next-line prefer-spread - list.push.apply( - list, - params[key].map((param) => ({ key: `${scope}${key}][`, value: param })), + list.push( + ...params[key].map((param: string) => ({ + key: `${scoped}[]`, + value: param, + })), ); } } else if (typeof params[key] === 'object') { - // eslint-disable-next-line prefer-spread - list.push.apply(list, parametrize(paramArrayType, params[key], `${key}.`)); + list.push(...parametrize(paramArrayType, params[key], scoped)); } else { - list.push({ key: `${scope}${key}`, value: params[key] }); + list.push({ key: scoped, value: params[key] }); } }); @@ -86,7 +80,12 @@ function appendParams(url: string, params: Array): string { let newUrl = url; if (params.length) { - const separator = newUrl.indexOf('?') === -1 ? '?' : '&'; + let separator = ''; + if (newUrl.indexOf('?') === -1) { + separator = '?'; + } else if (!newUrl.endsWith('&') && !newUrl.endsWith('?')) { + separator = '&'; + } newUrl += separator + params.join('&'); } diff --git a/packages/datx-network/test/request.test.ts b/packages/datx-network/test/basic.test.ts similarity index 77% rename from packages/datx-network/test/request.test.ts rename to packages/datx-network/test/basic.test.ts index a68af58e8..ed7ef1337 100644 --- a/packages/datx-network/test/request.test.ts +++ b/packages/datx-network/test/basic.test.ts @@ -1,12 +1,7 @@ import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; -import { addInterceptor, cache, CachingStrategy, setUrl, Response } from '../src'; -import { clearAllCache } from '../src/cache'; +import { addInterceptor, setUrl } from '../src'; describe('Request', () => { - beforeEach(() => { - clearAllCache(); - }); - it('should initialize', () => { const request = new MockNetworkPipeline('foobar'); expect(request).toBeTruthy(); @@ -74,21 +69,4 @@ describe('Request', () => { expect(request2['baseFetch']).toHaveBeenCalledTimes(1); expect(request1['baseFetch']).toHaveBeenCalledTimes(0); }); - - describe('caching', () => { - it('should fail if no cache with CACHE_ONLY strategy', async () => { - const request1 = new MockNetworkPipeline('foobar'); - - const request2 = request1.pipe(setUrl('foobar'), cache(CachingStrategy.CacheOnly)); - - try { - await request2.fetch(); - expect(true).toBe(false); - } catch (resp) { - expect(resp).toBeInstanceOf(Response); - } - - expect(request2['baseFetch']).toHaveBeenCalledTimes(0); - }); - }); }); diff --git a/packages/datx-network/test/caching.test.ts b/packages/datx-network/test/caching.test.ts new file mode 100644 index 000000000..769aa7532 --- /dev/null +++ b/packages/datx-network/test/caching.test.ts @@ -0,0 +1,19 @@ +import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { setUrl, cache, CachingStrategy, Response } from '../src'; + +describe('caching', () => { + it('should fail if no cache with CACHE_ONLY strategy', async () => { + const request1 = new MockNetworkPipeline('foobar'); + + const request2 = request1.pipe(setUrl('foobar'), cache(CachingStrategy.CacheOnly)); + + try { + await request2.fetch(); + expect(true).toBe(false); + } catch (resp) { + expect(resp).toBeInstanceOf(Response); + } + + expect(request2['baseFetch']).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/datx-network/test/headers.test.ts b/packages/datx-network/test/headers.test.ts new file mode 100644 index 000000000..68579fc46 --- /dev/null +++ b/packages/datx-network/test/headers.test.ts @@ -0,0 +1,16 @@ +import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { setUrl, header } from '../src'; + +describe('headers', () => { + it('should work with basic headers', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test'), + header('foo', '1'), + header('bar', '2'), + ); + + await request.fetch(); + + expect(request['lastHeaders']).toEqual({ foo: '1', bar: '2' }); + }); +}); diff --git a/packages/datx-network/test/mock/MockNetworkPipeline.ts b/packages/datx-network/test/mock/MockNetworkPipeline.ts index fcda26af3..9456bbd3c 100644 --- a/packages/datx-network/test/mock/MockNetworkPipeline.ts +++ b/packages/datx-network/test/mock/MockNetworkPipeline.ts @@ -2,4 +2,24 @@ import { NetworkPipeline } from '../../src'; export class MockNetworkPipeline extends NetworkPipeline { protected baseFetch = jest.fn().mockResolvedValue(Promise.resolve({ status: 200 })); + + private get lastRequest(): [string, string, any, any] { + return this.baseFetch.mock.calls[this.baseFetch.mock.calls.length - 1]; + } + + protected get lastMethod(): string { + return this.lastRequest[0]; + } + + protected get lastUrl(): string { + return this.lastRequest[1]; + } + + protected get lastBody(): any { + return this.lastRequest[2]; + } + + protected get lastHeaders(): any { + return this.lastRequest[3]; + } } diff --git a/packages/datx-network/test/params.test.ts b/packages/datx-network/test/params.test.ts new file mode 100644 index 000000000..d8b71914d --- /dev/null +++ b/packages/datx-network/test/params.test.ts @@ -0,0 +1,77 @@ +import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { setUrl, params } from '../src'; + +describe('params', () => { + it('should work for a basic params case', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test/{testId}/{mockId}'), + params('testId', '123'), + params('mockId', '456'), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('/test/123/456'); + }); + + it('should work for object params', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test/{testId}/{mockId}'), + params({ + testId: '234', + mockId: '345', + }), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('/test/234/345'); + }); + + it('should work with missing params', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test/{testId}/{mockId}'), + params('testId', '123'), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('/test/123/{mockId}'); + }); + + it('should work with fetch addon', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test/{testId}/{mockId}'), + params('testId', '123'), + ); + + await request.fetch({ mockId: '321' }); + expect(request['lastUrl']).toBe('/test/123/321'); + }); + + it('should work with partial fetch addon', async () => { + const request = new MockNetworkPipeline('foobar').pipe(setUrl('/test/{testId}/{mockId}')); + + await request.fetch({ mockId: '321' }); + expect(request['lastUrl']).toBe('/test/{testId}/321'); + }); + + it('should work with full fetch params', async () => { + const request = new MockNetworkPipeline('foobar').pipe(setUrl('/test/{testId}/{mockId}')); + + await request.fetch({ testId: '432', mockId: '321' }); + expect(request['lastUrl']).toBe('/test/432/321'); + }); + + it('should work with multiple pipes', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test/{testId}/{mockId}'), + params('testId', '123'), + ); + + const request2 = request.pipe(params('testId', '321')); + + await request2.fetch(); + await request.fetch(); + + expect(request['lastUrl']).toBe('/test/123/{mockId}'); + expect(request2['lastUrl']).toBe('/test/321/{mockId}'); + }); +}); diff --git a/packages/datx-network/test/query-params.test.ts b/packages/datx-network/test/query-params.test.ts new file mode 100644 index 000000000..693698798 --- /dev/null +++ b/packages/datx-network/test/query-params.test.ts @@ -0,0 +1,121 @@ +import { query, setUrl, paramArrayType, ParamArrayType, encodeQueryString } from '../src'; +import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; + +describe('query params', () => { + it('should work for a basic params case', async () => { + const request = new MockNetworkPipeline('foobar').pipe(setUrl('/test'), query('test', '123')); + + await request.fetch(); + expect(request['lastUrl']).toBe('/test?test=123'); + }); + + it('should work for a basic params case and existing queryparams', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test?foo=1'), + query('test', '123'), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('/test?foo=1&test=123'); + }); + + it('should work for a basic params case and empty query params', async () => { + const request = new MockNetworkPipeline('foobar').pipe(setUrl('/test?'), query('test', '123')); + + await request.fetch(); + expect(request['lastUrl']).toBe('/test?test=123'); + }); + + it('should work for a basic params case and next query params', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test?foo=1&'), + query('test', '123'), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('/test?foo=1&test=123'); + }); + + it('should work for a multiple params case', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test'), + query('test', '123'), + query('foo', '1'), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('/test?test=123&foo=1'); + }); + + it('should work for complex query and ParamArray', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test'), + query('test', ['123', '234']), + query('foo', { + bar: '1', + baz: ['2', '3'], + test: { + foo: { + bar: ['4', '5'], + }, + }, + }), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe( + '/test?test[]=123&test[]=234&foo[bar]=1&foo[baz][]=2&foo[baz][]=3&foo[test][foo][bar][]=4&foo[test][foo][bar][]=5', + ); + }); + + it('should work for complex query and ParamArray', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test'), + query('test', ['123', '234']), + query('foo', { + bar: '1', + baz: ['2', '3'], + test: { + foo: { + bar: ['4', '5'], + }, + }, + }), + paramArrayType(ParamArrayType.MultipleParams), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe( + '/test?test=123&test=234&foo[bar]=1&foo[baz]=2&foo[baz]=3&foo[test][foo][bar]=4&foo[test][foo][bar]=5', + ); + }); + + it('should work for complex query and ParamArray', async () => { + const request = new MockNetworkPipeline('foobar').pipe( + setUrl('/test'), + query('test', ['123', '234']), + query('foo', { + bar: '1', + baz: ['2', '3'], + test: { + foo: { + bar: ['4', '5'], + }, + }, + }), + paramArrayType(ParamArrayType.CommaSeparated), + ); + + const request2 = request.pipe(encodeQueryString(false)); + await request2.fetch(); + await request.fetch(); + + expect(request['lastUrl']).toBe( + '/test?test=123%2C234&foo[bar]=1&foo[baz]=2%2C3&foo[test][foo][bar]=4%2C5', + ); + + expect(request2['lastUrl']).toBe( + '/test?test=123,234&foo[bar]=1&foo[baz]=2,3&foo[test][foo][bar]=4,5', + ); + }); +}); diff --git a/packages/datx-network/test/setup.ts b/packages/datx-network/test/setup.ts index f3a231554..6e0be8785 100644 --- a/packages/datx-network/test/setup.ts +++ b/packages/datx-network/test/setup.ts @@ -1,6 +1,11 @@ import { configure } from 'mobx'; +import { clearAllCache } from '../src/cache'; configure({ enforceActions: 'observed', // computedRequiresReaction: true, }); + +beforeEach(() => { + clearAllCache(); +}); From 0c0161309fbc3b6a6d434c3187ec657a19b3ea5d Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 8 Aug 2020 17:27:54 +0200 Subject: [PATCH 08/21] Refactor tests to use the default baseFetch --- packages/datx-network/src/BaseRequest.ts | 7 +-- packages/datx-network/src/index.ts | 2 +- packages/datx-network/test/basic.test.ts | 24 +++++------ packages/datx-network/test/caching.test.ts | 6 +-- packages/datx-network/test/headers.test.ts | 10 +++-- .../datx-network/test/mock/MockBaseRequest.ts | 43 +++++++++++++++++++ .../test/mock/MockNetworkPipeline.ts | 25 ----------- packages/datx-network/test/params.test.ts | 20 ++++----- .../datx-network/test/query-params.test.ts | 25 +++++------ 9 files changed, 91 insertions(+), 71 deletions(-) create mode 100644 packages/datx-network/test/mock/MockBaseRequest.ts delete mode 100644 packages/datx-network/test/mock/MockNetworkPipeline.ts diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index 85d3946bb..afc798fa8 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -186,13 +186,14 @@ export class BaseRequest( - NetworkPipelineConstructor: typeof BaseRequest = this.constructor as typeof BaseRequest, + BaseRequestConstructor: typeof BaseRequest = this.constructor as typeof BaseRequest, ): BaseRequest { - // Can't use `new NetworkPipeline`, because we would lose the overridden methods - const clone = new NetworkPipelineConstructor(this._config.baseUrl); + // Can't use `new BaseRequest`, because we would lose the overridden methods + const clone = new BaseRequestConstructor(this._config.baseUrl); clone._config = deepCopy(this._config); clone._interceptors = this._interceptors.slice(); clone._options = deepCopy(this._options); + clone.config.fetchReference = this.config.fetchReference; return clone as BaseRequest; } diff --git a/packages/datx-network/src/index.ts b/packages/datx-network/src/index.ts index f51b6b546..22bbbf3fc 100644 --- a/packages/datx-network/src/index.ts +++ b/packages/datx-network/src/index.ts @@ -1,4 +1,4 @@ -export { BaseRequest as NetworkPipeline } from './BaseRequest'; +export { BaseRequest } from './BaseRequest'; export { Response } from './Response'; export { diff --git a/packages/datx-network/test/basic.test.ts b/packages/datx-network/test/basic.test.ts index ed7ef1337..1a201c3cd 100644 --- a/packages/datx-network/test/basic.test.ts +++ b/packages/datx-network/test/basic.test.ts @@ -1,18 +1,18 @@ -import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { MockBaseRequest } from './mock/MockBaseRequest'; import { addInterceptor, setUrl } from '../src'; describe('Request', () => { it('should initialize', () => { - const request = new MockNetworkPipeline('foobar'); + const request = new MockBaseRequest('foobar'); expect(request).toBeTruthy(); expect(request.config.baseUrl).toBe('foobar'); expect(request.config.maxCacheAge).toBe(Infinity); - expect(request).toBeInstanceOf(MockNetworkPipeline); + expect(request).toBeInstanceOf(MockBaseRequest); }); it('should clone the request', () => { - class FooRequest extends MockNetworkPipeline {} - const request1 = new MockNetworkPipeline('foobar'); + class FooRequest extends MockBaseRequest {} + const request1 = new MockBaseRequest('foobar'); const request2 = request1.clone(FooRequest as any); const request3 = request1.pipe(); @@ -25,18 +25,18 @@ describe('Request', () => { expect(request3).not.toBe(request2); expect(request3.config).not.toBe(request2.config); - expect(request1).toBeInstanceOf(MockNetworkPipeline); + expect(request1).toBeInstanceOf(MockBaseRequest); expect(request1).not.toBeInstanceOf(FooRequest); - expect(request2).toBeInstanceOf(MockNetworkPipeline); + expect(request2).toBeInstanceOf(MockBaseRequest); expect(request2).toBeInstanceOf(FooRequest); - expect(request3).toBeInstanceOf(MockNetworkPipeline); + expect(request3).toBeInstanceOf(MockBaseRequest); expect(request3).not.toBeInstanceOf(FooRequest); }); it('should run the pipes in the right order', () => { - const request1 = new MockNetworkPipeline('foobar'); + const request1 = new MockBaseRequest('foobar'); const request2 = request1.pipe(setUrl('foo'), setUrl('bar')); @@ -55,7 +55,7 @@ describe('Request', () => { }; } - const request1 = new MockNetworkPipeline('foobar'); + const request1 = new MockBaseRequest('foobar'); const request2 = request1.pipe( setUrl('foobar'), @@ -66,7 +66,7 @@ describe('Request', () => { await request2.fetch(); - expect(request2['baseFetch']).toHaveBeenCalledTimes(1); - expect(request1['baseFetch']).toHaveBeenCalledTimes(0); + expect(request2.config.fetchReference).toHaveBeenCalledTimes(1); + expect(request1.config.fetchReference).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/datx-network/test/caching.test.ts b/packages/datx-network/test/caching.test.ts index 769aa7532..f0f4d7abe 100644 --- a/packages/datx-network/test/caching.test.ts +++ b/packages/datx-network/test/caching.test.ts @@ -1,9 +1,9 @@ -import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { MockBaseRequest } from './mock/MockBaseRequest'; import { setUrl, cache, CachingStrategy, Response } from '../src'; describe('caching', () => { it('should fail if no cache with CACHE_ONLY strategy', async () => { - const request1 = new MockNetworkPipeline('foobar'); + const request1 = new MockBaseRequest('foobar'); const request2 = request1.pipe(setUrl('foobar'), cache(CachingStrategy.CacheOnly)); @@ -14,6 +14,6 @@ describe('caching', () => { expect(resp).toBeInstanceOf(Response); } - expect(request2['baseFetch']).toHaveBeenCalledTimes(0); + expect(request2.config.fetchReference).toHaveBeenCalledTimes(0); }); }); diff --git a/packages/datx-network/test/headers.test.ts b/packages/datx-network/test/headers.test.ts index 68579fc46..9065bbda5 100644 --- a/packages/datx-network/test/headers.test.ts +++ b/packages/datx-network/test/headers.test.ts @@ -1,9 +1,9 @@ -import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { MockBaseRequest } from './mock/MockBaseRequest'; import { setUrl, header } from '../src'; describe('headers', () => { it('should work with basic headers', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test'), header('foo', '1'), header('bar', '2'), @@ -11,6 +11,10 @@ describe('headers', () => { await request.fetch(); - expect(request['lastHeaders']).toEqual({ foo: '1', bar: '2' }); + expect(request['lastHeaders']).toEqual({ + foo: '1', + bar: '2', + 'content-type': 'application/vnd.api+json', + }); }); }); diff --git a/packages/datx-network/test/mock/MockBaseRequest.ts b/packages/datx-network/test/mock/MockBaseRequest.ts new file mode 100644 index 000000000..1ea9c194e --- /dev/null +++ b/packages/datx-network/test/mock/MockBaseRequest.ts @@ -0,0 +1,43 @@ +import { BaseRequest } from '../../src'; + +export class MockBaseRequest extends BaseRequest { + constructor(baseUrl: string) { + super(baseUrl); + this.resetMock( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve({}); + }, + }), + ); + } + + protected resetMock(mockResponse: any): void { + this.config.fetchReference = jest.fn().mockResolvedValue(mockResponse); + } + + private get lastRequest(): [ + string, + { method: string; body: string | FormData | undefined; headers: Record }, + ] { + const mockFetch = (this.config.fetchReference as jest.Mock).mock.calls; + return mockFetch[mockFetch.length - 1]; + } + + protected get lastMethod(): string { + return this.lastRequest[1].method; + } + + protected get lastUrl(): string { + return this.lastRequest[0]; + } + + protected get lastBody(): string | FormData | undefined { + return this.lastRequest[1].body; + } + + protected get lastHeaders(): Record { + return this.lastRequest[1].headers; + } +} diff --git a/packages/datx-network/test/mock/MockNetworkPipeline.ts b/packages/datx-network/test/mock/MockNetworkPipeline.ts deleted file mode 100644 index 9456bbd3c..000000000 --- a/packages/datx-network/test/mock/MockNetworkPipeline.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NetworkPipeline } from '../../src'; - -export class MockNetworkPipeline extends NetworkPipeline { - protected baseFetch = jest.fn().mockResolvedValue(Promise.resolve({ status: 200 })); - - private get lastRequest(): [string, string, any, any] { - return this.baseFetch.mock.calls[this.baseFetch.mock.calls.length - 1]; - } - - protected get lastMethod(): string { - return this.lastRequest[0]; - } - - protected get lastUrl(): string { - return this.lastRequest[1]; - } - - protected get lastBody(): any { - return this.lastRequest[2]; - } - - protected get lastHeaders(): any { - return this.lastRequest[3]; - } -} diff --git a/packages/datx-network/test/params.test.ts b/packages/datx-network/test/params.test.ts index d8b71914d..088ed5c08 100644 --- a/packages/datx-network/test/params.test.ts +++ b/packages/datx-network/test/params.test.ts @@ -1,9 +1,9 @@ -import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { MockBaseRequest } from './mock/MockBaseRequest'; import { setUrl, params } from '../src'; describe('params', () => { it('should work for a basic params case', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test/{testId}/{mockId}'), params('testId', '123'), params('mockId', '456'), @@ -14,7 +14,7 @@ describe('params', () => { }); it('should work for object params', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test/{testId}/{mockId}'), params({ testId: '234', @@ -27,7 +27,7 @@ describe('params', () => { }); it('should work with missing params', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test/{testId}/{mockId}'), params('testId', '123'), ); @@ -37,7 +37,7 @@ describe('params', () => { }); it('should work with fetch addon', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test/{testId}/{mockId}'), params('testId', '123'), ); @@ -47,21 +47,21 @@ describe('params', () => { }); it('should work with partial fetch addon', async () => { - const request = new MockNetworkPipeline('foobar').pipe(setUrl('/test/{testId}/{mockId}')); + const request = new MockBaseRequest('foobar').pipe(setUrl('/test/{testId}/{mockId}')); await request.fetch({ mockId: '321' }); expect(request['lastUrl']).toBe('/test/{testId}/321'); }); it('should work with full fetch params', async () => { - const request = new MockNetworkPipeline('foobar').pipe(setUrl('/test/{testId}/{mockId}')); + const request = new MockBaseRequest('foobar').pipe(setUrl('/test/{testId}/{mockId}')); await request.fetch({ testId: '432', mockId: '321' }); expect(request['lastUrl']).toBe('/test/432/321'); }); it('should work with multiple pipes', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test/{testId}/{mockId}'), params('testId', '123'), ); @@ -69,9 +69,9 @@ describe('params', () => { const request2 = request.pipe(params('testId', '321')); await request2.fetch(); - await request.fetch(); + expect(request2['lastUrl']).toBe('/test/321/{mockId}'); + await request.fetch(); expect(request['lastUrl']).toBe('/test/123/{mockId}'); - expect(request2['lastUrl']).toBe('/test/321/{mockId}'); }); }); diff --git a/packages/datx-network/test/query-params.test.ts b/packages/datx-network/test/query-params.test.ts index 693698798..1d07b1d29 100644 --- a/packages/datx-network/test/query-params.test.ts +++ b/packages/datx-network/test/query-params.test.ts @@ -1,33 +1,30 @@ import { query, setUrl, paramArrayType, ParamArrayType, encodeQueryString } from '../src'; -import { MockNetworkPipeline } from './mock/MockNetworkPipeline'; +import { MockBaseRequest } from './mock/MockBaseRequest'; describe('query params', () => { it('should work for a basic params case', async () => { - const request = new MockNetworkPipeline('foobar').pipe(setUrl('/test'), query('test', '123')); + const request = new MockBaseRequest('foobar').pipe(setUrl('/test'), query('test', '123')); await request.fetch(); expect(request['lastUrl']).toBe('/test?test=123'); }); it('should work for a basic params case and existing queryparams', async () => { - const request = new MockNetworkPipeline('foobar').pipe( - setUrl('/test?foo=1'), - query('test', '123'), - ); + const request = new MockBaseRequest('foobar').pipe(setUrl('/test?foo=1'), query('test', '123')); await request.fetch(); expect(request['lastUrl']).toBe('/test?foo=1&test=123'); }); it('should work for a basic params case and empty query params', async () => { - const request = new MockNetworkPipeline('foobar').pipe(setUrl('/test?'), query('test', '123')); + const request = new MockBaseRequest('foobar').pipe(setUrl('/test?'), query('test', '123')); await request.fetch(); expect(request['lastUrl']).toBe('/test?test=123'); }); it('should work for a basic params case and next query params', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test?foo=1&'), query('test', '123'), ); @@ -37,7 +34,7 @@ describe('query params', () => { }); it('should work for a multiple params case', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test'), query('test', '123'), query('foo', '1'), @@ -48,7 +45,7 @@ describe('query params', () => { }); it('should work for complex query and ParamArray', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test'), query('test', ['123', '234']), query('foo', { @@ -69,7 +66,7 @@ describe('query params', () => { }); it('should work for complex query and ParamArray', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test'), query('test', ['123', '234']), query('foo', { @@ -91,7 +88,7 @@ describe('query params', () => { }); it('should work for complex query and ParamArray', async () => { - const request = new MockNetworkPipeline('foobar').pipe( + const request = new MockBaseRequest('foobar').pipe( setUrl('/test'), query('test', ['123', '234']), query('foo', { @@ -107,13 +104,13 @@ describe('query params', () => { ); const request2 = request.pipe(encodeQueryString(false)); - await request2.fetch(); - await request.fetch(); + await request.fetch(); expect(request['lastUrl']).toBe( '/test?test=123%2C234&foo[bar]=1&foo[baz]=2%2C3&foo[test][foo][bar]=4%2C5', ); + await request2.fetch(); expect(request2['lastUrl']).toBe( '/test?test=123,234&foo[bar]=1&foo[baz]=2,3&foo[test][foo][bar]=4,5', ); From 358b910f0de54cc26e45269a780520a907abed35 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 8 Aug 2020 17:45:28 +0200 Subject: [PATCH 09/21] Add body tests, bugfixes --- packages/datx-network/src/BaseRequest.ts | 5 +- packages/datx-network/src/operators.ts | 14 +++- packages/datx-network/test/basic.test.ts | 25 ++++++- packages/datx-network/test/body.test.ts | 86 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 packages/datx-network/test/body.test.ts diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index afc798fa8..dc00cb0f5 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -90,12 +90,12 @@ export class BaseRequest { - pipeline.options.body = pipeline.config.serialize(body, bodyType); - pipeline.options.bodyType = bodyType; + if (bodyType || bodyType === 0) { + pipeline.options.bodyType = bodyType; + } else if (body instanceof FormData) { + pipeline.options.bodyType = BodyType.Multipart; + } else if (typeof body === 'object') { + pipeline.options.bodyType = BodyType.Json; + } else { + pipeline.options.bodyType = BodyType.Raw; + } + pipeline.options.body = pipeline.config.serialize(body, pipeline.options.bodyType); }; } diff --git a/packages/datx-network/test/basic.test.ts b/packages/datx-network/test/basic.test.ts index 1a201c3cd..bdb4e58eb 100644 --- a/packages/datx-network/test/basic.test.ts +++ b/packages/datx-network/test/basic.test.ts @@ -1,5 +1,5 @@ import { MockBaseRequest } from './mock/MockBaseRequest'; -import { addInterceptor, setUrl } from '../src'; +import { addInterceptor, setUrl, fetchReference } from '../src'; describe('Request', () => { it('should initialize', () => { @@ -69,4 +69,27 @@ describe('Request', () => { expect(request2.config.fetchReference).toHaveBeenCalledTimes(1); expect(request1.config.fetchReference).toHaveBeenCalledTimes(1); }); + + it('should use the correct fetcher reference', async () => { + const request1 = new MockBaseRequest('foobar'); + + const request2 = request1.pipe( + setUrl('foobar'), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve({}); + }, + }), + ), + ), + ); + + await request2.fetch(); + + expect(request2.config.fetchReference).toHaveBeenCalledTimes(1); + expect(request1.config.fetchReference).toHaveBeenCalledTimes(0); + }); }); diff --git a/packages/datx-network/test/body.test.ts b/packages/datx-network/test/body.test.ts new file mode 100644 index 000000000..063be622c --- /dev/null +++ b/packages/datx-network/test/body.test.ts @@ -0,0 +1,86 @@ +import { MockBaseRequest } from './mock/MockBaseRequest'; +import { body, setUrl, method, HttpMethod } from '../src'; +import { BodyType } from '../src/enums/BodyType'; + +describe('body', () => { + it('should not send anything for get', async () => { + const request = new MockBaseRequest('foobar').pipe(setUrl('foobar'), body('sdasdsad')); + + await request.fetch(); + + expect(request['lastBody']).toBeUndefined(); + }); + + it('should send something if the method is supported', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('foobar'), + body('sdasdsad'), + method(HttpMethod.Post), + ); + + await request.fetch(); + + expect(request['lastBody']).toBe('sdasdsad'); + expect(request['lastMethod']).toBe('POST'); + expect(request['lastHeaders']).toEqual({ 'content-type': 'application/vnd.api+json' }); + }); + + it('should send the default content type if body type is raw', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('foobar'), + body('sdasdsad', BodyType.Raw), + method(HttpMethod.Post), + ); + + await request.fetch(); + + expect(request['lastBody']).toBe('sdasdsad'); + expect(request['lastMethod']).toBe('POST'); + expect(request['lastHeaders']).toEqual({ 'content-type': 'application/vnd.api+json' }); + }); + + it('should send the correct urlencoded data', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('foobar'), + body({ foo: 1, bar: 2 }, BodyType.Urlencoded), + method(HttpMethod.Post), + ); + + await request.fetch(); + + expect(request['lastBody']).toBe('foo=1&bar=2'); + expect(request['lastMethod']).toBe('POST'); + expect(request['lastHeaders']).toEqual({ 'content-type': 'application/x-www-form-urlencoded' }); + }); + + it('should send the correct json data', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('foobar'), + body({ foo: 1, bar: 2 }), + method(HttpMethod.Post), + ); + + await request.fetch(); + + expect(request['lastBody']).toBe('{"foo":1,"bar":2}'); + expect(request['lastMethod']).toBe('POST'); + expect(request['lastHeaders']).toEqual({ 'content-type': 'application/json' }); + }); + + it('should send the correct FormData', async () => { + const data = new FormData(); + data.append('foo', '1'); + + const request = new MockBaseRequest('foobar').pipe( + setUrl('foobar'), + body(data), + method(HttpMethod.Post), + ); + + await request.fetch(); + + expect(request['lastBody']).toBe(data); + expect(request['lastMethod']).toBe('POST'); + expect(request['lastHeaders']).toEqual({ 'content-type': 'multipart/form-data' }); + }); +}); From c3663928f083811e656d6ab110b3ee856227a793 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Mon, 10 Aug 2020 09:06:47 +0200 Subject: [PATCH 10/21] Add multiple tests, bugfixes --- packages/datx-network/src/BaseRequest.ts | 10 + packages/datx-network/src/cache.ts | 14 +- packages/datx-network/src/defaults.ts | 5 +- packages/datx-network/test/basic.test.ts | 24 ++ packages/datx-network/test/caching.test.ts | 333 +++++++++++++++++- .../datx-network/test/mock/MockBaseRequest.ts | 18 +- 6 files changed, 389 insertions(+), 15 deletions(-) diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index dc00cb0f5..61e2489c0 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -82,6 +82,16 @@ export class BaseRequest { + return Promise.reject({ + data: resp.data, + status: resp.status, + headers: resp.headers, + requestHeaders: resp.requestHeaders, + error: resp.error, + collection: resp.collection, + }); + }, ); } diff --git a/packages/datx-network/src/cache.ts b/packages/datx-network/src/cache.ts index c2d78d705..9e9718126 100644 --- a/packages/datx-network/src/cache.ts +++ b/packages/datx-network/src/cache.ts @@ -72,7 +72,7 @@ export function clearCacheByType(type: IType): void { } export function getCacheByCollection( - collection: PureCollection, + collection?: PureCollection, ): Array> { return cacheStorage .filter((item) => item.collection === collection) @@ -81,7 +81,7 @@ export function getCacheByCollection( export function saveCacheForCollection( cacheItems: Array>, - collection: PureCollection, + collection?: PureCollection, ): void { // eslint-disable-next-line prefer-spread cacheStorage.push.apply( @@ -119,6 +119,16 @@ function makeNetworkCall( saveCache(params.url, response); } return response; + }) + .catch((response: IResponseObject) => { + const collectionResponse = Object.assign({}, response, { collection: params.collection }); + throw new Response( + networkPipeline.config.parse(collectionResponse), + params.collection, + params.options, + undefined, + params.views, + ); }); } diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts index ca8b127ba..08a540c03 100644 --- a/packages/datx-network/src/defaults.ts +++ b/packages/datx-network/src/defaults.ts @@ -27,7 +27,7 @@ export function baseFetch( requestHeaders: IHeaders = {}, ): Promise { let data: object; - let status: number; + let status = 0; let headers: IResponseHeaders; const request: Promise = Promise.resolve(); @@ -60,6 +60,9 @@ export function baseFetch( if (status === 204) { return null; } + if (status === 0) { + throw null; + } throw error; }) .then((responseData: object) => { diff --git a/packages/datx-network/test/basic.test.ts b/packages/datx-network/test/basic.test.ts index bdb4e58eb..eacea21ce 100644 --- a/packages/datx-network/test/basic.test.ts +++ b/packages/datx-network/test/basic.test.ts @@ -10,6 +10,30 @@ describe('Request', () => { expect(request).toBeInstanceOf(MockBaseRequest); }); + it('throw if no url is set', async () => { + const request = new MockBaseRequest('foobar'); + try { + await request.fetch(); + expect(true).toBe(false); + } catch (e) { + expect(e).toEqual(new Error('URL should be defined')); + } + }); + + it('throw on server error', async () => { + const request = new MockBaseRequest('foobar').pipe(setUrl('foobar')); + request['resetMock']({ + status: 404, + json: async () => ({}), + }); + try { + await request.fetch(); + expect(true).toBe(false); + } catch (e) { + expect(e.error).toEqual({ message: 'Invalid HTTP status: 404', status: 404 }); + } + }); + it('should clone the request', () => { class FooRequest extends MockBaseRequest {} const request1 = new MockBaseRequest('foobar'); diff --git a/packages/datx-network/test/caching.test.ts b/packages/datx-network/test/caching.test.ts index f0f4d7abe..36e712030 100644 --- a/packages/datx-network/test/caching.test.ts +++ b/packages/datx-network/test/caching.test.ts @@ -1,8 +1,13 @@ import { MockBaseRequest } from './mock/MockBaseRequest'; -import { setUrl, cache, CachingStrategy, Response } from '../src'; +import { setUrl, cache, CachingStrategy, Response, BaseRequest } from '../src'; +import { PureModel } from 'datx'; +import { getCacheByCollection, saveCacheForCollection, clearAllCache } from '../src/cache'; + +const sleep = (duration: number): Promise => + new Promise((resolve) => setTimeout(resolve, duration)); describe('caching', () => { - it('should fail if no cache with CACHE_ONLY strategy', async () => { + it('should fail if no cache with CacheOnly strategy', async () => { const request1 = new MockBaseRequest('foobar'); const request2 = request1.pipe(setUrl('foobar'), cache(CachingStrategy.CacheOnly)); @@ -16,4 +21,328 @@ describe('caching', () => { expect(request2.config.fetchReference).toHaveBeenCalledTimes(0); }); + + describe('caching strategies', () => { + let request: BaseRequest = new MockBaseRequest('foobar').pipe(setUrl('/test')); + + describe('NetworkOnly', () => { + beforeEach(() => { + request = request.pipe(cache(CachingStrategy.NetworkOnly)); + }); + + it('should fail if no network', async () => { + request['resetMock']({ status: 0 }, false); + try { + await request.fetch(); + throw Error('The request should fail'); + } catch (response) { + expect(response?.error).toEqual(new Error('Network not available')); + } + }); + + it('should use network in all calls', async () => { + request['resetMock']({ status: 200, json: async () => ({}) }); + + await request.fetch(); + const response = await request.fetch(); + + expect(response.isSuccess).toBeTruthy(); + expect(request.config.fetchReference).toBeCalledTimes(2); + }); + }); + + describe('NetworkFirst', () => { + beforeEach(() => { + request = request.pipe(cache(CachingStrategy.NetworkFirst)); + }); + + it('should use network if available', async () => { + request['resetMock']({ status: 200, json: async () => ({}) }); + + const response = await request.fetch(); + + expect(response.isSuccess).toBeTruthy(); + }); + + it('should fall back to cache if network not available', async () => { + request['resetMock']({ status: 200, json: async () => ({}) }); + await request.fetch(); + + request['resetMock']({ status: 0 }, false); + const response = await request.fetch(); + + expect(response.isSuccess).toBeTruthy(); + }); + + it('should fail if network and cache not available', async () => { + request['resetMock']({ status: 0 }, false); + + try { + await request.fetch(); + throw Error('The request should fail'); + } catch (response) { + expect(response?.error).toEqual(new Error('Network not available')); + } + }); + }); + + describe('StaleWhileRevalidate', () => { + beforeEach(() => { + request = request.pipe(cache(CachingStrategy.StaleWhileRevalidate)); + }); + + it('should show new data if no cache', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + + const response = await request.fetch(); + const test = response?.data; + + if (test instanceof PureModel) { + expect(test['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + + it('should use cached data if available', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + await request.fetch(); + + request['resetMock']({ status: 0 }, false); + const response = await request.fetch(); + + const test = response?.data; + + if (test instanceof PureModel) { + expect(test['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + + it('should update the cache after call is done', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + const response1 = await request.fetch(); + + // Initial response + const test1 = response1?.data; + + if (test1 instanceof PureModel) { + expect(test1['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + + request['resetMock']({ status: 200, json: async () => ({ foo: 2 }) }); + const response2 = await request.fetch(); + + // Cached 1st response + const test2 = response2?.data; + + if (test2 instanceof PureModel) { + expect(test2['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + + await sleep(1); + request['resetMock']({ status: 0 }, false); + const response3 = await request.fetch(); + + // Cached 2nd response + const test3 = response3?.data; + + if (test3 instanceof PureModel) { + expect(test3['foo']).toBe(2); + } else { + throw new Error('Response is wrong'); + } + }); + }); + + describe('CacheOnly', () => { + beforeEach(() => { + request = request.pipe(cache(CachingStrategy.CacheOnly)); + }); + + it('should fail if no cache', async () => { + request['resetMock']({ status: 0 }, false); + + try { + await request.fetch(); + throw Error('The request should fail'); + } catch (response) { + expect(response?.error?.toString()).toBe('Error: No cache for this request'); + } + }); + + it('should use cache in all calls', async () => { + const requestNetwork = request.pipe(cache(CachingStrategy.NetworkFirst)); + requestNetwork['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + await requestNetwork.fetch(); + + request['resetMock']({ status: 0 }, false); + await request.fetch(); + await request.fetch(); + await request.fetch(); + const response = await request.fetch(); + + const test = response?.data; + + if (test instanceof PureModel) { + expect(test['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + }); + + describe('CacheFirst', () => { + beforeEach(() => { + request = request.pipe(cache(CachingStrategy.CacheFirst)); + }); + + it('should use cache if available', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + await request.pipe(cache(CachingStrategy.NetworkFirst)).fetch(); + + request['resetMock']({ status: 0 }, false); + await request.fetch(); + await request.fetch(); + await request.fetch(); + const response = await request.fetch(); + + const test = response?.data; + + if (test instanceof PureModel) { + expect(test['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + + it('should fall back to network if cache not available', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + await request.fetch(); + + request['resetMock']({ status: 0 }, false); + await request.fetch(); + await request.fetch(); + const response = await request.fetch(); + + const test = response?.data; + + if (test instanceof PureModel) { + expect(test['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + + it('should fail if network and cache not available', async () => { + request['resetMock']({ status: 0 }, false); + + try { + await request.fetch(); + throw Error('The request should fail'); + } catch (response) { + expect(response?.error?.toString()).toBe('Error: Network not available'); + } + }); + + it('should not use a dirty model for caching', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + const firstResponse = await request.fetch(); + const firstTest = firstResponse.data; + firstTest.foo = 2; + + request['resetMock']({ status: 0 }, false); + const response = await request.fetch(); + const test = response?.data; + + if (test instanceof PureModel) { + expect(test['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + + it('should support cache serialization', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + + await request.pipe(cache(CachingStrategy.NetworkFirst)).fetch(); + + const cacheJson = getCacheByCollection(); + + const rawCache = JSON.parse(JSON.stringify(cacheJson)); + + clearAllCache(); + + request['resetMock']({ status: 0 }, false); + try { + await request.fetch(); + throw Error('The request should fail'); + } catch (e) { + expect(e?.error?.toString()).toBe('Error: Network not available'); + } + + saveCacheForCollection(rawCache); + + const response = await request.fetch(); + const test = response?.data; + + if (test instanceof PureModel) { + expect(test['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + }); + + describe('StaleAndUpdate', () => { + beforeEach(() => { + request = request.pipe(cache(CachingStrategy.StaleAndUpdate)); + }); + + it('should show new data if no cache', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + const response1 = await request.fetch(); + + const test1 = response1?.data; + if (test1 instanceof PureModel) { + expect(test1['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + + it('should use cached data if available', async () => { + request['resetMock']({ status: 200, json: async () => ({ foo: 1 }) }); + await request.fetch(); + + request['resetMock']({ status: 0 }, false); + await request.fetch(); + await request.fetch(); + const response = await request.fetch(); + const test = response?.data; + + if (test instanceof PureModel) { + expect(test['foo']).toBe(1); + } else { + throw new Error('Response is wrong'); + } + }); + + it('should fail if invalid strategy', async () => { + request = request.pipe(cache(-9999)); + + try { + await request.fetch(); + throw Error('The request should fail'); + } catch (response) { + expect(response?.error?.toString()).toBe('Error: Invalid caching strategy'); + } + }); + }); + }); }); diff --git a/packages/datx-network/test/mock/MockBaseRequest.ts b/packages/datx-network/test/mock/MockBaseRequest.ts index 1ea9c194e..d71352492 100644 --- a/packages/datx-network/test/mock/MockBaseRequest.ts +++ b/packages/datx-network/test/mock/MockBaseRequest.ts @@ -3,18 +3,16 @@ import { BaseRequest } from '../../src'; export class MockBaseRequest extends BaseRequest { constructor(baseUrl: string) { super(baseUrl); - this.resetMock( - Promise.resolve({ - status: 200, - json() { - return Promise.resolve({}); - }, - }), - ); + this.resetMock({ + status: 200, + json: async () => ({}), + }); } - protected resetMock(mockResponse: any): void { - this.config.fetchReference = jest.fn().mockResolvedValue(mockResponse); + protected resetMock(mockResponse: any, success = true): void { + this.config.fetchReference = success + ? jest.fn().mockResolvedValue(mockResponse) + : jest.fn().mockRejectedValue(mockResponse); } private get lastRequest(): [ From dead620f8715035ee4715065d5999a351eb1d530 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Fri, 14 Aug 2020 15:15:53 +0200 Subject: [PATCH 11/21] Add the collection operator --- packages/datx-network/package.json | 2 +- packages/datx-network/src/defaults.ts | 11 +++++++++-- packages/datx-network/src/interfaces/IConfigType.ts | 2 ++ packages/datx-network/src/operators.ts | 7 +++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/datx-network/package.json b/packages/datx-network/package.json index 9fb12bfe8..91bad9ab3 100644 --- a/packages/datx-network/package.json +++ b/packages/datx-network/package.json @@ -1,6 +1,6 @@ { "name": "datx-network", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.6", "description": "DatX network layer", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts index 08a540c03..47618f481 100644 --- a/packages/datx-network/src/defaults.ts +++ b/packages/datx-network/src/defaults.ts @@ -74,10 +74,17 @@ export function baseFetch( }; } - return { data, headers, requestHeaders, status }; + return { data, headers, requestHeaders, status, collection: requestObj.config.collection }; }) .catch((error) => { - throw { data, error, headers, requestHeaders, status }; + throw { + data, + error, + headers, + requestHeaders, + status, + collection: requestObj.config.collection, + }; }); } diff --git a/packages/datx-network/src/interfaces/IConfigType.ts b/packages/datx-network/src/interfaces/IConfigType.ts index 7ef414de0..60b9ef718 100644 --- a/packages/datx-network/src/interfaces/IConfigType.ts +++ b/packages/datx-network/src/interfaces/IConfigType.ts @@ -2,6 +2,7 @@ import { CachingStrategy } from '../enums/CachingStrategy'; import { ParamArrayType } from '../enums/ParamArrayType'; import { IResponseObject } from './IResponseObject'; import { BodyType } from '../enums/BodyType'; +import { PureCollection } from 'datx'; export interface IConfigType { baseUrl: string; @@ -13,4 +14,5 @@ export interface IConfigType { encodeQueryString: boolean; serialize(data: IResponseObject, type: BodyType): IResponseObject; parse(data: IResponseObject): IResponseObject; + collection?: PureCollection; } diff --git a/packages/datx-network/src/operators.ts b/packages/datx-network/src/operators.ts index d88ab03e1..46fcd9ab0 100644 --- a/packages/datx-network/src/operators.ts +++ b/packages/datx-network/src/operators.ts @@ -4,6 +4,7 @@ import { CachingStrategy } from './enums/CachingStrategy'; import { HttpMethod } from './enums/HttpMethod'; import { BodyType } from './enums/BodyType'; import { ParamArrayType } from './enums/ParamArrayType'; +import { PureCollection } from 'datx'; export function setUrl(url: string) { return (pipeline: BaseRequest): void => { @@ -98,3 +99,9 @@ export function parser(parse: (data: object) => object) { pipeline.config.parse = parse; }; } + +export function collection(collection?: PureCollection) { + return (pipeline: BaseRequest): void => { + pipeline.config.collection = collection; + }; +} From cde053ad0eea78be85ee1e158e0c6f0044963b6c Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 15 Aug 2020 22:36:13 +0200 Subject: [PATCH 12/21] Misc bugfixes --- packages/datx-network/package.json | 2 +- packages/datx-network/src/BaseRequest.ts | 69 ++-- packages/datx-network/src/Response.ts | 50 +-- packages/datx-network/src/cache.ts | 39 +- packages/datx-network/src/defaults.ts | 20 +- packages/datx-network/src/index.ts | 1 + .../src/interfaces/IConfigType.ts | 6 +- .../src/interfaces/IResponseObject.ts | 3 +- packages/datx-network/src/operators.ts | 48 +-- packages/datx-network/test/basic.test.ts | 363 +++++++++++++++++- packages/datx-network/test/caching.test.ts | 4 +- .../datx-network/test/mock/MockBaseRequest.ts | 4 +- packages/datx-network/test/params.test.ts | 16 +- .../datx-network/test/query-params.test.ts | 18 +- 14 files changed, 500 insertions(+), 143 deletions(-) diff --git a/packages/datx-network/package.json b/packages/datx-network/package.json index 91bad9ab3..c05756940 100644 --- a/packages/datx-network/package.json +++ b/packages/datx-network/package.json @@ -1,6 +1,6 @@ { "name": "datx-network", - "version": "2.0.0-beta.6", + "version": "2.0.0-beta.7", "description": "DatX network layer", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index 61e2489c0..c061aad17 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -31,6 +31,7 @@ interface IRequestOptions { export class BaseRequest { private _config: IConfigType = getDefaultConfig(); + private _interceptors: Array = []; private _options: IRequestOptions = { method: HttpMethod.Get, headers: {}, @@ -38,15 +39,6 @@ export class BaseRequest = []; - - public get config(): IConfigType { - return this._config; - } - - public get options(): IRequestOptions { - return this._options; - } constructor(baseUrl: string) { this._config.baseUrl = baseUrl; @@ -61,9 +53,10 @@ export class BaseRequest( - ...operators: Array - ): BaseRequest { + public pipe< + TNewModel extends PureModel | Array = TModel, + TNewParams extends object = TParams + >(...operators: Array): BaseRequest { const destinationPipeline = this.clone(); operators.forEach((operator) => operator(destinationPipeline)); @@ -71,7 +64,7 @@ export class BaseRequest { - return this.baseFetch(options.method, options.url, options.data, this.options.headers).then( + return this.baseFetch(options.method, options.url, options.data, this._options.headers).then( (resp) => { return { data: resp.data, @@ -80,6 +73,7 @@ export class BaseRequest { @@ -90,6 +84,7 @@ export class BaseRequest> { - if (!this.options.url) { + if (!this._options.url) { throw new Error('URL should be defined'); } - const urlParams = Object.assign({}, this.options.params, params); - const url = interpolateParams(this.options.url, urlParams); + const urlParams = Object.assign({}, this._options.params, params); + const url = interpolateParams(`${this._config.baseUrl}${this._options.url}`, urlParams); const processedUrl = appendQueryParams( url, - this.options.query, - this.config.paramArrayType, - this.config.encodeQueryString, + this._options.query, + this._config.paramArrayType, + this._config.encodeQueryString, ); const initialCallback = this._interceptors.reverse().reduce( (callback: INetworkHandler, interceptor: IInterceptor) => { @@ -157,6 +151,13 @@ export class BaseRequest; } diff --git a/packages/datx-network/src/Response.ts b/packages/datx-network/src/Response.ts index a06748ec4..c26e9cd76 100644 --- a/packages/datx-network/src/Response.ts +++ b/packages/datx-network/src/Response.ts @@ -16,6 +16,7 @@ import { IResponseInternal } from './interfaces/IResponseInternal'; import { IResponseSnapshot } from './interfaces/IResponseSnapshot'; import { action, runInAction } from 'mobx'; import { IResponseObject } from './interfaces/IResponseObject'; +import { mapItems } from 'datx-utils'; function serializeHeaders( headers: Array<[string, string]> | IResponseHeaders, @@ -41,35 +42,32 @@ function initHeaders(headers: Array<[string, string]> | IResponseHeaders): IResp return headers; } -function initData( +function initData>( response: IResponseObject, collection?: PureCollection, - overrideData?: T | Array, + overrideData?: T, ): any { + let data: any = null; if (collection && response.data) { - const data: any = overrideData || collection.add(response.data); - - return new Bucket.ToOneOrMany(data, collection as any, true); + data = + overrideData || + (response.type + ? collection.add(response.data, response.type) + : collection.add(response.data)); + } else if (response.data) { + const ModelConstructor = + response.type === PureModel || Object.isPrototypeOf.call(PureModel, response.type || {}) + ? (response.type as typeof PureModel) + : PureModel; + + data = overrideData || mapItems(response.data, (item) => new ModelConstructor(item)); + return { value: data }; } - if (response.data) { - // The case when a record is not in a store and save/remove are used - if (response.data) { - if (response.data instanceof Array) { - throw new Error('A save/remove operation should not return an array of results'); - } - - return { - value: new PureModel(response.data), // TODO: Make a Generic model - // value: overrideData || (new GenericModel(flattenModel(undefined, resp.data)) as T), - }; - } - } - - return new Bucket.ToOneOrMany(null, collection as any, true); + return new Bucket.ToOneOrMany(data, collection as any, true); } -export class Response { +export class Response> { private __data; private __internal: IResponseInternal = { @@ -133,7 +131,7 @@ export class Response { return !this.error; } - public get data(): T | Array | null { + public get data(): T | null { return this.__data.value; } @@ -141,13 +139,17 @@ export class Response { response: IResponseObject, collection?: PureCollection, options?: IRequestOptions, - overrideData?: T | Array, + overrideData?: T, views?: Array, ) { this.collection = collection; runInAction(() => { this.__updateInternal(response, options, views); - this.__data = initData(response, collection, overrideData); + try { + this.__data = initData(response, collection, overrideData); + } catch (e) { + this.__internal.error = e; + } this.views.forEach((view) => { if (this.__data.value) { diff --git a/packages/datx-network/src/cache.ts b/packages/datx-network/src/cache.ts index 9e9718126..e8d281b48 100644 --- a/packages/datx-network/src/cache.ts +++ b/packages/datx-network/src/cache.ts @@ -97,39 +97,40 @@ function makeNetworkCall( doCacheResponse = false, existingResponse?: Response, ): Promise> { - return next(params) - .then((response: IResponseObject) => { + return next(params).then( + (response: IResponseObject) => { const collectionResponse = Object.assign({}, response, { collection: params.collection }); + let newResponse; if (existingResponse) { existingResponse.update(collectionResponse, params.views); - return existingResponse; + newResponse = existingResponse; + } else { + newResponse = new Response( + networkPipeline['_config'].parse(collectionResponse), + params.collection, + params.options, + undefined, + params.views, + ); } - return new Response( - networkPipeline.config.parse(collectionResponse), - params.collection, - params.options, - undefined, - params.views, - ); - }) - .then((response: Response) => { if (doCacheResponse) { - saveCache(params.url, response); + saveCache(params.url, newResponse); } - return response; - }) - .catch((response: IResponseObject) => { + return newResponse; + }, + (response: IResponseObject) => { const collectionResponse = Object.assign({}, response, { collection: params.collection }); throw new Response( - networkPipeline.config.parse(collectionResponse), + networkPipeline['_config'].parse(collectionResponse), params.collection, params.options, undefined, params.views, ); - }); + }, + ); } function getLocalNetworkError( @@ -154,7 +155,7 @@ export function cacheInterceptor( networkPipeline: BaseRequest, ) { return (request: IFetchOptions, next: INextHandler): Promise> => { - const isCacheSupported = request.method === HttpMethod.Get; + const isCacheSupported = request.method.toUpperCase() === HttpMethod.Get; const cacheStrategy = request.options?.cacheOptions?.skipCache || !isCacheSupported diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts index 47618f481..53861230a 100644 --- a/packages/datx-network/src/defaults.ts +++ b/packages/datx-network/src/defaults.ts @@ -37,16 +37,16 @@ export function baseFetch( return request .then(() => { - const defaultHeaders = requestObj.config.defaultFetchOptions.headers || {}; + const defaultHeaders = requestObj['_config'].defaultFetchOptions.headers || {}; const reqHeaders: IHeaders = Object.assign({}, defaultHeaders, requestHeaders) as IHeaders; - const options = Object.assign({}, requestObj.config.defaultFetchOptions, { + const options = Object.assign({}, requestObj['_config'].defaultFetchOptions, { body: (isBodySupported && body) || undefined, headers: reqHeaders, method, }); - if (requestObj.config.fetchReference) { - return requestObj.config.fetchReference(url, options); + if (requestObj['_config'].fetchReference) { + return requestObj['_config'].fetchReference(url, options); } throw new Error('Fetch reference needs to be defined before using the network'); }) @@ -74,7 +74,12 @@ export function baseFetch( }; } - return { data, headers, requestHeaders, status, collection: requestObj.config.collection }; + return { + data, + headers, + requestHeaders, + status, + }; }) .catch((error) => { throw { @@ -83,7 +88,6 @@ export function baseFetch( headers, requestHeaders, status, - collection: requestObj.config.collection, }; }); } @@ -117,11 +121,11 @@ export function getDefaultConfig(): IConfigType { // Determines how will the request param arrays be stringified paramArrayType: ParamArrayType.ParamArray, - serialize(data: object, _type: BodyType): object { + serialize(data: any, _type: BodyType): any { return data; }, - parse(data: object): object { + parse(data: IResponseObject): IResponseObject { return data; }, }; diff --git a/packages/datx-network/src/index.ts b/packages/datx-network/src/index.ts index 22bbbf3fc..c6d229be6 100644 --- a/packages/datx-network/src/index.ts +++ b/packages/datx-network/src/index.ts @@ -15,6 +15,7 @@ export { paramArrayType, serializer, parser, + collection, } from './operators'; export { CachingStrategy } from './enums/CachingStrategy'; diff --git a/packages/datx-network/src/interfaces/IConfigType.ts b/packages/datx-network/src/interfaces/IConfigType.ts index 60b9ef718..f0d9c230c 100644 --- a/packages/datx-network/src/interfaces/IConfigType.ts +++ b/packages/datx-network/src/interfaces/IConfigType.ts @@ -2,7 +2,7 @@ import { CachingStrategy } from '../enums/CachingStrategy'; import { ParamArrayType } from '../enums/ParamArrayType'; import { IResponseObject } from './IResponseObject'; import { BodyType } from '../enums/BodyType'; -import { PureCollection } from 'datx'; +import { PureCollection, IType, PureModel, View } from 'datx'; export interface IConfigType { baseUrl: string; @@ -12,7 +12,9 @@ export interface IConfigType { fetchReference?: typeof fetch; paramArrayType: ParamArrayType; encodeQueryString: boolean; - serialize(data: IResponseObject, type: BodyType): IResponseObject; + serialize(data: any, type: BodyType): any; parse(data: IResponseObject): IResponseObject; collection?: PureCollection; + type?: IType | typeof PureModel; + views?: Array; } diff --git a/packages/datx-network/src/interfaces/IResponseObject.ts b/packages/datx-network/src/interfaces/IResponseObject.ts index c392f55a1..588ff4440 100644 --- a/packages/datx-network/src/interfaces/IResponseObject.ts +++ b/packages/datx-network/src/interfaces/IResponseObject.ts @@ -1,6 +1,6 @@ import { IResponseHeaders } from './IResponseHeaders'; import { IHeaders } from './IHeaders'; -import { PureCollection } from 'datx'; +import { PureCollection, IType, PureModel } from 'datx'; export interface IResponseObject { data?: object; @@ -9,4 +9,5 @@ export interface IResponseObject { requestHeaders?: IHeaders; status?: number; collection?: PureCollection; + type?: IType | typeof PureModel; } diff --git a/packages/datx-network/src/operators.ts b/packages/datx-network/src/operators.ts index 46fcd9ab0..cf5ce5074 100644 --- a/packages/datx-network/src/operators.ts +++ b/packages/datx-network/src/operators.ts @@ -4,11 +4,13 @@ import { CachingStrategy } from './enums/CachingStrategy'; import { HttpMethod } from './enums/HttpMethod'; import { BodyType } from './enums/BodyType'; import { ParamArrayType } from './enums/ParamArrayType'; -import { PureCollection } from 'datx'; +import { PureCollection, IType, PureModel } from 'datx'; +import { IResponseObject } from './interfaces/IResponseObject'; -export function setUrl(url: string) { +export function setUrl(url: string, type: IType | typeof PureModel = PureModel) { return (pipeline: BaseRequest): void => { - pipeline.options.url = url; + pipeline['_options'].url = url; + pipeline['_config'].type = type; }; } @@ -20,41 +22,41 @@ export function addInterceptor(interceptor: IInterceptor) { export function cache(strategy: CachingStrategy, maxAge = Infinity) { return (pipeline: BaseRequest): void => { - pipeline.config.cache = strategy; - pipeline.config.maxCacheAge = maxAge; + pipeline['_config'].cache = strategy; + pipeline['_config'].maxCacheAge = maxAge; }; } export function method(method: HttpMethod) { return (pipeline: BaseRequest): void => { - pipeline.options.method = method; + pipeline['_options'].method = method; }; } export function body(body: any, bodyType?: BodyType) { return (pipeline: BaseRequest): void => { if (bodyType || bodyType === 0) { - pipeline.options.bodyType = bodyType; + pipeline['_options'].bodyType = bodyType; } else if (body instanceof FormData) { - pipeline.options.bodyType = BodyType.Multipart; + pipeline['_options'].bodyType = BodyType.Multipart; } else if (typeof body === 'object') { - pipeline.options.bodyType = BodyType.Json; + pipeline['_options'].bodyType = BodyType.Json; } else { - pipeline.options.bodyType = BodyType.Raw; + pipeline['_options'].bodyType = BodyType.Raw; } - pipeline.options.body = pipeline.config.serialize(body, pipeline.options.bodyType); + pipeline['_options'].body = body; }; } export function query(name: string, value: string | Array | object) { return (pipeline: BaseRequest): void => { - pipeline.options.query[name] = value; + pipeline['_options'].query[name] = value; }; } export function header(name: string, value: string) { return (pipeline: BaseRequest): void => { - pipeline.options.headers[name] = value; + pipeline['_options'].headers[name] = value; }; } @@ -63,45 +65,45 @@ export function params(params: Record): (pipeline: BaseRequest) export function params(name: string | Record, value?: string) { return (pipeline: BaseRequest): void => { if (typeof name === 'string') { - pipeline.options.params[name] = value as string; + pipeline['_options'].params[name] = value as string; } else { - Object.assign(pipeline.options.params, name); + Object.assign(pipeline['_options'].params, name); } }; } export function fetchReference(fetchReference: typeof fetch) { return (pipeline: BaseRequest): void => { - pipeline.config.fetchReference = fetchReference; + pipeline['_config'].fetchReference = fetchReference; }; } export function encodeQueryString(encodeQueryString: boolean) { return (pipeline: BaseRequest): void => { - pipeline.config.encodeQueryString = encodeQueryString; + pipeline['_config'].encodeQueryString = encodeQueryString; }; } export function paramArrayType(paramArrayType: ParamArrayType) { return (pipeline: BaseRequest): void => { - pipeline.config.paramArrayType = paramArrayType; + pipeline['_config'].paramArrayType = paramArrayType; }; } -export function serializer(serialize: (data: object, _type: BodyType) => object) { +export function serializer(serialize: (data: any, type: BodyType) => any) { return (pipeline: BaseRequest): void => { - pipeline.config.serialize = serialize; + pipeline['_config'].serialize = serialize; }; } -export function parser(parse: (data: object) => object) { +export function parser(parse: (data: IResponseObject) => IResponseObject) { return (pipeline: BaseRequest): void => { - pipeline.config.parse = parse; + pipeline['_config'].parse = parse; }; } export function collection(collection?: PureCollection) { return (pipeline: BaseRequest): void => { - pipeline.config.collection = collection; + pipeline['_config'].collection = collection; }; } diff --git a/packages/datx-network/test/basic.test.ts b/packages/datx-network/test/basic.test.ts index eacea21ce..9aa66b780 100644 --- a/packages/datx-network/test/basic.test.ts +++ b/packages/datx-network/test/basic.test.ts @@ -1,12 +1,24 @@ import { MockBaseRequest } from './mock/MockBaseRequest'; -import { addInterceptor, setUrl, fetchReference } from '../src'; +import { + addInterceptor, + setUrl, + fetchReference, + collection, + parser, + method, + HttpMethod, + body, + serializer, +} from '../src'; +import { PureModel, Attribute, Collection } from 'datx'; +import { clearAllCache } from '../src/cache'; describe('Request', () => { it('should initialize', () => { const request = new MockBaseRequest('foobar'); expect(request).toBeTruthy(); - expect(request.config.baseUrl).toBe('foobar'); - expect(request.config.maxCacheAge).toBe(Infinity); + expect(request['_config'].baseUrl).toBe('foobar'); + expect(request['_config'].maxCacheAge).toBe(Infinity); expect(request).toBeInstanceOf(MockBaseRequest); }); @@ -41,13 +53,13 @@ describe('Request', () => { const request3 = request1.pipe(); expect(request1).not.toBe(request2); - expect(request1.config).not.toBe(request2.config); + expect(request1['_config']).not.toBe(request2['_config']); expect(request1).not.toBe(request3); - expect(request1.config).not.toBe(request3.config); + expect(request1['_config']).not.toBe(request3['_config']); expect(request3).not.toBe(request2); - expect(request3.config).not.toBe(request2.config); + expect(request3['_config']).not.toBe(request2['_config']); expect(request1).toBeInstanceOf(MockBaseRequest); expect(request1).not.toBeInstanceOf(FooRequest); @@ -64,8 +76,8 @@ describe('Request', () => { const request2 = request1.pipe(setUrl('foo'), setUrl('bar')); - expect(request1.options.url).toBe(undefined); - expect(request2.options.url).toBe('bar'); + expect(request1['_options'].url).toBe(undefined); + expect(request2['_options'].url).toBe('bar'); }); it('should call interceptors in the correct order', async () => { @@ -90,8 +102,8 @@ describe('Request', () => { await request2.fetch(); - expect(request2.config.fetchReference).toHaveBeenCalledTimes(1); - expect(request1.config.fetchReference).toHaveBeenCalledTimes(1); + expect(request2['_config'].fetchReference).toHaveBeenCalledTimes(1); + expect(request1['_config'].fetchReference).toHaveBeenCalledTimes(1); }); it('should use the correct fetcher reference', async () => { @@ -113,7 +125,334 @@ describe('Request', () => { await request2.fetch(); - expect(request2.config.fetchReference).toHaveBeenCalledTimes(1); - expect(request1.config.fetchReference).toHaveBeenCalledTimes(0); + expect(request2['_config'].fetchReference).toHaveBeenCalledTimes(1); + expect(request1['_config'].fetchReference).toHaveBeenCalledTimes(0); + }); + + describe('model initialization', () => { + beforeEach(() => { + clearAllCache(); + }); + + it('default - PureModel, single model', async () => { + const request1 = new MockBaseRequest('foobar'); + + const request2 = request1.pipe( + setUrl('foobar'), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve({ + title: 'Test', + }); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data).toBeInstanceOf(PureModel); + expect(response.data?.['title']).toBe('Test'); + }); + + it('default - PureModel, array', async () => { + const request1 = new MockBaseRequest('foobar'); + + const request2 = request1.pipe( + setUrl('foobar'), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve([ + { + title: 'Test', + }, + ]); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data?.[0]).toBeInstanceOf(PureModel); + expect(response.data?.[0]['title']).toBe('Test'); + }); + + it('FooModel, single model', async () => { + const request1 = new MockBaseRequest('foobar'); + class Foo extends PureModel { + @Attribute() + public title!: string; + } + + const request2 = request1.pipe( + setUrl('foobar', Foo), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve({ + title: 'Test', + }); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data).toBeInstanceOf(Foo); + expect(response.data?.title).toBe('Test'); + }); + + it('FooModel, array', async () => { + const request1 = new MockBaseRequest('foobar'); + class Foo extends PureModel { + @Attribute() + public title!: string; + } + + const request2 = request1.pipe>( + setUrl('foobar', Foo), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve([ + { + title: 'Test', + }, + ]); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data?.[0]).toBeInstanceOf(Foo); + expect(response.data?.[0]['title']).toBe('Test'); + }); + + it('collection, single model', async () => { + const request1 = new MockBaseRequest('foobar'); + class Foo extends PureModel { + public static type = 'foo'; + + @Attribute() + public title!: string; + } + class TestStore extends Collection { + public static types = [Foo]; + } + + const store = new TestStore(); + + const request2 = request1.pipe( + setUrl('foobar', 'foo'), + collection(store), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve({ + title: 'Test', + }); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data).toBeInstanceOf(Foo); + expect(response.data?.title).toBe('Test'); + }); + + it('collection, array', async () => { + const request1 = new MockBaseRequest('foobar'); + class Foo extends PureModel { + public static type = 'foo'; + + @Attribute() + public title!: string; + } + class TestStore extends Collection { + public static types = [Foo]; + } + + const store = new TestStore(); + + const request2 = request1.pipe>( + setUrl('foobar', 'foo'), + collection(store), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve([ + { + title: 'Test', + }, + ]); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data?.[0]).toBeInstanceOf(Foo); + expect(response.data?.[0]['title']).toBe('Test'); + }); + + it('collection default, single model', async () => { + const request1 = new MockBaseRequest('foobar'); + class Foo extends PureModel { + public static type = 'foo'; + + @Attribute() + public title!: string; + } + class TestStore extends Collection { + public static types = [Foo]; + } + + const store = new TestStore(); + + const request2 = request1.pipe( + setUrl('foobar'), + collection(store), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve({ + title: 'Test', + }); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data).not.toBeInstanceOf(Foo); + expect(response.data?.title).toBe('Test'); + }); + + it('collection default, array', async () => { + const request1 = new MockBaseRequest('foobar'); + class Foo extends PureModel { + public static type = 'foo'; + + @Attribute() + public title!: string; + } + class TestStore extends Collection { + public static types = [Foo]; + } + + const store = new TestStore(); + + const request2 = request1.pipe>( + setUrl('foobar'), + collection(store), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve([ + { + title: 'Test', + }, + ]); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data?.[0]).not.toBeInstanceOf(Foo); + expect(response.data?.[0]['title']).toBe('Test'); + }); + }); + + it('should work with parsers', async () => { + const request1 = new MockBaseRequest('foobar'); + + const request2 = request1.pipe( + setUrl('foobar'), + parser((response) => ({ ...response, data: response.data?.['data'] })), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve({ + data: { + title: 'Test', + }, + }); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data).toBeInstanceOf(PureModel); + expect(response.data?.['title']).toBe('Test'); + }); + + it('should work with serializers', async () => { + const request1 = new MockBaseRequest('foobar'); + + const request2 = request1.pipe( + setUrl('foobar'), + method(HttpMethod.Post), + body({ test: true }), + serializer((data) => ({ data })), + fetchReference( + jest.fn().mockResolvedValue( + Promise.resolve({ + status: 200, + json() { + return Promise.resolve({ + title: 'Test', + }); + }, + }), + ), + ), + ); + + const response = await request2.fetch(); + + expect(response.data).toBeInstanceOf(PureModel); + expect(response.data?.['title']).toBe('Test'); + expect(request2['lastBody']).toBe(JSON.stringify({ data: { test: true } })); }); }); diff --git a/packages/datx-network/test/caching.test.ts b/packages/datx-network/test/caching.test.ts index 36e712030..3d22100cc 100644 --- a/packages/datx-network/test/caching.test.ts +++ b/packages/datx-network/test/caching.test.ts @@ -19,7 +19,7 @@ describe('caching', () => { expect(resp).toBeInstanceOf(Response); } - expect(request2.config.fetchReference).toHaveBeenCalledTimes(0); + expect(request2['_config'].fetchReference).toHaveBeenCalledTimes(0); }); describe('caching strategies', () => { @@ -47,7 +47,7 @@ describe('caching', () => { const response = await request.fetch(); expect(response.isSuccess).toBeTruthy(); - expect(request.config.fetchReference).toBeCalledTimes(2); + expect(request['_config'].fetchReference).toBeCalledTimes(2); }); }); diff --git a/packages/datx-network/test/mock/MockBaseRequest.ts b/packages/datx-network/test/mock/MockBaseRequest.ts index d71352492..6886d0935 100644 --- a/packages/datx-network/test/mock/MockBaseRequest.ts +++ b/packages/datx-network/test/mock/MockBaseRequest.ts @@ -10,7 +10,7 @@ export class MockBaseRequest extends BaseRequest { } protected resetMock(mockResponse: any, success = true): void { - this.config.fetchReference = success + this['_config'].fetchReference = success ? jest.fn().mockResolvedValue(mockResponse) : jest.fn().mockRejectedValue(mockResponse); } @@ -19,7 +19,7 @@ export class MockBaseRequest extends BaseRequest { string, { method: string; body: string | FormData | undefined; headers: Record }, ] { - const mockFetch = (this.config.fetchReference as jest.Mock).mock.calls; + const mockFetch = (this['_config'].fetchReference as jest.Mock).mock.calls; return mockFetch[mockFetch.length - 1]; } diff --git a/packages/datx-network/test/params.test.ts b/packages/datx-network/test/params.test.ts index 088ed5c08..a0e0158c9 100644 --- a/packages/datx-network/test/params.test.ts +++ b/packages/datx-network/test/params.test.ts @@ -10,7 +10,7 @@ describe('params', () => { ); await request.fetch(); - expect(request['lastUrl']).toBe('/test/123/456'); + expect(request['lastUrl']).toBe('foobar/test/123/456'); }); it('should work for object params', async () => { @@ -23,7 +23,7 @@ describe('params', () => { ); await request.fetch(); - expect(request['lastUrl']).toBe('/test/234/345'); + expect(request['lastUrl']).toBe('foobar/test/234/345'); }); it('should work with missing params', async () => { @@ -33,7 +33,7 @@ describe('params', () => { ); await request.fetch(); - expect(request['lastUrl']).toBe('/test/123/{mockId}'); + expect(request['lastUrl']).toBe('foobar/test/123/{mockId}'); }); it('should work with fetch addon', async () => { @@ -43,21 +43,21 @@ describe('params', () => { ); await request.fetch({ mockId: '321' }); - expect(request['lastUrl']).toBe('/test/123/321'); + expect(request['lastUrl']).toBe('foobar/test/123/321'); }); it('should work with partial fetch addon', async () => { const request = new MockBaseRequest('foobar').pipe(setUrl('/test/{testId}/{mockId}')); await request.fetch({ mockId: '321' }); - expect(request['lastUrl']).toBe('/test/{testId}/321'); + expect(request['lastUrl']).toBe('foobar/test/{testId}/321'); }); it('should work with full fetch params', async () => { const request = new MockBaseRequest('foobar').pipe(setUrl('/test/{testId}/{mockId}')); await request.fetch({ testId: '432', mockId: '321' }); - expect(request['lastUrl']).toBe('/test/432/321'); + expect(request['lastUrl']).toBe('foobar/test/432/321'); }); it('should work with multiple pipes', async () => { @@ -69,9 +69,9 @@ describe('params', () => { const request2 = request.pipe(params('testId', '321')); await request2.fetch(); - expect(request2['lastUrl']).toBe('/test/321/{mockId}'); + expect(request2['lastUrl']).toBe('foobar/test/321/{mockId}'); await request.fetch(); - expect(request['lastUrl']).toBe('/test/123/{mockId}'); + expect(request['lastUrl']).toBe('foobar/test/123/{mockId}'); }); }); diff --git a/packages/datx-network/test/query-params.test.ts b/packages/datx-network/test/query-params.test.ts index 1d07b1d29..b65c19681 100644 --- a/packages/datx-network/test/query-params.test.ts +++ b/packages/datx-network/test/query-params.test.ts @@ -6,21 +6,21 @@ describe('query params', () => { const request = new MockBaseRequest('foobar').pipe(setUrl('/test'), query('test', '123')); await request.fetch(); - expect(request['lastUrl']).toBe('/test?test=123'); + expect(request['lastUrl']).toBe('foobar/test?test=123'); }); it('should work for a basic params case and existing queryparams', async () => { const request = new MockBaseRequest('foobar').pipe(setUrl('/test?foo=1'), query('test', '123')); await request.fetch(); - expect(request['lastUrl']).toBe('/test?foo=1&test=123'); + expect(request['lastUrl']).toBe('foobar/test?foo=1&test=123'); }); it('should work for a basic params case and empty query params', async () => { const request = new MockBaseRequest('foobar').pipe(setUrl('/test?'), query('test', '123')); await request.fetch(); - expect(request['lastUrl']).toBe('/test?test=123'); + expect(request['lastUrl']).toBe('foobar/test?test=123'); }); it('should work for a basic params case and next query params', async () => { @@ -30,7 +30,7 @@ describe('query params', () => { ); await request.fetch(); - expect(request['lastUrl']).toBe('/test?foo=1&test=123'); + expect(request['lastUrl']).toBe('foobar/test?foo=1&test=123'); }); it('should work for a multiple params case', async () => { @@ -41,7 +41,7 @@ describe('query params', () => { ); await request.fetch(); - expect(request['lastUrl']).toBe('/test?test=123&foo=1'); + expect(request['lastUrl']).toBe('foobar/test?test=123&foo=1'); }); it('should work for complex query and ParamArray', async () => { @@ -61,7 +61,7 @@ describe('query params', () => { await request.fetch(); expect(request['lastUrl']).toBe( - '/test?test[]=123&test[]=234&foo[bar]=1&foo[baz][]=2&foo[baz][]=3&foo[test][foo][bar][]=4&foo[test][foo][bar][]=5', + 'foobar/test?test[]=123&test[]=234&foo[bar]=1&foo[baz][]=2&foo[baz][]=3&foo[test][foo][bar][]=4&foo[test][foo][bar][]=5', ); }); @@ -83,7 +83,7 @@ describe('query params', () => { await request.fetch(); expect(request['lastUrl']).toBe( - '/test?test=123&test=234&foo[bar]=1&foo[baz]=2&foo[baz]=3&foo[test][foo][bar]=4&foo[test][foo][bar]=5', + 'foobar/test?test=123&test=234&foo[bar]=1&foo[baz]=2&foo[baz]=3&foo[test][foo][bar]=4&foo[test][foo][bar]=5', ); }); @@ -107,12 +107,12 @@ describe('query params', () => { await request.fetch(); expect(request['lastUrl']).toBe( - '/test?test=123%2C234&foo[bar]=1&foo[baz]=2%2C3&foo[test][foo][bar]=4%2C5', + 'foobar/test?test=123%2C234&foo[bar]=1&foo[baz]=2%2C3&foo[test][foo][bar]=4%2C5', ); await request2.fetch(); expect(request2['lastUrl']).toBe( - '/test?test=123,234&foo[bar]=1&foo[baz]=2,3&foo[test][foo][bar]=4,5', + 'foobar/test?test=123,234&foo[bar]=1&foo[baz]=2,3&foo[test][foo][bar]=4,5', ); }); }); From 689eb49eba105597179fbb565d55924bd1667fc2 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sun, 16 Aug 2020 18:56:56 +0200 Subject: [PATCH 13/21] Add some network docs --- docs/jsonapi/jsonapi-config.md | 2 +- docs/jsonapi/jsonapi-response.md | 2 +- docs/mixins/network-mixin.md | 32 ++++++ docs/network/base-request.md | 57 ++++++++++ docs/network/caching.md | 23 +++++ docs/network/fetching.md | 6 ++ docs/network/getting-started.md | 25 +++++ docs/network/interceptors.md | 6 ++ docs/network/operators.md | 143 ++++++++++++++++++++++++++ docs/network/parse-serialize.md | 6 ++ docs/network/response.md | 40 +++++++ docs/network/typescript-interfaces.md | 71 +++++++++++++ packages/datx-jsonapi/README.md | 16 ++- packages/datx-network/README.md | 116 +++++++++++++++++++++ 14 files changed, 538 insertions(+), 7 deletions(-) create mode 100644 docs/mixins/network-mixin.md create mode 100644 docs/network/base-request.md create mode 100644 docs/network/caching.md create mode 100644 docs/network/fetching.md create mode 100644 docs/network/getting-started.md create mode 100644 docs/network/interceptors.md create mode 100644 docs/network/operators.md create mode 100644 docs/network/parse-serialize.md create mode 100644 docs/network/response.md create mode 100644 docs/network/typescript-interfaces.md diff --git a/docs/jsonapi/jsonapi-config.md b/docs/jsonapi/jsonapi-config.md index f2f7f58d0..555243403 100644 --- a/docs/jsonapi/jsonapi-config.md +++ b/docs/jsonapi/jsonapi-config.md @@ -122,4 +122,4 @@ cache: CachingStrategy; maxCacheAge: number; // seconds ``` -Options for caching of requests. This can be overriden on a collection or request level. +Options for caching of requests. This can be overridden on a collection or request level. diff --git a/docs/jsonapi/jsonapi-response.md b/docs/jsonapi/jsonapi-response.md index 9f606358e..d717c7b8a 100644 --- a/docs/jsonapi/jsonapi-response.md +++ b/docs/jsonapi/jsonapi-response.md @@ -21,7 +21,7 @@ Replace the response model with a different model. Used to replace a model while ## data -An model or an array of model received from the API +An model or an array of models received from the API ## error diff --git a/docs/mixins/network-mixin.md b/docs/mixins/network-mixin.md new file mode 100644 index 000000000..2e7101ee3 --- /dev/null +++ b/docs/mixins/network-mixin.md @@ -0,0 +1,32 @@ +--- +id: network-mixin +title: Network Mixin +--- + +If you're using an API for your application, you can install `datx-network` to take the full advantage of the `datx` library: + +```bash +npm install --save datx datx-network mobx +``` + +**Note** If you're using the [JSON API specification](https://jsonapi.org/), check out [datx-jsonapi](./jsonapi-mixin) instead. + +## Polyfilling + +The lib makes use of the following features that are not yet available everywhere. Based on your browser support, you might want to polyfill them: + +- [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) +- [Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) +- [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) +- [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) + +## Documentation + +- [BaseRequest](../network/base-request) +- [Response](../network/response) +- [operators](../network/operators) +- [caching](../network/caching) +- [interceptors](../network/interceptors) +- [parse/serialize](../network/parse-serialize) +- [fetching](../network/fetching) +- [TypeScript Interfaces](../network/typescript-interfaces) diff --git a/docs/network/base-request.md b/docs/network/base-request.md new file mode 100644 index 000000000..7b1be0a62 --- /dev/null +++ b/docs/network/base-request.md @@ -0,0 +1,57 @@ +--- +id: base-request +title: BaseRequest +--- + +BaseRequest is the main class to create a new request pipeline. + +# constructor + +```typescript +constructor(baseUrl: string): BaseRequest; +``` + +The constructor has one mandatory argument and that's the base url. + +# pipe + +```typescript +pipe< + TNewModel extends PureModel | Array = TModel, + TNewParams extends object = TParams +>(...operators: Array): BaseRequest; +``` + +The pipe method is used to configure the requests. It will always create a clone of the current base request and modify the clone. +To see what's possible to configure, check out the [operators](./operators) page. +This method can receive two types in the generic. The first one is the expected return type of the `fetch` method. This can be either a model type or an array of models. The second type is a `Record` defining which params are expected in the fetch method - this should match the `setUrl` placeholders that weren't defined in the `params` operator. + +# clone + +```typescript +clone( + BaseRequestConstructor?: typeof BaseRequest, +): BaseRequest; +``` + +This method clones the current BaseRequest class instance. Since it's possible to use custom BaseRequest classes that extend the original one, the clone method will make sure the correct class is used. If needed, you can also pass a different BaseClass implementation. + +# fetch + +```typescript +fetch(params?: TParams): Promise>; +``` + +The fetch method will make the API request and either return a [`Response`](./response) with the `data` property if successful or throw a `Response` with an `error` property. + +# useHook + +```typescript +useHook( + params?: TParams, + options?: { suspense: boolean }, +): [Response | null, boolean, string | Error | null]; +``` + +This method is a React hook. It can receive two arguments - the first one is the params object, and the second one is a hook options object. The only option supported right now is a `suspense` flag that will work with the [React suspense for data fetching](https://reactjs.org/docs/concurrent-mode-suspense.html). +The return value is an array consisting of three items: The first one is a `Response` that will contain either the `data` or `error` property. The second value is a boolean loading flag, and the third one is an error value that will either be `null`, a string or an `Error` object. diff --git a/docs/network/caching.md b/docs/network/caching.md new file mode 100644 index 000000000..2ff0eeaa9 --- /dev/null +++ b/docs/network/caching.md @@ -0,0 +1,23 @@ +--- +id: caching +title: Caching +--- + +The library can use multiple caching strategies in order to optimize your network communication. The caching works only for `GET` requests and it can be controlled by using the [`cache` operator](./operators). + +Supported caching strategies are: + +```typescript +enum CachingStrategy { + NetworkOnly = 1, // Ignore cache + NetworkFirst = 2, // Fallback to cache only on network error + StaleWhileRevalidate = 3, // Use cache and update it in background + CacheOnly = 4, // Fail if nothing in cache + CacheFirst = 5, // Use cache if available + StaleAndUpdate = 6, // Use cache and update response once network is complete +} +``` + +The default caching strategy in the browser is `CachingStrategy.CacheFirst` and on the server it's `CachingStrategy.NetworkOnly`. + +Besides the caching strategy, you can also set the `maxAge` value, which is a number of seconds a response will be cached for. The default maxAge is `Infinity`. diff --git a/docs/network/fetching.md b/docs/network/fetching.md new file mode 100644 index 000000000..54ff7ddc5 --- /dev/null +++ b/docs/network/fetching.md @@ -0,0 +1,6 @@ +--- +id: fetching +title: Fetching TODO +--- + +# TODO diff --git a/docs/network/getting-started.md b/docs/network/getting-started.md new file mode 100644 index 000000000..98fbb0081 --- /dev/null +++ b/docs/network/getting-started.md @@ -0,0 +1,25 @@ +--- +id: getting-started +title: Getting started with DatX Network +--- + +# Getting started + +Note: `datx-network` has a peer dependency to `mobx@^4.2.0` or `mobx@^5.5.0`, so don't forget to install the latest MobX version: + +```bash +npm install --save datx-network mobx +``` + +## Polyfilling + +The lib makes use of the following features that are not yet available everywhere. Based on your browser support, you might want to polyfill them: + +- [Symbol.for](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) +- [Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) +- [Array.prototype.find](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) +- [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) +- [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) + +[How to add the polyfills](https://datx.dev/docs/troubleshooting/known-issues#the-library-doesnt-work-in-internet-explorer-11). +Note: Fetch API is not included in the polyfills mentioned in the Troubleshooting page. Instead, you need to add it as a separate library. If you don't have any special requirements (like server-side rendering), you can use the [window.fetch polyfill](https://github.com/github/fetch#installation). diff --git a/docs/network/interceptors.md b/docs/network/interceptors.md new file mode 100644 index 000000000..1265cba1c --- /dev/null +++ b/docs/network/interceptors.md @@ -0,0 +1,6 @@ +--- +id: interceptors +title: Interceptors TODO +--- + +# TODO diff --git a/docs/network/operators.md b/docs/network/operators.md new file mode 100644 index 000000000..3f2158784 --- /dev/null +++ b/docs/network/operators.md @@ -0,0 +1,143 @@ +--- +id: operators +title: Operators +--- + +The operators are used to configure the [base request](./base-request). + +## setUrl + +```typescript +function setUrl(url: string, type: IType | typeof PureModel = PureModel); +``` + +Set the endpoint that should be loaded and the model that should be initialized. The url can contain placeholders that will be filled by using the `params` operator or as a fetch argument. +The url will be appended to the base url. An example with placeholders: `/articles/{articleId}`. + +## addInterceptor + +```typescript +function addInterceptor(interceptor: IInterceptor); +``` + +The library is using a concept of interceptors to handle use cases like authentication or other flows. This is conceptually similar to [Angular interceptors](https://angular.io/api/common/http/HttpInterceptor) or [Express middleware](https://expressjs.com/en/guide/using-middleware.html). + +To find more about interceptors, check out the [interceptors](./interceptors) page. + +## cache + +```typescript +function cache(strategy: CachingStrategy, maxAge?: number); +``` + +The library supports multiple caching strategies. Only the GET request can be cached and you can configure the behavior by using the `cache` operator. The operator receives a strategy and the max age. +The default caching strategy in the browser is `CachingStrategy.CacheFirst` and on the server it's `CachingStrategy.NetworkOnly`. The default maxAge is `Infinity`. + +To find out more about the caching strategies, check out the [caching](./caching) page. + +## method + +```typescript +function method(method: HttpMethod); +``` + +Set the HTTP method that should be used for the request. + +## body + +```typescript +function body(body: any, bodyType?: BodyType); +``` + +To send a body with your request, use the `body` operator. As a first argument, it receives the payload to be send. If the second argument is not set, the library will try to discover what type should be used. The body type is used for the payload serialization and `Content-Type` header value. + +## query + +```typescript +function query(name: string, value: string | Array | object); +``` + +With this operator you can add query parameters to your url. The parameters can also be objects and arrays, and the `paramArrayType` operator can be used to set the way the query params will be serialized. + +## header + +```typescript +function header(name: string, value: string); +``` + +Set the HTTP headers that should be used for the request. Calling it multiple times with the same name will override the value. + +## params + +```typescript +function params(name: string, value: string): (pipeline: BaseRequest) => void; +function params(params: Record): (pipeline: BaseRequest) => void; +``` + +Set the parameters that will be set in the url. Calling it multiple times with the same name will override the value. + +## fetchReference + +```typescript +function fetchReference(fetchReference: typeof fetch); +``` + +Set the reference to the fetch method. When running in the browser, this will default to `window.fetch`, while there will be no default for the server. To make the network work across server and client, you can use a library like `isomorphic-fetch`. + +## encodeQueryString + +```typescript +function encodeQueryString(encodeQueryString: boolean); +``` + +By default, all query strings added by using the `query` operator will be url encoded. If needed, you can disable the encoding with this operator. + +## paramArrayType + +```typescript +function paramArrayType(paramArrayType: ParamArrayType); +``` + +Since the `query` operator params can be complex objects and there are multiple ways to serialize them, this option defines a desired way to do it. The default value is `ParamArrayType.ParamArray` and the possible options are: + +```typescript +export enum ParamArrayType { + MultipleParams, // filter[a]=1&filter[a]=2 + CommaSeparated, // filter[a]=1,2 + ParamArray, // filter[a][]=1&filter[a][]=2 +} +``` + +## serializer + +```typescript +function serializer(serialize: (data: any, type: BodyType) => any); +``` + +Prepare the body of the request for sending. The default serializer just passes the unmodified data argument. + +## parser + +```typescript +function parser(parse: (data: IResponseObject) => IResponseObject); +``` + +Parse the API response before data initialization. The function also receives other data that could be useful for parsing. An example use case for the parser is if all your API response is wrapped in a `data` object or something similar. + +## collection + +```typescript +function collection(collection?: PureCollection); +``` + +Link the base request to a specific DatX collection. By using this, all the models received by the API response will be added to the specified collection. This is also required in order to use references inside of your models. + +## Custom operators + +You can also create custom operators that work with the base request. The operator needs to follow the `IPipeOperator` typing. + +```typescript +type IPipeOperator = (request: BaseRequest) => void; +``` + +For now, no options of the base request are publicly exposed, but they might be in future. If you have some specific requests, please open an issue with the suggestion. diff --git a/docs/network/parse-serialize.md b/docs/network/parse-serialize.md new file mode 100644 index 000000000..2286bb08d --- /dev/null +++ b/docs/network/parse-serialize.md @@ -0,0 +1,6 @@ +--- +id: parse-serialize +title: Parsing & serializing TODO +--- + +# TODO diff --git a/docs/network/response.md b/docs/network/response.md new file mode 100644 index 000000000..903eaf602 --- /dev/null +++ b/docs/network/response.md @@ -0,0 +1,40 @@ +--- +id: response +title: Response +--- + +## constructor + +```typescript +constructor(response: IResponseObject, collection?: PureCollection, options?: IRequestOptions, overrideData?: PureModel |Array) +``` + +Creates a new `Response` object instance. It needs to receive at lest the [response object](typescript-interfaces#iresponseobject). + +## replaceData + +```typescript +replaceData(data: PureModel): Response +``` + +Replace the response model with a different model. Used to replace a model while keeping the same reference. Mostly for internal use. + +## data + +An model or an array of models received from the API + +## error + +An error object received from the API + +## headers + +A [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object returned from the API + +## requestHeaders + +A key/value object with custom headers sent to the server in the API call + +## status + +HTTP response status diff --git a/docs/network/typescript-interfaces.md b/docs/network/typescript-interfaces.md new file mode 100644 index 000000000..eff78e5c2 --- /dev/null +++ b/docs/network/typescript-interfaces.md @@ -0,0 +1,71 @@ +--- +id: typescript-interfaces +title: TypeScript interfaces +--- + +## IResponseObject + +```typescript +interface IResponseObject { + data?: object; + error?: Error; + headers?: Headers; + requestHeaders?: Record; + status?: number; + collection?: PureCollection; + type?: IType | typeof PureModel; +} +``` + +## IRequestOptions + +```typescript +interface IRequestOptions { + query?: Array<{ key: string; value: string } | string>; + cacheOptions?: { + cachingStrategy?: CachingStrategy; + maxAge?: number; + skipCache?: boolean; + }; + networkConfig?: { + headers?: Record; + }; +} +``` + +## IPipeOperator + +```typescript +type IPipeOperator = (request: BaseRequest) => void; +``` + +## IInterceptor + +```typescript +type IInterceptor = ( + request: IFetchOptions, + next: INetworkHandler, +) => Promise>; +``` + +## INetworkHandler + +```typescript +type INetworkHandler = ( + request: IFetchOptions, +) => Promise>; +``` + +## IFetchOptions + +```typescript +interface IFetchOptions { + url: string; + options?: IRequestOptions; + data?: string | FormData; + method: HttpMethod; + collection?: PureCollection; + skipCache?: boolean; + views?: Array; +} +``` diff --git a/packages/datx-jsonapi/README.md b/packages/datx-jsonapi/README.md index 9bc40ef6e..dfe4dddeb 100644 --- a/packages/datx-jsonapi/README.md +++ b/packages/datx-jsonapi/README.md @@ -9,18 +9,24 @@ DatX is an opinionated data store for use with the [MobX](https://mobx.js.org/) ## Basic usage ```typescript -import { Collection, Model, prop } from 'datx'; +import { Collection, Model, Attribute } from 'datx'; import { jsonapi } from 'datx-jsonapi'; import { computed } from 'mobx'; class Person extends jsonapi(Model) { public static type = 'person'; // Unique name of the model class - @prop name: string; // A normal observable property without a default value - @prop surname: string; - @prop.toOne(Person) spouse?: Person; // A reference to a Person model + @Attribute() + public name: string; // A normal observable property without a default value - @computed get fullName() { + @Attribute() + public surname: string; + + @Attribute({ toOne: Person }) + public spouse?: Person; // A reference to a Person model + + @computed + public get fullName() { // Standard MobX computed props return `${this.name} ${this.surname}`; } diff --git a/packages/datx-network/README.md b/packages/datx-network/README.md index e69de29bb..66b452243 100644 --- a/packages/datx-network/README.md +++ b/packages/datx-network/README.md @@ -0,0 +1,116 @@ +# datx-network + +DatX is an opinionated data store for use with the [MobX](https://mobx.js.org/) state management library. It features support for simple observable property definition, references to other models and first-class TypeScript support. + +`datx-network` is a datx mixin that adds a networking layer support. It can be used with any REST-like API and probably also other types of an API. + +--- + +## Basic usage + +```tsx +import { Collection, Model, Attribute } from 'datx'; +import { BaseRequest, collection, setUrl } from 'datx-network'; + +class Person extends Model { + public static type = 'person'; // Unique name of the model class + + @Attribute() + public name: string; // A normal observable property without a default value + + @Attribute() + public surname: string; + + @Attribute({ toOne: Person }) + public spouse?: Person; // A reference to a Person model + + @computed + public get fullName() { + // Standard MobX computed props + return `${this.name} ${this.surname}`; + } +} + +class AppData extends Collection { + public static types = [Person]; // A list of models available in the collection +} + +const store = new AppData(); + +// Create a base request with a basic configuration (baseUrl and linked collection) +const baseRequest = new BaseRequest('https://example.com').pipe(collection(store)); + +// Create separate request points +const getPerson = baseRequest.pipe(setUrl('/people/{id}', Person)); +const getPeople = baseRequest.pipe>(setUrl('/people', Person)); + +// Pure JS loading +const peopleResponse = await getPeople.fetch(); + +// Loading in a React component +const PersonInfo = ({ userId }) => { + const [response, loading, error] = getPerson.useHook({ id: userId }); + + if (loading || error) { + return null; + } + + const user = response.data; + + return
{user.fullName}
; +}; +``` + +## Getting started + +Note: `datx-network` has a peer dependency to `mobx@^4.2.0` or `mobx@^5.5.0`, so don't forget to install the latest MobX version: + +```bash +npm install --save datx-network mobx +``` + +### Polyfilling + +The lib makes use of the following features that are not yet available everywhere. Based on your browser support, you might want to polyfill them: + +- [Symbol.for](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) +- [Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) +- [Array.prototype.find](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) +- [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) +- [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) + +[How to add the polyfills](https://datx.dev/docs/troubleshooting/known-issues#the-library-doesnt-work-in-internet-explorer-11). +Note: Fetch API is not included in the polyfills mentioned in the Troubleshooting page. Instead, you need to add it as a separate library. If you don't have any special requirements (like server-side rendering), you can use the [window.fetch polyfill](https://github.com/github/fetch#installation). + +## API reference + +- [BaseRequest](https://datx.dev/docs/network/base-request) +- [Response](https://datx.dev/docs/network/response) +- [operators](https://datx.dev/docs/network/operators) +- [caching](https://datx.dev/docs/network/caching) +- [interceptors](https://datx.dev/docs/network/interceptors) +- [parse/serialize](https://datx.dev/docs/network/parse-serialize) +- [fetching](https://datx.dev/docs/network/fetching) +- [TypeScript Interfaces](https://datx.dev/docs/network/typescript-interfaces) + +## Troubleshooting + +Having issues with the library? Check out the [troubleshooting](https://datx.dev/docs/troubleshooting/known-issues) page or [open](https://github.com/infinum/datx/issues/new) an issue. + +--- + +[![Build Status](https://travis-ci.org/infinum/datx.svg?branch=master)](https://travis-ci.org/infinum/datx) +[![npm version](https://badge.fury.io/js/datx-network.svg)](https://badge.fury.io/js/datx-network) +[![Dependency Status](https://david-dm.org/infinum/datx.svg?path=packages/datx-network)](https://david-dm.org/infinum/datx?path=packages/datx-network) +[![devDependency Status](https://david-dm.org/infinum/datx/dev-status.svg?path=packages/datx-network)](https://david-dm.org/infinum/datx?path=packages/datx-network#info=devDependencies) + +## License + +The [MIT License](LICENSE) + +## Credits + +datx-network is maintained and sponsored by +[Infinum](https://www.infinum.com). + + From cf2b7a0cd8abb158ab61f3d1d2263e4215ad4494 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sun, 16 Aug 2020 18:58:29 +0200 Subject: [PATCH 14/21] Fix the dependency issue --- packages/datx-utils/package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/datx-utils/package.json b/packages/datx-utils/package.json index 58578dfba..6487ed41d 100644 --- a/packages/datx-utils/package.json +++ b/packages/datx-utils/package.json @@ -62,8 +62,5 @@ }, "preset": "ts-jest", "testMatch": null - }, - "dependencies": { - "datx": "^2.0.0-beta.4" } } From 7c05d846e9a79730bb78091fd9121ce1e12ac2cd Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Mon, 17 Aug 2020 15:42:27 +0200 Subject: [PATCH 15/21] Add withNetowrk model support --- packages/datx-network/src/BaseRequest.ts | 15 ++- packages/datx-network/src/consts.ts | 1 + packages/datx-network/src/decorateModel.ts | 50 ++----- packages/datx-network/src/defaults.ts | 12 +- packages/datx-network/src/helpers/model.ts | 126 ++++++++++++++++++ .../src/interfaces/IConfigType.ts | 1 - .../src/interfaces/IModelNetworkConfig.ts | 11 ++ .../interfaces/INetworkModelConstructor.ts | 8 ++ .../src/interfaces/IRequestOptions.ts | 2 +- packages/datx-network/src/operators.ts | 28 +++- packages/datx-network/test/body.test.ts | 22 ++- packages/datx-network/test/headers.test.ts | 1 - 12 files changed, 212 insertions(+), 65 deletions(-) create mode 100644 packages/datx-network/src/consts.ts create mode 100644 packages/datx-network/src/helpers/model.ts create mode 100644 packages/datx-network/src/interfaces/IModelNetworkConfig.ts create mode 100644 packages/datx-network/src/interfaces/INetworkModelConstructor.ts diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index c061aad17..c59e51278 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -56,9 +56,11 @@ export class BaseRequest = TModel, TNewParams extends object = TParams - >(...operators: Array): BaseRequest { + >(...operators: Array): BaseRequest { const destinationPipeline = this.clone(); - operators.forEach((operator) => operator(destinationPipeline)); + operators + .filter(Boolean) + .forEach((operator) => (operator as IPipeOperator)(destinationPipeline)); return destinationPipeline as BaseRequest; } @@ -100,10 +102,12 @@ export class BaseRequest string} - * @memberOf Record - */ - public static endpoint: string | (() => string); - - public static getAutoId(): string { - return super.getAutoId().toString(); - } - - constructor(rawData: IRawModel | IRecord = {}, collection?: PureCollection) { - let data = rawData; - - if (rawData && 'type' in rawData && ('attributes' in rawData || 'relationships' in rawData)) { - const classRefs = getModelClassRefs(BaseClass); - - data = flattenModel(classRefs, rawData as IRecord); - } + constructor(data: IRawModel = {}, collection?: PureCollection) { super(data, collection); const modelMeta = data?.[META_FIELD] || {}; @@ -63,5 +35,5 @@ export function decorateModel(BaseClass: typeof PureModel): typeof PureModel { } } - return NetworkModel as typeof PureModel; + return NetworkModel; } diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts index 53861230a..cc93332b8 100644 --- a/packages/datx-network/src/defaults.ts +++ b/packages/datx-network/src/defaults.ts @@ -37,9 +37,8 @@ export function baseFetch( return request .then(() => { - const defaultHeaders = requestObj['_config'].defaultFetchOptions.headers || {}; - const reqHeaders: IHeaders = Object.assign({}, defaultHeaders, requestHeaders) as IHeaders; - const options = Object.assign({}, requestObj['_config'].defaultFetchOptions, { + const reqHeaders: IHeaders = Object.assign({}, requestHeaders) as IHeaders; + const options = Object.assign({ body: (isBodySupported && body) || undefined, headers: reqHeaders, method, @@ -101,13 +100,6 @@ export function getDefaultConfig(): IConfigType { cache: isBrowser ? CachingStrategy.CacheFirst : CachingStrategy.NetworkOnly, maxCacheAge: Infinity, - // Default options that will be passed to the fetch function - defaultFetchOptions: { - headers: { - 'content-type': 'application/vnd.api+json', - }, - }, - encodeQueryString: true, // Reference of the fetch method that should be used diff --git a/packages/datx-network/src/helpers/model.ts b/packages/datx-network/src/helpers/model.ts new file mode 100644 index 000000000..017c85cd0 --- /dev/null +++ b/packages/datx-network/src/helpers/model.ts @@ -0,0 +1,126 @@ +import { INetworkModel } from '../interfaces/INetworkModel'; +import { IRequestOptions } from '../interfaces/IRequestOptions'; +import { getMeta, setMeta } from 'datx-utils'; +import { NETWORK_PERSISTED } from '../consts'; +import { INetworkModelConstructor } from '../interfaces/INetworkModelConstructor'; +import { BaseRequest } from '../BaseRequest'; +import { method, setUrl, query, header, cache, body } from '../operators'; +import { HttpMethod } from '../enums/HttpMethod'; +import { Response } from '../Response'; +import { PureModel, commitModel, getModelCollection, modelToJSON } from 'datx'; +import { action } from 'mobx'; + +function handleResponse( + record: T, +): (response: Response) => T { + return action( + (response: Response): T => { + if (response.error) { + throw response.error; + } + + if (response.status === 204) { + setMeta(record, NETWORK_PERSISTED, true); + + return record; + } + + setMeta(record, NETWORK_PERSISTED, true); + + const data = (response.replaceData(record).data as T) || record; + + commitModel(data); + + return data; + }, + ); +} + +export function saveModel( + model: TModel, + options?: IRequestOptions, +): Promise { + const ModelConstructor = model.constructor as INetworkModelConstructor; + if (!ModelConstructor.network) { + throw new Error('The network property needs to be defined on the model'); + } + const isPersisted = getMeta(model, NETWORK_PERSISTED, false); + + let baseRequest: BaseRequest | undefined; + if (ModelConstructor.network instanceof BaseRequest) { + baseRequest = isPersisted + ? ModelConstructor.network.pipe( + method(HttpMethod.Put), + setUrl( + `${ModelConstructor.network['_options'].url}/{id}`, + (ModelConstructor as unknown) as typeof PureModel, + ), + ) + : ModelConstructor.network.pipe(method(HttpMethod.Post)); + } else { + baseRequest = isPersisted ? ModelConstructor.network.update : ModelConstructor.network.create; + } + + if (!baseRequest) { + throw new Error('The base request has not been defined'); + } + + const request = baseRequest.pipe( + options?.query && query(options?.query), + options?.networkConfig?.headers && header(options?.networkConfig?.headers), + options?.cacheOptions?.cachingStrategy && + cache(options?.cacheOptions?.cachingStrategy, options?.cacheOptions?.maxAge), + body(modelToJSON(model)), + ); + + return request.fetch().then(handleResponse(model)); +} + +export function removeModel( + model: TModel, + options?: IRequestOptions, +): Promise { + const ModelConstructor = model.constructor as INetworkModelConstructor; + if (!ModelConstructor.network) { + throw new Error('The network property needs to be defined on the model'); + } + const isPersisted = getMeta(model, NETWORK_PERSISTED, false); + + let baseRequest: BaseRequest | false | undefined; + if (ModelConstructor.network instanceof BaseRequest) { + baseRequest = isPersisted + ? ModelConstructor.network.pipe( + method(HttpMethod.Delete), + setUrl( + `${ModelConstructor.network['_options'].url}/{id}`, + (ModelConstructor as unknown) as typeof PureModel, + ), + ) + : false; + } else { + baseRequest = isPersisted ? ModelConstructor.network.destroy : false; + } + + if (!baseRequest && baseRequest !== false) { + throw new Error('The base request has not been defined'); + } + + if (baseRequest) { + baseRequest + .pipe( + options?.query && query(options?.query), + options?.networkConfig?.headers && header(options?.networkConfig?.headers), + options?.cacheOptions?.cachingStrategy && + cache(options?.cacheOptions?.cachingStrategy, options?.cacheOptions?.maxAge), + ) + .fetch() + .then(() => { + const collection = getModelCollection(model); + if (collection) { + collection.removeOne(model); + } + }); + } + + return Promise.resolve(); +} diff --git a/packages/datx-network/src/interfaces/IConfigType.ts b/packages/datx-network/src/interfaces/IConfigType.ts index f0d9c230c..78640c0e3 100644 --- a/packages/datx-network/src/interfaces/IConfigType.ts +++ b/packages/datx-network/src/interfaces/IConfigType.ts @@ -8,7 +8,6 @@ export interface IConfigType { baseUrl: string; cache: CachingStrategy; maxCacheAge: number; - defaultFetchOptions: Record; fetchReference?: typeof fetch; paramArrayType: ParamArrayType; encodeQueryString: boolean; diff --git a/packages/datx-network/src/interfaces/IModelNetworkConfig.ts b/packages/datx-network/src/interfaces/IModelNetworkConfig.ts new file mode 100644 index 000000000..0fe0a4ea6 --- /dev/null +++ b/packages/datx-network/src/interfaces/IModelNetworkConfig.ts @@ -0,0 +1,11 @@ +import { BaseRequest } from '../BaseRequest'; + +export type IModelNetworkConfig = + | BaseRequest + | { + getMany?: BaseRequest; + getOne?: BaseRequest; + create?: BaseRequest; + update?: BaseRequest; + destroy?: BaseRequest; + }; diff --git a/packages/datx-network/src/interfaces/INetworkModelConstructor.ts b/packages/datx-network/src/interfaces/INetworkModelConstructor.ts new file mode 100644 index 000000000..3015fafc8 --- /dev/null +++ b/packages/datx-network/src/interfaces/INetworkModelConstructor.ts @@ -0,0 +1,8 @@ +import { INetworkModel } from './INetworkModel'; +import { IModelNetworkConfig } from './IModelNetworkConfig'; +import { PureModel } from 'datx'; + +export interface INetworkModelConstructor extends PureModel { + network?: IModelNetworkConfig; + new (): INetworkModel; +} diff --git a/packages/datx-network/src/interfaces/IRequestOptions.ts b/packages/datx-network/src/interfaces/IRequestOptions.ts index 4b4914e97..867a2b2bd 100644 --- a/packages/datx-network/src/interfaces/IRequestOptions.ts +++ b/packages/datx-network/src/interfaces/IRequestOptions.ts @@ -2,7 +2,7 @@ import { IHeaders } from './IHeaders'; import { CachingStrategy } from '../enums/CachingStrategy'; export interface IRequestOptions { - query?: Array<{ key: string; value: string } | string>; + query?: Record | object>; cacheOptions?: { cachingStrategy?: CachingStrategy; maxAge?: number; diff --git a/packages/datx-network/src/operators.ts b/packages/datx-network/src/operators.ts index cf5ce5074..55bd5a4fb 100644 --- a/packages/datx-network/src/operators.ts +++ b/packages/datx-network/src/operators.ts @@ -48,15 +48,35 @@ export function body(body: any, bodyType?: BodyType) { }; } -export function query(name: string, value: string | Array | object) { +export function query( + name: string, + value: string | Array | object, +): (pipeline: BaseRequest) => void; +export function query( + params: Record | object>, +): (pipeline: BaseRequest) => void; +export function query( + name: string | Record | object>, + value?: string | Array | object, +) { return (pipeline: BaseRequest): void => { - pipeline['_options'].query[name] = value; + if (typeof name === 'string') { + pipeline['_options'].query[name] = value || ''; + } else { + Object.assign(pipeline['_options'].query, name); + } }; } -export function header(name: string, value: string) { +export function header(name: string, value: string): (pipeline: BaseRequest) => void; +export function header(params: Record): (pipeline: BaseRequest) => void; +export function header(name: string | Record, value?: string) { return (pipeline: BaseRequest): void => { - pipeline['_options'].headers[name] = value; + if (typeof name === 'string') { + pipeline['_options'].headers[name] = value || ''; + } else { + Object.assign(pipeline['_options'].headers, name); + } }; } diff --git a/packages/datx-network/test/body.test.ts b/packages/datx-network/test/body.test.ts index 063be622c..c45d7e07c 100644 --- a/packages/datx-network/test/body.test.ts +++ b/packages/datx-network/test/body.test.ts @@ -1,5 +1,5 @@ import { MockBaseRequest } from './mock/MockBaseRequest'; -import { body, setUrl, method, HttpMethod } from '../src'; +import { body, setUrl, method, HttpMethod, header } from '../src'; import { BodyType } from '../src/enums/BodyType'; describe('body', () => { @@ -22,10 +22,9 @@ describe('body', () => { expect(request['lastBody']).toBe('sdasdsad'); expect(request['lastMethod']).toBe('POST'); - expect(request['lastHeaders']).toEqual({ 'content-type': 'application/vnd.api+json' }); }); - it('should send the default content type if body type is raw', async () => { + it('should send the no content type if body type is raw', async () => { const request = new MockBaseRequest('foobar').pipe( setUrl('foobar'), body('sdasdsad', BodyType.Raw), @@ -36,7 +35,7 @@ describe('body', () => { expect(request['lastBody']).toBe('sdasdsad'); expect(request['lastMethod']).toBe('POST'); - expect(request['lastHeaders']).toEqual({ 'content-type': 'application/vnd.api+json' }); + expect(request['lastHeaders']).toEqual({}); }); it('should send the correct urlencoded data', async () => { @@ -67,6 +66,21 @@ describe('body', () => { expect(request['lastHeaders']).toEqual({ 'content-type': 'application/json' }); }); + it('should use the custom content-type header if set', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('foobar'), + header('content-type', 'foobar'), + body({ foo: 1, bar: 2 }), + method(HttpMethod.Post), + ); + + await request.fetch(); + + expect(request['lastBody']).toBe('{"foo":1,"bar":2}'); + expect(request['lastMethod']).toBe('POST'); + expect(request['lastHeaders']).toEqual({ 'content-type': 'foobar' }); + }); + it('should send the correct FormData', async () => { const data = new FormData(); data.append('foo', '1'); diff --git a/packages/datx-network/test/headers.test.ts b/packages/datx-network/test/headers.test.ts index 9065bbda5..96486d89d 100644 --- a/packages/datx-network/test/headers.test.ts +++ b/packages/datx-network/test/headers.test.ts @@ -14,7 +14,6 @@ describe('headers', () => { expect(request['lastHeaders']).toEqual({ foo: '1', bar: '2', - 'content-type': 'application/vnd.api+json', }); }); }); From 7d7b25ee2ea7a3847125b6a0fe97c97b91f7a296 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Wed, 19 Aug 2020 11:22:09 +0200 Subject: [PATCH 16/21] Refactor - fetch & cache interceptors --- docs/assets/datx-network-interceptors.svg | 215 ++++++++++++++++++ docs/network/interceptors.md | 53 ++++- docs/network/operators.md | 34 ++- docs/network/parse-serialize.md | 6 - docs/network/typescript-interfaces.md | 35 +-- packages/datx-network/src/BaseRequest.ts | 115 ++++------ packages/datx-network/src/Response.ts | 31 +-- packages/datx-network/src/defaults.ts | 111 +-------- .../src/{ => interceptors}/cache.ts | 82 +++---- .../datx-network/src/interceptors/fetch.ts | 111 +++++++++ .../src/interfaces/IConfigType.ts | 14 +- .../src/interfaces/IFetchOptions.ts | 5 +- .../src/interfaces/IInterceptor.ts | 2 +- packages/datx-network/src/operators.ts | 72 ++++-- packages/datx-network/test/basic.test.ts | 11 +- packages/datx-network/test/caching.test.ts | 6 +- .../datx-network/test/mock/MockBaseRequest.ts | 10 +- packages/datx-network/test/setup.ts | 2 +- 18 files changed, 593 insertions(+), 322 deletions(-) create mode 100644 docs/assets/datx-network-interceptors.svg delete mode 100644 docs/network/parse-serialize.md rename packages/datx-network/src/{ => interceptors}/cache.ts (68%) create mode 100644 packages/datx-network/src/interceptors/fetch.ts diff --git a/docs/assets/datx-network-interceptors.svg b/docs/assets/datx-network-interceptors.svg new file mode 100644 index 000000000..15c803b0b --- /dev/null +++ b/docs/assets/datx-network-interceptors.svg @@ -0,0 +1,215 @@ + + + + + +
+
+
BaseRequest fetch
+
+
+
+ BaseRequest fetch +
+ + + +
+
+
Interceptor C
+
+
+
+ Interceptor C +
+ + + +
+
+
Interceptor B
+
+
+
+ Interceptor B +
+ + + +
+
+
Interceptor A
+
+
+
+ Interceptor A +
+ + + +
+
+
Cache interceptor
+
+
+
+ Cache interceptor +
+ + + +
+
+
Fetch interceptor
+
+
+
+ Fetch interceptor +
+ + + +
+
+
Network
+
+
+
+ Network +
+ + + + + + +
+
+
next(request)
+
+
+
+ next(reque... +
+ + + + +
+
+
next(request)
+
+
+
+ next(reque... +
+ + + + +
+
+
next(request)
+
+
+
+ next(reque... +
+ + + + +
+
+
+ next(request) +
+
+
+
+ next(reque... +
+ + + + +
+
+
+ next(request) +
+
+
+
+ next(reque... +
+ + + + +
+
+
+ Promise<Response<TModel>> +
+
+
+
+ Promise<Re... +
+ + + + +
+
+
+ Promise<Response<TModel>> +
+
+
+
+ Promise<Re... +
+ + + + +
+
+
+ Promise<Response<TModel>> +
+
+
+
+ Promise<Re... +
+ + + + +
+
+
+ Promise<Response<TModel>> +
+
+
+
+ Promise<Re... +
+ + + + +
+
+
Promise<Response<TModel>>
+
+
+
+ Promise<Re... +
+
\ No newline at end of file diff --git a/docs/network/interceptors.md b/docs/network/interceptors.md index 1265cba1c..3d71493f4 100644 --- a/docs/network/interceptors.md +++ b/docs/network/interceptors.md @@ -3,4 +3,55 @@ id: interceptors title: Interceptors TODO --- -# TODO +The library is using a concept of interceptors to handle use cases like authentication or other flows. This is conceptually similar to [Angular interceptors](https://angular.io/api/common/http/HttpInterceptor) or [Express middleware](https://expressjs.com/en/guide/using-middleware.html). + +THe interceptors are responsible for most of the flow once the fetch method is called - including caching and doing the actual API request. + +The interceptors receive the request object ([`IFetchOptions`](./typescript-ionterfaces#ifetchoptions)) and an optional `next` function representing the next interceptor in the chain. The return value of every interceptor should be a promise of the response object. + +Only the bottom interceptor won't get the next function and that means that it will be responsible for making the actual API call. + +## Call order + +If we execute the following code: + +```typescript +request + .pipe(addInterceptor(interceptorA), addInterceptor(interceptorB), addInterceptor(interceptorC)) + .fetch(); +``` + +the call stack will look like this: + +![Interceptor stacking diagram](../assets/datx-network-interceptors.svg) + +Basically, the last added interceptor will be the first one called, and the last one getting the response back. + +## Builtin interceptors + +The library contains two builtin interceptors that try to make lib usage as simple as possible. However, in order to make things as flexible as possible, you can either completely remove or replace any of the interceptors with your implementation. + +Bellow is the description of the builtin interceptors and what to do if you want to replace them. + +**Note:** If you replace one of the builtin interceptors, make sure you don't use any of the operators mentioned bellow, as they will restore the original interceptor. + +### Fetch + +The fetch interceptor is responsible for making the request. It has three configurable options, each one can be modified by using one of the operators. + +Options: + +- fetch reference - this needs to be defined as `window.fetch` or any other compatible function, like the one from the `isomorphic-fetch` library. In the browser, the reference will be automatically set to `window.fetch` if it exists. If you're running the lib outside of the main browser process or the browser might not support the Fetch API, then you can set your own fetch implementation by using the [`fetchReference` operator](./operators#fetchreference). +- serializer - this function prepares the request body for the API call. This might be something like transforming a model snapshot to a format supported by the API. The function can be defined by using the [`serializer` operator](./operators#serializer) +- parser - this function prepares the API response for the [`Response`](./response) initialization. This might be e.g. because the response objects need to be modified or because the response is nested in a way that's not compatible with the `Response` class. + +### Cache + +The cache interceptor is responsible for caching of the network request. This implementation will cache only the `GET` requests. The interceptor supports two options described bellow, and they can be changed by using the [`cache` operator](./operators#cache). + +Options: + +- The default caching strategy in the browser is `CachingStrategy.CacheFirst` and on the server it's `CachingStrategy.NetworkOnly`. +- Besides the caching strategy, you can also set the `maxAge` value, which is a number of seconds a response will be cached for. The default maxAge is `Infinity`. + +You can find more about the caching and configuration details on the [caching page](./caching). diff --git a/docs/network/operators.md b/docs/network/operators.md index 3f2158784..7f89b3e5d 100644 --- a/docs/network/operators.md +++ b/docs/network/operators.md @@ -17,13 +17,31 @@ The url will be appended to the base url. An example with placeholders: `/articl ## addInterceptor ```typescript -function addInterceptor(interceptor: IInterceptor); +function addInterceptor(interceptor: IInterceptor, name?: string); ``` The library is using a concept of interceptors to handle use cases like authentication or other flows. This is conceptually similar to [Angular interceptors](https://angular.io/api/common/http/HttpInterceptor) or [Express middleware](https://expressjs.com/en/guide/using-middleware.html). +The interceptors can have an optional name so it's easier to manipulate them in later stages. The default name, if none given, will be the given function name. + To find more about interceptors, check out the [interceptors](./interceptors) page. +## upsertInterceptor + +```typescript +function upsertInterceptor(interceptor: IInterceptor, name?: string); +``` + +Replace the interceptor with the given name. The new interceptor will be placed in the same place in the order as the old interceptor. + +## removeInterceptor + +```typescript +function removeInterceptor(name: string); +``` + +Remove the interceptor with the given name. + ## cache ```typescript @@ -33,7 +51,9 @@ function cache(strategy: CachingStrategy, maxAge?: number); The library supports multiple caching strategies. Only the GET request can be cached and you can configure the behavior by using the `cache` operator. The operator receives a strategy and the max age. The default caching strategy in the browser is `CachingStrategy.CacheFirst` and on the server it's `CachingStrategy.NetworkOnly`. The default maxAge is `Infinity`. -To find out more about the caching strategies, check out the [caching](./caching) page. +To find out more about the caching strategies, check out the [caching](./caching) page. To implement your custom caching strategy, check out the [interceptors](./interceptors) page about how to replace builtin interceptors. + +_Note:_ Used only with the [built in cache interceptor](./interceptors#cache). ## method @@ -84,6 +104,8 @@ function fetchReference(fetchReference: typeof fetch); Set the reference to the fetch method. When running in the browser, this will default to `window.fetch`, while there will be no default for the server. To make the network work across server and client, you can use a library like `isomorphic-fetch`. +_Note:_ Used only with the [built in fetch interceptor](./interceptors#fetch). + ## encodeQueryString ```typescript @@ -111,19 +133,23 @@ export enum ParamArrayType { ## serializer ```typescript -function serializer(serialize: (data: any, type: BodyType) => any); +function serializer(serialize: (request: IFetchOptions) => IFetchOptions); ``` Prepare the body of the request for sending. The default serializer just passes the unmodified data argument. +_Note:_ Used only with the [built in fetch interceptor](./interceptors#fetch). + ## parser ```typescript -function parser(parse: (data: IResponseObject) => IResponseObject); +function parser(parse: (data: object, response: IResponseObject) => object); ``` Parse the API response before data initialization. The function also receives other data that could be useful for parsing. An example use case for the parser is if all your API response is wrapped in a `data` object or something similar. +_Note:_ Used only with the [built in fetch interceptor](./interceptors#fetch). + ## collection ```typescript diff --git a/docs/network/parse-serialize.md b/docs/network/parse-serialize.md deleted file mode 100644 index 2286bb08d..000000000 --- a/docs/network/parse-serialize.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -id: parse-serialize -title: Parsing & serializing TODO ---- - -# TODO diff --git a/docs/network/typescript-interfaces.md b/docs/network/typescript-interfaces.md index eff78e5c2..c47dd52ed 100644 --- a/docs/network/typescript-interfaces.md +++ b/docs/network/typescript-interfaces.md @@ -3,25 +3,11 @@ id: typescript-interfaces title: TypeScript interfaces --- -## IResponseObject - -```typescript -interface IResponseObject { - data?: object; - error?: Error; - headers?: Headers; - requestHeaders?: Record; - status?: number; - collection?: PureCollection; - type?: IType | typeof PureModel; -} -``` - ## IRequestOptions ```typescript interface IRequestOptions { - query?: Array<{ key: string; value: string } | string>; + query?: Record | object>; cacheOptions?: { cachingStrategy?: CachingStrategy; maxAge?: number; @@ -33,6 +19,20 @@ interface IRequestOptions { } ``` +## IResponseObject + +```typescript +interface IResponseObject { + data?: object; + error?: Error; + headers?: Headers; + requestHeaders?: Record; + status?: number; + collection?: PureCollection; + type?: IType | typeof PureModel; +} +``` + ## IPipeOperator ```typescript @@ -44,7 +44,7 @@ type IPipeOperator = (request: BaseRequest) => void; ```typescript type IInterceptor = ( request: IFetchOptions, - next: INetworkHandler, + next?: INetworkHandler, ) => Promise>; ``` @@ -62,10 +62,11 @@ type INetworkHandler = ( interface IFetchOptions { url: string; options?: IRequestOptions; - data?: string | FormData; + data?: string | object | FormData; method: HttpMethod; collection?: PureCollection; skipCache?: boolean; views?: Array; + type?: IType | typeof PureModel; } ``` diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index c59e51278..e0853cc37 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -1,19 +1,19 @@ import { PureModel } from 'datx'; import { useCallback, useEffect, useState } from 'react'; -import { baseFetch, getDefaultConfig } from './defaults'; +import { getDefaultConfig } from './defaults'; import { IConfigType } from './interfaces/IConfigType'; import { IHeaders } from './interfaces/IHeaders'; import { IInterceptor } from './interfaces/IInterceptor'; import { IPipeOperator } from './interfaces/IPipeOperator'; import { Response } from './Response'; import { IFetchOptions } from './interfaces/IFetchOptions'; -import { IResponseObject } from './interfaces/IResponseObject'; -import { deepCopy, interpolateParams, appendQueryParams } from './helpers/utils'; -import { INetworkHandler } from './interfaces/INetworkHandler'; +import { deepCopy, interpolateParams, appendQueryParams, isBrowser } from './helpers/utils'; import { HttpMethod } from './enums/HttpMethod'; -import { cacheInterceptor } from './cache'; +import { cacheInterceptor } from './interceptors/cache'; import { BodyType } from './enums/BodyType'; +import { CachingStrategy } from './enums/CachingStrategy'; +import { fetchInterceptor } from './interceptors/fetch'; interface IHookOptions { suspense?: boolean; @@ -31,7 +31,6 @@ interface IRequestOptions { export class BaseRequest { private _config: IConfigType = getDefaultConfig(); - private _interceptors: Array = []; private _options: IRequestOptions = { method: HttpMethod.Get, headers: {}, @@ -40,19 +39,23 @@ export class BaseRequest }> = [ + { + fn: fetchInterceptor(this._config.fetchReference, this._config.serialize, this._config.parse), + name: 'fetch', + }, + { + fn: cacheInterceptor( + isBrowser ? CachingStrategy.CacheFirst : CachingStrategy.NetworkOnly, + ), + name: 'cache', + }, + ]; + constructor(baseUrl: string) { this._config.baseUrl = baseUrl; } - protected baseFetch( - method: string, - url: string, - body?: string | FormData, - requestHeaders?: IHeaders, - ): Promise { - return baseFetch(this, method, url, body, requestHeaders); - } - public pipe< TNewModel extends PureModel | Array = TModel, TNewParams extends object = TParams @@ -65,63 +68,34 @@ export class BaseRequest; } - private doRequest(options: IFetchOptions): Promise { - return this.baseFetch(options.method, options.url, options.data, this._options.headers).then( - (resp) => { - return { - data: resp.data, - status: resp.status, - headers: resp.headers, - requestHeaders: resp.requestHeaders, - error: resp.error, - collection: resp.collection, - type: this._config.type, - }; - }, - (resp) => { - return Promise.reject({ - data: resp.data, - status: resp.status, - headers: resp.headers, - requestHeaders: resp.requestHeaders, - error: resp.error, - collection: resp.collection, - type: this._config.type, - }); - }, - ); - } - - private processBody(): string | FormData | undefined { + private processBody(): object | string | FormData | undefined { if (!this._options.body) { return; } - const body = this._config.serialize - ? this._config.serialize(this._options.body, this._options.bodyType) - : this._options.body; - if (this._options.bodyType === BodyType.Json) { this._options.headers['content-type'] = this._options.headers['content-type'] || 'application/json'; - return typeof body === 'string' ? body : JSON.stringify(body); + return this._options.body; } else if (this._options.bodyType === BodyType.Urlencoded) { this._options.headers['content-type'] = this._options.headers['content-type'] || 'application/x-www-form-urlencoded'; - return typeof body === 'string' - ? body + return typeof this._options.body === 'string' + ? this._options.body : appendQueryParams( '', - body, + this._options.body, this._config.paramArrayType, this._config.encodeQueryString, ).slice(1); } else if (this._options.bodyType === BodyType.Multipart) { this._options.headers['content-type'] = this._options.headers['content-type'] || 'multipart/form-data'; - return body instanceof FormData ? body : new FormData(body); + return this._options.body instanceof FormData + ? this._options.body + : new FormData(this._options.body); } else { - return typeof body === 'string' ? body : JSON.stringify(body); + return this._options.body; } } @@ -137,22 +111,8 @@ export class BaseRequest, interceptor: IInterceptor) => { - return (options: IFetchOptions): Promise> => - interceptor(options, callback); - }, - (options: IFetchOptions) => { - return cacheInterceptor( - this._config.cache, - this._config.maxCacheAge, - this, - )(options, this.doRequest.bind(this)); - }, - ); - // The error is not handled on purpose so UnhandledPromiseRejectionWarning is triggered if the client doesn't handle the error - return initialCallback({ + const request: IFetchOptions = { url: processedUrl, method: this._options.method, data: this.processBody(), @@ -163,7 +123,18 @@ export class BaseRequest { + return (options: IFetchOptions): Promise> => interceptor.fn(options, next); + }, undefined); + + if (!interceptorChain) { + throw new Error('Something went wrong'); + } + + return interceptorChain(request); } public useHook( @@ -207,14 +178,16 @@ export class BaseRequest { // Can't use `new BaseRequest`, because we would lose the overridden methods const clone = new BaseRequestConstructor(this._config.baseUrl); + + // @ts-ignore + clone.interceptors = deepCopy(this.interceptors); + clone._config = deepCopy(this._config); - clone._interceptors = this._interceptors.slice(); clone._options = deepCopy(this._options); // Manually copy complex objects clone._config.collection = this._config.collection; clone._config.type = this._config.type; - clone._config.fetchReference = this._config.fetchReference; return clone as BaseRequest; } diff --git a/packages/datx-network/src/Response.ts b/packages/datx-network/src/Response.ts index c26e9cd76..fc5c79e90 100644 --- a/packages/datx-network/src/Response.ts +++ b/packages/datx-network/src/Response.ts @@ -11,7 +11,6 @@ import { } from 'datx'; import { IResponseHeaders } from './interfaces/IResponseHeaders'; import { IHeaders } from './interfaces/IHeaders'; -import { IRequestOptions } from './interfaces/IRequestOptions'; import { IResponseInternal } from './interfaces/IResponseInternal'; import { IResponseSnapshot } from './interfaces/IResponseSnapshot'; import { action, runInAction } from 'mobx'; @@ -138,13 +137,12 @@ export class Response> { constructor( response: IResponseObject, collection?: PureCollection, - options?: IRequestOptions, overrideData?: T, views?: Array, ) { this.collection = collection; runInAction(() => { - this.__updateInternal(response, options, views); + this.__updateInternal(response, views); try { this.__data = initData(response, collection, overrideData); } catch (e) { @@ -165,15 +163,7 @@ export class Response> { }); } - private __updateInternal( - response: IResponseObject, - options?: IRequestOptions, - views?: Array, - ): void { - if (options) { - this.__internal.options = options; - } - + private __updateInternal(response: IResponseObject, views?: Array): void { this.__internal.response = response; this.__internal.headers = response.headers && initHeaders(response.headers); this.__internal.requestHeaders = response.requestHeaders; @@ -224,16 +214,11 @@ export class Response> { } }); - return new Response(this.__internal.response, this.collection, this.__internal.options, data); + return new Response(this.__internal.response, this.collection, data); } public clone(): Response { - return new Response( - this.__internal.response, - this.collection, - this.__internal.options, - this.data || undefined, - ); + return new Response(this.__internal.response, this.collection, this.data || undefined); } public get snapshot(): IResponseSnapshot { @@ -243,14 +228,14 @@ export class Response> { this.__internal.response.headers && serializeHeaders(this.__internal.response.headers), collection: undefined, }), - options: this.__internal.options, }; } @action - public update(response: IResponseObject, views?: Array): Response { - this.__updateInternal(response, undefined, views); - const newData = initData(response, this.collection); + public update(response: IResponseObject | Response, views?: Array): Response { + const responseData = response instanceof Response ? response.__internal.response : response; + this.__updateInternal(responseData, views); + const newData = initData(responseData, this.collection); this.__data.__readonlyValue = newData.value; diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts index cc93332b8..a4a55ad95 100644 --- a/packages/datx-network/src/defaults.ts +++ b/packages/datx-network/src/defaults.ts @@ -1,124 +1,17 @@ import { IConfigType } from './interfaces/IConfigType'; -import { IHeaders } from './interfaces/IHeaders'; -import { CachingStrategy } from './enums/CachingStrategy'; -import { isBrowser } from './helpers/utils'; import { ParamArrayType } from './enums/ParamArrayType'; -import { BaseRequest } from './BaseRequest'; -import { IResponseHeaders } from './interfaces/IResponseHeaders'; -import { PureModel } from 'datx'; -import { IResponseObject } from './interfaces/IResponseObject'; -import { BodyType } from './enums/BodyType'; - -/** - * Base implementation of the fetch function (can be overridden) - * - * @param {IConfigType} config The request config - * @param {string} method API call method - * @param {string} url API call URL - * @param {object} [body] API call body - * @param {IHeaders} [requestHeaders] Headers that will be sent - * @returns {Promise} Resolves with a raw response object - */ -export function baseFetch( - requestObj: BaseRequest, - method: string, - url: string, - body?: string | FormData, - requestHeaders: IHeaders = {}, -): Promise { - let data: object; - let status = 0; - let headers: IResponseHeaders; - - const request: Promise = Promise.resolve(); - - const uppercaseMethod = method.toUpperCase(); - const isBodySupported = uppercaseMethod !== 'GET' && uppercaseMethod !== 'HEAD'; - - return request - .then(() => { - const reqHeaders: IHeaders = Object.assign({}, requestHeaders) as IHeaders; - const options = Object.assign({ - body: (isBodySupported && body) || undefined, - headers: reqHeaders, - method, - }); - - if (requestObj['_config'].fetchReference) { - return requestObj['_config'].fetchReference(url, options); - } - throw new Error('Fetch reference needs to be defined before using the network'); - }) - .then((response: Response) => { - status = response.status; - headers = response.headers; - - return response.json(); - }) - .catch((error: Error) => { - if (status === 204) { - return null; - } - if (status === 0) { - throw null; - } - throw error; - }) - .then((responseData: object) => { - data = responseData; - if (status >= 400) { - throw { - message: `Invalid HTTP status: ${status}`, - status, - }; - } - - return { - data, - headers, - requestHeaders, - status, - }; - }) - .catch((error) => { - throw { - data, - error, - headers, - requestHeaders, - status, - }; - }); -} +import { isBrowser } from './helpers/utils'; export function getDefaultConfig(): IConfigType { return { // Base URL for all API calls baseUrl: '/', - // Enable caching by default in the browser - cache: isBrowser ? CachingStrategy.CacheFirst : CachingStrategy.NetworkOnly, - maxCacheAge: Infinity, - encodeQueryString: true, - // Reference of the fetch method that should be used - fetchReference: - (isBrowser && - 'fetch' in window && - typeof window.fetch === 'function' && - window.fetch.bind(window)) || - undefined, - // Determines how will the request param arrays be stringified paramArrayType: ParamArrayType.ParamArray, - serialize(data: any, _type: BodyType): any { - return data; - }, - - parse(data: IResponseObject): IResponseObject { - return data; - }, + fetchReference: isBrowser ? window.fetch?.bind?.(window) : undefined, }; } diff --git a/packages/datx-network/src/cache.ts b/packages/datx-network/src/interceptors/cache.ts similarity index 68% rename from packages/datx-network/src/cache.ts rename to packages/datx-network/src/interceptors/cache.ts index e8d281b48..3c4540b79 100644 --- a/packages/datx-network/src/cache.ts +++ b/packages/datx-network/src/interceptors/cache.ts @@ -1,15 +1,12 @@ import { getModelType, IType, PureModel, PureCollection } from 'datx'; import { mapItems } from 'datx-utils'; -import { Response } from './Response'; -import { IResponseSnapshot } from './interfaces/IResponseSnapshot'; -import { CachingStrategy } from './enums/CachingStrategy'; -import { IFetchOptions } from './interfaces/IFetchOptions'; -import { IResponseObject } from './interfaces/IResponseObject'; -import { HttpMethod } from './enums/HttpMethod'; -import { BaseRequest } from './BaseRequest'; - -export type INextHandler = (request: IFetchOptions) => Promise; +import { Response } from '../Response'; +import { IResponseSnapshot } from '../interfaces/IResponseSnapshot'; +import { CachingStrategy } from '../enums/CachingStrategy'; +import { IFetchOptions } from '../interfaces/IFetchOptions'; +import { HttpMethod } from '../enums/HttpMethod'; +import { INetworkHandler } from '../interfaces/INetworkHandler'; export interface ICache { response: Response; @@ -83,52 +80,31 @@ export function saveCacheForCollection( cacheItems: Array>, collection?: PureCollection, ): void { - // eslint-disable-next-line prefer-spread - cacheStorage.push.apply( - cacheStorage, - cacheItems.map((item) => Object.assign({ collection }, item)), + cacheStorage.push( + ...cacheStorage, + ...cacheItems.map((item) => Object.assign({ collection }, item)), ); } function makeNetworkCall( params: IFetchOptions, - next: INextHandler, - networkPipeline: BaseRequest, + next: INetworkHandler, doCacheResponse = false, existingResponse?: Response, ): Promise> { return next(params).then( - (response: IResponseObject) => { - const collectionResponse = Object.assign({}, response, { collection: params.collection }); - let newResponse; - + (response: Response) => { + let finalResponse = response; if (existingResponse) { - existingResponse.update(collectionResponse, params.views); - newResponse = existingResponse; - } else { - newResponse = new Response( - networkPipeline['_config'].parse(collectionResponse), - params.collection, - params.options, - undefined, - params.views, - ); + finalResponse = existingResponse.update(response, params.views); } - if (doCacheResponse) { - saveCache(params.url, newResponse); + saveCache(params.url, finalResponse); } - return newResponse; + return finalResponse; }, - (response: IResponseObject) => { - const collectionResponse = Object.assign({}, response, { collection: params.collection }); - throw new Response( - networkPipeline['_config'].parse(collectionResponse), - params.collection, - params.options, - undefined, - params.views, - ); + (response: Response) => { + throw response; }, ); } @@ -141,20 +117,22 @@ function getLocalNetworkError( return new Response( { error: new Error(message), - // collection, + collection, requestHeaders: reqOptions.options?.networkConfig?.headers, }, collection, - reqOptions.options, ); } export function cacheInterceptor( cache: CachingStrategy, - maxCacheAge: number, - networkPipeline: BaseRequest, + maxCacheAge = Infinity, ) { - return (request: IFetchOptions, next: INextHandler): Promise> => { + return (request: IFetchOptions, next?: INetworkHandler): Promise> => { + if (!next) { + throw new Error("Cache interceptor can't be the last interceptor"); + } + const isCacheSupported = request.method.toUpperCase() === HttpMethod.Get; const cacheStrategy = @@ -164,7 +142,7 @@ export function cacheInterceptor( // NetworkOnly - Ignore cache if (cacheStrategy === CachingStrategy.NetworkOnly) { - return makeNetworkCall(request, next, networkPipeline); + return makeNetworkCall(request, next); } const cacheContent: { response: Response } | undefined = (getCache( @@ -174,7 +152,7 @@ export function cacheInterceptor( // NetworkFirst - Fallback to cache only on network error if (cacheStrategy === CachingStrategy.NetworkFirst) { - return makeNetworkCall(request, next, networkPipeline, true).catch((errorResponse) => { + return makeNetworkCall(request, next, true).catch((errorResponse) => { if (cacheContent) { return cacheContent.response; } @@ -184,7 +162,7 @@ export function cacheInterceptor( // StaleWhileRevalidate - Use cache and update it in background if (cacheStrategy === CachingStrategy.StaleWhileRevalidate) { - const network = makeNetworkCall(request, next, networkPipeline, true); + const network = makeNetworkCall(request, next, true); if (cacheContent) { network.catch(() => { @@ -211,14 +189,14 @@ export function cacheInterceptor( if (cacheStrategy === CachingStrategy.CacheFirst) { return cacheContent ? Promise.resolve(cacheContent.response) - : makeNetworkCall(request, next, networkPipeline, true); + : makeNetworkCall(request, next, true); } // StaleAndUpdate - Use cache and update response once network is complete if (cacheStrategy === CachingStrategy.StaleAndUpdate) { const existingResponse = cacheContent?.response?.clone(); - const network = makeNetworkCall(request, next, networkPipeline, true, existingResponse); + const network = makeNetworkCall(request, next, true, existingResponse); if (existingResponse) { network.catch(() => { @@ -235,5 +213,3 @@ export function cacheInterceptor( ); }; } - -export type IInterceptor = (request: IFetchOptions, next: INextHandler) => Promise; diff --git a/packages/datx-network/src/interceptors/fetch.ts b/packages/datx-network/src/interceptors/fetch.ts new file mode 100644 index 000000000..e3bf60e07 --- /dev/null +++ b/packages/datx-network/src/interceptors/fetch.ts @@ -0,0 +1,111 @@ +import { PureModel } from 'datx'; +import { IFetchOptions } from '../interfaces/IFetchOptions'; +import { INetworkHandler } from '../interfaces/INetworkHandler'; +import { IResponseObject } from '../interfaces/IResponseObject'; +import { IResponseHeaders } from '../interfaces/IResponseHeaders'; +import { IHeaders } from '../interfaces/IHeaders'; +import { Response } from '../Response'; + +function parseResponse( + response: IResponseObject, + parse: (data: object, options: IResponseObject) => object, +): IResponseObject { + if (response.data) { + return { + ...response, + data: parse(response.data, response), + }; + } + + return response; +} + +export function fetchInterceptor( + fetchReference?: typeof fetch, + serialize: (options: IFetchOptions) => IFetchOptions = (options): IFetchOptions => options, + parse: (data: object, options: IResponseObject) => object = (data): object => data, +) { + return (request: IFetchOptions, _next?: INetworkHandler): Promise> => { + if (!fetchReference) { + throw new Error('The fetch reference must be defined'); + } + + const payload = serialize ? serialize(request) : request; + + let data: object; + let status = 0; + let headers: IResponseHeaders; + + const uppercaseMethod = payload.method.toUpperCase(); + const isBodySupported = uppercaseMethod !== 'GET' && uppercaseMethod !== 'HEAD'; + const bodyContent = + typeof payload.data === 'object' && !(payload.data instanceof FormData) + ? JSON.stringify(payload.data) + : payload.data; + const body = isBodySupported ? bodyContent : undefined; + + const requestHeaders: IHeaders = Object.assign( + {}, + payload.options?.networkConfig?.headers, + ) as IHeaders; + const options = Object.assign({ + body, + headers: requestHeaders, + method: payload.method, + }); + + return fetchReference(payload.url, options) + .then((response: globalThis.Response) => { + status = response.status; + headers = response.headers; + + return response.json(); + }) + .catch((error: Error) => { + if (status === 204) { + return null; + } + if (status === 0) { + throw new Error('Network not available'); + } + throw error; + }) + .then((responseData: object) => { + data = responseData; + if (status >= 400) { + throw { + message: `Invalid HTTP status: ${status}`, + status, + }; + } + + const response = parseResponse( + { + data, + headers, + requestHeaders, + status, + collection: request.collection, + type: request.type, + }, + parse, + ); + return new Response(response, request.collection, undefined, request.views); + }) + .catch((error) => { + const response = parseResponse( + { + data, + error, + headers, + requestHeaders, + status, + collection: request.collection, + type: request.type, + }, + parse, + ); + throw new Response(response, request.collection, undefined, request.views); + }); + }; +} diff --git a/packages/datx-network/src/interfaces/IConfigType.ts b/packages/datx-network/src/interfaces/IConfigType.ts index 78640c0e3..b08ab3c16 100644 --- a/packages/datx-network/src/interfaces/IConfigType.ts +++ b/packages/datx-network/src/interfaces/IConfigType.ts @@ -1,19 +1,17 @@ -import { CachingStrategy } from '../enums/CachingStrategy'; +import { PureCollection, IType, PureModel, View } from 'datx'; + import { ParamArrayType } from '../enums/ParamArrayType'; +import { IFetchOptions } from './IFetchOptions'; import { IResponseObject } from './IResponseObject'; -import { BodyType } from '../enums/BodyType'; -import { PureCollection, IType, PureModel, View } from 'datx'; export interface IConfigType { baseUrl: string; - cache: CachingStrategy; - maxCacheAge: number; - fetchReference?: typeof fetch; paramArrayType: ParamArrayType; encodeQueryString: boolean; - serialize(data: any, type: BodyType): any; - parse(data: IResponseObject): IResponseObject; collection?: PureCollection; type?: IType | typeof PureModel; views?: Array; + fetchReference?: typeof fetch; + serialize?: (options: IFetchOptions) => IFetchOptions; + parse?: (data: object, options: IResponseObject) => object; } diff --git a/packages/datx-network/src/interfaces/IFetchOptions.ts b/packages/datx-network/src/interfaces/IFetchOptions.ts index 48a6305dd..f43ce19e8 100644 --- a/packages/datx-network/src/interfaces/IFetchOptions.ts +++ b/packages/datx-network/src/interfaces/IFetchOptions.ts @@ -1,4 +1,4 @@ -import { View, PureCollection } from 'datx'; +import { View, PureCollection, IType, PureModel } from 'datx'; import { IRequestOptions } from './IRequestOptions'; import { HttpMethod } from '../enums/HttpMethod'; @@ -6,9 +6,10 @@ import { HttpMethod } from '../enums/HttpMethod'; export interface IFetchOptions { url: string; options?: IRequestOptions; - data?: string | FormData; + data?: string | object | FormData; method: HttpMethod; collection?: PureCollection; skipCache?: boolean; views?: Array; + type?: IType | typeof PureModel; } diff --git a/packages/datx-network/src/interfaces/IInterceptor.ts b/packages/datx-network/src/interfaces/IInterceptor.ts index 55991f578..7f522f865 100644 --- a/packages/datx-network/src/interfaces/IInterceptor.ts +++ b/packages/datx-network/src/interfaces/IInterceptor.ts @@ -5,5 +5,5 @@ import { PureModel } from 'datx'; export type IInterceptor = ( request: IFetchOptions, - next: INetworkHandler, + next?: INetworkHandler, ) => Promise>; diff --git a/packages/datx-network/src/operators.ts b/packages/datx-network/src/operators.ts index 55bd5a4fb..4cb048231 100644 --- a/packages/datx-network/src/operators.ts +++ b/packages/datx-network/src/operators.ts @@ -5,7 +5,10 @@ import { HttpMethod } from './enums/HttpMethod'; import { BodyType } from './enums/BodyType'; import { ParamArrayType } from './enums/ParamArrayType'; import { PureCollection, IType, PureModel } from 'datx'; +import { cacheInterceptor } from './interceptors/cache'; import { IResponseObject } from './interfaces/IResponseObject'; +import { IFetchOptions } from './interfaces/IFetchOptions'; +import { fetchInterceptor } from './interceptors/fetch'; export function setUrl(url: string, type: IType | typeof PureModel = PureModel) { return (pipeline: BaseRequest): void => { @@ -14,19 +17,43 @@ export function setUrl(url: string, type: IType | typeof PureModel = PureModel) }; } -export function addInterceptor(interceptor: IInterceptor) { +export function addInterceptor(fn: IInterceptor, name: string = fn.name) { return (pipeline: BaseRequest): void => { - pipeline['_interceptors'].push(interceptor); + pipeline.interceptors = pipeline.interceptors.filter( + (interceptor) => interceptor.name !== name, + ); + + pipeline.interceptors.push({ name, fn }); + }; +} + +export function upsertInterceptor(fn: IInterceptor, name: string = fn.name) { + return (pipeline: BaseRequest): void => { + const interceptor = pipeline.interceptors.find((interceptor) => interceptor.name === name); + + if (interceptor) { + interceptor.fn = fn; + } else { + pipeline.interceptors.push({ name, fn }); + } }; } -export function cache(strategy: CachingStrategy, maxAge = Infinity) { +export function removeInterceptor(name: string) { return (pipeline: BaseRequest): void => { - pipeline['_config'].cache = strategy; - pipeline['_config'].maxCacheAge = maxAge; + pipeline.interceptors = pipeline.interceptors.filter( + (interceptor) => interceptor.name !== name, + ); }; } +export function cache( + strategy: CachingStrategy, + maxAge = Infinity, +): (pipeline: BaseRequest) => void { + return upsertInterceptor(cacheInterceptor(strategy, maxAge), 'cache'); +} + export function method(method: HttpMethod) { return (pipeline: BaseRequest): void => { pipeline['_options'].method = method; @@ -92,12 +119,6 @@ export function params(name: string | Record, value?: string) { }; } -export function fetchReference(fetchReference: typeof fetch) { - return (pipeline: BaseRequest): void => { - pipeline['_config'].fetchReference = fetchReference; - }; -} - export function encodeQueryString(encodeQueryString: boolean) { return (pipeline: BaseRequest): void => { pipeline['_config'].encodeQueryString = encodeQueryString; @@ -110,15 +131,36 @@ export function paramArrayType(paramArrayType: ParamArrayType) { }; } -export function serializer(serialize: (data: any, type: BodyType) => any) { +export function fetchReference(fetchReference: typeof fetch) { + return (pipeline: BaseRequest): void => { + const config = pipeline['_config']; + config.fetchReference = fetchReference; + upsertInterceptor( + fetchInterceptor(config.fetchReference, config.serialize, config.parse), + 'fetch', + )(pipeline); + }; +} + +export function serializer(serialize: (request: IFetchOptions) => IFetchOptions) { return (pipeline: BaseRequest): void => { - pipeline['_config'].serialize = serialize; + const config = pipeline['_config']; + config.serialize = serialize; + upsertInterceptor( + fetchInterceptor(config.fetchReference, config.serialize, config.parse), + 'fetch', + )(pipeline); }; } -export function parser(parse: (data: IResponseObject) => IResponseObject) { +export function parser(parse: (data: object, response: IResponseObject) => object) { return (pipeline: BaseRequest): void => { - pipeline['_config'].parse = parse; + const config = pipeline['_config']; + config.parse = parse; + upsertInterceptor( + fetchInterceptor(config.fetchReference, config.serialize, config.parse), + 'fetch', + )(pipeline); }; } diff --git a/packages/datx-network/test/basic.test.ts b/packages/datx-network/test/basic.test.ts index 9aa66b780..1ed6d3cb4 100644 --- a/packages/datx-network/test/basic.test.ts +++ b/packages/datx-network/test/basic.test.ts @@ -11,14 +11,13 @@ import { serializer, } from '../src'; import { PureModel, Attribute, Collection } from 'datx'; -import { clearAllCache } from '../src/cache'; +import { clearAllCache } from '../src/interceptors/cache'; describe('Request', () => { it('should initialize', () => { const request = new MockBaseRequest('foobar'); expect(request).toBeTruthy(); expect(request['_config'].baseUrl).toBe('foobar'); - expect(request['_config'].maxCacheAge).toBe(Infinity); expect(request).toBeInstanceOf(MockBaseRequest); }); @@ -95,9 +94,9 @@ describe('Request', () => { const request2 = request1.pipe( setUrl('foobar'), - addInterceptor(mockInterceptor(0)), - addInterceptor(mockInterceptor(1)), addInterceptor(mockInterceptor(2)), + addInterceptor(mockInterceptor(1)), + addInterceptor(mockInterceptor(0)), ); await request2.fetch(); @@ -404,7 +403,7 @@ describe('Request', () => { const request2 = request1.pipe( setUrl('foobar'), - parser((response) => ({ ...response, data: response.data?.['data'] })), + parser((data) => data['data']), fetchReference( jest.fn().mockResolvedValue( Promise.resolve({ @@ -434,7 +433,7 @@ describe('Request', () => { setUrl('foobar'), method(HttpMethod.Post), body({ test: true }), - serializer((data) => ({ data })), + serializer((req) => ({ ...req, data: { data: req.data } })), fetchReference( jest.fn().mockResolvedValue( Promise.resolve({ diff --git a/packages/datx-network/test/caching.test.ts b/packages/datx-network/test/caching.test.ts index 3d22100cc..3a5b92fbd 100644 --- a/packages/datx-network/test/caching.test.ts +++ b/packages/datx-network/test/caching.test.ts @@ -1,7 +1,11 @@ import { MockBaseRequest } from './mock/MockBaseRequest'; import { setUrl, cache, CachingStrategy, Response, BaseRequest } from '../src'; import { PureModel } from 'datx'; -import { getCacheByCollection, saveCacheForCollection, clearAllCache } from '../src/cache'; +import { + getCacheByCollection, + saveCacheForCollection, + clearAllCache, +} from '../src/interceptors/cache'; const sleep = (duration: number): Promise => new Promise((resolve) => setTimeout(resolve, duration)); diff --git a/packages/datx-network/test/mock/MockBaseRequest.ts b/packages/datx-network/test/mock/MockBaseRequest.ts index 6886d0935..1cee79644 100644 --- a/packages/datx-network/test/mock/MockBaseRequest.ts +++ b/packages/datx-network/test/mock/MockBaseRequest.ts @@ -1,4 +1,4 @@ -import { BaseRequest } from '../../src'; +import { BaseRequest, fetchReference } from '../../src'; export class MockBaseRequest extends BaseRequest { constructor(baseUrl: string) { @@ -10,9 +10,11 @@ export class MockBaseRequest extends BaseRequest { } protected resetMock(mockResponse: any, success = true): void { - this['_config'].fetchReference = success - ? jest.fn().mockResolvedValue(mockResponse) - : jest.fn().mockRejectedValue(mockResponse); + fetchReference( + success + ? jest.fn().mockResolvedValue(mockResponse) + : jest.fn().mockRejectedValue(mockResponse), + )(this); } private get lastRequest(): [ diff --git a/packages/datx-network/test/setup.ts b/packages/datx-network/test/setup.ts index 6e0be8785..d8d9a7840 100644 --- a/packages/datx-network/test/setup.ts +++ b/packages/datx-network/test/setup.ts @@ -1,5 +1,5 @@ import { configure } from 'mobx'; -import { clearAllCache } from '../src/cache'; +import { clearAllCache } from '../src/interceptors/cache'; configure({ enforceActions: 'observed', From 2486951887d193e7be3b8f479707b2bb42fa169e Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Fri, 28 Aug 2020 15:10:14 +0200 Subject: [PATCH 17/21] Add a withNetwork mixin --- .../datx-network/src/decorateCollection.ts | 111 ++++++++++++++++++ packages/datx-network/src/helpers/utils.ts | 15 +++ packages/datx-network/src/index.ts | 7 ++ .../src/interfaces/INetworkCollection.ts | 16 +-- .../INetworkCollectionConstructor.ts | 7 ++ packages/datx-network/src/withNetwork.ts | 12 +- 6 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 packages/datx-network/src/decorateCollection.ts create mode 100644 packages/datx-network/src/interfaces/INetworkCollectionConstructor.ts diff --git a/packages/datx-network/src/decorateCollection.ts b/packages/datx-network/src/decorateCollection.ts new file mode 100644 index 000000000..3a96afa8a --- /dev/null +++ b/packages/datx-network/src/decorateCollection.ts @@ -0,0 +1,111 @@ +import { PureCollection, PureModel, IType, getModelType } from 'datx'; + +import { IRequestOptions } from './interfaces/IRequestOptions'; +import { INetworkCollection } from './interfaces/INetworkCollection'; +import { INetworkModel } from './interfaces/INetworkModel'; +import { Response } from './Response'; +import { INetworkModelConstructor } from './interfaces/INetworkModelConstructor'; +import { BaseRequest } from './BaseRequest'; +import { addOptionsToRequest } from './helpers/utils'; +import { setUrl } from './operators'; +import { INetworkCollectionConstructor } from './interfaces/INetworkCollectionConstructor'; + +export function decorateCollection( + BaseClass: typeof PureCollection, +): INetworkCollectionConstructor { + class NetworkCollection extends BaseClass implements INetworkCollection { + private getConstructor( + type: IType | INetworkModel | INetworkModelConstructor, + ): INetworkModelConstructor { + return (getModelType(type) as unknown) as INetworkModelConstructor; + } + + public getOne( + type: IType | INetworkModelConstructor, + id: string, + options?: IRequestOptions, + ): Promise> { + const root = this.getConstructor(type); + + if (!root || !root.network) { + throw new Error('The network configuration is wrong for the given model'); + } + + if (root.network instanceof BaseRequest) { + return addOptionsToRequest( + root.network.pipe( + setUrl(`${root.network['_options'].url}/{id}`, (root as unknown) as typeof PureModel), + ), + options, + ).fetch({ + id, + }); + } + + if (!root.network.getOne) { + throw new Error('The getOne network request was not defined on the given model'); + } + + return addOptionsToRequest(root.network.getOne as any, options).fetch({ + id, + }); + } + + public getMany( + type: IType | INetworkModelConstructor, + options?: IRequestOptions, + ): Promise> { + const root = this.getConstructor(type); + + if (!root || !root.network) { + throw new Error('The network configuration is wrong for the given model'); + } + + if (root.network instanceof BaseRequest) { + return addOptionsToRequest(root.network as any, options).fetch(); + } + + if (!root.network.getMany) { + throw new Error('The getMany network request was not defined on the given model'); + } + + return addOptionsToRequest(root.network.getMany as any, options).fetch(); + } + + public removeOne( + type: IType | typeof PureModel, + id: string, + options?: boolean | IRequestOptions, + ): Promise; + public removeOne(model: INetworkModel, options?: boolean | IRequestOptions): Promise; + public removeOne( + type: IType | typeof PureModel | INetworkModel, + id?: string | boolean | IRequestOptions, + options?: boolean | IRequestOptions, + ): Promise { + const realType = getModelType(type); + const realId = + typeof id !== 'object' && typeof id !== 'undefined' && typeof id !== 'boolean' ? id : null; + const realOptions: boolean | IRequestOptions | undefined = (realId !== null + ? options + : id) as boolean | IRequestOptions | undefined; + const model = realId ? this.findOne(realType, realId) : realType; + + if (!model) { + throw new Error('The model was not found'); + } + + if (realOptions === false || realOptions === undefined) { + return Promise.resolve(super.removeOne(model)); + } + + return (model as INetworkModel) + .destroy(typeof realOptions === 'object' ? realOptions : {}) + .then(() => { + super.removeOne(model); + }); + } + } + + return (NetworkCollection as unknown) as INetworkCollectionConstructor & typeof PureCollection; +} diff --git a/packages/datx-network/src/helpers/utils.ts b/packages/datx-network/src/helpers/utils.ts index b841a3507..58ac6b1ab 100644 --- a/packages/datx-network/src/helpers/utils.ts +++ b/packages/datx-network/src/helpers/utils.ts @@ -1,6 +1,9 @@ import { ParamArrayType } from '../enums/ParamArrayType'; import { PureModel, IFieldDefinition, IReferenceDefinition } from 'datx'; import { getMeta } from 'datx-utils'; +import { BaseRequest } from '../BaseRequest'; +import { query, header, cache } from '../operators'; +import { IRequestOptions } from '../interfaces/IRequestOptions'; export const isBrowser: boolean = typeof window !== 'undefined'; @@ -123,3 +126,15 @@ export function getModelClassRefs( return refs; } + +export function addOptionsToRequest( + request: BaseRequest, + options?: IRequestOptions, +): BaseRequest { + return request.pipe( + options?.query && query(options?.query), + options?.networkConfig?.headers && header(options?.networkConfig?.headers), + options?.cacheOptions?.cachingStrategy && + cache(options?.cacheOptions?.cachingStrategy, options?.cacheOptions?.maxAge), + ); +} diff --git a/packages/datx-network/src/index.ts b/packages/datx-network/src/index.ts index c6d229be6..b4847e0ee 100644 --- a/packages/datx-network/src/index.ts +++ b/packages/datx-network/src/index.ts @@ -28,3 +28,10 @@ export { IInterceptor } from './interfaces/IInterceptor'; export { INetworkHandler } from './interfaces/INetworkHandler'; export { IPipeOperator } from './interfaces/IPipeOperator'; export { IResponseObject } from './interfaces/IResponseObject'; + +export { withNetwork } from './withNetwork'; + +export { INetworkCollection } from './interfaces/INetworkCollection'; +export { INetworkCollectionConstructor } from './interfaces/INetworkCollectionConstructor'; +export { INetworkModel } from './interfaces/INetworkModel'; +export { INetworkModelConstructor } from './interfaces/INetworkModelConstructor'; diff --git a/packages/datx-network/src/interfaces/INetworkCollection.ts b/packages/datx-network/src/interfaces/INetworkCollection.ts index f0fb55054..84d901c70 100644 --- a/packages/datx-network/src/interfaces/INetworkCollection.ts +++ b/packages/datx-network/src/interfaces/INetworkCollection.ts @@ -1,25 +1,19 @@ -import { PureCollection, IType, PureModel, IModelConstructor } from 'datx'; +import { PureModel, PureCollection, IType } from 'datx'; import { IRequestOptions } from './IRequestOptions'; import { Response } from '../Response'; import { INetworkModel } from './INetworkModel'; +import { INetworkModelConstructor } from './INetworkModelConstructor'; export interface INetworkCollection extends PureCollection { getOne( - type: IType | IModelConstructor, + type: IType | INetworkModelConstructor, id: string, options?: IRequestOptions, ): Promise>; getMany( - type: IType | IModelConstructor, - options?: IRequestOptions, - ): Promise>; - - request( - url: string, - method?: string, - data?: object, + type: IType | INetworkModelConstructor, options?: IRequestOptions, ): Promise>; @@ -28,5 +22,5 @@ export interface INetworkCollection extends PureCollection { id: string, options?: boolean | IRequestOptions, ): Promise; - removeOne(model: PureModel, options?: boolean | IRequestOptions): Promise; + removeOne(model: INetworkModel, options?: boolean | IRequestOptions): Promise; } diff --git a/packages/datx-network/src/interfaces/INetworkCollectionConstructor.ts b/packages/datx-network/src/interfaces/INetworkCollectionConstructor.ts new file mode 100644 index 000000000..54e41f36d --- /dev/null +++ b/packages/datx-network/src/interfaces/INetworkCollectionConstructor.ts @@ -0,0 +1,7 @@ +import { PureCollection, IRawModel, IRawCollection } from 'datx'; + +import { INetworkCollection } from './INetworkCollection'; + +export interface INetworkCollectionConstructor extends PureCollection { + new (data?: Array | IRawCollection): INetworkCollection; +} diff --git a/packages/datx-network/src/withNetwork.ts b/packages/datx-network/src/withNetwork.ts index 605dd36f7..f73c3fce1 100644 --- a/packages/datx-network/src/withNetwork.ts +++ b/packages/datx-network/src/withNetwork.ts @@ -1,8 +1,8 @@ -import { isCollection, isModel, isView, PureCollection, PureModel, View } from 'datx'; +import { isCollection, isModel, PureCollection, PureModel, View } from 'datx'; import { decorateCollection } from './decorateCollection'; import { decorateModel } from './decorateModel'; -import { decorateView } from './decorateView'; +// import { decorateView } from './decorateView'; import { INetworkCollection } from './interfaces/INetworkCollection'; import { INetworkModel } from './interfaces/INetworkModel'; import { INetworkView } from './interfaces/INetworkView'; @@ -35,10 +35,10 @@ export function withNetwork( return decorateCollection(Base); } - if (isView(Base)) { - // @ts-ignore - return decorateView(Base); - } + // if (isView(Base)) { + // // @ts-ignore + // return decorateView(Base); + // } throw new Error('The instance needs to be a model, collection or a view'); } From d6b4b3298f084c78a5bb786fc98da98d896bf7b4 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sun, 30 Aug 2020 16:59:12 +0200 Subject: [PATCH 18/21] WIP jsonapi network phase 1 --- packages/datx-jsonapi/package.json | 10 +- packages/datx-jsonapi/src/BaseRequest.ts | 23 ++++ packages/datx-jsonapi/src/NetworkResponse.ts | 119 ++++++++++++++++++ packages/datx-jsonapi/src/NetworkUtils.ts | 35 +++--- packages/datx-jsonapi/src/consts.ts | 2 +- .../datx-jsonapi/src/decorateCollection.ts | 6 +- .../datx-jsonapi/src/enums/CachingStrategy.ts | 10 -- .../datx-jsonapi/src/enums/ParamArrayType.ts | 6 - packages/datx-jsonapi/src/helpers/model.ts | 2 +- packages/datx-jsonapi/src/helpers/url.ts | 11 +- packages/datx-jsonapi/src/index.ts | 39 +++++- .../src/interfaces/IRequestOptions.ts | 2 +- packages/datx-jsonapi/src/operators.ts | 51 ++++++++ .../test/{general.ts => general.test.ts} | 0 .../test/{issues.ts => issues.test.ts} | 9 +- packages/datx-jsonapi/test/main.ts | 15 --- .../network/{basics.ts => basics.test.ts} | 13 +- .../network/{caching.ts => caching.test.ts} | 60 ++++----- ...ror-handling.ts => error-handling.test.ts} | 5 +- .../network/{headers.ts => headers.test.ts} | 5 +- .../network/{params.ts => params.test.ts} | 35 ++---- .../network/{updates.ts => updates.test.ts} | 4 +- packages/datx-jsonapi/test/setup.ts | 5 + .../test/{views.ts => views.test.ts} | 4 +- packages/datx-network/src/BaseRequest.ts | 3 + packages/datx-network/src/Response.ts | 13 +- .../datx-network/src/decorateCollection.ts | 59 +++++++-- packages/datx-network/src/defaults.ts | 18 +++ packages/datx-network/src/helpers/model.ts | 8 +- packages/datx-network/src/helpers/utils.ts | 15 --- packages/datx-network/src/index.ts | 3 + .../datx-network/src/interceptors/fetch.ts | 5 +- .../src/interfaces/IConfigType.ts | 4 + .../interfaces/INetworkModelConstructor.ts | 4 +- packages/datx-network/src/operators.ts | 39 +++++- packages/datx-network/src/withNetwork.ts | 3 +- 36 files changed, 446 insertions(+), 199 deletions(-) create mode 100644 packages/datx-jsonapi/src/BaseRequest.ts create mode 100644 packages/datx-jsonapi/src/NetworkResponse.ts delete mode 100644 packages/datx-jsonapi/src/enums/CachingStrategy.ts delete mode 100644 packages/datx-jsonapi/src/enums/ParamArrayType.ts create mode 100644 packages/datx-jsonapi/src/operators.ts rename packages/datx-jsonapi/test/{general.ts => general.test.ts} (100%) rename packages/datx-jsonapi/test/{issues.ts => issues.test.ts} (95%) delete mode 100644 packages/datx-jsonapi/test/main.ts rename packages/datx-jsonapi/test/network/{basics.ts => basics.test.ts} (99%) rename packages/datx-jsonapi/test/network/{caching.ts => caching.test.ts} (93%) rename packages/datx-jsonapi/test/network/{error-handling.ts => error-handling.test.ts} (98%) rename packages/datx-jsonapi/test/network/{headers.ts => headers.test.ts} (97%) rename packages/datx-jsonapi/test/network/{params.ts => params.test.ts} (89%) rename packages/datx-jsonapi/test/network/{updates.ts => updates.test.ts} (99%) create mode 100644 packages/datx-jsonapi/test/setup.ts rename packages/datx-jsonapi/test/{views.ts => views.test.ts} (98%) diff --git a/packages/datx-jsonapi/package.json b/packages/datx-jsonapi/package.json index 5a7d03185..0097bc82c 100644 --- a/packages/datx-jsonapi/package.json +++ b/packages/datx-jsonapi/package.json @@ -58,7 +58,7 @@ "ts", "js" ], - "testRegex": "test/main.ts", + "testRegex": "test/(.*).test.ts$", "globals": { "ts-jest": { "diagnostics": { @@ -67,10 +67,14 @@ } }, "preset": "ts-jest", - "testMatch": null + "testMatch": null, + "setupFilesAfterEnv": [ + "./test/setup.ts" + ] }, "dependencies": { "datx": "^2.0.0-beta.4", - "datx-utils": "^2.0.0-beta.4" + "datx-utils": "^2.0.0-beta.4", + "datx-network": "^2.0.0-beta.7" } } diff --git a/packages/datx-jsonapi/src/BaseRequest.ts b/packages/datx-jsonapi/src/BaseRequest.ts new file mode 100644 index 000000000..454561c0e --- /dev/null +++ b/packages/datx-jsonapi/src/BaseRequest.ts @@ -0,0 +1,23 @@ +import { BaseRequest as BaseNetworkRequest, IPipeOperator } from 'datx-network'; +import { isJsonapi } from './operators'; + +export class BaseJsonapiRequest extends BaseNetworkRequest { + constructor(baseUrl: string) { + super(baseUrl); + isJsonapi()(this); + } +} + +let genericBaseRequest: BaseJsonapiRequest = new BaseJsonapiRequest('/'); + +export function setBaseRequest(request: BaseJsonapiRequest): void { + genericBaseRequest = request.pipe(isJsonapi()); +} + +export function getBaseRequest(): BaseJsonapiRequest { + return genericBaseRequest; +} + +export function pipeBaseRequest(...operators: Array): BaseJsonapiRequest { + return (genericBaseRequest = genericBaseRequest.pipe(...operators)); +} diff --git a/packages/datx-jsonapi/src/NetworkResponse.ts b/packages/datx-jsonapi/src/NetworkResponse.ts new file mode 100644 index 000000000..7f83f9372 --- /dev/null +++ b/packages/datx-jsonapi/src/NetworkResponse.ts @@ -0,0 +1,119 @@ +import { View, PureCollection } from 'datx'; +import { Response as BaseResponse, IResponseObject } from 'datx-network'; +import { assignComputed } from 'datx-utils'; + +import { IJsonapiModel } from './interfaces/IJsonapiModel'; +import { IJsonApiObject, ILink } from './interfaces/JsonApi'; +import { IResponseInternal } from './interfaces/IResponseInternal'; + +import { fetchLink } from './NetworkUtils'; +import { IJsonapiCollection } from './interfaces/IJsonapiCollection'; + +export class NetworkResponse extends BaseResponse { + /** + * API response metadata + * + * @type {object} + * @memberOf Response + */ + public get meta(): object | undefined { + return (this.__internal as IResponseInternal).meta; + } + + /** + * API response links + * + * @type {object} + * @memberOf Response + */ + public get links(): Record | undefined { + return (this.__internal as IResponseInternal).links; + } + + /** + * The JSON API object returned by the server + * + * @type {JsonApi.IJsonApiObject} + * @memberOf Response + */ + public get jsonapi(): IJsonApiObject | undefined { + return (this.__internal as IResponseInternal).jsonapi; + } + + /** + * First data page + * + * @type {Promise} + * @memberOf Response + */ + public first?: () => Promise>; // Handled by the __fetchLink + + /** + * Previous data page + * + * @type {Promise} + * @memberOf Response + */ + public prev?: () => Promise>; // Handled by the __fetchLink + + /** + * Next data page + * + * @type {Promise} + * @memberOf Response + */ + public next?: () => Promise>; // Handled by the __fetchLink + + /** + * Last data page + * + * @type {Promise} + * @memberOf Response + */ + public last?: () => Promise>; // Handled by the __fetchLink + + public get views(): Array { + return this.__internal.views; + } + + constructor( + response: IResponseObject, + collection?: PureCollection, + overrideData?: T, + views?: Array, + ) { + super(response, collection, overrideData, views); + + if (this.links) { + Object.keys(this.links).forEach((link: string) => { + assignComputed(this, link, () => this.__fetchLink(link)); + }); + } + } + + /** + * Function called when a link is being fetched. The returned value is cached + * + * @private + * @param {string} name Link name + * @returns Promise that resolves with a Response object + * + * @memberOf Response + */ + private __fetchLink(name: string): () => Promise> { + if (!this.__cache[name]) { + const link: ILink | null = this.links && name in this.links ? this.links[name] : null; + + if (link) { + const options = Object.assign({}, this.__internal.options); + + options.networkConfig = options.networkConfig || {}; + options.networkConfig.headers = this.requestHeaders; + this.__cache[name] = (): Promise> => + fetchLink(link, this.collection as IJsonapiCollection, options, this.views); + } + } + + return this.__cache[name]; + } +} diff --git a/packages/datx-jsonapi/src/NetworkUtils.ts b/packages/datx-jsonapi/src/NetworkUtils.ts index 8ebb27f5a..befccf535 100644 --- a/packages/datx-jsonapi/src/NetworkUtils.ts +++ b/packages/datx-jsonapi/src/NetworkUtils.ts @@ -2,14 +2,12 @@ import { View, commitModel } from 'datx'; import { setMeta } from 'datx-utils'; import { action } from 'mobx'; -import { getCache, saveCache } from './cache'; import { MODEL_PERSISTED_FIELD, MODEL_PROP_FIELD, MODEL_QUEUE_FIELD, MODEL_RELATED_FIELD, } from './consts'; -import { ParamArrayType } from './enums/ParamArrayType'; import { isBrowser } from './helpers/utils'; import { ICollectionFetchOpts } from './interfaces/ICollectionFetchOpts'; import { IHeaders } from './interfaces/IHeaders'; @@ -20,7 +18,8 @@ import { IRequestOptions } from './interfaces/IRequestOptions'; import { IResponseHeaders } from './interfaces/IResponseHeaders'; import { ILink, IResponse } from './interfaces/JsonApi'; import { Response as LibResponse } from './Response'; -import { CachingStrategy } from './enums/CachingStrategy'; +import { CachingStrategy, ParamArrayType } from 'datx-network'; +import { saveCache, getCache } from './cache'; export type FetchType = ( method: string, @@ -60,7 +59,7 @@ export const config: IConfigType = { baseUrl: '/', // Enable caching by default in the browser - cache: isBrowser ? CachingStrategy.CACHE_FIRST : CachingStrategy.NETWORK_ONLY, + cache: isBrowser ? CachingStrategy.CacheFirst : CachingStrategy.NetworkOnly, maxCacheAge: Infinity, // Default options that will be passed to the fetch function @@ -81,7 +80,7 @@ export const config: IConfigType = { undefined, // Determines how will the request param arrays be stringified - paramArrayType: ParamArrayType.COMMA_SEPARATED, // As recommended by the spec + paramArrayType: ParamArrayType.CommaSeparated, // As recommended by the spec /** * Base implementation of the fetch function (can be overridden) @@ -232,7 +231,7 @@ function collectionFetch( const cacheStrategy = reqOptions.options?.cacheOptions?.skipCache || !isCacheSupported - ? CachingStrategy.NETWORK_ONLY + ? CachingStrategy.NetworkOnly : reqOptions.options?.cacheOptions?.cachingStrategy || collectionCache || config.cache; let maxCacheAge: number = config.maxCacheAge || Infinity; @@ -244,8 +243,8 @@ function collectionFetch( maxCacheAge = reqOptions.options?.cacheOptions?.maxAge; } - // NETWORK_ONLY - Ignore cache - if (cacheStrategy === CachingStrategy.NETWORK_ONLY) { + // NetworkOnly - Ignore cache + if (cacheStrategy === CachingStrategy.NetworkOnly) { return makeNetworkCall(params); } @@ -254,8 +253,8 @@ function collectionFetch( maxCacheAge, ) as unknown) as { response: LibResponse } | undefined; - // NETWORK_FIRST - Fallback to cache only on network error - if (cacheStrategy === CachingStrategy.NETWORK_FIRST) { + // NetworkFirst - Fallback to cache only on network error + if (cacheStrategy === CachingStrategy.NetworkFirst) { return makeNetworkCall(params, true).catch((errorResponse) => { if (cacheContent) { return cacheContent.response; @@ -264,8 +263,8 @@ function collectionFetch( }); } - // STALE_WHILE_REVALIDATE - Use cache and update it in background - if (cacheStrategy === CachingStrategy.STALE_WHILE_REVALIDATE) { + // StaleWhileRevalidate - Use cache and update it in background + if (cacheStrategy === CachingStrategy.StaleWhileRevalidate) { const network = makeNetworkCall(params, true); if (cacheContent) { @@ -278,8 +277,8 @@ function collectionFetch( return network; } - // CACHE_ONLY - Fail if nothing in cache - if (cacheStrategy === CachingStrategy.CACHE_ONLY) { + // CacheOnly - Fail if nothing in cache + if (cacheStrategy === CachingStrategy.CacheOnly) { if (cacheContent) { return Promise.resolve(cacheContent.response); } @@ -290,13 +289,13 @@ function collectionFetch( } // PREFER_CACHE - Use cache if available - if (cacheStrategy === CachingStrategy.CACHE_FIRST) { + if (cacheStrategy === CachingStrategy.CacheFirst) { return cacheContent ? Promise.resolve(cacheContent.response) : makeNetworkCall(params, true); } - // STALE_AND_UPDATE - Use cache and update response once network is complete - if (cacheStrategy === CachingStrategy.STALE_AND_UPDATE) { - const existingResponse = cacheContent?.response?.clone(); + // StaleAndUpdate - Use cache and update response once network is complete + if (cacheStrategy === CachingStrategy.StaleAndUpdate) { + const existingResponse = cacheContent?.response?.clone() as LibResponse; const network = makeNetworkCall(params, true, existingResponse); diff --git a/packages/datx-jsonapi/src/consts.ts b/packages/datx-jsonapi/src/consts.ts index 3b3888605..e85f3360b 100644 --- a/packages/datx-jsonapi/src/consts.ts +++ b/packages/datx-jsonapi/src/consts.ts @@ -2,7 +2,7 @@ export const MODEL_LINKS_FIELD = 'jsonapiLinks'; export const MODEL_REF_LINKS_FIELD = 'jsonapiRefLinks'; export const MODEL_REF_META_FIELD = 'jsonapiRefMeta'; export const MODEL_META_FIELD = 'jsonapiMeta'; -export const MODEL_PERSISTED_FIELD = 'jsonapiPersisted'; +export const MODEL_PERSISTED_FIELD = 'networkPersisted'; // Needs to be the same as in datx-network export const MODEL_PROP_FIELD = 'jsonapiProp'; export const MODEL_QUEUE_FIELD = 'jsonapiQueue'; export const MODEL_RELATED_FIELD = 'jsonapiRelated'; diff --git a/packages/datx-jsonapi/src/decorateCollection.ts b/packages/datx-jsonapi/src/decorateCollection.ts index 64e08c548..0193f924c 100644 --- a/packages/datx-jsonapi/src/decorateCollection.ts +++ b/packages/datx-jsonapi/src/decorateCollection.ts @@ -34,7 +34,7 @@ import { IRequestOptions } from './interfaces/IRequestOptions'; import { IDefinition, IRecord, IRelationship, IRequest, IResponse } from './interfaces/JsonApi'; import { libFetch, read } from './NetworkUtils'; import { Response } from './Response'; -import { CachingStrategy } from './enums/CachingStrategy'; +import { CachingStrategy } from 'datx-network'; type TSerialisedStore = IRawCollection & { cache?: Array> }; @@ -120,7 +120,7 @@ export function decorateCollection( id, Object.assign({}, options, { cacheOptions: Object.assign({}, options?.cacheOptions || {}, { - cachingStrategy: isBrowser ? CachingStrategy.CACHE_FIRST : CachingStrategy.NETWORK_ONLY, + cachingStrategy: isBrowser ? CachingStrategy.CacheFirst : CachingStrategy.NetworkOnly, }), }), ); @@ -142,7 +142,7 @@ export function decorateCollection( type, Object.assign({}, options, { cacheOptions: Object.assign({}, options?.cacheOptions || {}, { - cachingStrategy: isBrowser ? CachingStrategy.CACHE_FIRST : CachingStrategy.NETWORK_ONLY, + cachingStrategy: isBrowser ? CachingStrategy.CacheFirst : CachingStrategy.NetworkOnly, }), }), ); diff --git a/packages/datx-jsonapi/src/enums/CachingStrategy.ts b/packages/datx-jsonapi/src/enums/CachingStrategy.ts deleted file mode 100644 index 1fa75ad92..000000000 --- a/packages/datx-jsonapi/src/enums/CachingStrategy.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Based on service worker strategies https://developers.google.com/web/tools/workbox/modules/workbox-strategies - -export enum CachingStrategy { - NETWORK_ONLY = 1, // Ignore cache - NETWORK_FIRST = 2, // Fallback to cache only on network error - STALE_WHILE_REVALIDATE = 3, // Use cache and update it in background - CACHE_ONLY = 4, // Fail if nothing in cache - CACHE_FIRST = 5, // Use cache if available - STALE_AND_UPDATE = 6, // Use cache and update response once network is complete -} diff --git a/packages/datx-jsonapi/src/enums/ParamArrayType.ts b/packages/datx-jsonapi/src/enums/ParamArrayType.ts deleted file mode 100644 index dbd3898e5..000000000 --- a/packages/datx-jsonapi/src/enums/ParamArrayType.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum ParamArrayType { - MULTIPLE_PARAMS, // filter[a]=1&filter[a]=2 - COMMA_SEPARATED, // filter[a]=1,2 - PARAM_ARRAY, // filter[a][]=1&filter[a][]=2 - OBJECT_PATH, // filter[a.0]=1&filter[a.1]=2 -} diff --git a/packages/datx-jsonapi/src/helpers/model.ts b/packages/datx-jsonapi/src/helpers/model.ts index b6005b9f7..69b9300f3 100644 --- a/packages/datx-jsonapi/src/helpers/model.ts +++ b/packages/datx-jsonapi/src/helpers/model.ts @@ -137,7 +137,7 @@ export function fetchModelLink( } setMeta(related, MODEL_PERSISTED_FIELD, true); - return response.replaceData(related); + return response.replaceData(related) as Response; } return response; diff --git a/packages/datx-jsonapi/src/helpers/url.ts b/packages/datx-jsonapi/src/helpers/url.ts index 70dd22cfe..48a6c7a08 100644 --- a/packages/datx-jsonapi/src/helpers/url.ts +++ b/packages/datx-jsonapi/src/helpers/url.ts @@ -1,31 +1,28 @@ import { getModelType, IType, PureCollection, PureModel } from 'datx'; import { URL_REGEX } from '../consts'; -import { ParamArrayType } from '../enums/ParamArrayType'; import { IFilters } from '../interfaces/IFilters'; import { IHeaders } from '../interfaces/IHeaders'; import { IJsonapiModel } from '../interfaces/IJsonapiModel'; import { IRequestOptions } from '../interfaces/IRequestOptions'; import { IRequest } from '../interfaces/JsonApi'; import { config } from '../NetworkUtils'; +import { ParamArrayType } from 'datx-network'; function parametrize(params: object, scope = ''): Array<{ key: string; value: string }> { const list: Array<{ key: string; value: string }> = []; Object.keys(params).forEach((key) => { if (params[key] instanceof Array) { - if (config.paramArrayType === ParamArrayType.OBJECT_PATH) { - // eslint-disable-next-line prefer-spread - list.push.apply(list, parametrize(params[key], `${key}.`)); - } else if (config.paramArrayType === ParamArrayType.COMMA_SEPARATED) { + if (config.paramArrayType === ParamArrayType.CommaSeparated) { list.push({ key: `${scope}${key}`, value: params[key].join(',') }); - } else if (config.paramArrayType === ParamArrayType.MULTIPLE_PARAMS) { + } else if (config.paramArrayType === ParamArrayType.MultipleParams) { // eslint-disable-next-line prefer-spread list.push.apply( list, params[key].map((param) => ({ key: `${scope}${key}`, value: param })), ); - } else if (config.paramArrayType === ParamArrayType.PARAM_ARRAY) { + } else if (config.paramArrayType === ParamArrayType.ParamArray) { // eslint-disable-next-line prefer-spread list.push.apply( list, diff --git a/packages/datx-jsonapi/src/index.ts b/packages/datx-jsonapi/src/index.ts index 5852fe6b6..2c97c348f 100644 --- a/packages/datx-jsonapi/src/index.ts +++ b/packages/datx-jsonapi/src/index.ts @@ -1,7 +1,6 @@ export { jsonapi } from './mixin'; export { Response } from './Response'; -export { config } from './NetworkUtils'; -export { ParamArrayType } from './enums/ParamArrayType'; +export { NetworkResponse } from './NetworkResponse'; export { GenericModel } from './GenericModel'; export { @@ -13,12 +12,13 @@ export { getModelRefMeta, getModelEndpointUrl, modelToJsonApi, - saveModel, saveRelationship, isModelPersisted, + saveModel, } from './helpers/model'; -export { clearAllCache, clearCacheByType } from './cache'; +export { BaseJsonapiRequest } from './BaseRequest'; +// export { setBaseRequest, getBaseRequest, pipeBaseRequest } from './BaseRequest'; export { ICollectionFetchOpts } from './interfaces/ICollectionFetchOpts'; export { IJsonapiCollection } from './interfaces/IJsonapiCollection'; @@ -26,3 +26,34 @@ export { IJsonapiModel } from './interfaces/IJsonapiModel'; export { IJsonapiView } from './interfaces/IJsonapiView'; export { IRawResponse } from './interfaces/IRawResponse'; export { IRequestOptions } from './interfaces/IRequestOptions'; + +export { config } from './NetworkUtils'; + +export { + BaseRequest, + addInterceptor, + cache, + method, + setUrl, + body, + query, + header, + params, + fetchReference, + encodeQueryString, + paramArrayType, + serializer, + parser, + collection, + ParamArrayType, + CachingStrategy, + HttpMethod, + IFetchOptions, + IHeaders, + IInterceptor, + INetworkHandler, + IPipeOperator, + IResponseObject, + clearAllCache, + clearCacheByType, +} from 'datx-network'; diff --git a/packages/datx-jsonapi/src/interfaces/IRequestOptions.ts b/packages/datx-jsonapi/src/interfaces/IRequestOptions.ts index 6b892ef71..69dd4cd43 100644 --- a/packages/datx-jsonapi/src/interfaces/IRequestOptions.ts +++ b/packages/datx-jsonapi/src/interfaces/IRequestOptions.ts @@ -1,6 +1,6 @@ import { IFilters } from './IFilters'; import { IHeaders } from './IHeaders'; -import { CachingStrategy } from '../enums/CachingStrategy'; +import { CachingStrategy } from 'datx-network'; export interface IRequestOptions { queryParams?: { diff --git a/packages/datx-jsonapi/src/operators.ts b/packages/datx-jsonapi/src/operators.ts new file mode 100644 index 000000000..fc54610d7 --- /dev/null +++ b/packages/datx-jsonapi/src/operators.ts @@ -0,0 +1,51 @@ +import { + BaseRequest, + header, + parser, + IResponseObject, + IFetchOptions, + serializer, +} from 'datx-network'; +import { IJsonapiCollection } from './interfaces/IJsonapiCollection'; +import { mapItems } from 'datx-utils'; +import { PureModel } from 'datx'; +import { modelToJsonApi } from './helpers/model'; +import { IJsonapiModel } from './interfaces/IJsonapiModel'; +import { Response } from './Response'; + +function jsonapiParser(data: object, response: IResponseObject): object { + return ( + (data && response.collection + ? ((response.collection as unknown) as IJsonapiCollection).sync(data) + : data) || {} + ); +} + +function jsonapiSerializer(request: IFetchOptions): IFetchOptions { + return { + ...request, + data: { + data: + mapItems(request.data, (obj) => + obj instanceof PureModel ? modelToJsonApi(obj as IJsonapiModel, true) : obj, + ) || undefined, + }, + }; +} + +export function isJsonapi() { + return (pipeline: BaseRequest): void => { + pipeline['_config'].Response = Response; + header('content-type', 'application/vnd.api+json')(pipeline); + parser(jsonapiParser)(pipeline); + serializer(jsonapiSerializer)(pipeline); + }; +} + +// filter +// sort +// page +// include +// sparse + +// transformersInterceptor diff --git a/packages/datx-jsonapi/test/general.ts b/packages/datx-jsonapi/test/general.test.ts similarity index 100% rename from packages/datx-jsonapi/test/general.ts rename to packages/datx-jsonapi/test/general.test.ts diff --git a/packages/datx-jsonapi/test/issues.ts b/packages/datx-jsonapi/test/issues.test.ts similarity index 95% rename from packages/datx-jsonapi/test/issues.ts rename to packages/datx-jsonapi/test/issues.test.ts index a1b9ac7f4..659a3f5bc 100644 --- a/packages/datx-jsonapi/test/issues.ts +++ b/packages/datx-jsonapi/test/issues.test.ts @@ -1,21 +1,16 @@ import { Collection, Model, prop, Attribute } from 'datx'; import * as fetch from 'isomorphic-fetch'; import { computed } from 'mobx'; -import { config, getModelMeta, getModelRefMeta, jsonapi, modelToJsonApi } from '../src'; -import { clearAllCache } from '../src/cache'; +import { getModelMeta, getModelRefMeta, jsonapi, modelToJsonApi, config } from '../src'; import { setupNetwork, setRequest, confirmNetwork } from './utils/api'; import { Event, LineItem, TestStore } from './utils/setup'; - -const baseTransformRequest = config.transformRequest; -const baseTransformResponse = config.transformResponse; +import { clearAllCache } from '../src/cache'; describe('Issues', () => { beforeEach(() => { config.fetchReference = fetch; config.baseUrl = 'https://example.com/'; - config.transformRequest = baseTransformRequest; - config.transformResponse = baseTransformResponse; clearAllCache(); setupNetwork(); }); diff --git a/packages/datx-jsonapi/test/main.ts b/packages/datx-jsonapi/test/main.ts deleted file mode 100644 index a05ab0275..000000000 --- a/packages/datx-jsonapi/test/main.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { configure } from 'mobx'; - -import './general'; -import './views'; - -import './network/basics'; -import './network/caching'; -import './network/error-handling'; -import './network/headers'; -import './network/params'; -import './network/updates'; - -import './issues'; - -configure({ enforceActions: 'observed' }); diff --git a/packages/datx-jsonapi/test/network/basics.ts b/packages/datx-jsonapi/test/network/basics.test.ts similarity index 99% rename from packages/datx-jsonapi/test/network/basics.ts rename to packages/datx-jsonapi/test/network/basics.test.ts index b3c0a57f9..a103f2c12 100644 --- a/packages/datx-jsonapi/test/network/basics.ts +++ b/packages/datx-jsonapi/test/network/basics.test.ts @@ -2,7 +2,6 @@ import { Collection, Model } from 'datx'; import * as fetch from 'isomorphic-fetch'; import { - config, fetchModelLink, fetchModelRefLink, getModelLinks, @@ -10,9 +9,10 @@ import { getModelRefMeta, jsonapi, modelToJsonApi, + config, } from '../../src'; - import { clearAllCache } from '../../src/cache'; + import { setRequest, setupNetwork, confirmNetwork } from '../utils/api'; import { Event, Image, TestStore } from '../utils/setup'; @@ -141,18 +141,14 @@ describe('Network basics', () => { name: 'events-1', url: 'event/all', }); - let hasTransformRequestHookBeenCalled = false; const store = new TestStore(); - config.transformRequest = (opts): any => { expect(opts.collection).toBe(store); hasTransformRequestHookBeenCalled = true; - return { ...opts, url: `${opts.url}/all` }; }; const events = await store.fetchAll('event'); - expect(events.data).toBeInstanceOf(Array); expect(hasTransformRequestHookBeenCalled).toBe(true); }); @@ -162,19 +158,14 @@ describe('Network basics', () => { name: 'events-1', url: 'event', }); - let hasTransformResponseHookBeenCalled = false; - config.transformResponse = (opts): any => { expect(opts.status).toBe(200); hasTransformResponseHookBeenCalled = true; - return { ...opts, status: 201 }; }; - const store = new TestStore(); const events = await store.fetchAll('event'); - expect(events.data).toBeInstanceOf(Array); expect(events.status).toBe(201); expect(hasTransformResponseHookBeenCalled).toBe(true); diff --git a/packages/datx-jsonapi/test/network/caching.ts b/packages/datx-jsonapi/test/network/caching.test.ts similarity index 93% rename from packages/datx-jsonapi/test/network/caching.ts rename to packages/datx-jsonapi/test/network/caching.test.ts index 6a2223063..4ba267551 100644 --- a/packages/datx-jsonapi/test/network/caching.ts +++ b/packages/datx-jsonapi/test/network/caching.test.ts @@ -1,11 +1,9 @@ import { autorun } from 'mobx'; -import { config } from '../../src'; - -import { clearAllCache } from '../../src/cache'; import { setupNetwork, setRequest } from '../utils/api'; import { Event, TestStore } from '../utils/setup'; -import { CachingStrategy } from '../../src/enums/CachingStrategy'; +import { CachingStrategy, config } from '../../src'; +import { clearAllCache } from '../../src/cache'; function sleep(duration: number): Promise { return new Promise((resolve) => setTimeout(resolve, duration)); @@ -14,7 +12,7 @@ function sleep(duration: number): Promise { describe('caching', () => { beforeEach(() => { config.baseUrl = 'https://example.com/'; - config.cache = CachingStrategy.CACHE_FIRST; + config.cache = CachingStrategy.CacheFirst; clearAllCache(); setupNetwork(); }); @@ -170,6 +168,8 @@ describe('caching', () => { const store = new TestStore(); const events = await store.fetchAll(Event); + // TODO: Fix array typing + // @ts-ignore const event = events.data as Array; expect(event).toBeInstanceOf(Array); @@ -188,6 +188,8 @@ describe('caching', () => { const store = new TestStore(); const events = await store.fetchAll(Event); + // TODO: Fix array typing + // @ts-ignore const event = events.data as Array; expect(event).toBeInstanceOf(Array); @@ -304,19 +306,9 @@ describe('caching', () => { }); describe('caching strategies', () => { - let baseCacheStrategy: CachingStrategy; - - beforeEach(() => { - baseCacheStrategy = config.cache; - }); - - afterEach(() => { - config.cache = baseCacheStrategy; - }); - - describe('NETWORK_ONLY', () => { + describe('NetworkOnly', () => { beforeEach(() => { - config.cache = CachingStrategy.NETWORK_ONLY; + config.cache = CachingStrategy.NetworkOnly; }); it('should fail if no network', async () => { @@ -356,9 +348,9 @@ describe('caching', () => { }); }); - describe('NETWORK_FIRST', () => { + describe('NetworkFirst', () => { beforeEach(() => { - config.cache = CachingStrategy.NETWORK_FIRST; + config.cache = CachingStrategy.NetworkFirst; }); it('should use network if available', async () => { @@ -406,9 +398,9 @@ describe('caching', () => { }); }); - describe('STALE_WHILE_REVALIDATE', () => { + describe('StaleWhileRevalidate', () => { beforeEach(() => { - config.cache = CachingStrategy.STALE_WHILE_REVALIDATE; + config.cache = CachingStrategy.StaleWhileRevalidate; }); it('should show new data if no cache', async () => { @@ -508,9 +500,9 @@ describe('caching', () => { }); }); - describe('CACHE_ONLY', () => { + describe('CacheOnly', () => { beforeEach(() => { - config.cache = CachingStrategy.CACHE_ONLY; + config.cache = CachingStrategy.CacheOnly; }); it('should fail if no cache', async () => { @@ -533,7 +525,7 @@ describe('caching', () => { }); await store.getOne(Event, '1', { - cacheOptions: { cachingStrategy: CachingStrategy.NETWORK_FIRST }, + cacheOptions: { cachingStrategy: CachingStrategy.NetworkFirst }, }); await store.getOne(Event, '1'); @@ -550,9 +542,9 @@ describe('caching', () => { }); }); - describe('CACHE_FIRST', () => { + describe('CacheFirst', () => { beforeEach(() => { - config.cache = CachingStrategy.CACHE_FIRST; + config.cache = CachingStrategy.CacheFirst; }); it('should use cache if available', async () => { @@ -564,7 +556,7 @@ describe('caching', () => { }); await store.getOne(Event, '1', { - cacheOptions: { cachingStrategy: CachingStrategy.NETWORK_FIRST }, + cacheOptions: { cachingStrategy: CachingStrategy.NetworkFirst }, }); await store.getOne(Event, '1'); @@ -629,7 +621,7 @@ describe('caching', () => { }); const eventResponse = await store.getOne(Event, '1', { - cacheOptions: { cachingStrategy: CachingStrategy.NETWORK_FIRST }, + cacheOptions: { cachingStrategy: CachingStrategy.NetworkFirst }, }); const originalEvent = eventResponse.data as Event; @@ -655,7 +647,7 @@ describe('caching', () => { }); await store.getOne(Event, '1', { - cacheOptions: { cachingStrategy: CachingStrategy.NETWORK_FIRST }, + cacheOptions: { cachingStrategy: CachingStrategy.NetworkFirst }, }); const storeJson = store.toJSON(); @@ -693,9 +685,9 @@ describe('caching', () => { }); }); - describe('STALE_AND_UPDATE', () => { + describe('StaleAndUpdate', () => { beforeEach(() => { - config.cache = CachingStrategy.STALE_AND_UPDATE; + config.cache = CachingStrategy.StaleAndUpdate; }); it('should show new data if no cache', async () => { @@ -786,7 +778,7 @@ describe('caching', () => { }); it('should use maxAge', async () => { - config.cache = CachingStrategy.CACHE_FIRST; + config.cache = CachingStrategy.CacheFirst; const store = new TestStore(); @@ -796,7 +788,7 @@ describe('caching', () => { }); await store.getOne(Event, '1', { - cacheOptions: { cachingStrategy: CachingStrategy.NETWORK_FIRST }, + cacheOptions: { cachingStrategy: CachingStrategy.NetworkFirst }, }); await store.getOne(Event, '1'); @@ -828,6 +820,8 @@ describe('caching', () => { }); it('should fail if invalid strategy', async () => { + // @ts-expect-error + config.cache = 'invalid-strategy'; const store = new TestStore(); try { diff --git a/packages/datx-jsonapi/test/network/error-handling.ts b/packages/datx-jsonapi/test/network/error-handling.test.ts similarity index 98% rename from packages/datx-jsonapi/test/network/error-handling.ts rename to packages/datx-jsonapi/test/network/error-handling.test.ts index 2b2d2643f..b7356a3ca 100644 --- a/packages/datx-jsonapi/test/network/error-handling.ts +++ b/packages/datx-jsonapi/test/network/error-handling.test.ts @@ -1,10 +1,9 @@ import * as fetch from 'isomorphic-fetch'; -import { config } from '../../src'; - -import { clearAllCache } from '../../src/cache'; import { setupNetwork, setRequest, confirmNetwork } from '../utils/api'; import { Event, TestStore } from '../utils/setup'; +import { clearAllCache } from '../../src/cache'; +import { config } from '../../src/NetworkUtils'; describe('error handling', () => { beforeEach(() => { diff --git a/packages/datx-jsonapi/test/network/headers.ts b/packages/datx-jsonapi/test/network/headers.test.ts similarity index 97% rename from packages/datx-jsonapi/test/network/headers.ts rename to packages/datx-jsonapi/test/network/headers.test.ts index 3975bdb2c..84f9403d6 100644 --- a/packages/datx-jsonapi/test/network/headers.ts +++ b/packages/datx-jsonapi/test/network/headers.test.ts @@ -1,10 +1,9 @@ import * as fetch from 'isomorphic-fetch'; -import { config } from '../../src'; - -import { clearAllCache } from '../../src/cache'; import { setupNetwork, setRequest, confirmNetwork } from '../utils/api'; import { TestStore } from '../utils/setup'; +import { config } from '../../src/NetworkUtils'; +import { clearAllCache } from '../../src/cache'; describe('headers', () => { beforeEach(() => { diff --git a/packages/datx-jsonapi/test/network/params.ts b/packages/datx-jsonapi/test/network/params.test.ts similarity index 89% rename from packages/datx-jsonapi/test/network/params.ts rename to packages/datx-jsonapi/test/network/params.test.ts index 39b0702a1..dff30362f 100644 --- a/packages/datx-jsonapi/test/network/params.ts +++ b/packages/datx-jsonapi/test/network/params.test.ts @@ -1,10 +1,10 @@ import * as fetch from 'isomorphic-fetch'; -import { config, ParamArrayType } from '../../src'; - -import { clearAllCache } from '../../src/cache'; import { setupNetwork, setRequest, confirmNetwork } from '../utils/api'; -import { Event, TestStore } from '../utils/setup'; +import { TestStore, Event } from '../utils/setup'; +import { ParamArrayType } from '../../src'; +import { clearAllCache } from '../../src/cache'; +import { config } from '../../src/NetworkUtils'; describe('params', () => { beforeEach(() => { @@ -109,10 +109,8 @@ describe('params', () => { query: { include: 'bar' }, url: 'event', }); - const store = new TestStore(); const event = store.add({}, Event); - await event.save({ queryParams: { include: 'bar' } }); }); @@ -175,7 +173,7 @@ describe('params', () => { describe('Param array types', () => { afterEach(() => { - config.paramArrayType = ParamArrayType.COMMA_SEPARATED; + config.paramArrayType = ParamArrayType.CommaSeparated; }); it('should work with coma separated values', async () => { @@ -185,7 +183,7 @@ describe('params', () => { url: 'event', }); - config.paramArrayType = ParamArrayType.COMMA_SEPARATED; + config.paramArrayType = ParamArrayType.CommaSeparated; const store = new TestStore(); const events = await store.fetchAll('event', { queryParams: { filter: { a: ['1', '2'], b: '3' } }, @@ -202,24 +200,7 @@ describe('params', () => { url: 'event', }); - config.paramArrayType = ParamArrayType.MULTIPLE_PARAMS; - const store = new TestStore(); - const events = await store.fetchAll('event', { - queryParams: { filter: { a: ['1', '2'], b: '3' } }, - }); - - expect(events.data).toBeInstanceOf(Array); - expect(events.data).toHaveLength(4); - }); - - it('should work with object paths', async () => { - setRequest({ - name: 'events-1', - query: 'filter[a.0]=1&filter[a.1]=2&filter[b]=3', - url: 'event', - }); - - config.paramArrayType = ParamArrayType.OBJECT_PATH; + config.paramArrayType = ParamArrayType.MultipleParams; const store = new TestStore(); const events = await store.fetchAll('event', { queryParams: { filter: { a: ['1', '2'], b: '3' } }, @@ -236,7 +217,7 @@ describe('params', () => { url: 'event', }); - config.paramArrayType = ParamArrayType.PARAM_ARRAY; + config.paramArrayType = ParamArrayType.ParamArray; const store = new TestStore(); const events = await store.fetchAll('event', { queryParams: { filter: { a: ['1', '2'], b: '3' } }, diff --git a/packages/datx-jsonapi/test/network/updates.ts b/packages/datx-jsonapi/test/network/updates.test.ts similarity index 99% rename from packages/datx-jsonapi/test/network/updates.ts rename to packages/datx-jsonapi/test/network/updates.test.ts index 08276667c..d616f0250 100644 --- a/packages/datx-jsonapi/test/network/updates.ts +++ b/packages/datx-jsonapi/test/network/updates.test.ts @@ -9,9 +9,9 @@ import { } from 'datx'; import * as fetch from 'isomorphic-fetch'; -import { config, fetchModelLink, jsonapi, modelToJsonApi, saveRelationship } from '../../src'; - +import { fetchModelLink, jsonapi, modelToJsonApi, saveRelationship, config } from '../../src'; import { clearAllCache } from '../../src/cache'; + import { setupNetwork, setRequest, confirmNetwork } from '../utils/api'; import { Event, TestStore } from '../utils/setup'; diff --git a/packages/datx-jsonapi/test/setup.ts b/packages/datx-jsonapi/test/setup.ts new file mode 100644 index 000000000..adfd1d5e9 --- /dev/null +++ b/packages/datx-jsonapi/test/setup.ts @@ -0,0 +1,5 @@ +import { configure } from 'mobx'; + +configure({ + enforceActions: 'observed', +}); diff --git a/packages/datx-jsonapi/test/views.ts b/packages/datx-jsonapi/test/views.test.ts similarity index 98% rename from packages/datx-jsonapi/test/views.ts rename to packages/datx-jsonapi/test/views.test.ts index c5a96a65f..78541e2c0 100644 --- a/packages/datx-jsonapi/test/views.ts +++ b/packages/datx-jsonapi/test/views.test.ts @@ -1,10 +1,10 @@ import { View, IViewConstructor, PureModel } from 'datx'; import * as fetch from 'isomorphic-fetch'; -import { config, IJsonapiView, jsonapi } from '../src'; -import { clearAllCache } from '../src/cache'; +import { IJsonapiView, jsonapi, config } from '../src'; import { setupNetwork, setRequest, confirmNetwork } from './utils/api'; import { Event, TestStore } from './utils/setup'; +import { clearAllCache } from '../src/cache'; const baseTransformRequest = config.transformRequest; const baseTransformResponse = config.transformResponse; diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index e0853cc37..5f7ef4ec5 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -187,6 +187,9 @@ export class BaseRequest; diff --git a/packages/datx-network/src/Response.ts b/packages/datx-network/src/Response.ts index fc5c79e90..5fa0f3303 100644 --- a/packages/datx-network/src/Response.ts +++ b/packages/datx-network/src/Response.ts @@ -67,9 +67,9 @@ function initData>( } export class Response> { - private __data; + protected __data; - private __internal: IResponseInternal = { + protected __internal: IResponseInternal = { response: {}, views: [], }; @@ -126,6 +126,15 @@ export class Response> { */ public readonly collection?: PureCollection; + /** + * Cache used for the link requests + * + * @protected + * @type {Record>} + * @memberOf Response + */ + protected readonly __cache: Record Promise>> = {}; + public get isSuccess(): boolean { return !this.error; } diff --git a/packages/datx-network/src/decorateCollection.ts b/packages/datx-network/src/decorateCollection.ts index 3a96afa8a..04c3b9d5d 100644 --- a/packages/datx-network/src/decorateCollection.ts +++ b/packages/datx-network/src/decorateCollection.ts @@ -1,4 +1,4 @@ -import { PureCollection, PureModel, IType, getModelType } from 'datx'; +import { PureCollection, PureModel, IType, getModelType, IRawModel, IRawCollection } from 'datx'; import { IRequestOptions } from './interfaces/IRequestOptions'; import { INetworkCollection } from './interfaces/INetworkCollection'; @@ -6,18 +6,45 @@ import { INetworkModel } from './interfaces/INetworkModel'; import { Response } from './Response'; import { INetworkModelConstructor } from './interfaces/INetworkModelConstructor'; import { BaseRequest } from './BaseRequest'; -import { addOptionsToRequest } from './helpers/utils'; -import { setUrl } from './operators'; +import { setUrl, requestOptions } from './operators'; import { INetworkCollectionConstructor } from './interfaces/INetworkCollectionConstructor'; +import { + saveCacheForCollection, + ICacheInternal, + getCacheByCollection, + clearCacheByType, +} from './interceptors/cache'; + +type TSerialisedStore = IRawCollection & { cache?: Array> }; + +function addOptionsToRequest( + request: BaseRequest, + options?: IRequestOptions, +): BaseRequest { + return request.pipe(requestOptions(options)); +} export function decorateCollection( BaseClass: typeof PureCollection, ): INetworkCollectionConstructor { class NetworkCollection extends BaseClass implements INetworkCollection { - private getConstructor( + constructor(data: Array | TSerialisedStore = []) { + super(data); + + if (!(data instanceof Array) && data?.cache) { + saveCacheForCollection(data.cache, this); + } + } + + protected getConstructor( type: IType | INetworkModel | INetworkModelConstructor, ): INetworkModelConstructor { - return (getModelType(type) as unknown) as INetworkModelConstructor; + const Collection = this.constructor as INetworkCollectionConstructor; + const modelType = getModelType(type); + return ( + // @ts-ignore + Collection.types.find((item) => getModelType(item) === modelType) || Collection.defaultModel + ); } public getOne( @@ -32,14 +59,14 @@ export function decorateCollection( } if (root.network instanceof BaseRequest) { - return addOptionsToRequest( - root.network.pipe( + return root.network + .pipe( + requestOptions(options), setUrl(`${root.network['_options'].url}/{id}`, (root as unknown) as typeof PureModel), - ), - options, - ).fetch({ - id, - }); + ) + .fetch({ + id, + }); } if (!root.network.getOne) { @@ -99,12 +126,20 @@ export function decorateCollection( return Promise.resolve(super.removeOne(model)); } + clearCacheByType(getModelType(type)); + return (model as INetworkModel) .destroy(typeof realOptions === 'object' ? realOptions : {}) .then(() => { super.removeOne(model); }); } + + public toJSON(): TSerialisedStore { + return Object.assign({}, super.toJSON(), { + cache: getCacheByCollection(this), + }); + } } return (NetworkCollection as unknown) as INetworkCollectionConstructor & typeof PureCollection; diff --git a/packages/datx-network/src/defaults.ts b/packages/datx-network/src/defaults.ts index a4a55ad95..21db81dff 100644 --- a/packages/datx-network/src/defaults.ts +++ b/packages/datx-network/src/defaults.ts @@ -1,6 +1,11 @@ import { IConfigType } from './interfaces/IConfigType'; import { ParamArrayType } from './enums/ParamArrayType'; import { isBrowser } from './helpers/utils'; +import { fetchInterceptor } from './interceptors/fetch'; +import { mapItems } from 'datx-utils'; +import { PureModel, modelToJSON } from 'datx'; +import { IFetchOptions } from './interfaces/IFetchOptions'; +import { Response } from './Response'; export function getDefaultConfig(): IConfigType { return { @@ -13,5 +18,18 @@ export function getDefaultConfig(): IConfigType { paramArrayType: ParamArrayType.ParamArray, fetchReference: isBrowser ? window.fetch?.bind?.(window) : undefined, + + fetchInterceptor, + + serialize: (request: IFetchOptions): IFetchOptions => { + return { + ...request, + data: + mapItems(request.data, (obj) => (obj instanceof PureModel ? modelToJSON(obj) : obj)) || + undefined, + }; + }, + + Response, }; } diff --git a/packages/datx-network/src/helpers/model.ts b/packages/datx-network/src/helpers/model.ts index 017c85cd0..d813fd350 100644 --- a/packages/datx-network/src/helpers/model.ts +++ b/packages/datx-network/src/helpers/model.ts @@ -7,7 +7,7 @@ import { BaseRequest } from '../BaseRequest'; import { method, setUrl, query, header, cache, body } from '../operators'; import { HttpMethod } from '../enums/HttpMethod'; import { Response } from '../Response'; -import { PureModel, commitModel, getModelCollection, modelToJSON } from 'datx'; +import { PureModel, commitModel, getModelCollection, getModelId } from 'datx'; import { action } from 'mobx'; function handleResponse( @@ -70,10 +70,10 @@ export function saveModel( options?.networkConfig?.headers && header(options?.networkConfig?.headers), options?.cacheOptions?.cachingStrategy && cache(options?.cacheOptions?.cachingStrategy, options?.cacheOptions?.maxAge), - body(modelToJSON(model)), + body(model), ); - return request.fetch().then(handleResponse(model)); + return request.fetch({ id: getModelId(model) }).then(handleResponse(model)); } export function removeModel( @@ -113,7 +113,7 @@ export function removeModel( options?.cacheOptions?.cachingStrategy && cache(options?.cacheOptions?.cachingStrategy, options?.cacheOptions?.maxAge), ) - .fetch() + .fetch({ id: getModelId(model) }) .then(() => { const collection = getModelCollection(model); if (collection) { diff --git a/packages/datx-network/src/helpers/utils.ts b/packages/datx-network/src/helpers/utils.ts index 58ac6b1ab..b841a3507 100644 --- a/packages/datx-network/src/helpers/utils.ts +++ b/packages/datx-network/src/helpers/utils.ts @@ -1,9 +1,6 @@ import { ParamArrayType } from '../enums/ParamArrayType'; import { PureModel, IFieldDefinition, IReferenceDefinition } from 'datx'; import { getMeta } from 'datx-utils'; -import { BaseRequest } from '../BaseRequest'; -import { query, header, cache } from '../operators'; -import { IRequestOptions } from '../interfaces/IRequestOptions'; export const isBrowser: boolean = typeof window !== 'undefined'; @@ -126,15 +123,3 @@ export function getModelClassRefs( return refs; } - -export function addOptionsToRequest( - request: BaseRequest, - options?: IRequestOptions, -): BaseRequest { - return request.pipe( - options?.query && query(options?.query), - options?.networkConfig?.headers && header(options?.networkConfig?.headers), - options?.cacheOptions?.cachingStrategy && - cache(options?.cacheOptions?.cachingStrategy, options?.cacheOptions?.maxAge), - ); -} diff --git a/packages/datx-network/src/index.ts b/packages/datx-network/src/index.ts index b4847e0ee..57d2f4a96 100644 --- a/packages/datx-network/src/index.ts +++ b/packages/datx-network/src/index.ts @@ -16,8 +16,11 @@ export { serializer, parser, collection, + requestOptions, } from './operators'; +export { clearAllCache, clearCacheByType } from './interceptors/cache'; + export { CachingStrategy } from './enums/CachingStrategy'; export { HttpMethod } from './enums/HttpMethod'; export { ParamArrayType } from './enums/ParamArrayType'; diff --git a/packages/datx-network/src/interceptors/fetch.ts b/packages/datx-network/src/interceptors/fetch.ts index e3bf60e07..9e35d08b3 100644 --- a/packages/datx-network/src/interceptors/fetch.ts +++ b/packages/datx-network/src/interceptors/fetch.ts @@ -4,7 +4,7 @@ import { INetworkHandler } from '../interfaces/INetworkHandler'; import { IResponseObject } from '../interfaces/IResponseObject'; import { IResponseHeaders } from '../interfaces/IResponseHeaders'; import { IHeaders } from '../interfaces/IHeaders'; -import { Response } from '../Response'; +import { Response as ResponseClass } from '../Response'; function parseResponse( response: IResponseObject, @@ -24,8 +24,9 @@ export function fetchInterceptor( fetchReference?: typeof fetch, serialize: (options: IFetchOptions) => IFetchOptions = (options): IFetchOptions => options, parse: (data: object, options: IResponseObject) => object = (data): object => data, + Response: typeof ResponseClass = ResponseClass, ) { - return (request: IFetchOptions, _next?: INetworkHandler): Promise> => { + return (request: IFetchOptions, _next?: INetworkHandler): Promise> => { if (!fetchReference) { throw new Error('The fetch reference must be defined'); } diff --git a/packages/datx-network/src/interfaces/IConfigType.ts b/packages/datx-network/src/interfaces/IConfigType.ts index b08ab3c16..2a729275c 100644 --- a/packages/datx-network/src/interfaces/IConfigType.ts +++ b/packages/datx-network/src/interfaces/IConfigType.ts @@ -3,6 +3,8 @@ import { PureCollection, IType, PureModel, View } from 'datx'; import { ParamArrayType } from '../enums/ParamArrayType'; import { IFetchOptions } from './IFetchOptions'; import { IResponseObject } from './IResponseObject'; +import { fetchInterceptor } from '../interceptors/fetch'; +import { Response } from '../Response'; export interface IConfigType { baseUrl: string; @@ -14,4 +16,6 @@ export interface IConfigType { fetchReference?: typeof fetch; serialize?: (options: IFetchOptions) => IFetchOptions; parse?: (data: object, options: IResponseObject) => object; + fetchInterceptor: typeof fetchInterceptor; + Response: typeof Response; } diff --git a/packages/datx-network/src/interfaces/INetworkModelConstructor.ts b/packages/datx-network/src/interfaces/INetworkModelConstructor.ts index 3015fafc8..e431b0aa7 100644 --- a/packages/datx-network/src/interfaces/INetworkModelConstructor.ts +++ b/packages/datx-network/src/interfaces/INetworkModelConstructor.ts @@ -1,8 +1,8 @@ import { INetworkModel } from './INetworkModel'; import { IModelNetworkConfig } from './IModelNetworkConfig'; -import { PureModel } from 'datx'; +import { IModelConstructor } from 'datx'; -export interface INetworkModelConstructor extends PureModel { +export interface INetworkModelConstructor extends IModelConstructor { network?: IModelNetworkConfig; new (): INetworkModel; } diff --git a/packages/datx-network/src/operators.ts b/packages/datx-network/src/operators.ts index 4cb048231..009cab76f 100644 --- a/packages/datx-network/src/operators.ts +++ b/packages/datx-network/src/operators.ts @@ -8,7 +8,7 @@ import { PureCollection, IType, PureModel } from 'datx'; import { cacheInterceptor } from './interceptors/cache'; import { IResponseObject } from './interfaces/IResponseObject'; import { IFetchOptions } from './interfaces/IFetchOptions'; -import { fetchInterceptor } from './interceptors/fetch'; +import { IRequestOptions } from './interfaces/IRequestOptions'; export function setUrl(url: string, type: IType | typeof PureModel = PureModel) { return (pipeline: BaseRequest): void => { @@ -136,7 +136,12 @@ export function fetchReference(fetchReference: typeof fetch) { const config = pipeline['_config']; config.fetchReference = fetchReference; upsertInterceptor( - fetchInterceptor(config.fetchReference, config.serialize, config.parse), + config.fetchInterceptor( + config.fetchReference, + config.serialize, + config.parse, + config.Response, + ), 'fetch', )(pipeline); }; @@ -147,7 +152,12 @@ export function serializer(serialize: (request: IFetchOptions) => IFetchOptions) const config = pipeline['_config']; config.serialize = serialize; upsertInterceptor( - fetchInterceptor(config.fetchReference, config.serialize, config.parse), + config.fetchInterceptor( + config.fetchReference, + config.serialize, + config.parse, + config.Response, + ), 'fetch', )(pipeline); }; @@ -158,7 +168,12 @@ export function parser(parse: (data: object, response: IResponseObject) => objec const config = pipeline['_config']; config.parse = parse; upsertInterceptor( - fetchInterceptor(config.fetchReference, config.serialize, config.parse), + config.fetchInterceptor( + config.fetchReference, + config.serialize, + config.parse, + config.Response, + ), 'fetch', )(pipeline); }; @@ -169,3 +184,19 @@ export function collection(collection?: PureCollection) { pipeline['_config'].collection = collection; }; } + +export function requestOptions(options?: IRequestOptions) { + return (pipeline: BaseRequest): void => { + if (options?.query) { + query(options?.query)(pipeline); + } + + if (options?.networkConfig?.headers) { + header(options?.networkConfig?.headers)(pipeline); + } + + if (options?.cacheOptions?.cachingStrategy) { + cache(options?.cacheOptions?.cachingStrategy, options?.cacheOptions?.maxAge)(pipeline); + } + }; +} diff --git a/packages/datx-network/src/withNetwork.ts b/packages/datx-network/src/withNetwork.ts index f73c3fce1..08e7de22e 100644 --- a/packages/datx-network/src/withNetwork.ts +++ b/packages/datx-network/src/withNetwork.ts @@ -7,6 +7,7 @@ import { INetworkCollection } from './interfaces/INetworkCollection'; import { INetworkModel } from './interfaces/INetworkModel'; import { INetworkView } from './interfaces/INetworkView'; import { BaseRequest } from './BaseRequest'; +import { INetworkCollectionConstructor } from './interfaces/INetworkCollectionConstructor'; interface INetwork { network?: BaseRequest; @@ -18,7 +19,7 @@ export function withNetwork(Base: T): T & INetwork( Base: T, -): T & INetwork; +): T & INetworkCollectionConstructor; export function withNetwork(Base: T): T & INetwork; From 34ae9e4477cf91dadfb63230e11c7fa373c21717 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 26 Sep 2020 18:47:56 +0200 Subject: [PATCH 19/21] Add the body param to fetch --- packages/datx-network/src/BaseRequest.ts | 43 ++++++++++++++---------- packages/datx-network/test/body.test.ts | 9 +++++ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/datx-network/src/BaseRequest.ts b/packages/datx-network/src/BaseRequest.ts index 5f7ef4ec5..1f104d131 100644 --- a/packages/datx-network/src/BaseRequest.ts +++ b/packages/datx-network/src/BaseRequest.ts @@ -14,6 +14,7 @@ import { cacheInterceptor } from './interceptors/cache'; import { BodyType } from './enums/BodyType'; import { CachingStrategy } from './enums/CachingStrategy'; import { fetchInterceptor } from './interceptors/fetch'; +import { body as bodyOperator } from './operators'; interface IHookOptions { suspense?: boolean; @@ -99,34 +100,40 @@ export class BaseRequest> { - if (!this._options.url) { + public fetch( + params?: TParams | null, + body?: any, + bodyType?: BodyType, + ): Promise> { + const request = body === undefined ? this : this.pipe(bodyOperator(body, bodyType)); + + if (!request._options.url) { throw new Error('URL should be defined'); } - const urlParams = Object.assign({}, this._options.params, params); - const url = interpolateParams(`${this._config.baseUrl}${this._options.url}`, urlParams); + const urlParams = Object.assign({}, request._options.params, params || {}); + const url = interpolateParams(`${request._config.baseUrl}${request._options.url}`, urlParams); const processedUrl = appendQueryParams( url, - this._options.query, - this._config.paramArrayType, - this._config.encodeQueryString, + request._options.query, + request._config.paramArrayType, + request._config.encodeQueryString, ); - const request: IFetchOptions = { + const requestRef: IFetchOptions = { url: processedUrl, - method: this._options.method, - data: this.processBody(), - collection: this._config.collection, + method: request._options.method, + data: request.processBody(), + collection: request._config.collection, options: { networkConfig: { - headers: this._options.headers, + headers: request._options.headers, }, }, - views: this._config.views, - type: this._config.type, + views: request._config.views, + type: request._config.type, }; - const interceptorChain = this.interceptors.reduce((next, interceptor) => { + const interceptorChain = request.interceptors.reduce((next, interceptor) => { return (options: IFetchOptions): Promise> => interceptor.fn(options, next); }, undefined); @@ -134,11 +141,13 @@ export class BaseRequest | null, boolean, string | Error | null] { const [loader, setLoader] = useState> | null>(null); @@ -146,7 +155,7 @@ export class BaseRequest(null); const execute = useCallback(() => { - const loaderPromise = this.fetch(params); + const loaderPromise = this.fetch(params, body, bodyType); setLoader(loaderPromise); setValue(null); setError(null); diff --git a/packages/datx-network/test/body.test.ts b/packages/datx-network/test/body.test.ts index c45d7e07c..214b245df 100644 --- a/packages/datx-network/test/body.test.ts +++ b/packages/datx-network/test/body.test.ts @@ -24,6 +24,15 @@ describe('body', () => { expect(request['lastMethod']).toBe('POST'); }); + it('should send something in fetch if the method is supported', async () => { + const request = new MockBaseRequest('foobar').pipe(setUrl('foobar'), method(HttpMethod.Post)); + + await request.fetch(null, 'sdasdsad'); + + expect(request['lastBody']).toBe('sdasdsad'); + expect(request['lastMethod']).toBe('POST'); + }); + it('should send the no content type if body type is raw', async () => { const request = new MockBaseRequest('foobar').pipe( setUrl('foobar'), From 8ccab9f31f13ef88b324d042d7f904c4b65e34f5 Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 26 Sep 2020 19:10:57 +0200 Subject: [PATCH 20/21] Add jsonapi support to network --- packages/datx-jsonapi/src/BaseRequest.ts | 16 +--- packages/datx-jsonapi/src/NetworkResponse.ts | 26 ++++-- packages/datx-jsonapi/src/index.ts | 1 - packages/datx-jsonapi/src/operators.ts | 52 ++++++++++-- .../test/network/pipeline.test.ts | 82 +++++++++++++++++++ .../test/utils/MockBaseRequest.ts | 43 ++++++++++ packages/datx-jsonapi/tsconfig.build.json | 3 +- packages/datx-jsonapi/tsconfig.json | 8 +- packages/datx-network/src/index.ts | 1 + 9 files changed, 193 insertions(+), 39 deletions(-) create mode 100644 packages/datx-jsonapi/test/network/pipeline.test.ts create mode 100644 packages/datx-jsonapi/test/utils/MockBaseRequest.ts diff --git a/packages/datx-jsonapi/src/BaseRequest.ts b/packages/datx-jsonapi/src/BaseRequest.ts index 454561c0e..2dc512bdd 100644 --- a/packages/datx-jsonapi/src/BaseRequest.ts +++ b/packages/datx-jsonapi/src/BaseRequest.ts @@ -1,4 +1,4 @@ -import { BaseRequest as BaseNetworkRequest, IPipeOperator } from 'datx-network'; +import { BaseRequest as BaseNetworkRequest } from 'datx-network'; import { isJsonapi } from './operators'; export class BaseJsonapiRequest extends BaseNetworkRequest { @@ -7,17 +7,3 @@ export class BaseJsonapiRequest extends BaseNetworkRequest { isJsonapi()(this); } } - -let genericBaseRequest: BaseJsonapiRequest = new BaseJsonapiRequest('/'); - -export function setBaseRequest(request: BaseJsonapiRequest): void { - genericBaseRequest = request.pipe(isJsonapi()); -} - -export function getBaseRequest(): BaseJsonapiRequest { - return genericBaseRequest; -} - -export function pipeBaseRequest(...operators: Array): BaseJsonapiRequest { - return (genericBaseRequest = genericBaseRequest.pipe(...operators)); -} diff --git a/packages/datx-jsonapi/src/NetworkResponse.ts b/packages/datx-jsonapi/src/NetworkResponse.ts index 7f83f9372..128c95a04 100644 --- a/packages/datx-jsonapi/src/NetworkResponse.ts +++ b/packages/datx-jsonapi/src/NetworkResponse.ts @@ -46,7 +46,7 @@ export class NetworkResponse extends BaseResponse { * @type {Promise} * @memberOf Response */ - public first?: () => Promise>; // Handled by the __fetchLink + public first?: () => Promise>; // Handled by the __fetchLink /** * Previous data page @@ -54,7 +54,7 @@ export class NetworkResponse extends BaseResponse { * @type {Promise} * @memberOf Response */ - public prev?: () => Promise>; // Handled by the __fetchLink + public prev?: () => Promise>; // Handled by the __fetchLink /** * Next data page @@ -62,7 +62,7 @@ export class NetworkResponse extends BaseResponse { * @type {Promise} * @memberOf Response */ - public next?: () => Promise>; // Handled by the __fetchLink + public next?: () => Promise>; // Handled by the __fetchLink /** * Last data page @@ -70,7 +70,7 @@ export class NetworkResponse extends BaseResponse { * @type {Promise} * @memberOf Response */ - public last?: () => Promise>; // Handled by the __fetchLink + public last?: () => Promise>; // Handled by the __fetchLink public get views(): Array { return this.__internal.views; @@ -98,9 +98,9 @@ export class NetworkResponse extends BaseResponse { * @param {string} name Link name * @returns Promise that resolves with a Response object * - * @memberOf Response + * @memberOf NetworkResponse */ - private __fetchLink(name: string): () => Promise> { + private __fetchLink(name: string): () => Promise> { if (!this.__cache[name]) { const link: ILink | null = this.links && name in this.links ? this.links[name] : null; @@ -109,11 +109,19 @@ export class NetworkResponse extends BaseResponse { options.networkConfig = options.networkConfig || {}; options.networkConfig.headers = this.requestHeaders; - this.__cache[name] = (): Promise> => - fetchLink(link, this.collection as IJsonapiCollection, options, this.views); + this.__cache[name] = (): Promise> => + fetchLink(link, this.collection as IJsonapiCollection, options, this.views).then( + (response) => + new NetworkResponse( + response['__internal'].response, + response.collection, + undefined, + response.views, + ), + ); } } - return this.__cache[name]; + return this.__cache[name] as () => Promise>; } } diff --git a/packages/datx-jsonapi/src/index.ts b/packages/datx-jsonapi/src/index.ts index 2c97c348f..74b8f468c 100644 --- a/packages/datx-jsonapi/src/index.ts +++ b/packages/datx-jsonapi/src/index.ts @@ -18,7 +18,6 @@ export { } from './helpers/model'; export { BaseJsonapiRequest } from './BaseRequest'; -// export { setBaseRequest, getBaseRequest, pipeBaseRequest } from './BaseRequest'; export { ICollectionFetchOpts } from './interfaces/ICollectionFetchOpts'; export { IJsonapiCollection } from './interfaces/IJsonapiCollection'; diff --git a/packages/datx-jsonapi/src/operators.ts b/packages/datx-jsonapi/src/operators.ts index fc54610d7..a035c8a3d 100644 --- a/packages/datx-jsonapi/src/operators.ts +++ b/packages/datx-jsonapi/src/operators.ts @@ -5,13 +5,17 @@ import { IResponseObject, IFetchOptions, serializer, + query, + paramArrayType, + ParamArrayType, + encodeQueryString, } from 'datx-network'; import { IJsonapiCollection } from './interfaces/IJsonapiCollection'; import { mapItems } from 'datx-utils'; import { PureModel } from 'datx'; import { modelToJsonApi } from './helpers/model'; import { IJsonapiModel } from './interfaces/IJsonapiModel'; -import { Response } from './Response'; +import { NetworkResponse } from './NetworkResponse'; function jsonapiParser(data: object, response: IResponseObject): object { return ( @@ -35,17 +39,49 @@ function jsonapiSerializer(request: IFetchOptions): IFetchOptions { export function isJsonapi() { return (pipeline: BaseRequest): void => { - pipeline['_config'].Response = Response; + pipeline['_config'].Response = NetworkResponse; header('content-type', 'application/vnd.api+json')(pipeline); parser(jsonapiParser)(pipeline); serializer(jsonapiSerializer)(pipeline); + paramArrayType(ParamArrayType.CommaSeparated)(pipeline); + encodeQueryString(false)(pipeline); }; } -// filter -// sort -// page -// include -// sparse +export function filter(filter: Record) { + return (pipeline: BaseRequest): void => { + query('filter', filter)(pipeline); + }; +} + +export enum Direction { + Asc = '', + Desc = '-', +} + +export function sort(...params: Array) { + return (pipeline: BaseRequest): void => { + query( + 'sort', + params.map((item) => (typeof item === 'string' ? item : item.reverse().join(''))), + )(pipeline); + }; +} + +export function page(page: Record) { + return (pipeline: BaseRequest): void => { + query('page', page)(pipeline); + }; +} + +export function include(...included: Array) { + return (pipeline: BaseRequest): void => { + query('include', included)(pipeline); + }; +} -// transformersInterceptor +export function sparse(sparse: Record>) { + return (pipeline: BaseRequest): void => { + query('fields', sparse)(pipeline); + }; +} diff --git a/packages/datx-jsonapi/test/network/pipeline.test.ts b/packages/datx-jsonapi/test/network/pipeline.test.ts new file mode 100644 index 000000000..6bee68d3f --- /dev/null +++ b/packages/datx-jsonapi/test/network/pipeline.test.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs'; +import { MockBaseRequest } from '../utils/MockBaseRequest'; +import { filter, sort, Direction, page, include, sparse } from '../../src/operators'; +import { setUrl, collection } from '../../src'; +import { TestStore, Event, Image } from '../utils/setup'; + +describe('pipeline', () => { + it('should work for a basic filter', async () => { + const request = new MockBaseRequest('foobar').pipe(setUrl('/test'), filter({ test: '123' })); + + await request.fetch(); + expect(request['lastUrl']).toBe('foobar/test?filter[test]=123'); + }); + + it('should work for a complex filter', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('/test'), + filter({ test: '123', foo: { bar: '321', baz: '456' } }), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe( + 'foobar/test?filter[test]=123&filter[foo][bar]=321&filter[foo][baz]=456', + ); + }); + + it('should work for sorting', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('/test'), + sort('foo', ['bar', Direction.Asc], ['baz', Direction.Desc]), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('foobar/test?sort=foo,bar,-baz'); + }); + + it('should work for pagination', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('/test'), + page({ size: '123', offset: '0' }), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('foobar/test?page[size]=123&page[offset]=0'); + }); + + it('should work for include', async () => { + const request = new MockBaseRequest('foobar').pipe(setUrl('/test'), include('foo', 'bar.baz')); + + await request.fetch(); + expect(request['lastUrl']).toBe('foobar/test?include=foo,bar.baz'); + }); + + it('should work for sparse fields', async () => { + const request = new MockBaseRequest('foobar').pipe( + setUrl('/test'), + sparse({ foo: ['a', 'b'], bar: ['baz'] }), + ); + + await request.fetch(); + expect(request['lastUrl']).toBe('foobar/test?fields[foo]=a,b&fields[bar]=baz'); + }); + + it('should parse data correctly', async () => { + const store = new TestStore(); + + const request = new MockBaseRequest('foobar').pipe( + collection(store), + setUrl('/events/12345'), + ); + const mockEventWithRelationships = JSON.parse( + fs.readFileSync('./test/mock/event-1f.json', 'utf-8'), + ); + request['resetMock']({ status: 200, json: async () => mockEventWithRelationships }); + + const response = await request.fetch(); + + expect(response.data).toBeInstanceOf(Event); + expect(response.data?.images).toHaveLength(2); + expect(response.data?.images[0]).toBeInstanceOf(Image); + }); +}); diff --git a/packages/datx-jsonapi/test/utils/MockBaseRequest.ts b/packages/datx-jsonapi/test/utils/MockBaseRequest.ts new file mode 100644 index 000000000..d83262062 --- /dev/null +++ b/packages/datx-jsonapi/test/utils/MockBaseRequest.ts @@ -0,0 +1,43 @@ +import { fetchReference, BaseJsonapiRequest } from '../../src'; + +export class MockBaseRequest extends BaseJsonapiRequest { + constructor(baseUrl: string) { + super(baseUrl); + this.resetMock({ + status: 200, + json: async () => ({}), + }); + } + + protected resetMock(mockResponse: any, success = true): void { + fetchReference( + success + ? jest.fn().mockResolvedValue(mockResponse) + : jest.fn().mockRejectedValue(mockResponse), + )(this); + } + + private get lastRequest(): [ + string, + { method: string; body: string | FormData | undefined; headers: Record }, + ] { + const mockFetch = (this['_config'].fetchReference as jest.Mock).mock.calls; + return mockFetch[mockFetch.length - 1]; + } + + protected get lastMethod(): string { + return this.lastRequest[1].method; + } + + protected get lastUrl(): string { + return this.lastRequest[0]; + } + + protected get lastBody(): string | FormData | undefined { + return this.lastRequest[1].body; + } + + protected get lastHeaders(): Record { + return this.lastRequest[1].headers; + } +} diff --git a/packages/datx-jsonapi/tsconfig.build.json b/packages/datx-jsonapi/tsconfig.build.json index 4a5f84a25..7d3d6601b 100644 --- a/packages/datx-jsonapi/tsconfig.build.json +++ b/packages/datx-jsonapi/tsconfig.build.json @@ -17,7 +17,8 @@ "outDir": "./dist", "strict": true, "strictFunctionTypes": false, - "target": "es5" + "target": "es5", + "resolveJsonModule": true }, "exclude": ["node_modules", "examples"], "include": ["src/**/*"] diff --git a/packages/datx-jsonapi/tsconfig.json b/packages/datx-jsonapi/tsconfig.json index aa15bdb0e..06d157e74 100644 --- a/packages/datx-jsonapi/tsconfig.json +++ b/packages/datx-jsonapi/tsconfig.json @@ -1,11 +1,9 @@ { "extends": "./tsconfig.build.json", - "include": [ - "src/**/*", - "test/**/*" - ], + "include": ["src/**/*", "test/**/*"], "compilerOptions": { "moduleResolution": "node", - "noUnusedLocals": true + "noUnusedLocals": true, + "resolveJsonModule": true } } diff --git a/packages/datx-network/src/index.ts b/packages/datx-network/src/index.ts index 57d2f4a96..e4ae1abdc 100644 --- a/packages/datx-network/src/index.ts +++ b/packages/datx-network/src/index.ts @@ -17,6 +17,7 @@ export { parser, collection, requestOptions, + upsertInterceptor, } from './operators'; export { clearAllCache, clearCacheByType } from './interceptors/cache'; From a756669f0c31d955c3fe68b4f8e77c4fea3563ab Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Sat, 26 Sep 2020 19:27:21 +0200 Subject: [PATCH 21/21] Fix dependency version --- packages/datx-network/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datx-network/package.json b/packages/datx-network/package.json index 034bd249c..f042d1a7a 100644 --- a/packages/datx-network/package.json +++ b/packages/datx-network/package.json @@ -70,6 +70,6 @@ ] }, "dependencies": { - "datx": "^2.0.0-beta.7" + "datx": "^2.0.0-beta.6" } }