From de1f6264ab3437340861d50e7e4cd0b318f922cc Mon Sep 17 00:00:00 2001 From: Roberto Arce Date: Thu, 7 May 2026 14:03:29 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(types):=20allow=20round-trip=20of=20exp?= =?UTF-8?q?ort=20=E2=86=92=20create=20(closes=20#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseSchema` now detects full DocumentType payloads (the shape returned by `types export` / `types get` since #33) and unwraps the inner `jsonSchema`. Raw JSON Schema input keeps working unchanged. This removes the need for `jq .jsonSchema` between export and create. Also add a comment around the conversionMode cast in `types create` explaining why it stays: `docutray@0.1.3` does not yet declare `conversionMode` on the DocumentType type. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/types/create.ts | 9 ++- src/parse-schema.ts | 34 ++++++++---- test/parse-schema.test.ts | 103 +++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 test/parse-schema.test.ts diff --git a/src/commands/types/create.ts b/src/commands/types/create.ts index d1c436c..b84e64e 100644 --- a/src/commands/types/create.ts +++ b/src/commands/types/create.ts @@ -6,11 +6,12 @@ import {outputError, outputKeyValue, outputSuccess, setForceJson} from '../../ou import {parseSchema} from '../../parse-schema.js' export default class TypesCreate extends BaseCommand { - static description = `Create a new document type. Defines an extraction schema that DocuTray uses when converting documents. Requires a name, code, description, and JSON schema. The schema can be provided as a file path or inline JSON string.` + static description = `Create a new document type. Defines an extraction schema that DocuTray uses when converting documents. Requires a name, code, description, and JSON schema. The schema can be provided as a file path or inline JSON string. Files exported via "types export" are also accepted: the inner jsonSchema is extracted automatically.` static examples = [ {command: '<%= config.bin %> types create --name "Invoice" --code invoice --description "Standard invoice" --schema schema.json', description: 'Create from a schema file'}, {command: '<%= config.bin %> types create --name "Invoice" --code invoice --description "Standard invoice" --schema \'{"type":"object","properties":{"total":{"type":"number"}}}\'', description: 'Create with inline JSON schema'}, + {command: '<%= config.bin %> types export factura -o factura.json && <%= config.bin %> types create --name "Factura Copy" --code factura_copy --description "Copy of factura" --schema factura.json', description: 'Round-trip: export an existing type and re-create it'}, {command: '<%= config.bin %> types create --name "Invoice" --code invoice --description "Standard invoice" --schema schema.json --publish', description: 'Create and publish immediately'}, {command: '<%= config.bin %> types create --name "Invoice" --code invoice --description "Standard invoice" --schema schema.json --conversion-mode toon', description: 'Create with a specific conversion mode'}, ] @@ -49,12 +50,16 @@ export default class TypesCreate extends BaseCommand { promptHints: flags['prompt-hints'], }) + // The API returns `conversionMode` on the DocumentType, but `docutray@0.1.3` + // does not yet expose it on the SDK type. Read it through an unknown cast + // until the SDK declaration catches up. + const conversionMode = (result as unknown as {conversionMode?: string}).conversionMode outputKeyValue(result, [ {key: 'Code', value: result.codeType}, {key: 'Name', value: result.name}, {key: 'Description', value: result.description || '(none)'}, {key: 'Draft', value: result.isDraft ? 'yes' : 'no'}, - {key: 'Mode', value: (() => { const mode = (result as unknown as Record).conversionMode; return typeof mode === 'string' && mode.trim() !== '' ? mode : 'json'; })()}, + {key: 'Mode', value: conversionMode && conversionMode.trim() !== '' ? conversionMode : 'json'}, ]) outputSuccess({codeType: result.codeType, id: result.id}, `Created document type "${result.codeType}"`) diff --git a/src/parse-schema.ts b/src/parse-schema.ts index f641e3a..1767316 100644 --- a/src/parse-schema.ts +++ b/src/parse-schema.ts @@ -1,16 +1,31 @@ import {existsSync, readFileSync} from 'node:fs' +function unwrap(parsed: unknown): Record { + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('JSON schema must be an object') + } + + const obj = parsed as Record + // Accept full DocumentType payloads (from `types export` / `types get`) by + // unwrapping the inner jsonSchema. Lets users round-trip without piping jq. + if ( + 'jsonSchema' in obj && + typeof obj.jsonSchema === 'object' && + obj.jsonSchema !== null && + !Array.isArray(obj.jsonSchema) + ) { + return obj.jsonSchema as Record + } + + return obj +} + export function parseSchema(input: string): Record { // Try as file path first if (existsSync(input)) { const content = readFileSync(input, 'utf8') try { - const parsed = JSON.parse(content) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('JSON schema must be an object') - } - - return parsed as Record + return unwrap(JSON.parse(content)) } catch (error) { if (error instanceof SyntaxError) { throw new Error(`Invalid JSON in schema file "${input}": ${error.message}`) @@ -22,12 +37,7 @@ export function parseSchema(input: string): Record { // Try as inline JSON try { - const parsed = JSON.parse(input) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('JSON schema must be an object') - } - - return parsed as Record + return unwrap(JSON.parse(input)) } catch (error) { if (error instanceof SyntaxError) { throw new Error(`Invalid schema: not a valid file path or JSON string`) diff --git a/test/parse-schema.test.ts b/test/parse-schema.test.ts new file mode 100644 index 0000000..d047cdf --- /dev/null +++ b/test/parse-schema.test.ts @@ -0,0 +1,103 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ''), +})) + +import {existsSync, readFileSync} from 'node:fs' + +import {parseSchema} from '../src/parse-schema.js' + +const mockExistsSync = vi.mocked(existsSync) +const mockReadFileSync = vi.mocked(readFileSync) + +describe('parseSchema', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExistsSync.mockReturnValue(false) + }) + + describe('inline JSON', () => { + it('parses a raw JSON Schema', () => { + const schema = '{"type":"object","properties":{"total":{"type":"number"}},"required":["total"]}' + expect(parseSchema(schema)).toEqual({ + properties: {total: {type: 'number'}}, + required: ['total'], + type: 'object', + }) + }) + + it('unwraps a full DocumentType payload by extracting jsonSchema', () => { + const documentType = { + codeType: 'factura', + createdAt: '2026-01-01T00:00:00Z', + description: 'Factura electrónica', + id: 'dt_123', + isDraft: false, + isPublic: true, + jsonSchema: {properties: {total: {type: 'number'}}, required: ['total'], type: 'object'}, + name: 'Factura', + status: 'PUBLISHED', + updatedAt: '2026-01-02T00:00:00Z', + } + + expect(parseSchema(JSON.stringify(documentType))).toEqual(documentType.jsonSchema) + }) + + it('returns the object unchanged when jsonSchema key is absent', () => { + const schema = '{"type":"object","properties":{}}' + expect(parseSchema(schema)).toEqual({properties: {}, type: 'object'}) + }) + + it('does not unwrap when jsonSchema is null', () => { + const payload = {jsonSchema: null, properties: {}, type: 'object'} + expect(parseSchema(JSON.stringify(payload))).toEqual(payload) + }) + + it('does not unwrap when jsonSchema is not an object', () => { + const payload = {jsonSchema: 'not-an-object', type: 'object'} + expect(parseSchema(JSON.stringify(payload))).toEqual(payload) + }) + + it('rejects arrays', () => { + expect(() => parseSchema('[1,2,3]')).toThrow('JSON schema must be an object') + }) + + it('rejects invalid JSON', () => { + expect(() => parseSchema('not-json')).toThrow('not a valid file path or JSON string') + }) + }) + + describe('file path', () => { + it('reads and parses a raw schema file', () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue('{"type":"object","properties":{"amount":{"type":"number"}}}') + + expect(parseSchema('schema.json')).toEqual({ + properties: {amount: {type: 'number'}}, + type: 'object', + }) + }) + + it('unwraps a DocumentType payload from a file (round-trip)', () => { + const documentType = { + codeType: 'factura', + id: 'dt_123', + jsonSchema: {properties: {}, required: [], type: 'object'}, + name: 'Factura', + } + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue(JSON.stringify(documentType)) + + expect(parseSchema('factura.json')).toEqual(documentType.jsonSchema) + }) + + it('rejects invalid JSON in a file', () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue('not valid json') + + expect(() => parseSchema('bad.json')).toThrow('Invalid JSON in schema file "bad.json"') + }) + }) +}) From 3adb89ad5c1b34ab5f65d033c46404101f752b1b Mon Sep 17 00:00:00 2001 From: Roberto Arce Date: Thu, 7 May 2026 14:41:59 -0400 Subject: [PATCH 2/3] docs: link conversionMode cast to docutray-node#21 tracking issue Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/types/create.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/types/create.ts b/src/commands/types/create.ts index b84e64e..76235e2 100644 --- a/src/commands/types/create.ts +++ b/src/commands/types/create.ts @@ -51,8 +51,8 @@ export default class TypesCreate extends BaseCommand { }) // The API returns `conversionMode` on the DocumentType, but `docutray@0.1.3` - // does not yet expose it on the SDK type. Read it through an unknown cast - // until the SDK declaration catches up. + // does not yet expose it on the SDK type. Tracked in docutray-node#21; + // remove this cast once a release including that fix is pulled in. const conversionMode = (result as unknown as {conversionMode?: string}).conversionMode outputKeyValue(result, [ {key: 'Code', value: result.codeType}, From 980dbc7a9ff17a9b489935d02f1685073d284b3d Mon Sep 17 00:00:00 2001 From: Roberto Arce Date: Thu, 7 May 2026 15:07:49 -0400 Subject: [PATCH 3/3] chore(deps): bump docutray to 0.1.4 and remove conversionMode cast docutray-node 0.1.4 (PR #22) exposes `conversionMode` on `DocumentType` and re-exports a shared `ConversionMode` type. Replace the unknown casts in `types create` and `types update` with direct field access and the imported type alias. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 8 ++++---- package.json | 2 +- src/commands/types/create.ts | 9 +++------ src/commands/types/update.ts | 4 ++-- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 483e178..56d36a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@oclif/core": "^4", "@oclif/plugin-autocomplete": "^3.2.45", - "docutray": "^0.1.3", + "docutray": "^0.1.4", "open": "^11.0.0" }, "bin": { @@ -4343,9 +4343,9 @@ } }, "node_modules/docutray": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/docutray/-/docutray-0.1.3.tgz", - "integrity": "sha512-Lp20H0VljI5M8FG5fZ67lB9Rs/8hv2mX6REGXfGIrPnIarxmGZD6KaqtC6i23vcAWBRoRUeM/oObPA8+iQUQNQ==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/docutray/-/docutray-0.1.4.tgz", + "integrity": "sha512-Q9ZCbJjW4M/RaHUyVqrWjTs7aFWqHWZi2b8IioG1ogQOwMzmKpXMAwaYCXQ5q68r9dca+sgzMJS4NysdmBvdog==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 4dd29aa..53cb1a0 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "dependencies": { "@oclif/core": "^4", "@oclif/plugin-autocomplete": "^3.2.45", - "docutray": "^0.1.3", + "docutray": "^0.1.4", "open": "^11.0.0" }, "devDependencies": { diff --git a/src/commands/types/create.ts b/src/commands/types/create.ts index 76235e2..893ab59 100644 --- a/src/commands/types/create.ts +++ b/src/commands/types/create.ts @@ -1,4 +1,5 @@ import {Flags} from '@oclif/core' +import type {ConversionMode} from 'docutray' import {BaseCommand} from '../../base-command.js' import {createClient} from '../../client.js' @@ -40,7 +41,7 @@ export default class TypesCreate extends BaseCommand { const client = createClient() const result = await client.documentTypes.create({ codeType: flags.code, - conversionMode: flags['conversion-mode'] as 'json' | 'toon' | 'multi_prompt' | undefined, + conversionMode: flags['conversion-mode'] as ConversionMode | undefined, description: flags.description, identifyPromptHints: flags['identify-hints'], isDraft: flags.publish ? false : flags.draft, @@ -50,16 +51,12 @@ export default class TypesCreate extends BaseCommand { promptHints: flags['prompt-hints'], }) - // The API returns `conversionMode` on the DocumentType, but `docutray@0.1.3` - // does not yet expose it on the SDK type. Tracked in docutray-node#21; - // remove this cast once a release including that fix is pulled in. - const conversionMode = (result as unknown as {conversionMode?: string}).conversionMode outputKeyValue(result, [ {key: 'Code', value: result.codeType}, {key: 'Name', value: result.name}, {key: 'Description', value: result.description || '(none)'}, {key: 'Draft', value: result.isDraft ? 'yes' : 'no'}, - {key: 'Mode', value: conversionMode && conversionMode.trim() !== '' ? conversionMode : 'json'}, + {key: 'Mode', value: result.conversionMode || 'json'}, ]) outputSuccess({codeType: result.codeType, id: result.id}, `Created document type "${result.codeType}"`) diff --git a/src/commands/types/update.ts b/src/commands/types/update.ts index 360ef63..198c730 100644 --- a/src/commands/types/update.ts +++ b/src/commands/types/update.ts @@ -1,5 +1,5 @@ import {Args, Flags} from '@oclif/core' -import type {DocumentTypeUpdateParams} from 'docutray' +import type {ConversionMode, DocumentTypeUpdateParams} from 'docutray' import {BaseCommand} from '../../base-command.js' import {createClient} from '../../client.js' @@ -46,7 +46,7 @@ export default class TypesUpdate extends BaseCommand { if (flags.schema !== undefined) params.jsonSchema = parseSchema(flags.schema) if (flags['prompt-hints'] !== undefined) params.promptHints = flags['prompt-hints'] if (flags['identify-hints'] !== undefined) params.identifyPromptHints = flags['identify-hints'] - if (flags['conversion-mode'] !== undefined) params.conversionMode = flags['conversion-mode'] as 'json' | 'toon' | 'multi_prompt' + if (flags['conversion-mode'] !== undefined) params.conversionMode = flags['conversion-mode'] as ConversionMode if (flags['keep-ordering'] !== undefined) params.keepPropertyOrdering = flags['keep-ordering'] if (flags.publish) params.isDraft = false else if (flags.draft !== undefined) params.isDraft = flags.draft