diff --git a/README.md b/README.md index 26a8d1b..04168de 100644 --- a/README.md +++ b/README.md @@ -33,130 +33,126 @@ First, set your JSON:API credentials. ```ts -import Config from "../src/Config"; +import Config from '../src/Config'; Config.setAll({ - // The location of JSON:API - baseUrl: "https://jsonapi.v5tevkp4nowisbi4sic7gv.site", - - // The client ID - clientId: "Hcj7OqJC0KTObYMmMNmVbG3c", - - // The client secret - clientSecret: "Rtqe9lNoXsp9w9blIaVVlEA5", - - // Password - password: "", - - // Username - username: "" + // The location of JSON:API + baseUrl: 'https://jsonapi.v5tevkp4nowisbi4sic7gv.site', + + // The client ID + clientId: 'Hcj7OqJC0KTObYMmMNmVbG3c', + + // The client secret + clientSecret: 'Rtqe9lNoXsp9w9blIaVVlEA5', + + // Password + password: '', + + // Username + username: '' }); ``` ## 3. Models -Every resource fetched from JSON:API gets mapped to an entity or model. A good way to +Every resource fetched from JSON:API gets mapped to an entity or model. A good way to start getting familiar with this package, is by making your first model. ### 3.1 Model mapping -Override the default Model's map-method to provide your -model with data. In the map-method you'll have a -generic ResponseModel available that allows for safer object traversal +Override the default Model's map-method to provide your +model with data. In the map-method you'll have a +generic ResponseModel available that allows for safer object traversal through its get-method and various utility functions. E.g. `responseModel.get('category.title', 'This is a default value')` #### Example Author model: ```ts -import Model from "../src/Model"; -import {ResponseModelInterface} from "../src/contracts/ResponseModelInterface"; -import {DataProperties} from "../src/types/generic/data-properties"; - -export class Author extends Model -{ - // Define this model's properties - id!: string; - firstName!: string; - lastName!: string; - isGilke!: boolean; - - // Tell the model how to map from the response data - async map(responseModel: ResponseModelInterface): Promise> - { - return { - id: responseModel.get('id', ''), - firstName: responseModel.get('first_name', ''), - lastName: responseModel.get('lastName', ''), - isGilke: responseModel.get('first_name', '') === 'Gilke', - }; - } +import { ResponseModelInterface } from '../src/contracts/ResponseModelInterface'; +import Model from '../src/Model'; +import { DataProperties } from '../src/types/generic/data-properties'; + +export class Author extends Model { + // Define this model's properties + id!: string; + firstName!: string; + lastName!: string; + isGilke!: boolean; + + // Tell the model how to map from the response data + async map(responseModel: ResponseModelInterface): Promise> { + return { + id: responseModel.get('id', ''), + firstName: responseModel.get('first_name', ''), + lastName: responseModel.get('lastName', ''), + isGilke: responseModel.get('first_name', '') === 'Gilke', + }; + } } ``` #### Example BlogPost model: ```ts -export class BlogPost extends Model -{ - // Define the endpoint for this model (not required) - protected static endpoint: string = 'api/blog_post'; - - // When defining an endpoint in your Model, you'll have the - // opportunity to also define which includes to add by default - protected static include = ['author']; - - // Define this model's properties - id!: string; - title!: string; - author!: Author; - - // Tell the model how to map from the response data - async map(responseModel: ResponseModelInterface): Promise> - { - return { - id: responseModel.get('id', ''), - type: responseModel.get('type', ''), - title: responseModel.get('title', ''), - author: responseModel.hasOne('author'), - }; - } +export class BlogPost extends Model { + // Define the endpoint for this model (not required) + protected static endpoint: string = 'api/blog_post'; + + // When defining an endpoint in your Model, you'll have the + // opportunity to also define which includes to add by default + protected static include = ['author']; + + // Define this model's properties + id!: string; + title!: string; + author!: Author; + + // Tell the model how to map from the response data + async map(responseModel: ResponseModelInterface): Promise> { + return { + id: responseModel.get('id', ''), + type: responseModel.get('type', ''), + title: responseModel.get('title', ''), + author: responseModel.hasOne('author'), + }; + } } ``` ### 3.2 Retrieving model instances -Every model provides a static method `query` to retrieve a QueryBuilder +Every model provides a static method `query` to retrieve a QueryBuilder specifically for fetching instances of this Model. ```ts const queryBuilder = BlogPost.query(); ``` -The QueryBuilder provides an easy way to dynamically and programmatically -build queries. When the QueryBuilder is instantiated through a Model's query-method, +The QueryBuilder provides an easy way to dynamically and programmatically +build queries. When the QueryBuilder is instantiated through a Model's query-method, every result will be an instance of the Model it was called on. More info on using the QueryBuilder can be found in the section [QueryBuilder](#querybuilder). ### 3.3 Automapping -When you're not creating your query builder from a specific model, or the response -of your query encounters different types, you can specify how and when the +When you're not creating your query builder from a specific model, or the response +of your query encounters different types, you can specify how and when the query builder resolves these into instances of different models. -First, set a selector which receives the generic response model and a select value and +First, set a selector which receives the generic response model and a select value and returns a boolean which indicates whether we have a match. Set selector: ```ts AutoMapper.setSelector((responseModel: ResponseModelInterface, selectValue) => { - return responseModel.get('type') === selectValue; + return responseModel.get('type') === selectValue; }); ``` Now, register your select values (in this example drupal node types) with the corresponding model class: ```ts AutoMapper.register({ - 'node--blog-post': BlogPost, - 'node--author': Author, - 'node--blog-category': BlogCategory, + 'node--blog-post': BlogPost, + 'node--author': Author, + 'node--blog-category': BlogCategory, }); ``` -In this example, when the query builder encounters a resource with any of these types, it will +In this example, when the query builder encounters a resource with any of these types, it will automatically resolve it to the corresponding model. ## 4. QueryBuilder @@ -164,29 +160,29 @@ The QueryBuilder provides an easy way to dynamically and programmatically build queries and provides a safe API to communicate with the JSON:API. #### Instantiating a new query builder -Although it's more convenient to instantiate your query builder directly from the model, +Although it's more convenient to instantiate your query builder directly from the model, it's still possible to create ad-hoc query builders, like so: ```ts const queryBuilder = new QueryBuilder(new Client(), 'api/my_endpoint', (responseModel) => { - return { - id: responseModel.get('id'), - }; + return { + id: responseModel.get('id'), + }; }); ``` ### 4.1 Filtering -Filtering resources is as easy as calling the `where()` method on +Filtering resources is as easy as calling the `where()` method on a QueryBuilder instance. This method can be chained. ```ts BlogPost.query().where('author.name', '=', 'Rein').where('author.age', '>', 34); ``` -As with every chaining method on the QueryBuilder, this allows for +As with every chaining method on the QueryBuilder, this allows for greater flexibility while writing your queries: ```ts const qb = BlogPost.query().where('author.name', '=', 'Rein'); if (filterByAge) { - qb.where('age', '>', 34) + qb.where('age', '>', 34); } ``` @@ -201,63 +197,63 @@ BlogPost.query().sort('author.name', 'desc'); ``` ### 4.3 Grouping -The QueryBuilder provides an easy-to-use (and understand) interface +The QueryBuilder provides an easy-to-use (and understand) interface for filter-grouping. Possible methods for grouping are `or` & `and`. OR: ```ts BlogPost.query().group('or', (qb: QueryBuilder) => { - qb.where('author.name', '=', 'Rein'); - qb.where('author.name', '=', 'Gilke'); + qb.where('author.name', '=', 'Rein'); + qb.where('author.name', '=', 'Gilke'); }); ``` AND: ```ts BlogPost.query().group('and', (qb: QueryBuilder) => { - qb.where('author.name', '=', 'Rein'); - qb.where('age', '>', 34); + qb.where('author.name', '=', 'Rein'); + qb.where('age', '>', 34); }); ``` -Nested grouping is possible. The underlying library takes care of +Nested grouping is possible. The underlying library takes care of all the complex stuff for you! ```ts BlogPost.query().group('and', (qb: QueryBuilder) => { - qb.where('age', '>', 34); - qb.group('or', (qb: QueryBuilder) => { - qb.where('author.name', '=', 'Gilke').where('author.name', '=', 'Rein'); - }); + qb.where('age', '>', 34); + qb.group('or', (qb: QueryBuilder) => { + qb.where('author.name', '=', 'Gilke').where('author.name', '=', 'Rein'); + }); }); ``` ### 4.4 Macros -As parts of your query can become quite long and complicated, it gets -very cumbersome to retype these again and again. Architecturally -it's also not the best approach, especially for parts of your query +As parts of your query can become quite long and complicated, it gets +very cumbersome to retype these again and again. Architecturally +it's also not the best approach, especially for parts of your query that should be reusable (dry). -Because of this, you can abstract away query statements and register +Because of this, you can abstract away query statements and register them as macros, these can then be called on any QueryBuilder instance. #### Registering macros: ```ts -import QueryBuilder from "./QueryBuilder"; -import MacroRegistry from "./MacroRegistry"; +import MacroRegistry from './MacroRegistry'; +import QueryBuilder from './QueryBuilder'; MacroRegistry.registerMacro('filterByAgeAndName', (qb: QueryBuilder, age, names) => { - qb.group('and', (qb: QueryBuilder) => { - qb.where('author.age', '>', age); - qb.group('or', (qb: QueryBuilder) => { - names.forEach(name => { - qb.where('author.name', '=', name); - }); + qb.group('and', (qb: QueryBuilder) => { + qb.where('author.age', '>', age); + qb.group('or', (qb: QueryBuilder) => { + names.forEach((name) => { + qb.where('author.name', '=', name); + }); + }); }); - }); }); ``` ```ts MacroRegistry.registerMacro('sortByAuthorAge', (qb: QueryBuilder) => { - qb.sort('author.age', 'desc'); + qb.sort('author.age', 'desc'); }); ``` #### Macro usage: @@ -275,7 +271,7 @@ BlogPost.query().paginate(1, 10); ### 4.6 Fetching resources -On any QueryBuilder instance, you'll have these methods available for fetching +On any QueryBuilder instance, you'll have these methods available for fetching your resources: #### get() - Gets all results from the query builder diff --git a/eslint.config.js b/eslint.config.js index 9ae04eb..6d91c40 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,7 @@ export default antfu({ 'style/semi': ['warn', 'always'], 'symbol-description': ['off'], 'vue/custom-event-name-casing': ['off'], - '@typescript-eslint/consistent-type-definitions': ['type'] + '@typescript-eslint/consistent-type-definitions': ['warn', 'type'], }, stylistic: { indent: 4, diff --git a/package.json b/package.json index 218936c..6f13145 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "prepare": "husky", "dev": "npm run build && node ./dist/example/test.js", "test": "jest", - "build": "tsup" + "build": "tsup", + "lint": "eslint" }, "type": "module", "author": "Rein Van Oyen", diff --git a/src/Client.ts b/src/Client.ts index a6549b6..77d44cf 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,5 +1,5 @@ import type { ClientInterface } from './contracts/ClientInterface'; -import AuthTokenError from "./errors/AuthTokenError"; +import AuthTokenError from './errors/AuthTokenError'; export default class Client implements ClientInterface { /** @@ -46,7 +46,6 @@ export default class Client implements ClientInterface { * Gets the authentication token */ private async getAuthToken(): Promise { - if ( !this.accessToken || !this.accessTokenExpiryDate @@ -62,7 +61,6 @@ export default class Client implements ClientInterface { * Generates a new auth token, stores it as properties and returns it */ private async generateAuthToken(): Promise { - const url = `${this.baseUrl}/oauth/token`; const requestBody = new FormData(); @@ -90,7 +88,7 @@ export default class Client implements ClientInterface { throw new AuthTokenError(`Couldn\'t generate auth token: Unknown error.`, url); } - if (! json.access_token) { + if (!json.access_token) { throw new AuthTokenError(`${json.error}: ${json.error_description}`, url); } @@ -118,8 +116,9 @@ export default class Client implements ClientInterface { try { return await response.json(); - } catch (e: unknown) { - throw new Error('Response was not valid JSON.'); + } + catch (e: unknown) { + throw new Error(`Response was not valid JSON: ${e}`); } } } diff --git a/src/Config.ts b/src/Config.ts index e0e1ee7..8e8f74f 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -1,40 +1,40 @@ -import ConfigValuesNotSetError from "./errors/ConfigValuesNotSetError"; -import FalsyConfigValueError from "./errors/FalsyConfigValueError"; -import UnknownConfigValueError from "./errors/UnknownConfigValueError"; -import type { TConfigAttributes } from "./types/config-attributes"; -import { TNullable } from "./types/generic/nullable"; +import type { TConfigAttributes } from './types/config-attributes'; +import type { TNullable } from './types/generic/nullable'; +import ConfigValuesNotSetError from './errors/ConfigValuesNotSetError'; +import FalsyConfigValueError from './errors/FalsyConfigValueError'; +import UnknownConfigValueError from './errors/UnknownConfigValueError'; export default class Config { - /** - * @private - */ - private static attributes: TNullable = null; + /** + * @private + */ + private static attributes: TNullable = null; - /** - * @param attributes - */ - public static setAll(attributes: TConfigAttributes) { - const keys = Object.keys(attributes) as (keyof TConfigAttributes)[]; + /** + * @param attributes + */ + public static setAll(attributes: TConfigAttributes) { + const keys = Object.keys(attributes) as (keyof TConfigAttributes)[]; - keys.forEach((key) => { - if (!attributes[key]) { - throw new FalsyConfigValueError(key); - } - }); + keys.forEach((key) => { + if (!attributes[key]) { + throw new FalsyConfigValueError(key); + } + }); - this.attributes = attributes; - } + this.attributes = attributes; + } - /** - * @param attribute - */ - public static get(attribute: keyof TConfigAttributes): string { - if (this.attributes === null) { - throw new ConfigValuesNotSetError(); - } - if (!this.attributes[attribute]) { - throw new UnknownConfigValueError(attribute); - } - return this.attributes[attribute]; - } + /** + * @param attribute + */ + public static get(attribute: keyof TConfigAttributes): string { + if (this.attributes === null) { + throw new ConfigValuesNotSetError(); + } + if (!this.attributes[attribute]) { + throw new UnknownConfigValueError(attribute); + } + return this.attributes[attribute]; + } } diff --git a/src/Container.ts b/src/Container.ts index a339c90..c8432f8 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -1,70 +1,70 @@ -import {TContainerBindingFunction} from "./types/container-binding-function"; +import type { TContainerBindingFunction } from './types/container-binding-function'; export default class Container { - /** - * @private - */ - private static bindings: Record = {}; + /** + * @private + */ + private static bindings: Record = {}; - /** - * @private - */ - private static singletonBindings: Record = {}; + /** + * @private + */ + private static singletonBindings: Record = {}; - /** - * - * @private - */ - private static instances: Record = {}; + /** + * + * @private + */ + private static instances: Record = {}; - /** - * - * @param name - * @param bindingCall - */ - public static bind(name: string, bindingCall: TContainerBindingFunction): void { - this.bindings[name] = bindingCall; - } + /** + * + * @param name + * @param bindingCall + */ + public static bind(name: string, bindingCall: TContainerBindingFunction): void { + this.bindings[name] = bindingCall; + } - /** - * - * @param name - * @param bindingCall - */ - public static singleton(name: string, bindingCall: TContainerBindingFunction): void { - this.singletonBindings[name] = bindingCall; - } + /** + * + * @param name + * @param bindingCall + */ + public static singleton(name: string, bindingCall: TContainerBindingFunction): void { + this.singletonBindings[name] = bindingCall; + } - /** - * - * @param name - * @private - */ - private static getBinding(name: string): TContainerBindingFunction { - if (!this.bindings[name]) { - throw new Error(`No dependency was found for name "${name}"`); - } + /** + * + * @param name + * @private + */ + private static getBinding(name: string): TContainerBindingFunction { + if (!this.bindings[name]) { + throw new Error(`No dependency was found for name "${name}"`); + } - return this.bindings[name]; - } + return this.bindings[name]; + } - /** - * Makes and retrieves a new instance - * @param name - * @param args - */ - public static make(name: string, ...args: any[]): any { - // First check if it's a singleton instance we have to make - if (this.singletonBindings[name]) { - // Does it already exist? - if (!this.instances[name]) { - // If not, make it and store it in instances - this.instances[name] = this.singletonBindings[name](...args); - } + /** + * Makes and retrieves a new instance + * @param name + * @param args + */ + public static make(name: string, ...args: any[]): any { + // First check if it's a singleton instance we have to make + if (this.singletonBindings[name]) { + // Does it already exist? + if (!this.instances[name]) { + // If not, make it and store it in instances + this.instances[name] = this.singletonBindings[name](...args); + } - return this.instances[name]; - } + return this.instances[name]; + } - return this.getBinding(name)(...args); - } -} \ No newline at end of file + return this.getBinding(name)(...args); + } +} diff --git a/src/JsonApi.ts b/src/JsonApi.ts index a70d7b6..1fecf07 100644 --- a/src/JsonApi.ts +++ b/src/JsonApi.ts @@ -1,20 +1,18 @@ import type { TConfigAttributes } from './types/config-attributes'; -import Container from "./Container"; -import Client from "./Client"; -import Config from "./Config"; +import Client from './Client'; +import Config from './Config'; +import Container from './Container'; export default class JsonApi { + public static init(config: TConfigAttributes) { + Config.setAll(config); - public static init(config: TConfigAttributes) { - - Config.setAll(config); - - Container.singleton('ClientInterface', () => { - return new Client( - Config.get('baseUrl'), - Config.get('clientId'), - Config.get('clientSecret') - ); - }); - } + Container.singleton('ClientInterface', () => { + return new Client( + Config.get('baseUrl'), + Config.get('clientId'), + Config.get('clientSecret'), + ); + }); + } } diff --git a/src/MacroRegistry.ts b/src/MacroRegistry.ts index fb0965d..e9c57a5 100644 --- a/src/MacroRegistry.ts +++ b/src/MacroRegistry.ts @@ -1,6 +1,6 @@ +import type { QueryBuilderInterface } from './contracts/QueryBuilderInterface'; import type { TQueryBuilderMacroFunction } from './types/query-builder-macro-function'; -import UnknownMacroError from "./errors/UnknownMacroError"; -import {QueryBuilderInterface} from "./contracts/QueryBuilderInterface"; +import UnknownMacroError from './errors/UnknownMacroError'; export default class MacroRegistry { /** diff --git a/src/Model.ts b/src/Model.ts index e89def2..9556146 100644 --- a/src/Model.ts +++ b/src/Model.ts @@ -1,71 +1,71 @@ -import Container from "./Container"; -import QueryBuilder from "./QueryBuilder"; -import type { ResponseModelInterface } from "./contracts/ResponseModelInterface"; -import type { TMapper } from "./types/mapper"; +import type { ResponseModelInterface } from './contracts/ResponseModelInterface'; +import type { TMapper } from './types/mapper'; +import Container from './Container'; +import QueryBuilder from './QueryBuilder'; export default abstract class Model { - /** - * @protected - */ - protected static primaryKey: string = "id"; + /** + * @protected + */ + protected static primaryKey: string = 'id'; - /** - * @protected - */ - protected static defaultMacro: string; + /** + * @protected + */ + protected static defaultMacro: string; - /** - * @protected - */ - protected static endpoint: string; + /** + * @protected + */ + protected static endpoint: string; - /** - * @protected - */ - protected static include: string[] = []; + /** + * @protected + */ + protected static include: string[] = []; - /** - * - * @param response - */ - public static async createFromResponse(response: ResponseModelInterface) { - const instance = new (this as any)(); - Object.assign(instance, await instance.map(response)); - return instance; - } + /** + * + * @param response + */ + public static async createFromResponse(response: ResponseModelInterface) { + const instance = new (this as any)(); + Object.assign(instance, await instance.map(response)); + return instance; + } - /** - * Create a QueryBuilder instance specifically for this model - */ - public static query(this: T): QueryBuilder> { - if (!this.endpoint) { - throw new Error(`The model "${this.name}" doesn't have an endpoint, so can't be queried.`); - } + /** + * Create a QueryBuilder instance specifically for this model + */ + public static query(this: T): QueryBuilder> { + if (!this.endpoint) { + throw new Error(`The model "${this.name}" doesn't have an endpoint, so can't be queried.`); + } - const mapper: TMapper>> = async ( - response: ResponseModelInterface - ): Promise> => { - return await this.createFromResponse(response); - }; + const mapper: TMapper>> = async ( + response: ResponseModelInterface, + ): Promise> => { + return await this.createFromResponse(response); + }; - // Create a new QueryBuilder instance (and pass the type of the called class as a type) - const query = new QueryBuilder>(Container.make("ClientInterface"), this.endpoint, mapper); + // Create a new QueryBuilder instance (and pass the type of the called class as a type) + const query = new QueryBuilder>(Container.make('ClientInterface'), this.endpoint, mapper); - // Pass the includes to the query builder - query.include(this.include); + // Pass the includes to the query builder + query.include(this.include); - // Check if the model has a default macro - if (this.defaultMacro) { - query.macro(this.defaultMacro); - } + // Check if the model has a default macro + if (this.defaultMacro) { + query.macro(this.defaultMacro); + } - return query; - } + return query; + } - /** - * @param responseModel - */ - map(responseModel: ResponseModelInterface): unknown { - return responseModel; - } + /** + * @param responseModel + */ + map(responseModel: ResponseModelInterface): unknown { + return responseModel; + } } diff --git a/src/QueryBuilder.ts b/src/QueryBuilder.ts index 9083084..7469135 100644 --- a/src/QueryBuilder.ts +++ b/src/QueryBuilder.ts @@ -1,378 +1,377 @@ -import type Client from "./Client"; -import MacroRegistry from "./MacroRegistry"; -import ResponseModel from "./ResponseModel"; -import ResultSet from "./ResultSet"; -import type { QueryBuilderInterface } from "./contracts/QueryBuilderInterface"; -import { ResponseModelInterface } from "./contracts/ResponseModelInterface"; -import InvalidResponseError from "./errors/InvalidResponseError"; -import { isJsonApiResponse } from "./typeguards/isJsonApiResponse"; -import { isRawResponse } from "./typeguards/isRawResponse"; -import { isResponseWithErrors } from "./typeguards/isResponseWithErrors"; -import type { TFilterOperator } from "./types/filter-operator"; -import type { TNullable } from "./types/generic/nullable"; -import { TJsonApiResponse } from "./types/json-api-response"; -import type { TMapper } from "./types/mapper"; -import { TQueryBuilderGroupingFunction } from "./types/query-builder-grouping-function"; -import type { TQueryParams } from "./types/query-params"; -import type { TResultSetMeta } from "./types/resultset-meta"; -import { makeSearchParams } from "./utils/http"; +import type Client from './Client'; +import type { QueryBuilderInterface } from './contracts/QueryBuilderInterface'; +import type { TFilterOperator } from './types/filter-operator'; +import type { TNullable } from './types/generic/nullable'; +import type { TJsonApiResponse } from './types/json-api-response'; +import type { TMapper } from './types/mapper'; +import type { TQueryBuilderGroupingFunction } from './types/query-builder-grouping-function'; +import type { TQueryParams } from './types/query-params'; +import type { TResultSetMeta } from './types/resultset-meta'; +import InvalidResponseError from './errors/InvalidResponseError'; +import MacroRegistry from './MacroRegistry'; +import ResponseModel from './ResponseModel'; +import ResultSet from './ResultSet'; +import { isJsonApiResponse } from './typeguards/isJsonApiResponse'; +import { isRawResponse } from './typeguards/isRawResponse'; +import { isResponseWithErrors } from './typeguards/isResponseWithErrors'; +import { makeSearchParams } from './utils/http'; /** * This class provides an easy-to-use interface to build queries * specifically for JSON:API */ export default class QueryBuilder implements QueryBuilderInterface { - /** - * The locale in which we're going to query the entries - * @private - */ - private locale?: string; - - /** - * The mapping function to use when we received the response - * @private - */ - private readonly client: Client; - - /** - * The mapping function to use when we received the response - * @private - */ - private readonly mapper: TMapper>; - - /** - * @private - */ - private readonly endpoint: string; - - /** - * The registered query params - * @private - */ - private readonly queryParams: TQueryParams; - - /** - * @private - */ - private pageLimit: number = 50; - - /** - * @private - */ - private pageOffset: number = 0; - - /** - * The cache policy to pass to the Client once the query executes - * @private - */ - private cachePolicy: "force-cache" | "no-store" = "force-cache"; - - /** - * - * @private - */ - private response!: TJsonApiResponse; - - /** - * The last used filter group id, increments with each use of a filter-method to generate a unique filter group name - * @private - */ - private lastFilterGroupId: number = 0; - - /** - * - * @private - */ - private currentFilterGroupName: TNullable = null; - - /** - * @param client - * @param endpoint - * @param mapper - */ - constructor(client: Client, endpoint: string, mapper: TMapper>) { - this.client = client; - this.endpoint = endpoint; - this.mapper = mapper; - this.queryParams = {}; - } - - /** - * Executes a macro registered on MacroRegistry - * @param name - * @param args - */ - public macro(name: string, ...args: unknown[]): this { - MacroRegistry.execute(name, this, args); - return this; - } - - /** - * Disabled caching on the request - */ - public noCache(): this { - this.cachePolicy = "no-store"; - return this; - } - - /** - * Forces caching on the request - */ - public cache(): this { - this.cachePolicy = "force-cache"; - return this; - } - - /** - * Sets the locale of the query - * @param locale - */ - public setLocale(locale: string): this { - this.locale = locale; - return this; - } - - /** - * Registers one query param - * @param name - * @param value - */ - public param(name: string, value: string | number): this { - this.queryParams[name] = value; - return this; - } - - /** - * Registers multiple query params - * @param params - */ - public params(params: TQueryParams): this { - Object.keys(params).forEach((key) => { - this.param(key, params[key]); - }); - return this; - } - - /** - * @param path - * @param operator - * @param value - */ - public where(path: string, operator: TFilterOperator, value: string): this { - const groupName = this.createFilterGroupName(); - this.param(`filter[${groupName}][condition][path]`, path); - this.param(`filter[${groupName}][condition][operator]`, operator); - this.param(`filter[${groupName}][condition][value]`, value); - this.assignFilterGroupToCurrentFilterGroup(groupName); - return this; - } - - /** - * Registers includes (as described by JSON:API) - * @param includes - */ - public include(includes: string[]): this { - this.param("jsonapi_include", 1); - this.param("include", includes.join(",")); - return this; - } - - /** - * @param amount - */ - public limit(amount: number): this { - this.pageLimit = amount; - return this; - } - - /** - * @param page - * @param perPage - */ - paginate(page: number, perPage: number): this { - this.pageOffset = Math.max(page - 1, 0) * perPage; - this.limit(perPage); - return this; - } - - /** - * @param path - * @param direction - */ - public sort(path: string, direction: "asc" | "desc" = "asc"): this { - const groupName = this.createFilterGroupName(); - this.param(`sort[${groupName}][path]`, path); - this.param(`sort[${groupName}][direction]`, direction); - - return this; - } - - /** - * - * @param operator - * @param groupingFunction - */ - public group(operator: "or" | "and", groupingFunction: TQueryBuilderGroupingFunction): this { - const currentFilterGroupName = this.currentFilterGroupName; - const newGroupName = this.createFilterGroupName(); - this.assignFilterGroupToCurrentFilterGroup(newGroupName); - this.currentFilterGroupName = newGroupName; - this.param(`filter[${this.currentFilterGroupName}][group][conjunction]`, operator.toUpperCase()); - groupingFunction(this); - this.currentFilterGroupName = currentFilterGroupName; - return this; - } - - /** - * @private - */ - private createFilterGroupName(): string { - this.lastFilterGroupId = this.lastFilterGroupId + 1; - return `g${this.lastFilterGroupId}`; - } - - /** - * @param groupName - * @private - */ - private assignFilterGroupToCurrentFilterGroup(groupName: string) { - if (this.currentFilterGroupName) { - this.param(`filter[${groupName}][condition][memberOf]`, this.currentFilterGroupName); - } - } - - /** - * @param path - * @private - */ - private buildUrl(path: string): string { - this.param("page[limit]", this.pageLimit); - this.param("page[offset]", this.pageOffset); - - return `${this.locale ? `${this.locale}/` : ""}${path}/?${makeSearchParams(this.queryParams)}`; - } - - /** - * Executes the query (GET) - */ - private async performGetRequest(path: string) { - return await this.client.get(path, { - cache: this.cachePolicy, - }); - } - - /** - * Fetches the endpoint and uses the raw response to pass to the mapper - */ - async getRaw(): Promise { - const response = await this.performGetRequest(this.buildUrl(this.endpoint)); - - if (!isRawResponse(response)) { - throw new InvalidResponseError(); - } - - if (!this.mapper) { - throw new Error("No mapper"); - } - - return await this.mapper(new ResponseModel(response)); - } - - /** - * Maps and returns all entries in the response - */ - async get(): Promise> { - let start = Date.now(); - const url = this.buildUrl(this.endpoint); - const response = await this.performGetRequest(url); - - if (isResponseWithErrors(response)) { - const firstError = response.errors[0]; - throw new InvalidResponseError(`${firstError.title} (status: ${firstError.status}): ${firstError.detail}`); - } - - if (!isJsonApiResponse(response)) { - throw new InvalidResponseError( - "Couldn't verify the response as a valid JSON:API response. Potentially this is not a JSON:API resource or the JSON:API has a version mismatch (expected 1.0)" - ); - } - - this.response = response; - - const queryDuration = Date.now() - start; - start = Date.now(); - - const responseModels = this.response.data.map((entry: unknown) => new ResponseModel(entry)); - - if (!this.mapper) { - throw new Error("No mapper"); - } - - const resultSet = new ResultSet(); - - for await (const item of responseModels) { - const mapped = await this.mapper(item); - resultSet.push(mapped); - } - - const mappingDuration = Date.now() - start; - - let meta: TResultSetMeta = { - query: { - url, - params: this.queryParams, - }, - performance: { - query: queryDuration, - mapping: mappingDuration, - }, - }; - - if (this.response.meta) { - meta = { - count: this.response.meta.count || 0, - pages: Math.ceil(this.response.meta.count / this.pageLimit), - perPage: this.pageLimit, - ...meta, - }; - } - - resultSet.setMeta(meta); - - return resultSet; - } - - /** - * - */ - async first(): Promise { - return (await this.get()).get(0); - } - - /** - * Gets a single entry by UUID - * @param uuid - */ - async find(uuid: string | number): Promise { - if (!this.mapper) { - throw new Error("No mapper"); - } - - const response = await this.performGetRequest(this.buildUrl(`${this.endpoint}/${uuid}`)); - - if (!isJsonApiResponse(response)) { - throw new InvalidResponseError(); - } - - this.response = response; - - return this.mapper(new ResponseModel(this.response.data)); - } - - /** - * Turns the QueryBuilder into a string - */ - public toString(): string { - return this.buildUrl(this.endpoint); - } + /** + * The locale in which we're going to query the entries + * @private + */ + private locale?: string; + + /** + * The mapping function to use when we received the response + * @private + */ + private readonly client: Client; + + /** + * The mapping function to use when we received the response + * @private + */ + private readonly mapper: TMapper>; + + /** + * @private + */ + private readonly endpoint: string; + + /** + * The registered query params + * @private + */ + private readonly queryParams: TQueryParams; + + /** + * @private + */ + private pageLimit: number = 50; + + /** + * @private + */ + private pageOffset: number = 0; + + /** + * The cache policy to pass to the Client once the query executes + * @private + */ + private cachePolicy: 'force-cache' | 'no-store' = 'force-cache'; + + /** + * + * @private + */ + private response!: TJsonApiResponse; + + /** + * The last used filter group id, increments with each use of a filter-method to generate a unique filter group name + * @private + */ + private lastFilterGroupId: number = 0; + + /** + * + * @private + */ + private currentFilterGroupName: TNullable = null; + + /** + * @param client + * @param endpoint + * @param mapper + */ + constructor(client: Client, endpoint: string, mapper: TMapper>) { + this.client = client; + this.endpoint = endpoint; + this.mapper = mapper; + this.queryParams = {}; + } + + /** + * Executes a macro registered on MacroRegistry + * @param name + * @param args + */ + public macro(name: string, ...args: unknown[]): this { + MacroRegistry.execute(name, this, args); + return this; + } + + /** + * Disabled caching on the request + */ + public noCache(): this { + this.cachePolicy = 'no-store'; + return this; + } + + /** + * Forces caching on the request + */ + public cache(): this { + this.cachePolicy = 'force-cache'; + return this; + } + + /** + * Sets the locale of the query + * @param locale + */ + public setLocale(locale: string): this { + this.locale = locale; + return this; + } + + /** + * Registers one query param + * @param name + * @param value + */ + public param(name: string, value: string | number): this { + this.queryParams[name] = value; + return this; + } + + /** + * Registers multiple query params + * @param params + */ + public params(params: TQueryParams): this { + Object.keys(params).forEach((key) => { + this.param(key, params[key]); + }); + return this; + } + + /** + * @param path + * @param operator + * @param value + */ + public where(path: string, operator: TFilterOperator, value: string): this { + const groupName = this.createFilterGroupName(); + this.param(`filter[${groupName}][condition][path]`, path); + this.param(`filter[${groupName}][condition][operator]`, operator); + this.param(`filter[${groupName}][condition][value]`, value); + this.assignFilterGroupToCurrentFilterGroup(groupName); + return this; + } + + /** + * Registers includes (as described by JSON:API) + * @param includes + */ + public include(includes: string[]): this { + this.param('jsonapi_include', 1); + this.param('include', includes.join(',')); + return this; + } + + /** + * @param amount + */ + public limit(amount: number): this { + this.pageLimit = amount; + return this; + } + + /** + * @param page + * @param perPage + */ + paginate(page: number, perPage: number): this { + this.pageOffset = Math.max(page - 1, 0) * perPage; + this.limit(perPage); + return this; + } + + /** + * @param path + * @param direction + */ + public sort(path: string, direction: 'asc' | 'desc' = 'asc'): this { + const groupName = this.createFilterGroupName(); + this.param(`sort[${groupName}][path]`, path); + this.param(`sort[${groupName}][direction]`, direction); + + return this; + } + + /** + * + * @param operator + * @param groupingFunction + */ + public group(operator: 'or' | 'and', groupingFunction: TQueryBuilderGroupingFunction): this { + const currentFilterGroupName = this.currentFilterGroupName; + const newGroupName = this.createFilterGroupName(); + this.assignFilterGroupToCurrentFilterGroup(newGroupName); + this.currentFilterGroupName = newGroupName; + this.param(`filter[${this.currentFilterGroupName}][group][conjunction]`, operator.toUpperCase()); + groupingFunction(this); + this.currentFilterGroupName = currentFilterGroupName; + return this; + } + + /** + * @private + */ + private createFilterGroupName(): string { + this.lastFilterGroupId = this.lastFilterGroupId + 1; + return `g${this.lastFilterGroupId}`; + } + + /** + * @param groupName + * @private + */ + private assignFilterGroupToCurrentFilterGroup(groupName: string) { + if (this.currentFilterGroupName) { + this.param(`filter[${groupName}][condition][memberOf]`, this.currentFilterGroupName); + } + } + + /** + * @param path + * @private + */ + private buildUrl(path: string): string { + this.param('page[limit]', this.pageLimit); + this.param('page[offset]', this.pageOffset); + + return `${this.locale ? `${this.locale}/` : ''}${path}/?${makeSearchParams(this.queryParams)}`; + } + + /** + * Executes the query (GET) + */ + private async performGetRequest(path: string) { + return await this.client.get(path, { + cache: this.cachePolicy, + }); + } + + /** + * Fetches the endpoint and uses the raw response to pass to the mapper + */ + async getRaw(): Promise { + const response = await this.performGetRequest(this.buildUrl(this.endpoint)); + + if (!isRawResponse(response)) { + throw new InvalidResponseError(); + } + + if (!this.mapper) { + throw new Error('No mapper'); + } + + return await this.mapper(new ResponseModel(response)); + } + + /** + * Maps and returns all entries in the response + */ + async get(): Promise> { + let start = Date.now(); + const url = this.buildUrl(this.endpoint); + const response = await this.performGetRequest(url); + + if (isResponseWithErrors(response)) { + const firstError = response.errors[0]; + throw new InvalidResponseError(`${firstError.title} (status: ${firstError.status}): ${firstError.detail}`); + } + + if (!isJsonApiResponse(response)) { + throw new InvalidResponseError( + 'Couldn\'t verify the response as a valid JSON:API response. Potentially this is not a JSON:API resource or the JSON:API has a version mismatch (expected 1.0)', + ); + } + + this.response = response; + + const queryDuration = Date.now() - start; + start = Date.now(); + + const responseModels = this.response.data.map((entry: unknown) => new ResponseModel(entry)); + + if (!this.mapper) { + throw new Error('No mapper'); + } + + const resultSet = new ResultSet(); + + for await (const item of responseModels) { + const mapped = await this.mapper(item); + resultSet.push(mapped); + } + + const mappingDuration = Date.now() - start; + + let meta: TResultSetMeta = { + query: { + url, + params: this.queryParams, + }, + performance: { + query: queryDuration, + mapping: mappingDuration, + }, + }; + + if (this.response.meta) { + meta = { + count: this.response.meta.count || 0, + pages: Math.ceil(this.response.meta.count / this.pageLimit), + perPage: this.pageLimit, + ...meta, + }; + } + + resultSet.setMeta(meta); + + return resultSet; + } + + /** + * + */ + async first(): Promise { + return (await this.get()).get(0); + } + + /** + * Gets a single entry by UUID + * @param uuid + */ + async find(uuid: string | number): Promise { + if (!this.mapper) { + throw new Error('No mapper'); + } + + const response = await this.performGetRequest(this.buildUrl(`${this.endpoint}/${uuid}`)); + + if (!isJsonApiResponse(response)) { + throw new InvalidResponseError(); + } + + this.response = response; + + return this.mapper(new ResponseModel(this.response.data)); + } + + /** + * Turns the QueryBuilder into a string + */ + public toString(): string { + return this.buildUrl(this.endpoint); + } } diff --git a/src/ResponseModel.ts b/src/ResponseModel.ts index 8a8a82e..04a3c6e 100644 --- a/src/ResponseModel.ts +++ b/src/ResponseModel.ts @@ -1,86 +1,86 @@ -import AutoMapper from "./AutoMapper"; -import type { ResponseModelInterface } from "./contracts/ResponseModelInterface"; -import { isResponseWithData } from "./typeguards/isResponseWithData"; -import { TNullable } from "./types/generic/nullable"; +import type { ResponseModelInterface } from './contracts/ResponseModelInterface'; +import type { TNullable } from './types/generic/nullable'; +import AutoMapper from './AutoMapper'; +import { isResponseWithData } from './typeguards/isResponseWithData'; export default class ResponseModel implements ResponseModelInterface { - /** - * The raw, unprocessed response from the JSON:API - * @private - */ - private readonly rawResponse: unknown; - - /** - * @param rawResponse - */ - constructor(rawResponse: unknown) { - this.rawResponse = rawResponse; - } - - /** - * Gets a field from the node - * @param path - * @param defaultValue - */ - get(path: string | string[], defaultValue: T): T { - if (!Array.isArray(path)) { - path = path.replace(/\[(\d+)\]/g, ".$1").split("."); - } - - let result = this.rawResponse as Record; - - for (const key of path) { - result = result !== null && Object.prototype.hasOwnProperty.call(result, key) ? result[key] : undefined; - if (result === undefined) { - return defaultValue; - } - } - - return result as T; - } - - /** - * Gets a relationship from the node and optionally map it - * @param path - */ - async hasOne(path: string | string[]): Promise> { - let contentData: unknown = this.get(path, null); - - if (!contentData) { - return null; - } - - if (isResponseWithData(contentData)) { - contentData = contentData.data; - } - - return await AutoMapper.map(new ResponseModel(contentData)); - } - - /** - * @param path - */ - async hasMany(path: string | string[]): Promise> { - let contentData: unknown = this.get(path, null); - - if (!contentData) { - return null; - } - - if (isResponseWithData(contentData)) { - contentData = contentData.data; - } - - if (Array.isArray(contentData)) { - const result = []; - - for await (const item of contentData) { - result.push(await AutoMapper.map(new ResponseModel(item))); - } - - return result as T[]; - } - - return null; - } + /** + * The raw, unprocessed response from the JSON:API + * @private + */ + private readonly rawResponse: unknown; + + /** + * @param rawResponse + */ + constructor(rawResponse: unknown) { + this.rawResponse = rawResponse; + } + + /** + * Gets a field from the node + * @param path + * @param defaultValue + */ + get(path: string | string[], defaultValue: T): T { + if (!Array.isArray(path)) { + path = path.replace(/\[(\d+)\]/g, '.$1').split('.'); + } + + let result = this.rawResponse as Record; + + for (const key of path) { + result = result !== null && Object.prototype.hasOwnProperty.call(result, key) ? result[key] : undefined; + if (result === undefined) { + return defaultValue; + } + } + + return result as T; + } + + /** + * Gets a relationship from the node and optionally map it + * @param path + */ + async hasOne(path: string | string[]): Promise> { + let contentData: unknown = this.get(path, null); + + if (!contentData) { + return null; + } + + if (isResponseWithData(contentData)) { + contentData = contentData.data; + } + + return await AutoMapper.map(new ResponseModel(contentData)); + } + + /** + * @param path + */ + async hasMany(path: string | string[]): Promise> { + let contentData: unknown = this.get(path, null); + + if (!contentData) { + return null; + } + + if (isResponseWithData(contentData)) { + contentData = contentData.data; + } + + if (Array.isArray(contentData)) { + const result = []; + + for await (const item of contentData) { + result.push(await AutoMapper.map(new ResponseModel(item))); + } + + return result as T[]; + } + + return null; + } } diff --git a/src/ResultSet.ts b/src/ResultSet.ts index 50d49ef..bd7f36e 100644 --- a/src/ResultSet.ts +++ b/src/ResultSet.ts @@ -1,8 +1,7 @@ +import type { TNullable } from './types/generic/nullable'; import type { TResultSetMeta } from './types/resultset-meta'; -import {TNullable} from "./types/generic/nullable"; export default class ResultSet implements Iterable { - /** * @private */ diff --git a/src/contracts/ClientInterface.ts b/src/contracts/ClientInterface.ts index 19b82b1..03eb9f9 100644 --- a/src/contracts/ClientInterface.ts +++ b/src/contracts/ClientInterface.ts @@ -1,3 +1,3 @@ -export interface ClientInterface { +export type ClientInterface = { get: (path: string, options: Record) => Promise -} +}; diff --git a/src/contracts/QueryBuilderInterface.ts b/src/contracts/QueryBuilderInterface.ts index 5840597..22e30e0 100644 --- a/src/contracts/QueryBuilderInterface.ts +++ b/src/contracts/QueryBuilderInterface.ts @@ -1,31 +1,31 @@ +import type ResponseModel from '../ResponseModel'; import type ResultSet from '../ResultSet'; -import ResponseModel from "../ResponseModel"; -import {TRawResponse} from "../types/raw-response"; -import type {TQueryParams} from "../types/query-params"; -import type {TFilterOperator} from "../types/filter-operator"; -import {TQueryBuilderGroupingFunction} from "../types/query-builder-grouping-function"; +import type { TFilterOperator } from '../types/filter-operator'; +import type { TQueryBuilderGroupingFunction } from '../types/query-builder-grouping-function'; +import type { TQueryParams } from '../types/query-params'; +import type { TRawResponse } from '../types/raw-response'; -export interface QueryBuilderInterface { +export type QueryBuilderInterface = { - noCache: () => this; - cache: () => this; - setLocale: (locale: string) => this; - macro: (name: string, ...args: unknown[]) => this; + noCache: () => this + cache: () => this + setLocale: (locale: string) => this + macro: (name: string, ...args: unknown[]) => this - param: (name: string, value: string | number) => this; - params: (params: TQueryParams) => this; + param: (name: string, value: string | number) => this + params: (params: TQueryParams) => this - where: (path: string, operator: TFilterOperator, value: string) => this; - include: (includes: string[]) => this; - limit: (amount: number) => this; - paginate: (page: number, perPage: number) => this; - sort: (path: string, direction: 'asc' | 'desc') => this; - group: (operator: 'or' | 'and', groupingCall: TQueryBuilderGroupingFunction) => this; + where: (path: string, operator: TFilterOperator, value: string) => this + include: (includes: string[]) => this + limit: (amount: number) => this + paginate: (page: number, perPage: number) => this + sort: (path: string, direction: 'asc' | 'desc') => this + group: (operator: 'or' | 'and', groupingCall: TQueryBuilderGroupingFunction) => this - get: () => Promise>; - getRaw: () => Promise; - find: (id: string) => Promise; - first(): Promise; + get: () => Promise> + getRaw: () => Promise + find: (id: string) => Promise + first: () => Promise - toString: () => string; -} + toString: () => string +}; diff --git a/src/contracts/ResponseModelInterface.ts b/src/contracts/ResponseModelInterface.ts index c28e2c7..f4f5dc2 100644 --- a/src/contracts/ResponseModelInterface.ts +++ b/src/contracts/ResponseModelInterface.ts @@ -1,7 +1,7 @@ -import { TNullable } from "../types/generic/nullable"; +import type { TNullable } from '../types/generic/nullable'; -export interface ResponseModelInterface { - get: (path: string | string[], defaultValue: T) => T; - hasOne: (path: string | string[]) => Promise>; - hasMany: (path: string | string[]) => Promise>; -} +export type ResponseModelInterface = { + get: (path: string | string[], defaultValue: T) => T + hasOne: (path: string | string[]) => Promise> + hasMany: (path: string | string[]) => Promise> +}; diff --git a/src/errors/AuthTokenError.ts b/src/errors/AuthTokenError.ts index 6d48993..bb85604 100644 --- a/src/errors/AuthTokenError.ts +++ b/src/errors/AuthTokenError.ts @@ -1,6 +1,6 @@ export default class AuthTokenError extends Error { - constructor(message: string, url: string = '') { - super(`Couldn\'t generate auth token: ${message}. Using URL ${url}. Are you sure your credentials are correct and the API responds with a JSON:API response?`); - this.name = "AuthTokenError"; - } + constructor(message: string, url: string = '') { + super(`Couldn\'t generate auth token: ${message}. Using URL ${url}. Are you sure your credentials are correct and the API responds with a JSON:API response?`); + this.name = 'AuthTokenError'; + } } diff --git a/src/errors/ConfigValuesNotSetError.ts b/src/errors/ConfigValuesNotSetError.ts index d85b56d..4df66ac 100644 --- a/src/errors/ConfigValuesNotSetError.ts +++ b/src/errors/ConfigValuesNotSetError.ts @@ -1,6 +1,6 @@ export default class ConfigValuesNotSetError extends Error { - constructor() { - super('Config values are not set'); - this.name = "ConfigValuesNotSetError"; - } + constructor() { + super('Config values are not set'); + this.name = 'ConfigValuesNotSetError'; + } } diff --git a/src/errors/FalsyConfigValueError.ts b/src/errors/FalsyConfigValueError.ts index de983ff..503920a 100644 --- a/src/errors/FalsyConfigValueError.ts +++ b/src/errors/FalsyConfigValueError.ts @@ -1,6 +1,6 @@ export default class FalsyConfigValueError extends Error { - constructor(attributeName: string) { - super(`Falsy config value: ${attributeName}`); - this.name = "FalsyConfigValueError"; - } + constructor(attributeName: string) { + super(`Falsy config value: ${attributeName}`); + this.name = 'FalsyConfigValueError'; + } } diff --git a/src/errors/InvalidResponseError.ts b/src/errors/InvalidResponseError.ts index 92aecc9..dcd072e 100644 --- a/src/errors/InvalidResponseError.ts +++ b/src/errors/InvalidResponseError.ts @@ -1,6 +1,6 @@ export default class InvalidResponseError extends Error { - constructor(additionalMessage: string = 'Unknown reason') { - super(`Invalid response: ${additionalMessage}`); - this.name = "InvalidResponseError"; - } + constructor(additionalMessage: string = 'Unknown reason') { + super(`Invalid response: ${additionalMessage}`); + this.name = 'InvalidResponseError'; + } } diff --git a/src/errors/UnknownConfigValueError.ts b/src/errors/UnknownConfigValueError.ts index 5ab8276..b946cb2 100644 --- a/src/errors/UnknownConfigValueError.ts +++ b/src/errors/UnknownConfigValueError.ts @@ -1,6 +1,6 @@ export default class UnknownConfigValueError extends Error { - constructor(attributeName: string) { - super(`Unknown or undefined config value: ${attributeName}`); - this.name = "UnknownConfigValueError"; - } + constructor(attributeName: string) { + super(`Unknown or undefined config value: ${attributeName}`); + this.name = 'UnknownConfigValueError'; + } } diff --git a/src/errors/UnknownMacroError.ts b/src/errors/UnknownMacroError.ts index f3a0a09..90aa3c5 100644 --- a/src/errors/UnknownMacroError.ts +++ b/src/errors/UnknownMacroError.ts @@ -1,6 +1,6 @@ export default class UnknownMacroError extends Error { - constructor(macroName: string) { - super(`Unknown macro: ${macroName}`); - this.name = "UnknownMacroError"; - } + constructor(macroName: string) { + super(`Unknown macro: ${macroName}`); + this.name = 'UnknownMacroError'; + } } diff --git a/src/typeguards/isJsonApiResponse.ts b/src/typeguards/isJsonApiResponse.ts index 5066e55..93bb19e 100644 --- a/src/typeguards/isJsonApiResponse.ts +++ b/src/typeguards/isJsonApiResponse.ts @@ -1,15 +1,15 @@ -import { TJsonApiResponse } from "../types/json-api-response"; +import type { TJsonApiResponse } from '../types/json-api-response'; export function isJsonApiResponse(value: unknown): value is TJsonApiResponse { - return ( - typeof value === "object" && - value !== null && - "jsonapi" in value && - "data" in value && - typeof value.data === "object" && - value.jsonapi !== null && - typeof value.jsonapi === "object" && - "version" in value.jsonapi && - value.jsonapi.version === "1.0" - ); + return ( + typeof value === 'object' + && value !== null + && 'jsonapi' in value + && 'data' in value + && typeof value.data === 'object' + && value.jsonapi !== null + && typeof value.jsonapi === 'object' + && 'version' in value.jsonapi + && value.jsonapi.version === '1.0' + ); } diff --git a/src/typeguards/isRawResponse.ts b/src/typeguards/isRawResponse.ts index 413b128..cfca287 100644 --- a/src/typeguards/isRawResponse.ts +++ b/src/typeguards/isRawResponse.ts @@ -1,9 +1,8 @@ -import {TRawResponse} from "../types/raw-response"; +import type { TRawResponse } from '../types/raw-response'; export function isRawResponse(value: unknown): value is TRawResponse { - - return ( - typeof value === "object" && - value !== null - ); -} \ No newline at end of file + return ( + typeof value === 'object' + && value !== null + ); +} diff --git a/src/typeguards/isResponseWithData.ts b/src/typeguards/isResponseWithData.ts index 0787c05..00273db 100644 --- a/src/typeguards/isResponseWithData.ts +++ b/src/typeguards/isResponseWithData.ts @@ -1,5 +1,5 @@ -import { TResponseWithData } from "../types/response-with-data"; +import type { TResponseWithData } from '../types/response-with-data'; export function isResponseWithData(value: unknown): value is TResponseWithData { - return typeof value === "object" && value !== null && "data" in value; + return typeof value === 'object' && value !== null && 'data' in value; } diff --git a/src/typeguards/isResponseWithErrors.ts b/src/typeguards/isResponseWithErrors.ts index b89c7da..207acd4 100644 --- a/src/typeguards/isResponseWithErrors.ts +++ b/src/typeguards/isResponseWithErrors.ts @@ -1,11 +1,11 @@ -import { TResponseWithErrors } from "../types/response-with-errors"; +import type { TResponseWithErrors } from '../types/response-with-errors'; export function isResponseWithErrors(value: unknown): value is TResponseWithErrors { - return ( - typeof value === "object" && - value !== null && - "errors" in value && - Array.isArray(value.errors) && - value.errors.length > 0 - ); + return ( + typeof value === 'object' + && value !== null + && 'errors' in value + && Array.isArray(value.errors) + && value.errors.length > 0 + ); } diff --git a/src/types/config-attributes.ts b/src/types/config-attributes.ts index 6ee91f6..6ae4ad4 100644 --- a/src/types/config-attributes.ts +++ b/src/types/config-attributes.ts @@ -1,5 +1,5 @@ export type TConfigAttributes = { - baseUrl: string; - clientId: string; - clientSecret: string; -} + baseUrl: string + clientId: string + clientSecret: string +}; diff --git a/src/types/generic/data-properties.ts b/src/types/generic/data-properties.ts index 257a97c..a917887 100644 --- a/src/types/generic/data-properties.ts +++ b/src/types/generic/data-properties.ts @@ -1,3 +1,4 @@ // Utility types to extract data properties (non-methods) +// eslint-disable-next-line ts/no-unsafe-function-type type NonMethodKeys = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T]; -export type DataProperties = Pick>; \ No newline at end of file +export type DataProperties = Pick>; diff --git a/src/types/json-api-response.ts b/src/types/json-api-response.ts index efdd1f0..19fa654 100644 --- a/src/types/json-api-response.ts +++ b/src/types/json-api-response.ts @@ -1,5 +1,5 @@ export type TJsonApiResponse = { - json_api: Object; - data: Array; - meta?: Record; -}; \ No newline at end of file + json_api: object + data: Array + meta?: Record +}; diff --git a/src/types/mapper.ts b/src/types/mapper.ts index 6f4de26..639a111 100644 --- a/src/types/mapper.ts +++ b/src/types/mapper.ts @@ -1,4 +1,3 @@ -import type ResponseModel from "../ResponseModel"; -import type { ResponseModelInterface } from "../contracts/ResponseModelInterface"; +import type { ResponseModelInterface } from '../contracts/ResponseModelInterface'; export type TMapper = (model: ResponseModelInterface) => T; diff --git a/src/types/query-builder-grouping-function.ts b/src/types/query-builder-grouping-function.ts index 6f60b1b..30e1e47 100644 --- a/src/types/query-builder-grouping-function.ts +++ b/src/types/query-builder-grouping-function.ts @@ -1,3 +1,3 @@ -import {QueryBuilderInterface} from "../contracts/QueryBuilderInterface"; +import type { QueryBuilderInterface } from '../contracts/QueryBuilderInterface'; -export type TQueryBuilderGroupingFunction = (query: QueryBuilderInterface) => void; \ No newline at end of file +export type TQueryBuilderGroupingFunction = (query: QueryBuilderInterface) => void; diff --git a/src/types/query-builder-macro-function.ts b/src/types/query-builder-macro-function.ts index fdaf3c3..7c2fed3 100644 --- a/src/types/query-builder-macro-function.ts +++ b/src/types/query-builder-macro-function.ts @@ -1,3 +1,3 @@ -import {QueryBuilderInterface} from "../contracts/QueryBuilderInterface"; +import type { QueryBuilderInterface } from '../contracts/QueryBuilderInterface'; export type TQueryBuilderMacroFunction = (query: QueryBuilderInterface, ...args: unknown[]) => void; diff --git a/src/types/raw-response.ts b/src/types/raw-response.ts index dd7cf4b..ebfabef 100644 --- a/src/types/raw-response.ts +++ b/src/types/raw-response.ts @@ -1 +1 @@ -export type TRawResponse = Object; \ No newline at end of file +export type TRawResponse = object; diff --git a/src/types/response-with-data.ts b/src/types/response-with-data.ts index d68bdb3..ca5c549 100644 --- a/src/types/response-with-data.ts +++ b/src/types/response-with-data.ts @@ -1,3 +1,3 @@ export type TResponseWithData = { - data: Object; + data: object }; diff --git a/src/types/response-with-errors.ts b/src/types/response-with-errors.ts index 28fb05e..c564a92 100644 --- a/src/types/response-with-errors.ts +++ b/src/types/response-with-errors.ts @@ -1,8 +1,8 @@ export type TResponseWithErrors = { - errors: Array<{ - title: string; - status: string; - detail: string; - links: Object; - }>; + errors: Array<{ + title: string + status: string + detail: string + links: object + }> }; diff --git a/src/types/resultset-meta.ts b/src/types/resultset-meta.ts index d5f0232..98177b6 100644 --- a/src/types/resultset-meta.ts +++ b/src/types/resultset-meta.ts @@ -2,14 +2,14 @@ import type { TQueryParams } from './query-params'; export type TResultSetMeta = { query: { - url: string; - params: TQueryParams; - }; + url: string + params: TQueryParams + } performance: { query: number mapping: number - }; - count?: number; - pages?: number; - perPage?: number; -} + } + count?: number + pages?: number + perPage?: number +}; diff --git a/tests/auto-mapper.test.ts b/tests/auto-mapper.test.ts index 667e75d..46ea151 100644 --- a/tests/auto-mapper.test.ts +++ b/tests/auto-mapper.test.ts @@ -1,55 +1,55 @@ -import { AutoMapper, Model, ResponseModel } from "../src/index"; +import { AutoMapper, Model, ResponseModel } from '../src/index'; class Page extends Model {} class Event extends Model {} function initAutoMapper() { - AutoMapper.register({ - page: Page, - event: Event, - }); - - AutoMapper.setSelector((responseModel, selectValue) => { - return responseModel.get("type", "") === selectValue; - }); + AutoMapper.register({ + page: Page, + event: Event, + }); + + AutoMapper.setSelector((responseModel, selectValue) => { + return responseModel.get('type', '') === selectValue; + }); } -it("automaps to the correct model instances", async () => { - initAutoMapper(); - - const event = await AutoMapper.map( - new ResponseModel({ - type: "event", - title: "This is an event", - }) - ); - - const page = await AutoMapper.map( - new ResponseModel({ - type: "page", - title: "This is a page", - }) - ); - - expect(event).toBeInstanceOf(Event); - expect(page).toBeInstanceOf(Page); +it('automaps to the correct model instances', async () => { + initAutoMapper(); + + const event = await AutoMapper.map( + new ResponseModel({ + type: 'event', + title: 'This is an event', + }), + ); + + const page = await AutoMapper.map( + new ResponseModel({ + type: 'page', + title: 'This is a page', + }), + ); + + expect(event).toBeInstanceOf(Event); + expect(page).toBeInstanceOf(Page); }); -it("gives back the unaltered ResponseModel when it can't select an instance", async () => { - initAutoMapper(); +it('gives back the unaltered ResponseModel when it can\'t select an instance', async () => { + initAutoMapper(); - const unknown = await AutoMapper.map( - new ResponseModel({ - type: "unknown", - title: "This is an unknown entity", - }) - ); + const unknown = await AutoMapper.map( + new ResponseModel({ + type: 'unknown', + title: 'This is an unknown entity', + }), + ); - // Test for the instance type - expect(unknown).toBeInstanceOf(ResponseModel); + // Test for the instance type + expect(unknown).toBeInstanceOf(ResponseModel); - // Test if all data is still untouched - expect(unknown.get("type")).toBe("unknown"); - expect(unknown.get("title")).toBe("This is an unknown entity"); + // Test if all data is still untouched + expect(unknown.get('type')).toBe('unknown'); + expect(unknown.get('title')).toBe('This is an unknown entity'); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 90bfbea..a3bb3ec 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,45 +1,45 @@ -import ConfigValuesNotSetError from "../src/errors/ConfigValuesNotSetError"; -import FalsyConfigValueError from "../src/errors/FalsyConfigValueError"; -import UnknownConfigValueError from "../src/errors/UnknownConfigValueError"; -import { Config } from "../src/index"; -import { TConfigAttributes } from "../src/types/config-attributes"; - -test("Access of config value without config values being set throws ConfigValuesNotSetError", () => { - const t = () => { - Config.get("baseUrl"); - }; - - expect(t).toThrow(ConfigValuesNotSetError); +import type { TConfigAttributes } from '../src/types/config-attributes'; +import ConfigValuesNotSetError from '../src/errors/ConfigValuesNotSetError'; +import FalsyConfigValueError from '../src/errors/FalsyConfigValueError'; +import UnknownConfigValueError from '../src/errors/UnknownConfigValueError'; +import { Config } from '../src/index'; + +it('access of config value without config values being set throws ConfigValuesNotSetError', () => { + const t = () => { + Config.get('baseUrl'); + }; + + expect(t).toThrow(ConfigValuesNotSetError); }); -it("can setAll Config values and ", () => { - Config.setAll({ - baseUrl: "a", - clientId: "b", - clientSecret: "c", - }); +it('can setAll Config values and ', () => { + Config.setAll({ + baseUrl: 'a', + clientId: 'b', + clientSecret: 'c', + }); - expect(Config.get("baseUrl")).toBe("a"); - expect(Config.get("clientId")).toBe("b"); - expect(Config.get("clientSecret")).toBe("c"); + expect(Config.get('baseUrl')).toBe('a'); + expect(Config.get('clientId')).toBe('b'); + expect(Config.get('clientSecret')).toBe('c'); }); -test("Access of non-existent config value throws a UnknownConfigValueError", () => { - const t = () => { - Config.get("nonExistentConfigValue" as keyof TConfigAttributes); - }; +it('access of non-existent config value throws a UnknownConfigValueError', () => { + const t = () => { + Config.get('nonExistentConfigValue' as keyof TConfigAttributes); + }; - expect(t).toThrow(UnknownConfigValueError); + expect(t).toThrow(UnknownConfigValueError); }); -test("Empty string Config values throws a FalsyConfigValueError", () => { - const t = () => { - Config.setAll({ - baseUrl: "a", - clientId: "b", - clientSecret: "", - }); - }; +it('empty string Config values throws a FalsyConfigValueError', () => { + const t = () => { + Config.setAll({ + baseUrl: 'a', + clientId: 'b', + clientSecret: '', + }); + }; - expect(t).toThrow(FalsyConfigValueError); + expect(t).toThrow(FalsyConfigValueError); }); diff --git a/tests/macro-registry.test.ts b/tests/macro-registry.test.ts index eb8aef9..d3c769b 100644 --- a/tests/macro-registry.test.ts +++ b/tests/macro-registry.test.ts @@ -1,45 +1,43 @@ -import { MacroRegistry, QueryBuilder, Client } from '../src/index'; -import UnknownMacroError from "../src/errors/UnknownMacroError"; +import UnknownMacroError from '../src/errors/UnknownMacroError'; +import { Client, MacroRegistry, QueryBuilder } from '../src/index'; function makeMockClient() { - return new Client('https://baseurl.ext', '', ''); + return new Client('https://baseurl.ext', '', ''); } function makeQueryBuilder() { - return new QueryBuilder( - makeMockClient(), - 'api/endpoint', - async () => {}, - ); + return new QueryBuilder( + makeMockClient(), + 'api/endpoint', + async () => {}, + ); } it('correctly executes registered macros', () => { + MacroRegistry.register('macroName', (queryBuilder) => { + queryBuilder.limit(333); + }); - MacroRegistry.register('macroName', (queryBuilder) => { - queryBuilder.limit(333); - }); + const queryBuilder = makeQueryBuilder(); + queryBuilder.macro('macroName'); - const queryBuilder = makeQueryBuilder(); - queryBuilder.macro('macroName'); - - expect(queryBuilder.toString()).toBe('api/endpoint/?page%5Blimit%5D=333&page%5Boffset%5D=0'); + expect(queryBuilder.toString()).toBe('api/endpoint/?page%5Blimit%5D=333&page%5Boffset%5D=0'); }); it('throws an UnknownMacroError when executing non-existent macros', () => { + // First register a macro + MacroRegistry.register('macroName', (queryBuilder) => { + queryBuilder.limit(5); + }); - // First register a macro - MacroRegistry.register('macroName', (queryBuilder) => { - queryBuilder.limit(5); - }); - - // Make the QueryBuilder and execute the registered macro (to ensure registering macros as a whole is still working) - const queryBuilder = makeQueryBuilder(); - queryBuilder.macro('macroName'); + // Make the QueryBuilder and execute the registered macro (to ensure registering macros as a whole is still working) + const queryBuilder = makeQueryBuilder(); + queryBuilder.macro('macroName'); - // Test executing non-existent macros - const t = () => { - queryBuilder.macro('nonExistentMacroName'); - }; + // Test executing non-existent macros + const t = () => { + queryBuilder.macro('nonExistentMacroName'); + }; - expect(t).toThrow(UnknownMacroError); -}); \ No newline at end of file + expect(t).toThrow(UnknownMacroError); +}); diff --git a/tests/result-set.test.ts b/tests/result-set.test.ts index 43d636c..48b7c3c 100644 --- a/tests/result-set.test.ts +++ b/tests/result-set.test.ts @@ -1,268 +1,258 @@ import { ResultSet } from '../src/index'; it('has items retrievable by get()', () => { - - const resultSet = new ResultSet([ - { - id: 1, - title: 'Example 1' - }, - { - id: 2, - title: 'Example 2' - } - ]); - - expect(resultSet.get(0)).toEqual({ - id: 1, - title: 'Example 1' - }); - - expect(resultSet.get(1)).toEqual({ - id: 2, - title: 'Example 2' - }); + const resultSet = new ResultSet([ + { + id: 1, + title: 'Example 1', + }, + { + id: 2, + title: 'Example 2', + }, + ]); + + expect(resultSet.get(0)).toEqual({ + id: 1, + title: 'Example 1', + }); + + expect(resultSet.get(1)).toEqual({ + id: 2, + title: 'Example 2', + }); }); it('is iterable', () => { - - const resultSet = new ResultSet([ - { - id: 1, - title: 'Example 1' - }, - { - id: 2, - title: 'Example 2' - }, - { - id: 3, - title: 'Example 3' - } - ]); - - // First test forEach - resultSet.forEach(((item, index) => { - expect(item).toEqual({ - id: index + 1, - title: `Example ${index + 1}` - }); - })); - - // Then test for loop - for (let index = 0; index < resultSet.length; index++) { - expect(resultSet.get(index)).toEqual({ - id: index + 1, - title: `Example ${index + 1}` - }); - } + const resultSet = new ResultSet([ + { + id: 1, + title: 'Example 1', + }, + { + id: 2, + title: 'Example 2', + }, + { + id: 3, + title: 'Example 3', + }, + ]); + + // First test forEach + resultSet.forEach((item, index) => { + expect(item).toEqual({ + id: index + 1, + title: `Example ${index + 1}`, + }); + }); + + // Then test for loop + for (let index = 0; index < resultSet.length; index++) { + expect(resultSet.get(index)).toEqual({ + id: index + 1, + title: `Example ${index + 1}`, + }); + } }); it('is filterable', () => { - - const resultSet = new ResultSet([ - { - id: 1, - title: 'Example 1' - }, - { - id: 2, - title: 'Example 2' - }, - { - id: 3, - title: 'Example 3' - } - ]); - - const filteredItems = resultSet.filter(item => item.id !== 2); - - expect(filteredItems).toEqual([ - { - id: 1, - title: 'Example 1' - }, - { - id: 3, - title: 'Example 3' - } - ]); + const resultSet = new ResultSet([ + { + id: 1, + title: 'Example 1', + }, + { + id: 2, + title: 'Example 2', + }, + { + id: 3, + title: 'Example 3', + }, + ]); + + const filteredItems = resultSet.filter(item => item.id !== 2); + + expect(filteredItems).toEqual([ + { + id: 1, + title: 'Example 1', + }, + { + id: 3, + title: 'Example 3', + }, + ]); }); it('is findable', () => { - - const resultSet = new ResultSet([ - { - id: 1, - title: 'Example 1' - }, - { - id: 2, - title: 'Example 2' - }, - { - id: 3, - title: 'Example 3' - } - ]); - - const item = resultSet.find(item => item.id === 2); - - expect(item).toEqual({ - id: 2, - title: 'Example 2' - }); + const resultSet = new ResultSet([ + { + id: 1, + title: 'Example 1', + }, + { + id: 2, + title: 'Example 2', + }, + { + id: 3, + title: 'Example 3', + }, + ]); + + const item = resultSet.find(item => item.id === 2); + + expect(item).toEqual({ + id: 2, + title: 'Example 2', + }); }); it('is mappable', () => { - - const resultSet = new ResultSet([ - { - id: 1, - title: 'Example 1' - }, - { - id: 2, - title: 'Example 2' - }, - { - id: 3, - title: 'Example 3' - } - ]); - - const mappedItems = resultSet.map(item => { - return { - id: item.id, - hid: `Item with id ${item.id}`, - title: item.title, - }; - }); - - expect(mappedItems).toEqual([ - { - id: 1, - hid: 'Item with id 1', - title: 'Example 1' - }, - { - id: 2, - hid: 'Item with id 2', - title: 'Example 2' - }, - { - id: 3, - hid: 'Item with id 3', - title: 'Example 3' - } - ]); + const resultSet = new ResultSet([ + { + id: 1, + title: 'Example 1', + }, + { + id: 2, + title: 'Example 2', + }, + { + id: 3, + title: 'Example 3', + }, + ]); + + const mappedItems = resultSet.map((item) => { + return { + id: item.id, + hid: `Item with id ${item.id}`, + title: item.title, + }; + }); + + expect(mappedItems).toEqual([ + { + id: 1, + hid: 'Item with id 1', + title: 'Example 1', + }, + { + id: 2, + hid: 'Item with id 2', + title: 'Example 2', + }, + { + id: 3, + hid: 'Item with id 3', + title: 'Example 3', + }, + ]); }); it('has a length property', () => { - - const resultSet = new ResultSet([ - { - id: 1, - title: 'Example 1' - }, - ]); - - expect(resultSet.length).toBe(1); - - resultSet.push({ - id: 1, - title: 'Example 2' - }); + const resultSet = new ResultSet([ + { + id: 1, + title: 'Example 1', + }, + ]); + + expect(resultSet.length).toBe(1); + + resultSet.push({ + id: 1, + title: 'Example 2', + }); }); it('has items that can be popped', () => { - - const resultSet = new ResultSet([ - { - id: 1, - title: 'Example 1' - }, - { - id: 1, - title: 'Example 2' - }, - ]); - - resultSet.pop(); - - expect(resultSet.get(0)).toEqual({ - id: 1, - title: 'Example 1' - }); - - resultSet.pop(); - - expect(resultSet.get(0)).toBeUndefined(); - expect(resultSet.get(1)).toBeUndefined(); + const resultSet = new ResultSet([ + { + id: 1, + title: 'Example 1', + }, + { + id: 1, + title: 'Example 2', + }, + ]); + + resultSet.pop(); + + expect(resultSet.get(0)).toEqual({ + id: 1, + title: 'Example 1', + }); + + resultSet.pop(); + + expect(resultSet.get(0)).toBeUndefined(); + expect(resultSet.get(1)).toBeUndefined(); }); it('is pushable', () => { - - const resultSet = new ResultSet([ - { - id: 1, - title: 'Example 1' - }, - ]); - - resultSet.push({ - id: 2, - title: 'Example 2' - }); - - expect(resultSet.get(0)).toEqual({ - id: 1, - title: 'Example 1' - }); - - expect(resultSet.get(1)).toEqual({ - id: 2, - title: 'Example 2' - }); + const resultSet = new ResultSet([ + { + id: 1, + title: 'Example 1', + }, + ]); + + resultSet.push({ + id: 2, + title: 'Example 2', + }); + + expect(resultSet.get(0)).toEqual({ + id: 1, + title: 'Example 1', + }); + + expect(resultSet.get(1)).toEqual({ + id: 2, + title: 'Example 2', + }); }); it('has meta that is retrievable by using the meta property', () => { - - const resultSet = new ResultSet(); - - const meta = { - query: { - url: 'url-test/', - params: { - testParam: 1 - } - }, - performance: { - query: 50, - mapping: 100 - }, - count: 50, - pages: 2, - perPage: 25 - }; - - resultSet.setMeta(meta); - - expect(resultSet.meta).toBe(meta); + const resultSet = new ResultSet(); + + const meta = { + query: { + url: 'url-test/', + params: { + testParam: 1, + }, + }, + performance: { + query: 50, + mapping: 100, + }, + count: 50, + pages: 2, + perPage: 25, + }; + + resultSet.setMeta(meta); + + expect(resultSet.meta).toBe(meta); }); it('is reducable', () => { - - const resultSet = new ResultSet([ - 1, - 2, - 3, - 4, - ]); - - const result = resultSet.reduce((accumulator, currentValue) => { - return accumulator + currentValue; - }, 0); - - expect(result).toBe(10); -}); \ No newline at end of file + const resultSet = new ResultSet([ + 1, + 2, + 3, + 4, + ]); + + const result = resultSet.reduce((accumulator, currentValue) => { + return accumulator + currentValue; + }, 0); + + expect(result).toBe(10); +});