diff --git a/README.md b/README.md index 8e5e061..c67e5ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CiviCRM API -JavaScript (and TypeScript) client for [CiviCRM API v4](https://docs.civicrm.org/dev/en/latest/api/v4/usage/). +JavaScript (and TypeScript) client for the [CiviCRM API](https://docs.civicrm.org/dev/en/latest/api/v4/usage/). Currently only tested in Node.js, browser support is work in progress. @@ -24,6 +24,28 @@ const client = createClient({ const contactRequest = client.contact.get({ where: { id: 1 } }).one(); ``` +### API v3 + +You can optionally create an [API v3](https://docs.civicrm.org/dev/en/latest/api/v3/) client by providing the relevant configuration: + +```ts +const client = createClient({ + // ... + api3: { + enabled: true, + entities: { + contact: { + name: "Contact", + actions: { + getList: "getlist", + }, + }, + }, +}); + +const contactsRequest = client.contact.getList(); +``` + ## API ### `createClient(options: ClientOptions): Client` @@ -48,6 +70,7 @@ entity in CiviCRM: ```ts const client = createClient({ + // ... entities: { contact: "Contact", activity: "Activity", @@ -68,7 +91,42 @@ Headers will be merged with the default headers. Enable logging request and response details to the console. -### `client.: RequestBuilder` +#### options.api3.enabled + +Enable API v3 client. + +```ts +const client = createClient({ + // ... + api3: { + enabled: true, + }, +}); +``` + +#### options.api3.entities + +An object containing entities and actions the API v3 client will be used to make requests for. +Keys will be used to reference the entity within the client. The value contains the name of the entity in API v3, and an object of actions, where the key is used to reference the action within the client, and the value is the action in API v3. + +```ts +const client = createClient({ + // ... + api3: { + enabled: true, + entities: { + contact: { + name: "Contact", + actions: { + getList: "getlist", + }, + }, + }, + }, +}); +``` + +### `client.: Api4.RequestBuilder` Create a request builder for a configured entity. @@ -92,17 +150,17 @@ client.contact const contact = await client.contact.get({ where: { id: 1 } }).one(); ``` -#### `get(params?: Params): RequestBuilder` +#### `get(params?: Api4.Params): Api4.RequestBuilder` -#### `create(params?: Params): RequestBuilder` +#### `create(params?: Api4.Params): Api4.RequestBuilder` -#### `update(params?: Params): RequestBuilder` +#### `update(params?: Api4.Params): Api4.RequestBuilder` -#### `save(params?: Params): RequestBuilder` +#### `save(params?: Api4.Params): Api4.RequestBuilder` -#### `delete(params?: Params): RequestBuilder` +#### `delete(params?: Api4.Params): Api4.RequestBuilder` -#### `getChecksum(params?: Params): RequestBuilder` +#### `getChecksum(params?: Api4.Params): Api4.RequestBuilder` Set the action for the request to the method name, and optionally set request parameters. @@ -115,11 +173,11 @@ including `select`, `where`, `having`, `join`, Alternatively accepts a key-value object for methods like `getChecksum`. -#### `one(): RequestBuilder` +#### `one(): Api4.RequestBuilder` Return a single record (i.e. set the index of the request to 0). -#### `chain(label: string, requestBuilder: RequestBuilder): RequestBuilder` +#### `chain(label: string, requestBuilder: Api4.RequestBuilder): Api4.RequestBuilder` [Chain a request](https://docs.civicrm.org/dev/en/latest/api/v4/chaining/#apiv4-chaining) for another entity within the current API call. @@ -146,7 +204,7 @@ chained request within the response. A request builder for the chained request. -#### `options(requestOptions: RequestInit): RequestBuilder` +#### `options(requestOptions: RequestInit): Api4.RequestBuilder` Set request options. @@ -166,6 +224,59 @@ client.contact.get().options({ }); ``` +## `client.api3.: Api3.RequestBuilder` + +Create an API v3 request builder for a configured entity. + +### Request builder + +Request builders are used to build and execute requests. + +Methods can be chained, and the request is executed by +calling `.then()` or starting a chain with `await`. + +```ts +// Using .then() +client.api3.contact.getList({ input: "example" }).then((contacts) => { + // +}); + +// Using await +const contacts = await client.getList({ input: "example" }); +``` + +#### `(params?: Api3.Params): Api3.RequestBuilder` + +Set the action for the request, and optionally set request parameters. + +#### `addOption(option: string, value: Api3.Value): Api3.RequestBuilder` + +Set [API options](https://docs.civicrm.org/dev/en/latest/api/v3/options/). + +```ts +client.api3.contact.getList().addOption("limit", 10); +``` + +#### `options(requestOptions: RequestInit): Api3.RequestBuilder` + +Set request options. + +#### requestOptions + +Accepts +the [same options as `fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options). + +Headers will be merged with the default headers. + +```ts +client.api3.contact.getList().options({ + headers: { + "X-Custom-Header": "value", + }, + cache: "no-cache", +}); +``` + ## Alternatives - The [civicrm](https://www.npmjs.com/package/civicrm) package from [Tech to The People](https://github.com/TechToThePeople) offers a different approach to building requests and targets browsers and web workers as well as Node.js. diff --git a/package.json b/package.json index a50902b..8cecebd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "types": "./dist/index.d.ts", "scripts": { "build": "microbundle", - "prepublish": "microbundle", "dev": "microbundle watch", "test": "vitest" }, diff --git a/src/api3/client.ts b/src/api3/client.ts new file mode 100644 index 0000000..9aba193 --- /dev/null +++ b/src/api3/client.ts @@ -0,0 +1,29 @@ +import { forIn } from "lodash-es"; + +import { request } from "./request"; +import { Api3 } from "./types"; +import { RequestBuilder } from "./request-builder"; +import { ClientConfig } from "../types"; + +export function createApi3Client( + config: ClientConfig, +) { + const client = {} as Api3.Client; + + forIn(config.api3!.entities, ({ name, actions }: any, entity: string) => { + Reflect.defineProperty(client, entity, { + get: () => + new RequestBuilder( + name, + (requestParams, requestOptions) => + request.bind(config)(requestParams, { + ...config.requestOptions, + ...requestOptions, + }), + actions, + ), + }); + }); + + return client; +} diff --git a/src/api3/request-builder.ts b/src/api3/request-builder.ts new file mode 100644 index 0000000..1e63b10 --- /dev/null +++ b/src/api3/request-builder.ts @@ -0,0 +1,36 @@ +import { forIn } from "lodash-es"; + +import { Api3 } from "./types"; +import { RequestBuilder as BaseRequestBuilder } from "../lib/request-builder"; + +export class RequestBuilder< + A extends Api3.Actions, + Response = any, +> extends BaseRequestBuilder { + private action: string; + private params?: Api3.Params; + private opts: Record = {}; + + constructor(entity: string, request: Api3.RequestFn, actions: A) { + super(entity, request); + + forIn(actions, (action: string, key: string) => { + this[key] = (params?: Api3.Params) => { + this.action = action as any; + this.params = params; + + return this; + }; + }); + } + + get requestParams(): Api3.RequestParams { + return [this.entity, this.action, this.params, this.opts]; + } + + addOption(option: string, value: Api3.Value) { + this.opts[option] = value; + + return this; + } +} diff --git a/src/api3/request.ts b/src/api3/request.ts new file mode 100644 index 0000000..fa44870 --- /dev/null +++ b/src/api3/request.ts @@ -0,0 +1,26 @@ +import { isEmpty } from "lodash-es"; + +import { Api3 } from "./types"; +import { request as baseRequest } from "../lib/request"; + +const path = "civicrm/ajax/rest"; + +export async function request( + this: { + baseUrl: string; + apiKey: string; + debug?: boolean; + }, + [entity, action, params, options]: Api3.RequestParams, + requestOptions: RequestInit = {}, +) { + const json = isEmpty(options) ? { ...params } : { ...params, options }; + + const searchParams = new URLSearchParams({ + entity: entity, + action: action, + json: JSON.stringify(json), + }); + + return baseRequest.bind(this)(path, searchParams, requestOptions); +} diff --git a/src/api3/types.ts b/src/api3/types.ts new file mode 100644 index 0000000..67c3791 --- /dev/null +++ b/src/api3/types.ts @@ -0,0 +1,41 @@ +import { RequestBuilder } from "./request-builder"; +import { BaseRequestFn } from "../types"; + +export namespace Api3 { + export type EntitiesConfig = { + [key: string]: { + name: string; + actions: { + [key: string]: string; + }; + }; + }; + + export type Actions = { + [key: string]: string; + }; + export type ActionMethods = Record< + keyof A, + (params?: Api3.Params) => RequestBuilder + >; + + export type Client = { + [K in keyof E]: RequestBuilder & + ActionMethods; + }; + + export type Value = + | string + | number + | string[] + | number[] + | boolean + | boolean[] + | null; + + export type Params = Record; + export type Options = Record; + + export type RequestParams = [string, string, Params?, Options?]; + export type RequestFn = BaseRequestFn; +} diff --git a/src/api4/client.ts b/src/api4/client.ts new file mode 100644 index 0000000..ceea5f2 --- /dev/null +++ b/src/api4/client.ts @@ -0,0 +1,26 @@ +import { forIn } from "lodash-es"; + +import { request } from "./request"; +import { Api4 } from "./types"; +import { RequestBuilder } from "./request-builder"; +import { ClientConfig } from "../types"; + +export function createApi4Client( + config: ClientConfig, +) { + const client = {} as Api4.Client; + + forIn(config.entities, (entity: string, key: string) => { + Reflect.defineProperty(client, key, { + get: () => + new RequestBuilder(entity, (requestParams, requestOptions) => + request.bind(config)(requestParams, { + ...config.requestOptions, + ...requestOptions, + }), + ), + }); + }); + + return client; +} diff --git a/src/api4/request-builder.ts b/src/api4/request-builder.ts new file mode 100644 index 0000000..497a007 --- /dev/null +++ b/src/api4/request-builder.ts @@ -0,0 +1,86 @@ +import { isEmpty, mapValues } from "lodash-es"; + +import { Api4 } from "./types"; +import { RequestBuilder as BaseRequestBuilder } from "../lib/request-builder"; + +export class RequestBuilder extends BaseRequestBuilder< + Api4.RequestParams, + Response +> { + private action: Api4.Action; + private params?: Api4.Params; + private index?: Api4.Index; + private chains: Record = {}; + + constructor(entity: string, request: Api4.RequestFn) { + super(entity, request); + } + + get requestParams(): Api4.RequestParams { + const params: Api4.Params = { ...this.params }; + + if (!isEmpty(this.chains)) { + params.chain = mapValues( + this.chains, + (chainRequest: RequestBuilder) => chainRequest.requestParams, + ); + } + + return [this.entity, this.action, params, this.index]; + } + + get(params?: Api4.Params) { + this.action = Api4.Action.get; + this.params; + this.params = params; + + return this; + } + + create(params?: Api4.Params) { + this.action = Api4.Action.create; + this.params = params; + + return this; + } + + update(params?: Api4.Params) { + this.action = Api4.Action.update; + this.params = params; + + return this; + } + + save(params?: Api4.Params) { + this.action = Api4.Action.save; + this.params = params; + + return this; + } + + delete(params?: Api4.Params) { + this.action = Api4.Action.delete; + this.params = params; + + return this; + } + + getChecksum(params?: Api4.Params) { + this.action = Api4.Action.getChecksum; + this.params = params; + + return this; + } + + one() { + this.index = 0; + + return this; + } + + chain(label: string, requestBuilder: RequestBuilder) { + this.chains[label] = requestBuilder; + + return this; + } +} diff --git a/src/api4/request.ts b/src/api4/request.ts new file mode 100644 index 0000000..b785dcb --- /dev/null +++ b/src/api4/request.ts @@ -0,0 +1,27 @@ +import { isEmpty } from "lodash-es"; + +import { Api4 } from "./types"; +import { request as baseRequest } from "../lib/request"; + +export async function request( + this: { + baseUrl: string; + apiKey: string; + debug?: boolean; + }, + [entity, action, params, index]: Api4.RequestParams, + requestOptions: RequestInit = {}, +) { + const path = `civicrm/ajax/api4/${entity}/${action}`; + const searchParams = new URLSearchParams(); + + if (!isEmpty(params)) { + searchParams.append("params", JSON.stringify(params)); + } + + if (index !== undefined) { + searchParams.append("index", String(index)); + } + + return baseRequest.bind(this)(path, searchParams, requestOptions); +} diff --git a/src/api4/types.ts b/src/api4/types.ts new file mode 100644 index 0000000..e36fa75 --- /dev/null +++ b/src/api4/types.ts @@ -0,0 +1,85 @@ +import { BaseRequestFn } from "../types"; +import { RequestBuilder } from "./request-builder"; + +export namespace Api4 { + export type EntitiesConfig = Record; + + export type Client = { + [K in keyof E]: RequestBuilder; + }; + + export enum Action { + get = "get", + create = "create", + update = "update", + save = "save", + delete = "delete", + getChecksum = "getChecksum", + } + + export type Value = + | string + | number + | string[] + | number[] + | boolean + | boolean[] + | null; + + type Many = T | readonly T[]; + + type WhereOperator = + | "=" + | "<=" + | ">=" + | ">" + | "<" + | "LIKE" + | "<>" + | "!=" + | "NOT LIKE" + | "IN" + | "NOT IN" + | "BETWEEN" + | "NOT BETWEEN" + | "IS NOT NULL" + | "IS NULL" + | "CONTAINS" + | "NOT CONTAINS" + | "IS EMPTY" + | "IS NOT EMPTY" + | "REGEXP" + | "NOT REGEXP"; + + type Where = + | [string, WhereOperator, Value?] + | ["OR", [string, WhereOperator, Value?][]]; + + type Order = "ASC" | "DESC"; + + type JoinOperator = "LEFT" | "RIGHT" | "INNER" | "EXCLUDE"; + + type Join = + | [string, JoinOperator, string, ...Many] + | [string, JoinOperator, ...Many]; + + export type Params = + | { + select?: string[]; + where?: Where[]; + having?: Where[]; + join?: Join[]; + groupBy?: string[]; + orderBy?: Record; + limit?: number; + offset?: number; + values?: Record; + chain?: Record; + } + | Record; + + export type Index = number; + + export type RequestParams = [string, Action, Params?, Index?]; + export type RequestFn = BaseRequestFn; +} diff --git a/src/index.ts b/src/index.ts index 4fae03e..962c63b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ -import { forIn } from "lodash-es"; -import { request } from "./request"; -import { RequestBuilder } from "./request-builder"; -import { Client, ClientConfig, EntitiesConfig } from "./types"; +import { Client, ClientConfig } from "./types"; +import { Api4 } from "./api4/types"; +import { Api3 } from "./api3/types"; +import { createApi4Client } from "./api4/client"; +import { createApi3Client } from "./api3/client"; -export function createClient( - config: ClientConfig, -): Client { +export function createClient< + E extends Api4.EntitiesConfig, + F extends Api3.EntitiesConfig, +>(config: ClientConfig): Client { if (!config.baseUrl) { throw new Error("baseUrl is required"); } @@ -14,19 +16,11 @@ export function createClient( throw new Error("apiKey is required"); } - const client = {} as Client; + const client = createApi4Client(config) as Client; - forIn(config.entities, (entity: string, key: string) => { - Reflect.defineProperty(client, key, { - get: () => - new RequestBuilder(entity, (requestParams, requestOptions) => - request.bind(config)(requestParams, { - ...config.requestOptions, - ...requestOptions, - }), - ), - }); - }); + if (config.api3?.enabled) { + client.api3 = createApi3Client(config); + } return client; } diff --git a/src/lib/request-builder.ts b/src/lib/request-builder.ts new file mode 100644 index 0000000..667ea7e --- /dev/null +++ b/src/lib/request-builder.ts @@ -0,0 +1,46 @@ +import { BaseRequestFn } from "../types"; + +export class RequestBuilder + implements PromiseLike +{ + protected readonly entity: string; + protected readonly request: BaseRequestFn; + protected innerPromise: Promise; + protected requestOptions: RequestInit = {}; + + constructor(entity: string, request: BaseRequestFn) { + this.entity = entity; + this.request = request; + } + + options(requestOptions: RequestInit) { + this.requestOptions = requestOptions; + + return this; + } + + get requestParams(): RequestParams { + throw new Error(); + } + + then( + onFulfilled?: + | ((value: T) => TResult1 | PromiseLike) + | null + | undefined, + onRejected?: + | ((reason: Error) => TResult2 | PromiseLike) + | null + | undefined, + ): PromiseLike { + return this.getInnerPromise().then(onFulfilled, onRejected); + } + + getInnerPromise() { + if (!this.innerPromise) { + this.innerPromise = this.request(this.requestParams, this.requestOptions); + } + + return this.innerPromise; + } +} diff --git a/src/request.ts b/src/lib/request.ts similarity index 64% rename from src/request.ts rename to src/lib/request.ts index 954a95b..9a0595d 100644 --- a/src/request.ts +++ b/src/lib/request.ts @@ -1,29 +1,21 @@ -import { isEmpty } from "lodash-es"; import { bold, gray, yellow } from "picocolors"; -import { RequestParams } from "./types"; - export async function request( this: { baseUrl: string; apiKey: string; debug?: boolean; }, - [entity, action, params, index]: RequestParams, + path: string, + params?: URLSearchParams, { headers, ...requestOptions }: RequestInit = {}, ) { const requestId = crypto.randomUUID(); - const url = new URL(`civicrm/ajax/api4/${entity}/${action}`, this.baseUrl); - - if (!isEmpty(params)) { - url.search = new URLSearchParams({ - params: JSON.stringify(params), - }).toString(); - } + const url = new URL(path, this.baseUrl); - if (index !== undefined) { - url.searchParams.append("index", String(index)); + if (params) { + url.search = params.toString(); } const start = performance.now(); @@ -50,20 +42,28 @@ export async function request( if (!res.ok) { const error = await res.text(); - - if (this.debug) { - console.error(error); - console.groupEnd(); - } - - throw new Error("CiviCRM request failed"); + handleError.call(this, error); } const json = await res.json(); + if (json.is_error) { + handleError.call(this, json); + } + if (this.debug) { console.groupEnd(); } return json.values; } + +function handleError(error: any) { + if (this.debug) { + console.error(error); + console.groupEnd(); + } + + // TODO: make this error more informative + throw new Error("CiviCRM request failed"); +} diff --git a/src/request-builder.ts b/src/request-builder.ts deleted file mode 100644 index cdc337e..0000000 --- a/src/request-builder.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { isEmpty, mapValues } from "lodash-es"; -import { - Action, - Entity, - Index, - Params, - RequestFn, - RequestParams, -} from "./types"; - -export class RequestBuilder implements PromiseLike { - private readonly entity: Entity; - private readonly request: RequestFn; - private innerPromise: Promise; - private action: Action; - private params?: Params; - private index?: Index; - private chains: Record = {}; - private requestOptions: RequestInit = {}; - - constructor(entity: string, request: RequestFn) { - this.entity = entity; - this.request = request; - } - - get requestParams(): RequestParams { - const params: Params = { ...this.params }; - - if (!isEmpty(this.chains)) { - params.chain = mapValues( - this.chains, - (chainRequest: RequestBuilder) => chainRequest.requestParams, - ); - } - - return [this.entity, this.action, params, this.index]; - } - - get(params?: Params) { - this.action = Action.get; - this.params = params; - - return this; - } - - create(params?: Params) { - this.action = Action.create; - this.params = params; - - return this; - } - - update(params?: Params) { - this.action = Action.update; - this.params = params; - - return this; - } - - save(params?: Params) { - this.action = Action.save; - this.params = params; - - return this; - } - - delete(params?: Params) { - this.action = Action.delete; - this.params = params; - - return this; - } - - getChecksum(params?: Params) { - this.action = Action.getChecksum; - this.params = params; - - return this; - } - - one() { - this.index = 0; - - return this; - } - - options(requestOptions: RequestInit) { - this.requestOptions = requestOptions; - - return this; - } - - chain(label: string, requestBuilder: RequestBuilder) { - this.chains[label] = requestBuilder; - - return this; - } - - then( - onFulfilled?: - | ((value: T) => TResult1 | PromiseLike) - | null - | undefined, - onRejected?: - | ((reason: Error) => TResult2 | PromiseLike) - | null - | undefined, - ): PromiseLike { - return this.getInnerPromise().then(onFulfilled, onRejected); - } - - getInnerPromise() { - if (!this.innerPromise) { - this.innerPromise = this.request(this.requestParams, this.requestOptions); - } - - return this.innerPromise; - } -} diff --git a/src/types.ts b/src/types.ts index df4289c..ddaabdb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,96 +1,29 @@ -import { RequestBuilder } from "./request-builder"; - -export type EntitiesConfig = Record; +import { Api4 } from "./api4/types"; +import { Api3 } from "./api3/types"; + +export type Client< + E extends Api4.EntitiesConfig, + F extends Api3.EntitiesConfig, +> = Api4.Client & { + api3: Api3.Client; +}; -export interface ClientConfig { +export interface ClientConfig< + E extends Api4.EntitiesConfig, + F extends Api3.EntitiesConfig, +> { baseUrl: string; apiKey: string; - entities: E; + entities?: E; requestOptions?: RequestInit; debug?: boolean; + api3?: { + enabled: boolean; + entities: F; + }; } -export type Client = { - [K in keyof E]: RequestBuilder; -}; - -export type Entity = string; - -export enum Action { - get = "get", - create = "create", - update = "update", - save = "save", - delete = "delete", - getChecksum = "getChecksum", -} - -export type Field = string; -export type Value = - | string - | number - | string[] - | number[] - | boolean - | boolean[] - | null; - -type Many = T | readonly T[]; - -type WhereOperator = - | "=" - | "<=" - | ">=" - | ">" - | "<" - | "LIKE" - | "<>" - | "!=" - | "NOT LIKE" - | "IN" - | "NOT IN" - | "BETWEEN" - | "NOT BETWEEN" - | "IS NOT NULL" - | "IS NULL" - | "CONTAINS" - | "NOT CONTAINS" - | "IS EMPTY" - | "IS NOT EMPTY" - | "REGEXP" - | "NOT REGEXP"; - -type Where = - | [Field, WhereOperator, Value?] - | ["OR", [Field, WhereOperator, Value?][]]; - -type Order = "ASC" | "DESC"; - -type JoinOperator = "LEFT" | "RIGHT" | "INNER" | "EXCLUDE"; - -type Join = - | [Entity, JoinOperator, Entity, ...Many] - | [Entity, JoinOperator, ...Many]; - -export type Params = - | { - select?: Field[]; - where?: Where[]; - having?: Where[]; - join?: Join[]; - groupBy?: Field[]; - orderBy?: Record; - limit?: number; - offset?: number; - values?: Record; - chain?: Record; - } - | Record; - -export type Index = number; - -export type RequestParams = [Entity, Action, Params?, Index?]; -export type RequestFn = ( +export type BaseRequestFn = ( params: RequestParams, requestOptions: RequestInit, -) => Promise; +) => Promise; diff --git a/test/api3.test.ts b/test/api3.test.ts new file mode 100644 index 0000000..4a5a2a8 --- /dev/null +++ b/test/api3.test.ts @@ -0,0 +1,182 @@ +import { DeferredPromise } from "@open-draft/deferred-promise"; +import { noop } from "lodash-es"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { createClient } from "../src"; +import { server } from "./mock-server"; + +const config = { + baseUrl: "https://example.com", + apiKey: "mock-api-key", + api3: { + enabled: true, + entities: { + contact: { + name: "Contact", + actions: { + getList: "getlist", + delete: "delete", + }, + }, + activity: { + name: "Activity", + actions: { + getList: "getlist", + }, + }, + }, + }, +}; + +const client = createClient(config); + +let request: DeferredPromise; + +beforeEach(() => { + request = new DeferredPromise(); + + server.events.on("request:start", (req) => { + request.resolve(req.request); + }); +}); + +afterEach(() => { + server.events.removeAllListeners(); +}); + +test("makes a request", async () => { + await client.api3.contact.getList(); + const req = await request; + + expect(req.url).toBe( + "https://example.com/civicrm/ajax/rest?entity=Contact&action=getlist&json=%7B%7D", + ); +}); + +test("sets entity and action params", async () => { + await client.api3.contact.delete(); + const req = await request; + const url = new URL(req.url); + + expect(url.searchParams.get("entity")).toBe("Contact"); + expect(url.searchParams.get("action")).toBe("delete"); +}); + +test("sets request headers", async () => { + await client.api3.contact.getList(); + const req = await request; + + expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); + expect(req.headers.get("X-Requested-With")).toBe("XMLHttpRequest"); + expect(req.headers.get("Content-Type")).toBe( + "application/x-www-form-urlencoded", + ); +}); + +test("accepts request options", async () => { + await client.api3.contact.options({ cache: "no-cache" }).getList(); + const req = await request; + + expect(req.cache).toBe("no-cache"); +}); + +test("accepts additional headers", async () => { + await client.api3.contact + .options({ headers: { "X-Correlation-Id": "mock-correlation-id" } }) + .getList(); + const req = await request; + + expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); + expect(req.headers.get("X-Correlation-Id")).toBe("mock-correlation-id"); +}); + +test("applies default request options", async () => { + const clientWithDefaults = createClient({ + ...config, + requestOptions: { + cache: "no-cache", + headers: { "X-Correlation-Id": "mock-correlation-id" }, + }, + }); + await clientWithDefaults.api3.contact.getList(); + const req = await request; + + expect(req.cache).toBe("no-cache"); + expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); + expect(req.headers.get("X-Correlation-Id")).toBe("mock-correlation-id"); +}); + +test("parses response", () => { + expect(client.api3.contact.getList()).resolves.toEqual("Mock response"); +}); + +test("accepts params", async () => { + await client.api3.contact.getList({ input: "mock-input" }); + const req = await request; + + const searchParams = new URL(req.url).searchParams; + const params = JSON.parse(searchParams.get("json")!); + + expect(params).toEqual({ input: "mock-input" }); +}); + +test("accepts options", async () => { + await client.api3.contact.getList().addOption("limit", 10); + const req = await request; + + const searchParams = new URL(req.url).searchParams; + const params = JSON.parse(searchParams.get("json")!); + + expect(params).toEqual({ options: { limit: 10 } }); +}); + +test("accepts params and options", async () => { + await client.api3.contact + .getList({ input: "mock-input" }) + .addOption("limit", 10); + const req = await request; + + const searchParams = new URL(req.url).searchParams; + const params = JSON.parse(searchParams.get("json")!); + + expect(params).toEqual({ input: "mock-input", options: { limit: 10 } }); +}); + +describe("debug", () => { + const clientWithDebug = createClient({ ...config, debug: true }); + + const consoleSpy = { + group: vi.spyOn(console, "group").mockImplementation(noop), + groupEnd: vi.spyOn(console, "groupEnd").mockImplementation(noop), + error: vi.spyOn(console, "error").mockImplementation(noop), + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("logs request timing", async () => { + await clientWithDebug.api3.contact.getList(); + const req = await request; + const requestId = req.headers.get("X-Request-Id"); + + expect(consoleSpy.group).toHaveBeenCalledWith( + `\u001b[1mCiviCRM request\u001b[22m ${requestId} \u001b[90mhttps://example.com/civicrm/ajax/rest?entity=Contact&action=getlist&json=%7B%7D\u001b[39m 200 in \u001b[33m0ms\u001b[39m`, + ); + expect(consoleSpy.groupEnd).toHaveBeenCalled(); + }); + + test("logs request errors", async () => { + try { + await clientWithDebug.api3.activity.getList(); + } catch {} + + expect(consoleSpy.group).toHaveBeenCalled(); + expect(consoleSpy.error).toHaveBeenCalledWith("Internal Server Error"); + expect(consoleSpy.groupEnd).toHaveBeenCalled(); + }); +}); diff --git a/test/api4.test.ts b/test/api4.test.ts new file mode 100644 index 0000000..5d7253d --- /dev/null +++ b/test/api4.test.ts @@ -0,0 +1,245 @@ +import { DeferredPromise } from "@open-draft/deferred-promise"; +import { noop } from "lodash-es"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { createClient } from "../src"; +import { server } from "./mock-server"; + +const config = { + baseUrl: "https://example.com", + apiKey: "mock-api-key", + entities: { + contact: "Contact", + activity: "Activity", + }, +}; +const client = createClient(config); + +let request: DeferredPromise; + +beforeEach(() => { + request = new DeferredPromise(); + + server.events.on("request:start", (req) => { + request.resolve(req.request); + }); +}); + +afterEach(() => { + server.events.removeAllListeners(); +}); + +test("makes a request", async () => { + await client.contact.get(); + const req = await request; + + expect(req.url).toBe("https://example.com/civicrm/ajax/api4/Contact/get"); +}); + +test("sets request headers", async () => { + await client.contact.get(); + const req = await request; + + expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); + expect(req.headers.get("X-Requested-With")).toBe("XMLHttpRequest"); + expect(req.headers.get("Content-Type")).toBe( + "application/x-www-form-urlencoded", + ); +}); + +test("accepts request options", async () => { + await client.contact.options({ cache: "no-cache" }).get(); + const req = await request; + + expect(req.cache).toBe("no-cache"); +}); + +test("accepts additional headers", async () => { + await client.contact + .options({ headers: { "X-Correlation-Id": "mock-correlation-id" } }) + .get(); + const req = await request; + + expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); + expect(req.headers.get("X-Correlation-Id")).toBe("mock-correlation-id"); +}); + +test("applies default request options", async () => { + const clientWithDefaults = createClient({ + ...config, + requestOptions: { + cache: "no-cache", + headers: { "X-Correlation-Id": "mock-correlation-id" }, + }, + }); + await clientWithDefaults.contact.get(); + const req = await request; + + expect(req.cache).toBe("no-cache"); + expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); + expect(req.headers.get("X-Correlation-Id")).toBe("mock-correlation-id"); +}); + +test("parses response", () => { + expect(client.contact.get()).resolves.toEqual("Mock response"); +}); + +test("accepts params", async () => { + await client.contact.get({ select: ["name"], where: [["id", "=", 1]] }); + const req = await request; + + const searchParams = new URL(req.url).searchParams; + const params = JSON.parse(searchParams.get("params")!); + + expect(params).toEqual({ select: ["name"], where: [["id", "=", 1]] }); +}); + +test("requests a single resource", async () => { + await client.contact.get().one(); + const req = await request; + + const searchParams = new URL(req.url).searchParams; + + expect(searchParams.get("index")).toBe("0"); +}); + +test("makes chained requests", async () => { + await client.contact + .get({ where: [["id", "=", 1]] }) + .chain("activity", client.activity.get({ where: [["id", "=", 2]] }).one()); + const req = await request; + + const searchParams = new URL(req.url).searchParams; + const params = JSON.parse(searchParams.get("params")!); + + expect(params).toEqual({ + where: [["id", "=", 1]], + chain: { activity: ["Activity", "get", { where: [["id", "=", 2]] }, 0] }, + }); +}); + +test("makes nested chained requests", async () => { + await client.contact + .get({ where: [["id", "=", 1]] }) + .chain( + "activity", + client.activity + .get({ where: [["id", "=", 2]] }) + .chain( + "contact", + client.contact.get({ where: [["id", "=", 3]] }).one(), + ), + ); + const req = await request; + + const searchParams = new URL(req.url).searchParams; + const params = JSON.parse(searchParams.get("params")!); + + expect(params).toEqual({ + where: [["id", "=", 1]], + chain: { + activity: [ + "Activity", + "get", + { + where: [["id", "=", 2]], + chain: { + contact: ["Contact", "get", { where: [["id", "=", 3]] }, 0], + }, + }, + null, + ], + }, + }); +}); + +describe("request methods", () => { + test("makes a get request", async () => { + await client.contact.get(); + const req = await request; + + expect(req.url).toBe("https://example.com/civicrm/ajax/api4/Contact/get"); + }); + + test("makes a create request", async () => { + await client.contact.create(); + const req = await request; + + expect(req.url).toBe( + "https://example.com/civicrm/ajax/api4/Contact/create", + ); + }); + + test("makes a update request", async () => { + await client.contact.update(); + const req = await request; + + expect(req.url).toBe( + "https://example.com/civicrm/ajax/api4/Contact/update", + ); + }); + + test("makes a save request", async () => { + await client.contact.save(); + const req = await request; + + expect(req.url).toBe("https://example.com/civicrm/ajax/api4/Contact/save"); + }); + + test("makes a delete request", async () => { + await client.contact.delete(); + const req = await request; + + expect(req.url).toBe( + "https://example.com/civicrm/ajax/api4/Contact/delete", + ); + }); + + test("makes a getChecksum request", async () => { + await client.contact.getChecksum(); + const req = await request; + + expect(req.url).toBe( + "https://example.com/civicrm/ajax/api4/Contact/getChecksum", + ); + }); +}); + +describe("debug", () => { + const clientWithDebug = createClient({ ...config, debug: true }); + + const consoleSpy = { + group: vi.spyOn(console, "group").mockImplementation(noop), + groupEnd: vi.spyOn(console, "groupEnd").mockImplementation(noop), + error: vi.spyOn(console, "error").mockImplementation(noop), + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("logs request timing", async () => { + await clientWithDebug.contact.get(); + const req = await request; + const requestId = req.headers.get("X-Request-Id"); + + expect(consoleSpy.group).toHaveBeenCalledWith( + `\u001b[1mCiviCRM request\u001b[22m ${requestId} \u001b[90mhttps://example.com/civicrm/ajax/api4/Contact/get\u001b[39m 200 in \u001b[33m0ms\u001b[39m`, + ); + expect(consoleSpy.groupEnd).toHaveBeenCalled(); + }); + + test("logs request errors", async () => { + try { + await clientWithDebug.activity.get(); + } catch {} + + expect(consoleSpy.group).toHaveBeenCalled(); + expect(consoleSpy.error).toHaveBeenCalledWith("Internal Server Error"); + expect(consoleSpy.groupEnd).toHaveBeenCalled(); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index e1a4465..6f6a361 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,13 +1,16 @@ import { DeferredPromise } from "@open-draft/deferred-promise"; -import { noop } from "lodash-es"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, expect, test } from "vitest"; import { createClient } from "../src"; import { server } from "./mock-server"; -const client = createClient({ +const clientConfig = { baseUrl: "https://example.com", apiKey: "mock-api-key", +}; + +const client = createClient({ + ...clientConfig, entities: { contact: "Contact", activity: "Activity", @@ -47,274 +50,25 @@ test("creates methods for specified entities", () => { expect(client.activity).toBeDefined(); }); -test("makes a request", async () => { - await client.contact.get(); - const req = await request; - - expect(req.url).toBe("https://example.com/civicrm/ajax/api4/Contact/get"); -}); - -test("sets request headers", async () => { - await client.contact.get(); - const req = await request; - - expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); - expect(req.headers.get("X-Requested-With")).toBe("XMLHttpRequest"); - expect(req.headers.get("Content-Type")).toBe( - "application/x-www-form-urlencoded", - ); -}); - -test("accepts request options", async () => { - await client.contact - .options({ - cache: "no-cache", - }) - .get(); - const req = await request; - - expect(req.cache).toBe("no-cache"); -}); - -test("accepts additional headers", async () => { - await client.contact - .options({ - headers: { - "X-Correlation-Id": "mock-correlation-id", - }, - }) - .get(); - const req = await request; - - expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); - expect(req.headers.get("X-Correlation-Id")).toBe("mock-correlation-id"); -}); - -test("accepts default request options", async () => { - const clientWithDefaults = createClient({ - baseUrl: "https://example.com", - apiKey: "mock-api-key", - entities: { - contact: "Contact", - }, - requestOptions: { - cache: "no-cache", - headers: { - "X-Correlation-Id": "mock-correlation-id", - }, - }, - }); - await clientWithDefaults.contact.get(); - const req = await request; - - expect(req.cache).toBe("no-cache"); - expect(req.headers.get("X-Civi-Auth")).toBe("Bearer mock-api-key"); - expect(req.headers.get("X-Correlation-Id")).toBe("mock-correlation-id"); -}); - -test("parses response", () => { - expect(client.contact.get()).resolves.toEqual("Mock response"); -}); - -test("accepts params", async () => { - await client.contact.get({ - select: ["name"], - where: [["id", "=", 1]], - }); - const req = await request; - - const searchParams = new URL(req.url).searchParams; - const params = JSON.parse(searchParams.get("params")); - - expect(params).toEqual({ - select: ["name"], - where: [["id", "=", 1]], - }); -}); - -test("requests a single resource", async () => { - await client.contact.get().one(); - const req = await request; - - const searchParams = new URL(req.url).searchParams; - - expect(searchParams.get("index")).toBe("0"); -}); - -test("makes chained requests", async () => { - await client.contact - .get({ - where: [["id", "=", 1]], - }) - .chain( - "activity", - client.activity - .get({ - where: [["id", "=", 2]], - }) - .one(), - ); - const req = await request; - - const searchParams = new URL(req.url).searchParams; - const params = JSON.parse(searchParams.get("params")); - - expect(params).toEqual({ - where: [["id", "=", 1]], - chain: { - activity: [ - "Activity", - "get", - { - where: [["id", "=", 2]], - }, - 0, - ], - }, - }); +test("does not create api3 methods if not enabled", () => { + expect(client.api3).toBeUndefined(); }); -test("makes nested chained requests", async () => { - await client.contact - .get({ - where: [["id", "=", 1]], - }) - .chain( - "activity", - client.activity - .get({ - where: [["id", "=", 2]], - }) - .chain( - "contact", - client.contact.get({ where: [["id", "=", 3]] }).one(), - ), - ); - const req = await request; - - const searchParams = new URL(req.url).searchParams; - const params = JSON.parse(searchParams.get("params")); - - expect(params).toEqual({ - where: [["id", "=", 1]], - chain: { - activity: [ - "Activity", - "get", - { - where: [["id", "=", 2]], - chain: { - contact: [ - "Contact", - "get", - { - where: [["id", "=", 3]], - }, - 0, - ], +test("creates api3 methods if enabled", () => { + const api3Client = createClient({ + ...clientConfig, + api3: { + enabled: true, + entities: { + contact: { + name: "Contact", + actions: { + getList: "getlist", }, }, - null, - ], - }, - }); -}); - -describe("request methods", () => { - test("makes a get request", async () => { - await client.contact.get(); - const req = await request; - - expect(req.url).toBe("https://example.com/civicrm/ajax/api4/Contact/get"); - }); - - test("makes a create request", async () => { - await client.contact.create(); - const req = await request; - - expect(req.url).toBe( - "https://example.com/civicrm/ajax/api4/Contact/create", - ); - }); - - test("makes a update request", async () => { - await client.contact.update(); - const req = await request; - - expect(req.url).toBe( - "https://example.com/civicrm/ajax/api4/Contact/update", - ); - }); - - test("makes a save request", async () => { - await client.contact.save(); - const req = await request; - - expect(req.url).toBe("https://example.com/civicrm/ajax/api4/Contact/save"); - }); - - test("makes a delete request", async () => { - await client.contact.delete(); - const req = await request; - - expect(req.url).toBe( - "https://example.com/civicrm/ajax/api4/Contact/delete", - ); - }); - - test("makes a getChecksum request", async () => { - await client.contact.getChecksum(); - const req = await request; - - expect(req.url).toBe( - "https://example.com/civicrm/ajax/api4/Contact/getChecksum", - ); - }); -}); - -describe("debug", () => { - const clientWithDebug = createClient({ - debug: true, - baseUrl: "https://example.com", - apiKey: "mock-api-key", - entities: { - contact: "Contact", - activity: "Activity", + }, }, }); - const consoleSpy = { - group: vi.spyOn(console, "group").mockImplementation(noop), - groupEnd: vi.spyOn(console, "groupEnd").mockImplementation(noop), - error: vi.spyOn(console, "error").mockImplementation(noop), - }; - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - test("logs request timing", async () => { - await clientWithDebug.contact.get(); - const req = await request; - const requestId = req.headers.get("X-Request-Id"); - - expect(consoleSpy.group).toHaveBeenCalledWith( - `\u001b[1mCiviCRM request\u001b[22m ${requestId} \u001b[90mhttps://example.com/civicrm/ajax/api4/Contact/get\u001b[39m 200 in \u001b[33m0ms\u001b[39m`, - ); - expect(consoleSpy.groupEnd).toHaveBeenCalled(); - }); - - test("logs request errors", async () => { - try { - await clientWithDebug.activity.get(); - } catch {} - - expect(consoleSpy.group).toHaveBeenCalled(); - expect(consoleSpy.error).toHaveBeenCalledWith("Internal Server Error"); - expect(consoleSpy.groupEnd).toHaveBeenCalled(); - }); + expect(api3Client.api3.contact).toBeDefined(); }); diff --git a/test/mock-server.ts b/test/mock-server.ts index d7782ab..cbdb3e4 100644 --- a/test/mock-server.ts +++ b/test/mock-server.ts @@ -3,10 +3,9 @@ import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll } from "vitest"; export const restHandlers = [ + // APIv4 http.post(/https:\/\/example.com\/civicrm\/ajax\/api4\/Contact\/(.+)/, () => { - return HttpResponse.json({ - values: "Mock response", - }); + return HttpResponse.json({ values: "Mock response" }); }), http.post( /https:\/\/example.com\/civicrm\/ajax\/api4\/Activity\/(.+)/, @@ -14,12 +13,18 @@ export const restHandlers = [ return HttpResponse.text("Internal Server Error", { status: 500 }); }, ), + // APIv3 + http.post("https://example.com/civicrm/ajax/rest", ({ request }) => { + if (new URL(request.url).searchParams.get("entity") === "Activity") { + return HttpResponse.text("Internal Server Error", { status: 500 }); + } + + return HttpResponse.json({ values: "Mock response" }); + }), ]; export const server = setupServer(...restHandlers); -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); - +beforeAll(() => server.listen({ onUnhandledRequest: "warn" })); afterAll(() => server.close()); - afterEach(() => server.resetHandlers());