Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ const parsed = await client.v2.parse({

`password` applies to PDFs only, and the server rejects the three mistakes with a `422`, each naming its case: `password_unsupported_content_type` (a password sent with an image or an Office document), `encrypted_pdf_wrong_password` (the password does not open the PDF), and `encrypted_pdf_password_required` (a locked PDF submitted without one).

`password` is shorthand for the contract field `options.password`, which is where the SDK puts it on the wire — and the only place it puts it. Both forms work; if you supply both, the explicit `options.password` wins:

```ts
// sends options.password = "from-options"
await client.v2.parse({
document: fs.createReadStream('locked.pdf'),
options: { password: 'from-options' },
password: 'ignored',
});
```

That applies to an explicit `null` too: `options: { password: null }` means "no password" and silences the `password` shorthand behind it. `options` itself must be an object, or a JSON string that decodes to one — anything else throws `LandingAIADEError` before the request is sent.

[ade-python](https://github.com/landing-ai/ade-python) resolves the conflict the same way, so the two SDKs agree.

## Extract

Use `client.v2.extract` to pull structured fields out of Markdown (typically from a parse response) using a schema. The `schema` parameter accepts a JSON Schema object or a JSON string. Provide exactly one Markdown source: `markdown` or `markdown_url`.
Expand Down
85 changes: 60 additions & 25 deletions src/resources/v2/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ export interface V2ParseParams {
*/
model?: string | null;

/** Additional parsing options. Sent to the server as a JSON-encoded form field. */
/**
* Additional parsing options. Sent to the server as a JSON-encoded form field.
* Must be an object, or a JSON string that decodes to one -- anything else
* throws `LandingAIADEError` before the request is sent.
*/
options?: Record<string, unknown> | string | null;

/**
Expand All @@ -39,8 +43,9 @@ export interface V2ParseParams {
* supplying one for an image or an Office document returns a 422
* (`password_unsupported_content_type`). A wrong password returns a 422
* (`encrypted_pdf_wrong_password`), and omitting one for a locked PDF returns
* a 422 (`encrypted_pdf_password_required`). Sent within `options` on the
* wire.
* a 422 (`encrypted_pdf_password_required`). Sent on the wire as
* `options.password` and only there -- this is shorthand for that contract
* field, so an explicit `options.password` takes precedence over it.
*/
password?: string | null;
}
Expand Down Expand Up @@ -78,6 +83,34 @@ export interface V2JobListParams {
status?: string | null;
}

/**
* Coerce an accepted `options` value into a plain object. The contract sends
* `options` as a JSON object, so anything that does not decode to one is a
* caller mistake -- name the field here instead of leaving the gateway to
* reject the request without naming it. Mirrors `coerceSchema` in
* `src/lib/schema.ts`, which does the same job for `schema`.
*/
function coerceOptions(options: Record<string, unknown> | string): Record<string, unknown> {
if (typeof options === 'string') {
let parsed: unknown;
try {
parsed = JSON.parse(options);
} catch (err) {
throw new LandingAIADEError(`options is not valid JSON: ${(err as Error).message}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new LandingAIADEError('options JSON string must decode to an object');
}
return parsed as Record<string, unknown>;
}
if (typeof options === 'object' && options !== null && !Array.isArray(options)) {
return { ...options };
}
throw new LandingAIADEError(
`Unsupported options type: ${Array.isArray(options) ? 'array' : typeof options}`,
);
}

/**
* Build the multipart form body for parse. `options` is JSON-encoded per the
* contract; unset (`undefined`/`null`) fields are dropped so they aren't sent.
Expand All @@ -90,29 +123,31 @@ export function buildParseForm(params: V2ParseJobCreateParams): Record<string, u
body[key] = value;
}
}
// The parse request carries `password` inside `options` — it is the key that
// unlocks an encrypted PDF, so it has to survive the trip intact. Fold the
// top-level convenience param into the options object, mirroring how
// `buildExtractBody` folds `strict`.
let opts = options;
if (password !== undefined && password !== null) {
if (opts === undefined || opts === null) {
opts = { password };
} else if (typeof opts === 'object') {
opts = { ...opts, password };
} else {
// `options` was pre-serialized as a JSON string; merge into it when it
// parses as an object, otherwise keep the caller's string and pass the
// password as a top-level field so it is never silently dropped.
try {
opts = { ...(JSON.parse(opts) as Record<string, unknown>), password };
} catch {
body['password'] = password;
}
}
// `password` is shorthand for the contract field `options.password`, which is
// where the spec declares the password and the only place it declares it. Fold
// the shorthand in so the request carries the key exactly once, and never write
// a top-level `password` field: the contract has none, so a gateway drops it and
// the caller loses the key with nothing to show for it.
let opts = options === undefined || options === null ? undefined : coerceOptions(options);
// An explicit `options.password` beats the shorthand, `null` included -- the spec
// types the field `string | null`, and null means "no password". `undefined` is
// not a value here: it is how JS spells "absent", and `JSON.stringify` drops the
// key, so it must fall through to the shorthand rather than suppress it and leave
// the request carrying no password at all. Test the value, not key presence.
// ade-python breaks the tie the same way (`_build_parse_body`) -- the two SDKs
// used to disagree, which is what this rule exists to settle.
// Read it as an OWN property: `opts?.['password']` walks the prototype chain, so a
// polluted `Object.prototype.password` would suppress the shorthand on every call and
// then serialize to nothing -- a locked PDF sent with no password at all.
const explicit =
opts !== undefined && Object.prototype.hasOwnProperty.call(opts, 'password') ?
opts['password']
: undefined;
if (password !== undefined && password !== null && explicit === undefined) {
opts = { ...opts, password };
}
if (opts !== undefined && opts !== null) {
body['options'] = typeof opts === 'string' ? opts : JSON.stringify(opts);
if (opts !== undefined) {
body['options'] = JSON.stringify(opts);
}
return body;
}
Expand Down
209 changes: 182 additions & 27 deletions tests/api-resources/v2/v2.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import LandingAIADE, { UnprocessableEntityError, V2SyncTimeoutError, toFile } from 'landingai-ade';
import LandingAIADE, {
LandingAIADEError,
UnprocessableEntityError,
V2SyncTimeoutError,
toFile,
} from 'landingai-ade';
import type { Fetch } from 'landingai-ade/internal/builtin-types';

function jsonResponse(body: unknown, status = 200): Response {
Expand All @@ -8,16 +13,35 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}

/** A client backed by a stub fetch that records request URLs and returns `handler`'s response. */
function stubClient(handler: (url: string) => Response): { client: LandingAIADE; calls: string[] } {
/**
* A client backed by a stub fetch that returns `handler`'s response, recording the
* request URLs and the body of the last request.
*/
function stubClient(handler: (url: string) => Response): {
client: LandingAIADE;
calls: string[];
sentForm: () => FormData;
} {
const calls: string[] = [];
const fetch: Fetch = async (input) => {
let sentBody: unknown;
const fetch: Fetch = async (input, init) => {
const url = String(input);
if (!url.startsWith('data:')) calls.push(url); // ignore the FormData-support probe
if (!url.startsWith('data:')) {
// ignore the FormData-support probe
calls.push(url);
sentBody = init?.body;
}
return handler(url);
};
const client = new LandingAIADE({ apikey: 'k', environment: 'staging', maxRetries: 0, fetch });
return { client, calls };
return {
client,
calls,
sentForm: () => {
if (sentBody === undefined) throw new Error('no request was recorded');
return sentBody as FormData;
},
};
}

describe('client.v2 routing', () => {
Expand Down Expand Up @@ -397,18 +421,13 @@ describe('client.v2 routing', () => {
});

test('parse folds the password convenience param into options', async () => {
let sentBody: unknown;
const fetch: Fetch = async (input, init) => {
if (!String(input).startsWith('data:')) sentBody = init?.body;
return jsonResponse({ markdown: 'x', metadata: {} });
};
const client = new LandingAIADE({ apikey: 'k', environment: 'staging', maxRetries: 0, fetch });
const { client, sentForm } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'a.pdf'),
options: { inline_markdown: true },
password: 'hunter2',
});
const form = sentBody as FormData;
const form = sentForm();
expect(JSON.parse(String(form.get('options')))).toEqual({ inline_markdown: true, password: 'hunter2' });
expect(form.get('password')).toBeNull(); // no longer sent as a top-level field
});
Expand All @@ -417,38 +436,174 @@ describe('client.v2 routing', () => {
// Wired by the V2 spec-sync: encrypted PDFs are supported now — the password
// is the key that unlocks the document rather than a value the gateway
// rejects, so it has to reach the wire even when it is the only option.
let sentBody: unknown;
const fetch: Fetch = async (input, init) => {
if (!String(input).startsWith('data:')) sentBody = init?.body;
return jsonResponse({ markdown: 'x', metadata: {} });
};
const client = new LandingAIADE({ apikey: 'k', environment: 'staging', maxRetries: 0, fetch });
const { client, sentForm } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
password: 'hunter2',
});
const form = sentBody as FormData;
const form = sentForm();
expect(JSON.parse(String(form.get('options')))).toEqual({ password: 'hunter2' });
});

test('parseJobs.create folds the password into options too', async () => {
let sentBody: unknown;
const fetch: Fetch = async (input, init) => {
if (!String(input).startsWith('data:')) sentBody = init?.body;
return jsonResponse({ job_id: 'pj-pw' }, 202);
};
const client = new LandingAIADE({ apikey: 'k', environment: 'staging', maxRetries: 0, fetch });
const { client, sentForm } = stubClient(() => jsonResponse({ job_id: 'pj-pw' }, 202));
const job = await client.v2.parseJobs.create({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
password: 'hunter2',
service_tier: 'priority',
});
expect(job.job_id).toBe('pj-pw');
const form = sentBody as FormData;
const form = sentForm();
expect(JSON.parse(String(form.get('options')))).toEqual({ password: 'hunter2' });
expect(form.get('service_tier')).toBe('priority');
});

test('an explicit options.password wins over the password shorthand', async () => {
// `password` is shorthand for the contract field `options.password`, so the
// caller who wrote out the field is the deliberate one and takes the tie.
// ade-python's `_build_parse_body` breaks it the same way. The two SDKs used
// to disagree here, so the same call decrypted with a different password
// depending on the language -- and the losing one surfaced only as a 422
// `encrypted_pdf_wrong_password` that named no cause.
const { client, sentForm } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
options: { inline_markdown: true, password: 'from-options' },
password: 'shorthand',
});
const form = sentForm();
expect(JSON.parse(String(form.get('options')))).toEqual({
inline_markdown: true,
password: 'from-options',
});
expect(form.get('password')).toBeNull();
});

test('parseJobs.create gives options.password the same precedence', async () => {
const { client, sentForm } = stubClient(() => jsonResponse({ job_id: 'pj-pw2' }, 202));
await client.v2.parseJobs.create({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
options: { password: 'from-options' },
password: 'shorthand',
});
expect(JSON.parse(String(sentForm().get('options')))).toEqual({ password: 'from-options' });
});

test('a pre-serialized options string keeps its own password', async () => {
// `options` also accepts a JSON string, which takes a separate branch -- it
// used to lose this tie because the shorthand was spread in after the parse.
const { client, sentForm } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
options: JSON.stringify({ pages: [1, 2], password: 'from-options' }),
password: 'shorthand',
});
expect(JSON.parse(String(sentForm().get('options')))).toEqual({
pages: [1, 2],
password: 'from-options',
});
});

test('a pre-serialized options string still takes the password shorthand', async () => {
const { client, sentForm } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
options: JSON.stringify({ pages: [1, 2] }),
password: 'shorthand',
});
expect(JSON.parse(String(sentForm().get('options')))).toEqual({
pages: [1, 2],
password: 'shorthand',
});
});

test('an options.password of undefined falls through to the shorthand', async () => {
// `undefined` is how JS spells "absent", and `JSON.stringify` drops the key --
// so a key-presence test (`'password' in opts`) would suppress the shorthand AND
// then erase the key, sending a locked PDF with no password at all and earning an
// unattributable 422 `encrypted_pdf_password_required`. Only an explicit value
// wins. Reachable from typed callers: `{ password: cfg.password }` type-checks
// when `cfg.password` is optional.
const { client, sentForm } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
options: { pages: [1], password: undefined },
password: 'shorthand',
});
expect(JSON.parse(String(sentForm().get('options')))).toEqual({
pages: [1],
password: 'shorthand',
});
});

// A non-JSON `options` string used to fall back to a top-level `password` form field.
// No snapshot since 2026-07-13 declares one, so the gateway dropped it and the caller
// lost the key to a 422 that named nothing. `coerceOptions` validates the OBJECT
// branch too, so a JS caller cannot smuggle an array past the string check and have
// it spread into `{"0": ...}`.
test.each([
['array, object branch', ['x'] as unknown as Record<string, unknown>],
['array, string branch', '["x"]'],
['pair list, string branch', '[["password","sneaky"]]'],
['scalar, string branch', '5'],
['not JSON at all', 'not json'],
])('options that is not a JSON object is rejected (%s)', async (_label, options) => {
const { client } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await expect(
client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
options,
password: 'shorthand',
}),
).rejects.toThrow(LandingAIADEError);
});

test('a polluted Object.prototype.password cannot suppress the shorthand', async () => {
// The precedence check must read an OWN property. Through the prototype chain, any
// dependency setting `Object.prototype.password` would make every options object
// look like it already had one -- the shorthand suppressed, and nothing serialized,
// so a locked PDF ships with no password at all.
const { client, sentForm } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
(Object.prototype as Record<string, unknown>)['password'] = 'polluted';
try {
await client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
options: { pages: [1] },
password: 'shorthand',
});
} finally {
delete (Object.prototype as Record<string, unknown>)['password'];
}
expect(JSON.parse(String(sentForm().get('options')))).toEqual({
pages: [1],
password: 'shorthand',
});
});

test('a malformed options string is rejected even with no password involved', async () => {
// `options` is coerced unconditionally now, where it used to be touched only when a
// password was supplied -- so this throws on a call that has nothing to do with
// passwords. Deliberate: the gateway rejected these anyway, and naming the field
// client-side beats a 422 that does not.
const { client } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await expect(
client.v2.parse({ document: await toFile(Buffer.from('%PDF'), 'a.pdf'), options: 'pages=1-2' }),
).rejects.toThrow(LandingAIADEError);
});

test('an explicit options.password of null silences the shorthand', async () => {
// `null` is a value the spec allows (`string | null`) and it means "no password", so
// it wins the tie like any other explicit value -- unlike `undefined`, which means
// "absent" and falls through. ade-python resolves an explicit `None` the same way.
const { client, sentForm } = stubClient(() => jsonResponse({ markdown: 'x', metadata: {} }));
await client.v2.parse({
document: await toFile(Buffer.from('%PDF'), 'locked.pdf'),
options: { pages: [1], password: null },
password: 'shorthand',
});
expect(JSON.parse(String(sentForm().get('options')))).toEqual({ pages: [1], password: null });
});

test('a wrong password surfaces as a 422 naming the documented code', async () => {
// The spec scopes the password 422s to three named cases; this is the one a
// caller can act on, so pin that the body reaches them unmangled.
Expand Down