diff --git a/.github/workflows/_test-code-samples.yml b/.github/workflows/_test-code-samples.yml index 99557252..371787e9 100644 --- a/.github/workflows/_test-code-samples.yml +++ b/.github/workflows/_test-code-samples.yml @@ -11,7 +11,6 @@ jobs: strategy: matrix: node-version: - - "18" - "20" - "22" - "24" diff --git a/.github/workflows/_test-units.yml b/.github/workflows/_test-units.yml index 73ea66bc..76f45e76 100644 --- a/.github/workflows/_test-units.yml +++ b/.github/workflows/_test-units.yml @@ -18,6 +18,7 @@ jobs: - "18" - "20" - "22" + - "24" runs-on: ${{ matrix.os }} steps: diff --git a/src/client.ts b/src/client.ts index 3f5510d0..72c9d15a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -255,10 +255,11 @@ export class Client { * @returns A valid prediction */ try { - if (Object.prototype.hasOwnProperty.call(localResponse.asDict(), "job")) { - return new AsyncPredictResponse(productClass, localResponse.asDict()); + const asDict = await localResponse.asDict(); + if (Object.prototype.hasOwnProperty.call(asDict, "job")) { + return new AsyncPredictResponse(productClass, asDict); } - return new PredictResponse(productClass, localResponse.asDict()); + return new PredictResponse(productClass, asDict); } catch { throw new MindeeError("No prediction found in local response."); } diff --git a/src/clientV2.ts b/src/clientV2.ts index 4f24ce77..4b13aa06 100644 --- a/src/clientV2.ts +++ b/src/clientV2.ts @@ -1,7 +1,4 @@ -import { - Base64Input, BufferInput, BytesInput, InputSource, - PathInput, StreamInput, UrlInput, -} from "./input"; +import { InputSource } from "./input"; import { errorHandler } from "./errors/handler"; import { LOG_LEVELS, logger } from "./logger"; @@ -9,7 +6,6 @@ import { setTimeout } from "node:timers/promises"; import { ErrorResponse, InferenceResponse, JobResponse } from "./parsing/v2"; import { MindeeApiV2 } from "./http/mindeeApiV2"; import { MindeeHttpErrorV2 } from "./errors/mindeeError"; -import { Readable } from "stream"; /** * Parameters for the internal polling loop in {@link ClientV2.enqueueAndGetInference | enqueueAndGetInference()} . @@ -160,6 +156,9 @@ export class ClientV2 { if (inputSource === undefined) { throw new Error("The 'enqueue' function requires an input document."); } + if (!inputSource.isInitialized()) { + await inputSource.init(); + } return await this.mindeeApi.reqPostInferenceEnqueue(inputSource, params); } @@ -232,7 +231,7 @@ export class ClientV2 { * Send a document to an endpoint and poll the server until the result is sent or * until the maximum number of tries is reached. * - * @param inputDoc document to parse. + * @param inputSource file or URL to parse. * @param params parameters relating to prediction options. * * @typeParam T an extension of an `Inference`. Can be omitted as it will be inferred from the `productClass`. @@ -240,11 +239,11 @@ export class ClientV2 { * @returns a `Promise` containing parsing results. */ async enqueueAndGetInference( - inputDoc: InputSource, + inputSource: InputSource, params: InferenceParameters ): Promise { const validatedAsyncParams = this.#setAsyncParams(params.pollingOptions); - const enqueueResponse: JobResponse = await this.enqueueInference(inputDoc, params); + const enqueueResponse: JobResponse = await this.enqueueInference(inputSource, params); if (enqueueResponse.job.id === undefined || enqueueResponse.job.id.length === 0) { logger.error(`Failed enqueueing:\n${enqueueResponse.getRawHttp()}`); throw Error("Enqueueing of the document failed."); @@ -283,72 +282,4 @@ Job status: ${pollResults.job.status}.` " seconds" ); } - - /** - * Load an input source from a local path. - * @param inputPath - */ - sourceFromPath(inputPath: string): PathInput { - return new PathInput({ - inputPath: inputPath, - }); - } - - /** - * Load an input source from a base64 encoded string. - * @param inputString input content, as a string. - * @param filename file name. - */ - sourceFromBase64(inputString: string, filename: string): Base64Input { - return new Base64Input({ - inputString: inputString, - filename: filename, - }); - } - - /** - * Load an input source from a `stream.Readable` object. - * @param inputStream input content, as a readable stream. - * @param filename file name. - */ - sourceFromStream(inputStream: Readable, filename: string): StreamInput { - return new StreamInput({ - inputStream: inputStream, - filename: filename, - }); - } - - /** - * Load an input source from bytes. - * @param inputBytes input content, as a Uint8Array or Buffer. - * @param filename file name. - */ - sourceFromBytes(inputBytes: Uint8Array, filename: string): BytesInput { - return new BytesInput({ - inputBytes: inputBytes, - filename: filename, - }); - } - - /** - * Load an input source from a Buffer. - * @param buffer input content, as a buffer. - * @param filename file name. - */ - sourceFromBuffer(buffer: Buffer, filename: string): BufferInput { - return new BufferInput({ - buffer: buffer, - filename: filename, - }); - } - - /** - * Load an input source from a URL. - * @param url input url. Must be HTTPS. - */ - sourceFromUrl(url: string): UrlInput { - return new UrlInput({ - url: url, - }); - } } diff --git a/src/index.ts b/src/index.ts index 77c81598..7bb1cec6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * as product from "./product"; -export { Client, PredictOptions } from "./client"; +export { Client, PredictOptions, WorkflowOptions } from "./client"; export { ClientV2, InferenceParameters, PollingOptions } from "./clientV2"; export { AsyncPredictResponse, @@ -9,6 +9,10 @@ export { Document, Page, } from "./parsing/common"; +export { + InferenceResponse, + JobResponse, +} from "./parsing/v2"; export { InputSource, Base64Input, diff --git a/src/input/localResponse.ts b/src/input/localResponse.ts index a8f28fda..c13ca803 100644 --- a/src/input/localResponse.ts +++ b/src/input/localResponse.ts @@ -12,6 +12,7 @@ import { CommonResponse } from "../parsing/v2"; export class LocalResponse { private file: Buffer; private readonly inputHandle: Buffer | string; + protected initialized = false; /** * Creates an instance of LocalResponse. @@ -39,13 +40,17 @@ export class LocalResponse { } else { throw new MindeeError("Incompatible type for input."); } + this.initialized = true; } /** * Returns the dictionary representation of the file. * @returns A JSON-like object. */ - asDict(): StringDict { + async asDict(): Promise { + if (!this.initialized) { + await this.init(); + } try { const content = this.file.toString("utf-8"); return JSON.parse(content); @@ -60,6 +65,11 @@ export class LocalResponse { * @returns The HMAC signature of the local response. */ getHmacSignature(secretKey: string | Buffer | Uint8Array): string { + if (!this.initialized) { + throw new Error( + "The `init()` method must be called before calling `getHmacSignature()`." + ); + } const algorithm = "sha256"; try { const hmac = crypto.createHmac(algorithm, secretKey); @@ -76,7 +86,12 @@ export class LocalResponse { * @param signature - The signature to be compared with. * @returns True if the HMAC signature is valid. */ - isValidHmacSignature(secretKey: string | Buffer | Uint8Array, signature: string): boolean { + public isValidHmacSignature(secretKey: string | Buffer | Uint8Array, signature: string): boolean { + if (!this.initialized) { + throw new Error( + "The `init()` method must be called before calling `isValidHmacSignature()`." + ); + } return signature === this.getHmacSignature(secretKey); } @@ -90,11 +105,11 @@ export class LocalResponse { * @returns An instance of `responseClass` populated with the file content. * @throws MindeeError If the provided class cannot be instantiated. */ - public deserializeResponse( + public async deserializeResponse( responseClass: new (serverResponse: StringDict) => ResponseT - ): ResponseT { + ): Promise { try { - return new responseClass(this.asDict()); + return new responseClass(await this.asDict()); } catch { throw new MindeeError("Invalid response provided."); } diff --git a/src/input/sources/base64Input.ts b/src/input/sources/base64Input.ts index cbeb75cd..ef73c86f 100644 --- a/src/input/sources/base64Input.ts +++ b/src/input/sources/base64Input.ts @@ -23,5 +23,6 @@ export class Base64Input extends LocalInputSource { this.mimeType = await this.checkMimetype(); // clear out the string this.inputString = ""; + this.initialized = true; } } diff --git a/src/input/sources/bufferInput.ts b/src/input/sources/bufferInput.ts index 8a26c16a..6120bc80 100644 --- a/src/input/sources/bufferInput.ts +++ b/src/input/sources/bufferInput.ts @@ -17,5 +17,6 @@ export class BufferInput extends LocalInputSource { async init(): Promise { this.mimeType = await this.checkMimetype(); + this.initialized = true; } } diff --git a/src/input/sources/bytesInput.ts b/src/input/sources/bytesInput.ts index 9584947b..a765ba65 100644 --- a/src/input/sources/bytesInput.ts +++ b/src/input/sources/bytesInput.ts @@ -22,5 +22,6 @@ export class BytesInput extends LocalInputSource { this.fileObject = Buffer.from(this.inputBytes); this.mimeType = await this.checkMimetype(); this.inputBytes = new Uint8Array(0); + this.initialized = true; } } diff --git a/src/input/sources/inputSource.ts b/src/input/sources/inputSource.ts index 453cf52f..5513e38d 100644 --- a/src/input/sources/inputSource.ts +++ b/src/input/sources/inputSource.ts @@ -14,9 +14,13 @@ export const INPUT_TYPE_BUFFER = "buffer"; export abstract class InputSource { fileObject: Buffer | string = ""; + protected initialized: boolean = false; async init() { throw new Error("not Implemented"); } -} + public isInitialized() { + return this.initialized; + } +} diff --git a/src/input/sources/localInputSource.ts b/src/input/sources/localInputSource.ts index c9b9f8c8..36cf07c1 100644 --- a/src/input/sources/localInputSource.ts +++ b/src/input/sources/localInputSource.ts @@ -56,11 +56,7 @@ export abstract class LocalInputSource extends InputSource { logger.debug(`Loading file from: ${inputType}`); } - isPdf(): boolean { - return this.mimeType === "application/pdf"; - } - - async checkMimetype(): Promise { + protected async checkMimetype(): Promise { if (!(this.fileObject instanceof Buffer)) { throw new Error( `MIME type cannot be verified on input source of type ${this.inputType}.` @@ -87,11 +83,23 @@ export abstract class LocalInputSource extends InputSource { return mimeType; } + isPdf(): boolean { + if (!this.initialized) { + throw new Error( + "The `init()` method must be called before calling `isPdf()`." + ); + } + return this.mimeType === "application/pdf"; + } + /** * Cut PDF pages. * @param pageOptions */ - async applyPageOptions(pageOptions: PageOptions) { + public async applyPageOptions(pageOptions: PageOptions) { + if (!this.initialized) { + await this.init(); + } if (!(this.fileObject instanceof Buffer)) { throw new Error( `Cannot modify an input source of type ${this.inputType}.` @@ -104,7 +112,7 @@ export abstract class LocalInputSource extends InputSource { /** * Cut PDF pages. * @param pageOptions - * @deprecated Deprecated in favor of {@link LocalInputSource.applyPageOptions applyPageOptions()}. + * @deprecated Deprecated in favor of {@link LocalInputSource.applyPageOptions}. */ async cutPdf(pageOptions: PageOptions) { return this.applyPageOptions(pageOptions); @@ -122,13 +130,16 @@ export abstract class LocalInputSource extends InputSource { * * @returns A Promise that resolves when the compression is complete. */ - async compress( + public async compress( quality: number = 85, maxWidth: number | null = null, maxHeight: number | null = null, forceSourceText: boolean = false, disableSourceText: boolean = true ) { + if (!this.initialized) { + await this.init(); + } let buffer: Buffer; if (typeof this.fileObject === "string") { buffer = Buffer.from(this.fileObject); @@ -146,7 +157,10 @@ export abstract class LocalInputSource extends InputSource { * Returns true if the object is a PDF and has source text. False otherwise. * @return boolean */ - async hasSourceText() { + public async hasSourceText() { + if (!this.initialized) { + await this.init(); + } if (!this.isPdf()){ return false; } diff --git a/src/input/sources/pathInput.ts b/src/input/sources/pathInput.ts index b0e6b0ca..15860f0b 100644 --- a/src/input/sources/pathInput.ts +++ b/src/input/sources/pathInput.ts @@ -24,5 +24,6 @@ export class PathInput extends LocalInputSource { logger.debug(`Loading from: ${this.inputPath}`); this.fileObject = Buffer.from(await fs.readFile(this.inputPath)); this.mimeType = await this.checkMimetype(); + this.initialized = true; } } diff --git a/src/input/sources/streamInput.ts b/src/input/sources/streamInput.ts index 1a8d6c9b..9d04fdd9 100644 --- a/src/input/sources/streamInput.ts +++ b/src/input/sources/streamInput.ts @@ -22,6 +22,7 @@ export class StreamInput extends LocalInputSource { async init() { this.fileObject = await this.stream2buffer(this.inputStream); this.mimeType = await this.checkMimetype(); + this.initialized = true; } async stream2buffer(stream: Readable): Promise { diff --git a/src/input/sources/urlInput.ts b/src/input/sources/urlInput.ts index c646eb19..28b95608 100644 --- a/src/input/sources/urlInput.ts +++ b/src/input/sources/urlInput.ts @@ -20,6 +20,7 @@ export class UrlInput extends InputSource { throw new Error("URL must be HTTPS"); } this.fileObject = this.url; + this.initialized = true; } private async fetchFileContent(options: { diff --git a/src/parsing/v2/field/fieldConfidence.ts b/src/parsing/v2/field/fieldConfidence.ts index ca666f02..53e66d82 100644 --- a/src/parsing/v2/field/fieldConfidence.ts +++ b/src/parsing/v2/field/fieldConfidence.ts @@ -1,9 +1,11 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + /** * Confidence level of a field as returned by the V2 API. */ export enum FieldConfidence { - certain = "Certain", - high = "High", - medium = "Medium", - low = "Low", + Certain = "Certain", + High = "High", + Medium = "Medium", + Low = "Low", } diff --git a/tests/inputs/localResponse.spec.ts b/tests/inputs/localResponse.spec.ts index ad400644..0b85f3f7 100644 --- a/tests/inputs/localResponse.spec.ts +++ b/tests/inputs/localResponse.spec.ts @@ -1,7 +1,7 @@ import { LocalResponse } from "../../src"; import * as fs from "node:fs/promises"; import { expect } from "chai"; -import { Client, PredictResponse, AsyncPredictResponse } from "../../src"; +import { Client, PredictResponse, AsyncPredictResponse, InferenceResponse } from "../../src"; import { InternationalIdV2, InvoiceV4, MultiReceiptsDetectorV1 } from "../../src/product"; const signature: string = "5ed1673e34421217a5dbfcad905ee62261a3dd66c442f3edd19302072bbf70d0"; @@ -32,7 +32,7 @@ describe("A valid local response", () => { expect(localResponse.isValidHmacSignature(dummySecretKey, signature)).to.be.true; }); - it("should load a file properly.", async () => { + it("should load a buffer properly.", async () => { const fileStr = (await fs.readFile(filePath, { encoding: "utf-8" })).replace(/\r/g, "").replace(/\n/g, ""); const fileBuffer = Buffer.from(fileStr, "utf-8"); const localResponse = new LocalResponse(fileBuffer); @@ -46,7 +46,6 @@ describe("A valid local response", () => { it("should load into a sync prediction.", async () => { const fileObj = await fs.readFile(multiReceiptsDetectorPath, { encoding: "utf-8" }); const localResponse = new LocalResponse(fileObj); - await localResponse.init(); const dummyClient = new Client({ apiKey: "dummy-key" }); const prediction = await dummyClient.loadPrediction(MultiReceiptsDetectorV1, localResponse); expect(prediction).to.be.an.instanceof(PredictResponse); @@ -57,7 +56,6 @@ describe("A valid local response", () => { it("should load a failed prediction.", async () => { const fileObj = await fs.readFile(failedPath, { encoding: "utf-8" }); const localResponse = new LocalResponse(fileObj); - await localResponse.init(); const dummyClient = new Client({ apiKey: "dummy-key" }); const prediction = await dummyClient.loadPrediction(InvoiceV4, localResponse); expect(prediction).to.be.an.instanceof(AsyncPredictResponse); @@ -67,11 +65,20 @@ describe("A valid local response", () => { it("should load into an async prediction.", async () => { const fileObj = await fs.readFile(internationalIdPath, { encoding: "utf-8" }); const localResponse = new LocalResponse(fileObj); - await localResponse.init(); const dummyClient = new Client({ apiKey: "dummy-key" }); const prediction = await dummyClient.loadPrediction(InternationalIdV2, localResponse); expect(prediction).to.be.an.instanceof(AsyncPredictResponse); expect(JSON.stringify(prediction.getRawHttp())).to.eq(JSON.stringify(JSON.parse(fileObj))); }); + + it("should deserialize a prediction.", async () => { + const filePath = "tests/data/v2/inference/standard_field_types.json"; + const fileObj = await fs.readFile(filePath, { encoding: "utf-8" }); + const localResponse = new LocalResponse(fileObj); + const response = await localResponse.deserializeResponse(InferenceResponse); + expect(response).to.be.an.instanceof(InferenceResponse); + + expect(JSON.stringify(response.getRawHttp())).to.eq(JSON.stringify(JSON.parse(fileObj))); + }); }); diff --git a/tests/inputs/pageOperations.spec.ts b/tests/inputs/pageOperations.spec.ts index 0431c112..f322d4f2 100644 --- a/tests/inputs/pageOperations.spec.ts +++ b/tests/inputs/pageOperations.spec.ts @@ -12,7 +12,6 @@ describe("High level multi-page operations", () => { const input = new PathInput({ inputPath: path.join(__dirname, "../data/file_types/pdf/multipage.pdf"), }); - await input.init(); await input.applyPageOptions({ operation: PageOptionsOperation.KeepOnly, pageIndexes: [0, -2, -1], diff --git a/tests/inputs/sources.spec.ts b/tests/inputs/sources.spec.ts index d57f5997..a9b57e97 100644 --- a/tests/inputs/sources.spec.ts +++ b/tests/inputs/sources.spec.ts @@ -2,13 +2,13 @@ import { Base64Input, BufferInput, BytesInput, + PathInput, + StreamInput, INPUT_TYPE_BASE64, INPUT_TYPE_BUFFER, INPUT_TYPE_BYTES, INPUT_TYPE_PATH, INPUT_TYPE_STREAM, - PathInput, - StreamInput, } from "../../src/input"; import * as fs from "fs"; import * as path from "path"; @@ -149,7 +149,6 @@ describe("Test different types of input", () => { it("Image Quality Compress From Input Source", async () => { const receiptInput = new PathInput({ inputPath: path.join(resourcesPath, "file_types/receipt.jpg") }); - await receiptInput.init(); await receiptInput.compress(40); await fs.promises.writeFile(path.join(outputPath, "compress_indirect.jpg"), receiptInput.fileObject); @@ -238,10 +237,6 @@ describe("Test different types of input", () => { const hasNoSourceTextInput = new PathInput({ inputPath: hasNoSourceTextPath }); const hasNoSourceTextSinceItsImageInput = new PathInput({ inputPath: hasNoSourceTextSinceItsImagePath }); - await hasSourceTextInput.init(); - await hasNoSourceTextInput.init(); - await hasNoSourceTextSinceItsImageInput.init(); - expect(await hasSourceTextInput.hasSourceText()).to.be.true; expect(await hasNoSourceTextInput.hasSourceText()).to.be.false; expect(await hasNoSourceTextSinceItsImageInput.hasSourceText()).to.be.false; diff --git a/tests/parsing/v2/inference.spec.ts b/tests/parsing/v2/inference.spec.ts index 56f4b34d..4fbdda43 100644 --- a/tests/parsing/v2/inference.spec.ts +++ b/tests/parsing/v2/inference.spec.ts @@ -1,7 +1,6 @@ import { expect } from "chai"; import path from "node:path"; -import { InferenceResponse } from "../../../src/parsing/v2"; -import { LocalResponse } from "../../../src"; +import { LocalResponse, InferenceResponse } from "../../../src"; import { FieldConfidence, ListField, ObjectField, SimpleField } from "../../../src/parsing/v2/field"; import { promises as fs } from "node:fs"; import { Polygon } from "../../../src/geometry"; @@ -238,8 +237,11 @@ describe("inference", async () => { expect(polygon[3][0]).to.equal(0.948849); expect(polygon[3][1]).to.equal(0.244565); - expect(dateField.confidence).to.equal(FieldConfidence.medium); - expect(String(dateField.confidence)).to.equal("Medium"); + const isCertainEnum = dateField.confidence === FieldConfidence.Medium; + expect(isCertainEnum).to.be.true; + + const isCertainStr = dateField.confidence === "Medium"; + expect(isCertainStr).to.be.true; }).timeout(10000); }); diff --git a/tests/v2/clientV2.spec.ts b/tests/v2/clientV2.spec.ts index 7bf5524d..cee6445d 100644 --- a/tests/v2/clientV2.spec.ts +++ b/tests/v2/clientV2.spec.ts @@ -2,10 +2,10 @@ import { expect } from "chai"; import nock from "nock"; import path from "node:path"; -import { ClientV2, LocalResponse, PathInput } from "../../src"; +import { ClientV2, LocalResponse, PathInput, InferenceResponse } from "../../src"; import { MindeeHttpErrorV2 } from "../../src/errors/mindeeError"; import assert from "node:assert/strict"; -import { InferenceResponse } from "../../src/parsing/v2"; + /** * Injects a minimal set of environment variables so that the SDK behaves * as if it had been configured by the user. @@ -74,7 +74,7 @@ describe("ClientV2", () => { it("enqueue(path) rejects with MindeeHttpErrorV2 on 4xx", async () => { const filePath = path.join(fileTypesDir, "receipt.jpg"); - const inputDoc = client.sourceFromPath(filePath); + const inputDoc = new PathInput({ inputPath: filePath }); await assert.rejects( client.enqueueInference(inputDoc, { modelId: "dummy-model" }), @@ -84,7 +84,7 @@ describe("ClientV2", () => { it("enqueueAndParse(path) rejects with MindeeHttpErrorV2 on 4xx", async () => { const filePath = path.join(fileTypesDir, "receipt.jpg"); - const inputDoc = client.sourceFromPath(filePath); + const inputDoc = new PathInput({ inputPath: filePath }); await assert.rejects( client.enqueueAndGetInference( inputDoc, @@ -102,11 +102,10 @@ describe("ClientV2", () => { "complete.json" ); - const localResp = new LocalResponse(jsonPath); - await localResp.init(); - const prediction = localResp.deserializeResponse(InferenceResponse); + const localResponse = new LocalResponse(jsonPath); + const response: InferenceResponse = await localResponse.deserializeResponse(InferenceResponse); - expect(prediction.inference.model.id).to.equal( + expect(response.inference.model.id).to.equal( "12345678-1234-1234-1234-123456789abc" ); });