diff --git a/src/http/apiCore.ts b/src/http/apiCore.ts index c9845291..83fcdcb4 100644 --- a/src/http/apiCore.ts +++ b/src/http/apiCore.ts @@ -11,6 +11,7 @@ export interface RequestOptions { timeoutSecs: number; headers: any; body?: FormData | string; + queryParams?: Record; } export interface BaseHttpResponse { @@ -41,9 +42,14 @@ export async function sendRequestAndReadResponse( options: RequestOptions, url?: string, ): Promise { - url ??= `https://${options.hostname}${options.path}`; + const requestUrl = new URL(url ?? `https://${options.hostname}${options.path}`); + if (options.queryParams) { + for (const [key, value] of Object.entries(options.queryParams)) { + requestUrl.searchParams.set(key, value); + } + } const response = await request( - url, + requestUrl, { method: options.method, headers: options.headers, diff --git a/src/v2/client.ts b/src/v2/client.ts index 32cf0610..e066f093 100644 --- a/src/v2/client.ts +++ b/src/v2/client.ts @@ -5,6 +5,7 @@ import { MindeeError } from "@/errors/index.js"; import { errorHandler } from "@/errors/handler.js"; import { LOG_LEVELS, logger } from "@/logger.js"; import { ErrorResponse, JobResponse } from "./parsing/index.js"; +import { SearchResponse } from "./parsing/search/index.js"; import { MindeeApiV2 } from "./http/mindeeApiV2.js"; import { MindeeHttpErrorV2 } from "./http/errors.js"; import { PollingOptions, PollingOptionsConstructor } from "./clientOptions/index.js"; @@ -54,6 +55,16 @@ export class Client { logger.debug("Client V2 Initialized"); } + /** + * Search for models available to the account. + * @param name Optional name filter. + * @param modelType Optional model type filter. + * @returns a `Promise` containing the search response. + */ + async searchModels(name?: string, modelType?: string): Promise { + return await this.mindeeApi.reqGetSearchModel(name, modelType); + } + async enqueue

( product: P, inputSource: InputSource, @@ -66,7 +77,7 @@ export class Client { ? params : new product.parametersClass(params); await inputSource.init(); - const jobResponse = await this.mindeeApi.enqueueProduct( + const jobResponse = await this.mindeeApi.reqPostProductEnqueue( product, inputSource, paramsInstance ); if (jobResponse.job.id === undefined || jobResponse.job.id.length === 0) { @@ -94,7 +105,7 @@ export class Client { logger.debug( `Attempting to get inference with ID: ${inferenceId} using response type: ${product.name}` ); - return await this.mindeeApi.getProductResultById(product, inferenceId); + return await this.mindeeApi.reqGetProductResultById(product, inferenceId); } /** @@ -113,7 +124,7 @@ export class Client { logger.debug( `Attempting to get inference from: ${url} using response type: ${product.name}` ); - return await this.mindeeApi.getProductResultByUrl(product, url); + return await this.mindeeApi.reqGetProductResultByUrl(product, url); } /** @@ -126,7 +137,7 @@ export class Client { * parsing is complete. */ async getJob(jobId: string): Promise { - return await this.mindeeApi.getJob(jobId); + return await this.mindeeApi.reqGetJobById(jobId); } /** @@ -155,7 +166,7 @@ export class Client { product, inputSource, paramsInstance ); return await this.pollForResult( - product, pollingOptionsInstance, jobResponse.job.id + product, pollingOptionsInstance, jobResponse ); } @@ -167,7 +178,7 @@ export class Client { protected async pollForResult

( product: typeof BaseProduct, pollingOptions: PollingOptions, - jobId: string, + jobResponse: JobResponse, ): Promise> { logger.debug( `Waiting ${pollingOptions.initialDelaySec} seconds before polling.` @@ -178,7 +189,7 @@ export class Client { pollingOptions.initialTimerOptions ); logger.debug( - `Start polling for inference using job ID: ${jobId}.` + `Start polling for inference using job ID: ${jobResponse.job.id}.` ); let retryCounter: number = 1; let pollResults: JobResponse; @@ -186,7 +197,7 @@ export class Client { logger.debug( `Attempt ${retryCounter} of ${pollingOptions.maxRetries}` ); - pollResults = await this.getJob(jobId); + pollResults = await this.mindeeApi.reqGetJobByUrl(jobResponse.job.pollingUrl); const error: ErrorResponse | undefined = pollResults.job.error; if (error) { throw new MindeeHttpErrorV2(error); diff --git a/src/v2/http/mindeeApiV2.ts b/src/v2/http/mindeeApiV2.ts index 30d7013a..f29a058c 100644 --- a/src/v2/http/mindeeApiV2.ts +++ b/src/v2/http/mindeeApiV2.ts @@ -17,6 +17,7 @@ import { MindeeDeserializationError, MindeeError } from "@/errors/index.js"; import { MindeeHttpErrorV2 } from "./errors.js"; import { logger } from "@/logger.js"; import { BaseProduct } from "@/v2/product/baseProduct.js"; +import { SearchResponse } from "@/v2/parsing/search/index.js"; /** * Mindee V2 API handler. @@ -29,22 +30,55 @@ export class MindeeApiV2 { } /** - * Sends a file to the product inference queue. - * @param product product to enqueue. - * @param inputSource Local file loaded as an input. + * Search for models available to the account. + * @param name Optional name filter. + * @param modelType Optional model type filter. + * @returns a `Promise` containing the search response. + */ + async reqGetSearchModel(name?: string, modelType?: string): Promise { + const queryParams: Record = {}; + if (name) queryParams["name"] = name; + if (modelType) queryParams["model_type"] = modelType; + const options: RequestOptions = { + method: "GET", + headers: this.settings.baseHeaders, + hostname: this.settings.hostname, + path: "/v2/search/models", + queryParams: queryParams, + timeoutSecs: this.settings.timeoutSecs, + }; + const response: BaseHttpResponse = await sendRequestAndReadResponse(this.settings.dispatcher, options); + return this.#processResponse(response, SearchResponse); + } + + /** + * Sends a document to the inference queue. + * @param product Product to enqueue. + * @param inputSource Local or remote file as an input. * @param params {ExtractionParameters} parameters relating to the enqueueing options. - * @throws Error if the server's response contains an error. - * @returns a `Promise` containing a job response. */ - async enqueueProduct( + async reqPostProductEnqueue( product: typeof BaseProduct, inputSource: InputSource, params: BaseParameters ): Promise { - await inputSource.init(); - const result: BaseHttpResponse = await this.#reqPostProductEnqueue( - product, inputSource, params - ); + const form = params.getFormData(); + if (inputSource instanceof LocalInputSource) { + form.set("file", new Blob([inputSource.fileObject]), inputSource.filename); + } else { + form.set("url", (inputSource as UrlInput).url); + } + const path = `/v2/products/${product.slug}/enqueue`; + const options: RequestOptions = { + method: "POST", + headers: this.settings.baseHeaders, + hostname: this.settings.hostname, + path: path, + body: form, + timeoutSecs: this.settings.timeoutSecs, + }; + const result: BaseHttpResponse = await sendRequestAndReadResponse(this.settings.dispatcher, options); + if (result.data.error !== undefined) { throw new MindeeHttpErrorV2(result.data.error); } @@ -52,13 +86,33 @@ export class MindeeApiV2 { } /** - * Get the specified Job. + * Get the specified Job by its ID. * Throws an error if the server's response contains an error. * @param jobId The Job ID as returned by the enqueue request. * @returns a `Promise` containing the job response. */ - async getJob(jobId: string): Promise { - const response = await this.#reqGetJob(jobId); + async reqGetJobById(jobId: string): Promise { + return this.reqGetJobByUrl( + `https://${this.settings.hostname}/v2/jobs/${jobId}` + ); + } + + /** + * Get the specified Job from a polling URL. + * Throws an error if the server's response contains an error. + * @param pollingUrl The polling URL as returned by a Job's pollingUrl property. + * @returns a `Promise` containing the job response. + */ + async reqGetJobByUrl(pollingUrl: string): Promise { + if (!pollingUrl.startsWith("https://")) { + throw new MindeeError(`Invalid URL: ${pollingUrl}`); + } + const options: RequestOptions = { + method: "GET", + headers: this.settings.baseHeaders, + timeoutSecs: this.settings.timeoutSecs, + }; + const response: BaseHttpResponse = await sendRequestAndReadResponse(this.settings.dispatcher, options, pollingUrl); return this.#processResponse(response, JobResponse); } @@ -69,14 +123,14 @@ export class MindeeApiV2 { * @param inferenceId The inference ID for the result. * @returns a `Promise` containing the parsed result. */ - async getProductResultById

( + async reqGetProductResultById

( product: P, inferenceId: string, ): Promise> { - const queueResponse: BaseHttpResponse = await this.#reqGetProductResult( + return this.reqGetProductResultByUrl( + product, `https://${this.settings.hostname}/v2/products/${product.slug}/results/${inferenceId}` ); - return this.#processResponse(queueResponse, product.responseClass) as InstanceType; } /** @@ -86,12 +140,21 @@ export class MindeeApiV2 { * @param url The URL as returned by a Job's resultUrl property. * @returns a `Promise` containing the parsed result. */ - async getProductResultByUrl

( + async reqGetProductResultByUrl

( product: P, url: string, ): Promise> { - const queueResponse: BaseHttpResponse = await this.#reqGetProductResult(url); - return this.#processResponse(queueResponse, product.responseClass) as InstanceType; + const options: RequestOptions = { + method: "GET", + headers: this.settings.baseHeaders, + timeoutSecs: this.settings.timeoutSecs, + }; + if (!url.startsWith("https://")) { + throw new MindeeError(`Invalid URL: ${url}`); + } + const response: BaseHttpResponse = await sendRequestAndReadResponse(this.settings.dispatcher, options, url); + + return this.#processResponse(response, product.responseClass) as InstanceType; } #processResponse( @@ -125,61 +188,4 @@ export class MindeeApiV2 { throw new MindeeDeserializationError("Couldn't deserialize response object."); } } - - /** - * Sends a document to the inference queue. - * @param product Product to enqueue. - * @param inputSource Local or remote file as an input. - * @param params {ExtractionParameters} parameters relating to the enqueueing options. - */ - async #reqPostProductEnqueue( - product: typeof BaseProduct, - inputSource: InputSource, - params: BaseParameters - ): Promise { - const form = params.getFormData(); - if (inputSource instanceof LocalInputSource) { - form.set("file", new Blob([inputSource.fileObject]), inputSource.filename); - } else { - form.set("url", (inputSource as UrlInput).url); - } - const path = `/v2/products/${product.slug}/enqueue`; - const options: RequestOptions = { - method: "POST", - headers: this.settings.baseHeaders, - hostname: this.settings.hostname, - path: path, - body: form, - timeoutSecs: this.settings.timeoutSecs, - }; - return await sendRequestAndReadResponse(this.settings.dispatcher, options); - } - - async #reqGetJob(jobId: string): Promise { - const options: RequestOptions = { - method: "GET", - headers: this.settings.baseHeaders, - hostname: this.settings.hostname, - path: `/v2/jobs/${jobId}`, - timeoutSecs: this.settings.timeoutSecs, - }; - return await sendRequestAndReadResponse(this.settings.dispatcher, options); - } - - /** - * Make a request to GET the status of a document in the queue. - * @param url URL path to the result. - * @returns a `Promise` containing the parsed result. - */ - async #reqGetProductResult(url: string): Promise { - const options: RequestOptions = { - method: "GET", - headers: this.settings.baseHeaders, - timeoutSecs: this.settings.timeoutSecs, - }; - if (!url.startsWith("https://")) { - throw new MindeeError(`Invalid URL: ${url}`); - } - return await sendRequestAndReadResponse(this.settings.dispatcher, options, url); - } } diff --git a/src/v2/parsing/search/index.ts b/src/v2/parsing/search/index.ts new file mode 100644 index 00000000..da3ce8c4 --- /dev/null +++ b/src/v2/parsing/search/index.ts @@ -0,0 +1,4 @@ +export { PaginationMetadata } from "./paginationMetadata.js"; +export { SearchModel } from "./searchModel.js"; +export { SearchResponse } from "./searchResponse.js"; +export { ModelWebhook } from "./modelWebhook.js"; diff --git a/src/v2/parsing/search/modelWebhook.ts b/src/v2/parsing/search/modelWebhook.ts new file mode 100644 index 00000000..1791778e --- /dev/null +++ b/src/v2/parsing/search/modelWebhook.ts @@ -0,0 +1,27 @@ +import { StringDict } from "@/parsing/index.js"; + +/** + * Model webhook info. + */ +export class ModelWebhook { + /** + * ID of the webhook. + */ + public id: string; + + /** + * Name of the webhook. + */ + public name: string; + + /** + * URL of the webhook. + */ + public url: string; + + constructor(serverResponse: StringDict) { + this.id = serverResponse["id"]; + this.name = serverResponse["name"]; + this.url = serverResponse["url"]; + } +} diff --git a/src/v2/parsing/search/paginationMetadata.ts b/src/v2/parsing/search/paginationMetadata.ts new file mode 100644 index 00000000..9cb5b1e5 --- /dev/null +++ b/src/v2/parsing/search/paginationMetadata.ts @@ -0,0 +1,42 @@ +import { StringDict } from "@/parsing/index.js"; + +/** + * PaginationMetadata data associated with model search. + */ +export class PaginationMetadata { + /** + * Number of items per page. + */ + public perPage: number; + + /** + * 1-indexed page number. + */ + public page: number; + + /** + * Total items. + */ + public totalItems: number; + + /** + * Total number of pages. + */ + public totalPages: number; + + constructor(serverResponse: StringDict) { + this.perPage = serverResponse["per_page"]; + this.page = serverResponse["page"]; + this.totalItems = serverResponse["total_items"]; + this.totalPages = serverResponse["total_pages"]; + } + + toString(): string { + return [ + `:Per Page: ${this.perPage}`, + `:Page: ${this.page}`, + `:Total Items: ${this.totalItems}`, + `:Total Pages: ${this.totalPages}`, + ].join("\n"); + } +} diff --git a/src/v2/parsing/search/searchModel.ts b/src/v2/parsing/search/searchModel.ts new file mode 100644 index 00000000..cf90481b --- /dev/null +++ b/src/v2/parsing/search/searchModel.ts @@ -0,0 +1,44 @@ +import { StringDict } from "@/parsing/index.js"; +import { ModelWebhook } from "./modelWebhook.js"; + +/** + * Models search response. + */ +export class SearchModel { + /** + * ID of the model. + */ + public id: string; + + /** + * Name of the model. + */ + public name: string; + + /** + * Type of the model. + */ + public modelType: string; + + /** + * Webhooks associated with the model. + */ + public webhooks: ModelWebhook[]; + + constructor(serverResponse: StringDict) { + this.id = serverResponse["id"]; + this.name = serverResponse["name"]; + this.modelType = serverResponse["model_type"]; + this.webhooks = (serverResponse["webhooks"] ?? []).map( + (webhook: StringDict) => new ModelWebhook(webhook) + ); + } + + toString(): string { + return [ + `:Name: ${this.name}`, + `:ID: ${this.id}`, + `:Model Type: ${this.modelType}`, + ].join("\n"); + } +} diff --git a/src/v2/parsing/search/searchResponse.ts b/src/v2/parsing/search/searchResponse.ts new file mode 100644 index 00000000..4a3a1ecf --- /dev/null +++ b/src/v2/parsing/search/searchResponse.ts @@ -0,0 +1,39 @@ +import { StringDict } from "@/parsing/index.js"; +import { BaseResponse } from "@/v2/parsing/baseResponse.js"; +import { PaginationMetadata } from "./paginationMetadata.js"; +import { SearchModel } from "./searchModel.js"; + +/** + * Models search response. + */ +export class SearchResponse extends BaseResponse { + /** + * List of models returned by the search. + */ + public models: SearchModel[]; + + /** + * Pagination metadata. + */ + public pagination: PaginationMetadata; + + constructor(serverResponse: StringDict) { + super(serverResponse); + this.models = (serverResponse["models"] ?? []).map( + (model: StringDict) => new SearchModel(model) + ); + this.pagination = new PaginationMetadata(serverResponse["pagination"]); + } + + toString(): string { + const lines: string[] = ["Models", "#######"]; + for (const model of this.models) { + lines.push(`* :Name: ${model.name}`); + lines.push(` :ID: ${model.id}`); + lines.push(` :Model Type: ${model.modelType}`); + } + lines.push("Pagination", "##########"); + lines.push(this.pagination.toString()); + return lines.join("\n"); + } +} diff --git a/tests/v2/parsing/search.spec.ts b/tests/v2/parsing/search.spec.ts new file mode 100644 index 00000000..4c84de98 --- /dev/null +++ b/tests/v2/parsing/search.spec.ts @@ -0,0 +1,38 @@ +import path from "path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { LocalResponse } from "@/v2/index.js"; +import { SearchResponse } from "@/v2/parsing/search/index.js"; +import { V2_RESOURCE_PATH } from "../../index.js"; + +const filePath = path.join(V2_RESOURCE_PATH, "search/models.json"); + +describe("MindeeV2 - Search Models", () => { + it("should load search models locally", async () => { + const localResponse = new LocalResponse(filePath); + const response = await localResponse.deserializeResponse(SearchResponse); + + assert.ok(response instanceof SearchResponse); + + assert.strictEqual(response.models.length, 5); + assert.strictEqual(response.pagination.totalItems, 5); + assert.strictEqual(response.pagination.page, 1); + assert.strictEqual(response.pagination.perPage, 50); + assert.strictEqual(response.pagination.totalPages, 1); + + const firstModel = response.models[0]; + assert.strictEqual(firstModel.name, "Extraction With Webhooks"); + assert.strictEqual(firstModel.id, "afde5151-aa11-aa11-9289-fa04e50ca3b9"); + assert.strictEqual(firstModel.modelType, "extraction"); + + assert.strictEqual(firstModel.webhooks.length, 2); + assert.strictEqual(firstModel.webhooks[0].id, "a2286ed9-aa11-aa11-bdc5-2f8496c5641a"); + assert.strictEqual(firstModel.webhooks[0].name, "FAILURE"); + assert.strictEqual(firstModel.webhooks[0].url, "https://failure.mindee.com"); + + const lastModel = response.models[response.models.length - 1]; + assert.strictEqual(lastModel.name, "Extraction Without Webhooks Key"); + assert.strictEqual(lastModel.id, "e14e0923-ee55-ee55-a335-8d2110917d7b"); + }); +});