diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5a608af --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "editor.formatOnSave": true, + "xo.enable": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "prettier.prettierPath": "./node_modules/prettier", + "tailwindCSS.experimental.classRegex": [ + "tw`([^`]*)", + "tw\\.style\\(([^)]*)\\)" + ], + "tailwindCSS.includeLanguages": { + "tsx": "jsx" + } +} diff --git a/.xo-config.json b/.xo-config.json index 8b95568..f251610 100644 --- a/.xo-config.json +++ b/.xo-config.json @@ -3,10 +3,6 @@ "space": true, "nodeVersion": false, "rules": { - "@typescript-eslint/array-type": [ - "error", - { "default": "array", "readonly": "array" } - ], "@typescript-eslint/naming-convention": "off", "@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/parameter-properties": [ diff --git a/README.md b/README.md index 862d10b..e2708a3 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ - [Create a Mongo Client](#create-a-mongo-client) - [Select a Database](#select-a-database) - [Select a Collection](#select-a-collection) - - [Changing `fetch()` on the Fly](#changing-fetch-on-the-fly) - [Collection Methods](#collection-methods) - [Return Type](#return-type) + - [Specifying Operation Names](#specifying-operation-names) - [Methods](#methods) - [findOne](#findone) - [find](#find) @@ -130,7 +130,6 @@ const client = new MongoClient(options); - `options.dataSource` - `string` the `Data Source` for your Data API. On the Data API UI, this is the "Data Source" column, and usually is either a 1:1 mapping of your cluster name, or the default `mongodb-atlas` if you enabled Data API through the Atlas Admin UI. - `options.auth` - `AuthOptions` one of the authentication methods, either api key, email & password, or a custom JWT string. At this time, only [Credential Authentication](https://www.mongodb.com/docs/atlas/api/data-api/#credential-authentication) is supported. - `options.fetch?` - A custom `fetch` function conforming to the [native fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). We recommend [cross-fetch](https://www.npmjs.com/package/cross-fetch), as it asserts a complaint `fetch()` interface and avoids you having to do `fetch: _fetch as typeof fetch` to satisfy the TypeScript compiler - - `options.headers?` - Additional [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) to include with the request. May also be a simple object of key/value pairs. ## Select a Database @@ -149,15 +148,6 @@ const collection = db.collection(collectionName); - `collectionName` - `string` the name of the collection to connect to - `` - _generic_ A Type or Interface that describes the documents in this collection. Defaults to the generic MongoDB `Document` type -## Changing `fetch()` on the Fly - -```ts -const altDb = db.fetch(altFetch); -const altCollection = db.collection(collectionName).fetch(altFetch); -``` - -- `altFetch` - An alternate `fetch` implementation that will be used for that point forward. Useful for adding or removing retry support on a per-call level, changing the authentication required, or other fetch middleware operations. - ## Collection Methods The following [Data API resources](https://www.mongodb.com/docs/atlas/api/data-api-resources/) are supported @@ -188,6 +178,14 @@ interface DataAPIResponse { } ``` +### Specifying Operation Names + +To help with tracing and debugging, any Mongo Data API operation can be named by passing a string as the first parameter. This value is converted to the `x-realm-op-name` header and can be seen in the [Mongo Data API logs](https://www.mongodb.com/docs/atlas/api/data-api/#view-data-api-logs). + +```ts +const { data, error } = await collection./*operation*/("operation name for tracing", filter, options); +``` + ### Methods #### findOne diff --git a/src/client.ts b/src/client.ts index 39d18c0..49979ed 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,5 @@ -import { type OutgoingHttpHeaders } from "node:http"; -import { EJSON, type ObjectId } from "bson"; +import { EJSON } from "bson"; import type { - Sort, Filter, OptionalUnlessRequiredId, UpdateFilter, @@ -11,8 +9,26 @@ import type { } from "mongodb"; import type { AuthOptions } from "./authTypes.js"; import { MongoDataAPIError } from "./errors.js"; - -// reexport bson types +import { + type AggregateResponse, + type DataAPIResponse, + type DeleteManyResponse, + type DeleteOneResponse, + type FindOneRequestOptions, + type FindOneResponse, + type FindRequestOptions, + type FindResponse, + type InsertManyResponse, + type InsertOneResponse, + type ReplaceOneRequestOptions, + type ReplaceOneResponse, + type UpdateManyRequestOptions, + type UpdateManyResponse, + type UpdateOneRequestOptions, + type UpdateOneResponse, +} from "./responseTypes.js"; + +// reexport bson objects that make working with mongo-data-api easier export { EJSON, ObjectId } from "bson"; // reexport relevant mongo types @@ -26,13 +42,11 @@ export type { WithoutId, } from "mongodb"; -// eslint-disable-next-line @typescript-eslint/ban-types -type Nullable = T | null; - -/** A data API response object */ -type DataAPIResponse = { data?: T; error?: MongoDataAPIError }; - -export type MongoClientConstructorOptions = { +/** + * Defines a set of options that affect the request to make to the Atlas Data API. All + * fields are optional, but must eventually be resolved by the time you execute a query. + */ +export type RequestOptions = { /** * Your atlas endpoint. Usually in the form of * https://data.mongodb-api.com/app//endpoint/data/v1 @@ -54,28 +68,33 @@ export type MongoClientConstructorOptions = { * * Read more: https://www.mongodb.com/docs/atlas/api/data-api/#authenticate-requests */ - auth: AuthOptions; + auth?: AuthOptions; /** * Provide a compatibility layer with a custom fetch implementation. */ fetch?: typeof fetch; - /** - * Provide a custom Headers object for the request. In some situations such as testing, - * this allows you to control the response from the mock server. - */ - headers?: OutgoingHttpHeaders | Headers; }; +/** Options passed to the callAPI method */ +type callAPIOptions = { + /** Convienence property for setting the x-realm-op-name header */ + operationName?: string; +}; + +/** Describes a set of resolved connection options, with a normalized headers object and fetch method */ type ConnectionOptions = { - dataSource: string; - endpoint: string; + dataSource?: string; + endpoint?: string; headers: Record; + fetch: typeof fetch; }; +/** Extends the ConnectionOptions to add a database field */ type ConnectionOptionsWithDatabase = ConnectionOptions & { database: string; }; +/** Extends the ConnectionsOptionsWithDatabase to add a collection name */ type ConnectionOptionsWithCollection = ConnectionOptionsWithDatabase & { collection: string; }; @@ -89,6 +108,39 @@ const removeEmptyKeys = (object: Record) => { return Object.fromEntries(Object.entries(object).filter(([_, v]) => v)); }; +/** Converts a set of request options into connection options */ +const normalizeRequestOptions = ( + options?: RequestOptions +): ConnectionOptions => { + const { endpoint, dataSource, auth, fetch: customFetch } = options ?? {}; + const ftch = customFetch ?? globalThis.fetch; + + const connection: ConnectionOptions = { + dataSource, + endpoint: endpoint instanceof URL ? endpoint.toString() : endpoint, + headers: {}, + fetch: ftch, + }; + + // if auth is defined, it must be checked before being added to headers + if (auth) { + if ("apiKey" in auth) { + connection.headers.apiKey = auth.apiKey; + } else if ("jwtTokenString" in auth) { + connection.headers.jwtTokenString = auth.jwtTokenString; + } else if ("email" in auth && "password" in auth) { + connection.headers.email = auth.email; + connection.headers.password = auth.password; + } else if ("bearerToken" in auth) { + connection.headers.Authorization = `Bearer ${auth.bearerToken}`; + } else { + throw new Error("Invalid auth options"); + } + } + + return connection; +}; + /** * Create a MongoDB-like client for communicating with the Atlas Data API * @@ -102,69 +154,14 @@ const removeEmptyKeys = (object: Record) => { */ export class MongoClient { protected connection: ConnectionOptions; - protected ftch: typeof fetch; - - constructor({ - auth, - dataSource, - endpoint, - fetch: customFetch, - headers: h, - }: MongoClientConstructorOptions) { - this.ftch = customFetch ?? globalThis.fetch; - const headers: HeadersInit = {}; - - // accept a node-style or whatwg headers object with .keys() and .get() - if (typeof h?.keys === "function") { - for (const key of h.keys()) { - headers[key] = ( - typeof h.get === "function" ? h.get(key) : headers[key] - )!; - } - } - - this.connection = { - dataSource, - endpoint: endpoint instanceof URL ? endpoint.toString() : endpoint, - headers, - }; - - if (!this.ftch || typeof this.ftch !== "function") { - throw new Error( - "No viable fetch() found. Please provide a fetch interface" - ); - } - this.connection.headers["Content-Type"] = "application/ejson"; - this.connection.headers.Accept = "application/ejson"; - - if ("apiKey" in auth) { - this.connection.headers.apiKey = auth.apiKey; - return; - } - - if ("jwtTokenString" in auth) { - this.connection.headers.jwtTokenString = auth.jwtTokenString; - return; - } - - if ("email" in auth && "password" in auth) { - this.connection.headers.email = auth.email; - this.connection.headers.password = auth.password; - return; - } - - if ("bearerToken" in auth) { - this.connection.headers.Authorization = `Bearer ${auth.bearerToken}`; - return; - } - - throw new Error("Invalid auth options"); + constructor(options: RequestOptions) { + this.connection = normalizeRequestOptions(options); } /** Select a database from within the data source */ db(name: string) { - return new Database(name, this.connection, this.ftch); + return new Database(name, this.connection); } } @@ -175,14 +172,8 @@ export class MongoClient { */ export class Database { protected connection: ConnectionOptionsWithDatabase; - protected ftch: typeof fetch; - - constructor( - name: string, - connection: ConnectionOptions, - customFetch?: typeof fetch - ) { - this.ftch = customFetch ?? globalThis.fetch; + + constructor(name: string, connection: ConnectionOptions) { this.connection = { ...connection, database: name, @@ -196,21 +187,7 @@ export class Database { * @returns A Collection object of type `T` */ collection(name: string) { - return new Collection(name, this.connection, this.ftch); - } - - /** - * Change the fetch interface for this database. Returns a new Database object - * @param customFetch A fetch interface to use for subsequent calls - * @returns A new Database object with the custom fetch interface implemented - */ - fetch(customFetch: typeof fetch) { - const db = new Database( - this.connection.database, - this.connection, - customFetch - ); - return db; + return new Collection(name, this.connection); } } @@ -220,34 +197,14 @@ export class Database { */ export class Collection { protected connection: ConnectionOptionsWithCollection; - protected ftch: typeof fetch; - - constructor( - name: string, - database: ConnectionOptionsWithDatabase, - customFetch?: typeof fetch - ) { - this.ftch = customFetch ?? globalThis.fetch; + + constructor(name: string, database: ConnectionOptionsWithDatabase) { this.connection = { ...database, collection: name, }; } - /** - * Change the fetch interface for this collection. Returns a new Collection object - * @param customFetch A fetch interface to use for subsequent calls - * @returns A new Collection object with the custom fetch interface implemented - */ - fetch(customFetch: typeof fetch) { - const collection = new Collection( - this.connection.collection, - this.connection, - customFetch - ); - return collection; - } - /** * Find a single document in the collection. **note:** The `findOne()` operation does not provide a sort operation for finding a returning the first record in a set. If you must `findOne` and `sort`, use the `find()` operation instead. * [example](https://www.mongodb.com/docs/atlas/api/data-api-resources/#find-a-single-document) @@ -259,17 +216,28 @@ export class Collection { * @param options.projection A [MongoDB Query Projection](https://www.mongodb.com/docs/manual/tutorial/project-fields-from-query-results/). Depending on the projection, the returned document will either omit specific fields or include only specified fields or values. * @returns A matching document, or `null` if no document is found */ + async findOne( + operationName: string, + filter?: Filter, + options?: FindOneRequestOptions + ): FindOneResponse; async findOne( filter?: Filter, - options: { projection?: Document; sort?: Document } = {} - ): Promise>>> { + options?: FindOneRequestOptions + ): FindOneResponse; + async findOne(...args: unknown[]): FindOneResponse { + const [operationName, filter, options] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [string, Filter?, FindOneRequestOptions?]; + const { data, error } = await this.callApi<{ document: WithId }>( "findOne", { filter, - projection: options.projection, - sort: options.sort, - } + projection: options?.projection, + sort: options?.sort, + }, + { operationName } ); if (data) { @@ -283,6 +251,8 @@ export class Collection { * Find multiple documents in the collection matching a query. * [example](https://www.mongodb.com/docs/atlas/api/data-api-resources/#find-multiple-documents) * + * @param operationName The name of the operation to use for the request. This is used to set the `x-realm-op-name` header for tracing requests + * * @param filter A [MongoDB Query Filter](https://www.mongodb.com/docs/manual/tutorial/query-documents/). The find action returns documents in the collection that match this filter. * * If you do not specify a `filter`, the action matches all document in the collection. @@ -296,23 +266,34 @@ export class Collection { * @returns An array of matching documents */ async find( + operationName: string, filter?: Filter, - options?: { - projection?: Document; - sort?: Sort; - limit?: number; - skip?: number; - } - ): Promise[]>> { + options?: FindRequestOptions + ): FindResponse; + async find( + filter?: Filter, + options?: FindRequestOptions + ): FindResponse; + async find(...args: unknown[]): FindResponse { + const [operationName, filter, options] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [string, Filter?, FindRequestOptions?]; + const { data, error } = await this.callApi<{ - documents: WithId[]; - }>("find", { - filter, - projection: options?.projection, - sort: options?.sort, - limit: options?.limit, - skip: options?.skip, - }); + documents: Array>; + }>( + "find", + { + filter, + projection: options?.projection, + sort: options?.sort, + limit: options?.limit, + skip: options?.skip, + }, + { + operationName, + } + ); if (data) { return { data: data.documents, error }; @@ -327,11 +308,26 @@ export class Collection { * @returns The insertOne action returns the _id value of the inserted document as a string in the insertedId field */ async insertOne( + operationName: string, document: OptionalUnlessRequiredId - ): Promise> { - return this.callApi("insertOne", { - document, - }); + ): InsertOneResponse; + async insertOne( + document: OptionalUnlessRequiredId + ): InsertOneResponse; + async insertOne(...args: unknown[]): InsertOneResponse { + const [operationName, document] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [string, OptionalUnlessRequiredId]; + + return this.callApi( + "insertOne", + { + document, + }, + { + operationName, + } + ); } /** @@ -340,9 +336,18 @@ export class Collection { * @returns The insertMany action returns the _id values of all inserted documents as an array of strings in the insertedIds field */ async insertMany( - documents: OptionalUnlessRequiredId[] - ): Promise> { - return this.callApi("insertMany", { documents }); + operationName: string, + documents: Array> + ): InsertManyResponse; + async insertMany( + documents: Array> + ): InsertManyResponse; + async insertMany(...args: unknown[]): InsertManyResponse { + const [operationName, documents] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [string, Array>]; + + return this.callApi("insertMany", { documents }, { operationName }); } /** @@ -358,21 +363,37 @@ export class Collection { * If upsert is set to true and no documents match the filter, the action returns the `_id` value of the inserted document as a string in the `upsertedId` field */ async updateOne( + operationName: string, filter: Filter, update: UpdateFilter | Partial, - options: { upsert?: boolean } = {} - ): Promise< - DataAPIResponse<{ - matchedCount: number; - modifiedCount: number; - upsertedId?: string; - }> - > { - return this.callApi("updateOne", { - filter, - update, - upsert: options.upsert, - }); + options?: UpdateOneRequestOptions + ): UpdateOneResponse; + async updateOne( + filter: Filter, + update: UpdateFilter | Partial, + options?: UpdateOneRequestOptions + ): UpdateOneResponse; + async updateOne(...args: unknown[]): UpdateOneResponse { + const [operationName, filter, update, options] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [ + string, + Filter, + UpdateFilter | Partial, + UpdateOneRequestOptions?, + ]; + + return this.callApi( + "updateOne", + { + filter, + update, + upsert: options?.upsert, + }, + { + operationName, + } + ); } /** @@ -388,21 +409,37 @@ export class Collection { * If upsert is set to true and no documents match the filter, the action returns the `_id` value of the inserted document as a string in the `upsertedId` field */ async updateMany( + operationName: string, filter: Filter, update: UpdateFilter, - { upsert }: { upsert?: boolean } = {} - ): Promise< - DataAPIResponse<{ - matchedCount: number; - modifiedCount: number; - upsertedId?: string; - }> - > { - return this.callApi("updateMany", { - filter, - update, - upsert, - }); + options?: UpdateManyRequestOptions + ): UpdateManyResponse; + async updateMany( + filter: Filter, + update: UpdateFilter, + options?: UpdateManyRequestOptions + ): UpdateManyResponse; + async updateMany(...args: unknown[]): UpdateManyResponse { + const [operationName, filter, update, options] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [ + string, + Filter, + UpdateFilter, + UpdateManyRequestOptions?, + ]; + + return this.callApi( + "updateMany", + { + filter, + update, + upsert: options?.upsert, + }, + { + operationName, + } + ); } /** @@ -418,21 +455,37 @@ export class Collection { * If upsert is set to true and no documents match the filter, the action returns the `_id` value of the inserted document as a string in the `upsertedId` field */ async replaceOne( + operationName: string, filter: Filter, replacement: WithoutId, - options: { upsert?: boolean } = {} - ): Promise< - DataAPIResponse<{ - matchedCount: number; - modifiedCount: number; - upsertedId?: string; - }> - > { - return this.callApi("replaceOne", { - filter, - replacement, - upsert: options.upsert, - }); + options?: ReplaceOneRequestOptions + ): ReplaceOneResponse; + async replaceOne( + filter: Filter, + replacement: WithoutId, + options?: ReplaceOneRequestOptions + ): ReplaceOneResponse; + async replaceOne(...args: unknown[]): ReplaceOneResponse { + const [operationName, filter, replacement, options] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [ + string, + Filter, + WithoutId, + ReplaceOneRequestOptions?, + ]; + + return this.callApi( + "replaceOne", + { + filter, + replacement, + upsert: options?.upsert, + }, + { + operationName, + } + ); } /** @@ -441,11 +494,24 @@ export class Collection { * @returns The deleteOne action returns the number of deleted documents in the deletedCount field */ async deleteOne( + operationName: string, filter: Filter - ): Promise> { - return this.callApi("deleteOne", { - filter, - }); + ): DeleteOneResponse; + async deleteOne(filter: Filter): DeleteOneResponse; + async deleteOne(...args: unknown[]): DeleteOneResponse { + const [operationName, filter] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [string, Filter]; + + return this.callApi( + "deleteOne", + { + filter, + }, + { + operationName, + } + ); } /** @@ -454,9 +520,22 @@ export class Collection { * @returns The deleteMany action returns the number of deleted documents in the deletedCount field */ async deleteMany( + operationName: string, filter: Filter - ): Promise> { - return this.callApi("deleteMany", { filter }); + ): DeleteManyResponse; + async deleteMany(filter: Filter): DeleteManyResponse; + async deleteMany(...args: unknown[]): DeleteManyResponse { + const [operationName, filter] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [string, Filter]; + + return this.callApi( + "deleteMany", + { filter }, + { + operationName, + } + ); } /** @@ -465,15 +544,37 @@ export class Collection { * @returns The aggregate action returns the result set of the final stage of the pipeline as an array of documents, automatically unwrapping the Atlas `documents` field */ async aggregate( + operationName: string, pipeline: Document[] - ): Promise> { - const response = await this.callApi<{ documents: TOutput[] }>("aggregate", { - pipeline, - }); + ): AggregateResponse; + async aggregate( + pipeline: Document[] + ): AggregateResponse; + async aggregate( + ...args: unknown[] + ): AggregateResponse { + const [operationName, pipeline] = ( + typeof args[0] === "string" ? args : ["default", ...args] + ) as [string, Document[]]; + + const response = await this.callApi<{ documents: TOutput[] }>( + "aggregate", + { + pipeline, + }, + { + operationName, + } + ); - return response.data - ? { data: response.data.documents } - : { error: response.error ?? new MongoDataAPIError("Unknown error", -1) }; + // unwrap and repackage + return ( + response.data + ? { data: response.data.documents } + : { + error: response.error ?? new MongoDataAPIError("Unknown error", -1), + } + ) as Awaited>; } /** @@ -484,13 +585,27 @@ export class Collection { */ async callApi( method: string, - body: Record + body: Record, + requestOptions: callAPIOptions ): Promise> { - const { endpoint, dataSource, headers, collection, database } = - this.connection; + // merge all options + const { + endpoint, + dataSource, + headers: h, + collection, + database, + fetch: ftch, + } = this.connection; + + const headers = new Headers(h); + if (requestOptions.operationName) { + headers.set("x-realm-op-name", requestOptions.operationName); + } + const url = `${endpoint}/action/${method}`; - const response = await this.ftch(url, { + const response = await ftch(url, { method: "POST", headers, body: EJSON.stringify({ diff --git a/src/responseTypes.ts b/src/responseTypes.ts new file mode 100644 index 0000000..a7e992e --- /dev/null +++ b/src/responseTypes.ts @@ -0,0 +1,91 @@ +import type { ObjectId } from "bson"; +import type { Sort, WithId, Document } from "mongodb"; +import { type MongoDataAPIError } from "./errors.js"; + +/** Describes a typing that may be null */ +// eslint-disable-next-line @typescript-eslint/ban-types +type Nullable = T | null; + +/** A data API response object */ +export type DataAPIResponse = { data?: T; error?: MongoDataAPIError }; + +export type FindOneRequestOptions = { projection?: Document; sort?: Document }; + +/** A data API response object */ +export type FindOneResponse = Promise< + DataAPIResponse>> +>; + +/** The MongoDB-specific options for this API method */ +export type FindRequestOptions = { + projection?: Document; + sort?: Sort; + limit?: number; + skip?: number; +}; + +/** A data API response object */ +export type FindResponse = Promise< + DataAPIResponse>> +>; + +/** A data API response object */ +export type InsertOneResponse = Promise< + DataAPIResponse<{ insertedId: ObjectId }> +>; + +/** A data API response object */ +export type InsertManyResponse = Promise< + DataAPIResponse<{ insertedIds: string[] }> +>; + +/** The MongoDB-specific options for this API method */ +export type UpdateOneRequestOptions = { upsert?: boolean }; + +/** A data API response object */ +export type UpdateOneResponse = Promise< + DataAPIResponse<{ + matchedCount: number; + modifiedCount: number; + upsertedId?: string; + }> +>; + +/** The MongoDB-specific options for this API method */ +export type UpdateManyRequestOptions = { upsert?: boolean }; + +/** A data API response object */ +export type UpdateManyResponse = Promise< + DataAPIResponse<{ + matchedCount: number; + modifiedCount: number; + upsertedId?: string; + }> +>; + +/** The MongoDB-specific options for this API method */ +export type ReplaceOneRequestOptions = { upsert?: boolean }; + +/** A data API response object */ +export type ReplaceOneResponse = Promise< + DataAPIResponse<{ + matchedCount: number; + modifiedCount: number; + upsertedId?: string; + }> +>; + +/** A data API response object */ +export type DeleteOneResponse = Promise< + DataAPIResponse<{ deletedCount: number }> +>; + +/** A data API response object */ +export type DeleteManyResponse = Promise< + DataAPIResponse<{ deletedCount: number }> +>; + +/** A data API response object */ +export type AggregateResponse = Promise< + DataAPIResponse +>; diff --git a/test/configure.spec.ts b/test/configure.spec.ts index 2d75997..feaa4cc 100644 --- a/test/configure.spec.ts +++ b/test/configure.spec.ts @@ -3,8 +3,8 @@ import fetch from "cross-fetch"; import { MongoClient } from "../src/client.js"; import { BASE_URL } from "./mocks/handlers.js"; -test("requires a fetch interface", (t) => { - t.throws(() => { +test("requires a valid fetch interface", async (t) => { + await t.throwsAsync(async () => { const c = new MongoClient({ endpoint: BASE_URL, dataSource: "test-datasource", @@ -12,6 +12,7 @@ test("requires a fetch interface", (t) => { // @ts-expect-error intentionally knocking out the fetch interface fetch: "not a fetch interface", }); + await c.db("any").collection("any").find(); }); }); diff --git a/test/mocks/handlers.ts b/test/mocks/handlers.ts index d01d2c5..01ef17a 100644 --- a/test/mocks/handlers.ts +++ b/test/mocks/handlers.ts @@ -3,8 +3,6 @@ import { rest } from "msw"; export const BASE_URL = "https://data.mongodb-api.com/app/validClientAppId/endpoint/data/v1"; -export const X_RESPONSE_KEY = "x-test-condition"; - // sub: ok // alg: HS256 // typ: JWT @@ -88,6 +86,7 @@ export const payloads: Record> = { }, ], }, + error: null, }, }; @@ -108,8 +107,8 @@ export const errors: Record = { /** Helper function to forcefully cast something to an array */ function asArray( - value: (T | undefined) | (T | undefined)[] | readonly (T | undefined)[] -): (T | undefined)[] { + value: (T | undefined) | Array | ReadonlyArray +): Array { return (Array.isArray(value) ? value : [value]) as T[]; } @@ -162,8 +161,12 @@ export const handlers = [ return response(ctx.status(401), ctx.json(errors["401"])); } - // a "X-FORCE-ERROR" header can be used to force a specific error - const forceError = request.headers.get("x-force-error"); + const u = new URL( + "http://localhost?" + request.headers.get("x-realm-op-name") + ); + const operation = u.searchParams; + + const forceError = operation.get("error"); if (forceError && errors[forceError]) { return response( ctx.status(Number(forceError)), @@ -188,7 +191,7 @@ export const handlers = [ throw new Error(`Unhandled test action ${action}`); } - const condition = request.headers.get(X_RESPONSE_KEY) ?? "success"; + const condition = operation.get("condition") ?? "success"; const payload = conditions[condition] as unknown; if (condition === "raw") { @@ -196,9 +199,9 @@ export const handlers = [ return undefined; } - if (!payload) { + if (!payload && payload !== null) { throw new Error( - `Unhandled test action + condition ${action} + ${condition}` + `Unhandled test action + condition: ${action} + ${condition}` ); } diff --git a/test/requests.spec.ts b/test/requests.spec.ts index 41064a5..31573b9 100644 --- a/test/requests.spec.ts +++ b/test/requests.spec.ts @@ -6,7 +6,6 @@ import { MongoDataAPIError } from "../src/errors.js"; import { BASE_URL, payloads, - X_RESPONSE_KEY, type TestDocument, type TestAggregateDocument, } from "./mocks/handlers.js"; @@ -33,13 +32,12 @@ const addMsw = ( t.context.msw = s; }; -const createMongoClient = (headers?: Headers) => { +const createMongoClient = () => { const c = new MongoClient({ endpoint: BASE_URL, dataSource: "test-datasource", auth: { apiKey: "validApiKey" }, fetch, - headers, }); return c; @@ -109,11 +107,16 @@ test("api: updateOne", async (t) => { test("api: updateOne upsert", async (t) => { addMsw(t); - const c = createMongoClient(new Headers({ [X_RESPONSE_KEY]: "upsert" })); + const c = createMongoClient(); const { data, error } = await c .db("test-db") .collection("test-collection") - .updateOne({ test: "test" }, { test: "tested" }, { upsert: true }); + .updateOne( + "condition=upsert", + { test: "test" }, + { test: "tested" }, + { upsert: true } + ); t.is(error, undefined); t.deepEqual(data, payloads.updateOne.upsert); @@ -133,11 +136,16 @@ test("api: updateMany", async (t) => { test("api: updateMany upsert", async (t) => { addMsw(t); - const c = createMongoClient(new Headers({ [X_RESPONSE_KEY]: "upsert" })); + const c = createMongoClient(); const { data, error } = await c .db("test-db") .collection("test-collection") - .updateMany({ test: "test" }, { test: "tested" }, { upsert: true }); + .updateMany( + "condition=upsert", + { test: "test" }, + { test: "tested" }, + { upsert: true } + ); t.is(error, undefined); t.deepEqual(data, payloads.updateMany.upsert); @@ -157,11 +165,16 @@ test("api: replaceOne", async (t) => { test("api: replaceOne upsert", async (t) => { addMsw(t); - const c = createMongoClient(new Headers({ [X_RESPONSE_KEY]: "upsert" })); + const c = createMongoClient(); const { data, error } = await c .db("test-db") .collection("test-collection") - .replaceOne({ test: "test" }, { test: "tested" }, { upsert: true }); + .replaceOne( + "condition=upsert", + { test: "test" }, + { test: "tested" }, + { upsert: true } + ); t.is(error, undefined); t.deepEqual(data, payloads.replaceOne.upsert); @@ -212,6 +225,26 @@ test("api: aggregate", async (t) => { t.deepEqual(data, payloads.aggregate.success.documents); }); +test("api: aggregate error", async (t) => { + addMsw(t); + const c = createMongoClient(); + const { data, error } = await c + .db("test-db") + .collection("test-collection") + .aggregate("condition=error", [ + { + $group: { + _id: "$status", + count: { $sum: 1 }, + text: { $push: "$text" }, + }, + }, + { $sort: { count: 1 } }, + ]); + + t.truthy(error); +}); + test("auth: disabled data source results in 400 from Mongo", async (t) => { addMsw(t); const c = new MongoClient({ @@ -269,11 +302,13 @@ test("auth: bad client ID results in 404 from Mongo", async (t) => { test("error: Force a 500 error in case Mongo has an internal server error", async (t) => { addMsw(t); - const c = createMongoClient(new Headers({ "x-force-error": "500" })); + const c = createMongoClient(); const { data, error } = await c .db("test-db") .collection("test-collection") - .findOne({ _id: new ObjectId("6193504e1be4ab27791c8133") }); + .findOne("error=500", { + _id: new ObjectId("6193504e1be4ab27791c8133"), + }); t.is(data, undefined); t.truthy(error instanceof MongoDataAPIError);