diff --git a/README.md b/README.md index 91f8354..ce75427 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The recommended way to encode. Define your entire workflow in a single [YAML tem ```bash bitmovin encoding templates start ./template.yaml --watch # Start from file bitmovin encoding templates start --id --watch # Start stored template -bitmovin encoding templates create ./template.yaml --name "Standard VOD" +bitmovin encoding templates create ./template.yaml # Name comes from metadata.name bitmovin encoding templates list bitmovin encoding templates get bitmovin encoding templates delete diff --git a/src/commands/encoding/templates/create.ts b/src/commands/encoding/templates/create.ts index e9d9707..194d90e 100644 --- a/src/commands/encoding/templates/create.ts +++ b/src/commands/encoding/templates/create.ts @@ -1,9 +1,37 @@ -import {Args, Flags} from '@oclif/core'; +import {Args} from '@oclif/core'; import {readFileSync} from 'node:fs'; import {BaseCommand} from '../../../lib/base-command.js'; +import {API_BASE_URL, resolveAuth} from '../../../lib/client.js'; + +interface CreateResponse { + data?: { + result?: { + id?: string; + [key: string]: unknown; + }; + }; +} + +interface ErrorResponse { + developerMessage?: string; + message?: string; + requestId?: string; + data?: { + developerMessage?: string; + message?: string; + requestId?: string; + }; +} + +interface ApiError extends Error { + httpStatusCode: number; + developerMessage: string; + requestId?: string; +} export default class EncodingTemplateCreate extends BaseCommand { - static override description = 'Store an encoding template for reuse'; + static override description = + 'Store an encoding template for reuse. The template name is taken from `metadata.name` in the YAML.'; static override args = { file: Args.string({description: 'Path to YAML template file', required: true}), @@ -11,15 +39,59 @@ export default class EncodingTemplateCreate extends BaseCommand { static override flags = { ...BaseCommand.baseFlags, - name: Flags.string({description: 'Template name', required: true}), }; async run(): Promise { const {args, flags} = await this.parse(EncodingTemplateCreate); const content = readFileSync(args.file, 'utf-8'); - const templatePayload: Record = {name: flags.name, template: content}; - const result = await (await this.getApi()).encoding.templates.create(templatePayload as never); - this.log(`Template created: ${(result as {id?: string}).id}`); + await this.isJsonMode(); + + const {apiKey, tenantOrgId} = resolveAuth(flags['api-key']); + + // The /encoding/templates endpoint expects a raw YAML body with + // Content-Type: application/yaml. The SDK only sends application/json, + // so we issue this request directly. + const headers: Record = { + 'X-Api-Key': apiKey, + 'X-Api-Client': '@bitmovin/cli', + 'Content-Type': 'application/yaml', + }; + if (tenantOrgId) headers['X-Tenant-Org-Id'] = tenantOrgId; + + const response = await fetch(`${API_BASE_URL}/encoding/templates`, { + method: 'POST', + headers, + body: content, + }); + + if (!response.ok) { + const rawBodyText = await response.text(); + let developerMessage = rawBodyText; + let requestId = response.headers?.get('X-Request-Id') ?? undefined; + + try { + const parsed = JSON.parse(rawBodyText) as ErrorResponse; + developerMessage = + parsed.developerMessage ?? + parsed.message ?? + parsed.data?.developerMessage ?? + parsed.data?.message ?? + rawBodyText; + requestId = requestId ?? parsed.requestId ?? parsed.data?.requestId; + } catch { + // leave developerMessage as raw response body + } + + const error = new Error(`Failed to create template (${response.status}): ${developerMessage}`) as ApiError; + error.httpStatusCode = response.status; + error.developerMessage = developerMessage; + if (requestId) error.requestId = requestId; + throw error; + } + + const json = (await response.json()) as CreateResponse; + const result = json.data?.result ?? {}; + this.log(`Template created: ${result.id ?? ''}`); await this.outputData(result); } } diff --git a/src/commands/skill.ts b/src/commands/skill.ts index 2af1488..d71c161 100644 --- a/src/commands/skill.ts +++ b/src/commands/skill.ts @@ -23,7 +23,7 @@ bitmovin config set organization bitmovin encoding templates start ./template.yaml --watch # Start encoding from YAML template bitmovin encoding templates list # List stored templates bitmovin encoding templates get # Get template details -bitmovin encoding templates create ./file.yaml --name "X" # Store template +bitmovin encoding templates create ./file.yaml # Store template (name from metadata.name) bitmovin encoding templates delete bitmovin encoding templates validate ./template.yaml # Validate YAML against schema \`\`\` diff --git a/src/lib/client.ts b/src/lib/client.ts index e22d95c..f695adc 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -32,6 +32,19 @@ const SdkModule = BitmovinApiSdk as unknown as {default?: BitmovinApiConstructor const BitmovinApi: BitmovinApiConstructor = SdkModule.default ?? (BitmovinApiSdk as unknown as BitmovinApiConstructor); export function getClient(apiKeyOverride?: string): ApiClient { + const {apiKey, tenantOrgId} = resolveAuth(apiKeyOverride); + return new BitmovinApi({ + apiKey, + ...(tenantOrgId && {tenantOrgId}), + }); +} + +/** + * Resolve API auth from CLI override, env var, or config file. Used both by + * `getClient()` and by commands that need to make raw HTTP requests outside + * the SDK (e.g. when an endpoint expects a non-JSON body). + */ +export function resolveAuth(apiKeyOverride?: string): {apiKey: string; tenantOrgId?: string} { const config = loadConfig(); const {value: apiKey} = resolveApiKey(config, apiKeyOverride); @@ -44,8 +57,7 @@ export function getClient(apiKeyOverride?: string): ApiClient { ); } - return new BitmovinApi({ - apiKey, - ...(config.tenantOrgId && {tenantOrgId: config.tenantOrgId}), - }); + return {apiKey, tenantOrgId: config.tenantOrgId}; } + +export const API_BASE_URL = 'https://api.bitmovin.com/v1'; diff --git a/test/commands/encoding-templates.test.ts b/test/commands/encoding-templates.test.ts index dcd5442..d993c87 100644 --- a/test/commands/encoding-templates.test.ts +++ b/test/commands/encoding-templates.test.ts @@ -22,6 +22,7 @@ afterEach(() => { vi.unstubAllGlobals(); delete process.env.BM_CLI_TEST_HOME; + delete process.env.BITMOVIN_API_KEY; }); const mockTemplates = [ @@ -45,6 +46,11 @@ vi.mock('../../src/lib/client.js', () => ({ }, }, }), + resolveAuth: (override?: string) => ({ + apiKey: override ?? process.env.BITMOVIN_API_KEY ?? 'mock-api-key', + tenantOrgId: undefined, + }), + API_BASE_URL: 'https://api.bitmovin.com/v1', })); function captureStdout(): {output: () => string; restore: () => void} { @@ -66,6 +72,25 @@ function captureStdout(): {output: () => string; restore: () => void} { }; } +function captureStderr(): {output: () => string; restore: () => void} { + let captured = ''; + const mock = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + captured += typeof chunk === 'string' ? chunk : chunk.toString(); + return true; + }); + let restored = false; + const restore = () => { + if (restored) return; + restored = true; + mock.mockRestore(); + }; + cleanupCallbacks.push(restore); + return { + output: () => captured, + restore, + }; +} + describe('encoding templates list', () => { it('outputs JSON', async () => { const cap = captureStdout(); @@ -113,6 +138,105 @@ describe('encoding templates delete', () => { }); }); +describe('encoding templates create', () => { + function setupCreate(): {dir: string; fetchMock: ReturnType} { + const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); + cleanupCallbacks.push(() => rmSync(dir, {recursive: true, force: true})); + process.env.BITMOVIN_API_KEY = 'test-key-create'; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({data: {result: {id: 'tmpl-new', name: 'Test'}}}), + }); + vi.stubGlobal('fetch', fetchMock); + return {dir, fetchMock}; + } + + it('posts the YAML body verbatim with Content-Type: application/yaml', async () => { + const {dir, fetchMock} = setupCreate(); + const file = join(dir, 't.yaml'); + const yamlBody = "metadata:\n name: Test\n type: LIVE\nencodings: {}\n"; + writeFileSync(file, yamlBody); + const cap = captureStdout(); + const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); + await Cmd.run([file]); + cap.restore(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.bitmovin.com/v1/encoding/templates'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).body).toBe(yamlBody); + const headers = (init as RequestInit).headers as Record; + expect(headers['Content-Type']).toBe('application/yaml'); + expect(headers['X-Api-Key']).toBe('test-key-create'); + }); + + it('outputs clean JSON with --json', async () => { + const {dir} = setupCreate(); + const file = join(dir, 't.yaml'); + writeFileSync(file, 'metadata:\n name: Test\n'); + const cap = captureStdout(); + const capErr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); + await Cmd.run([file, '--json']); + cap.restore(); + capErr.mockRestore(); + const data = JSON.parse(cap.output()); + expect(data).toEqual({id: 'tmpl-new', name: 'Test'}); + }); + + it('surfaces API error message on failure', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); + cleanupCallbacks.push(() => rmSync(dir, {recursive: true, force: true})); + process.env.BITMOVIN_API_KEY = 'test-key-create'; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => JSON.stringify({developerMessage: 'Could not parse encoding template'}), + }), + ); + const file = join(dir, 'bad.yaml'); + writeFileSync(file, 'metadata:\n type: LIVE\n'); + const capErr = captureStderr(); + const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); + await expect(Cmd.run([file])).rejects.toThrow(/EEXIT: 1/); + capErr.restore(); + expect(capErr.output()).toContain('API error: 400'); + expect(capErr.output()).toContain('Could not parse encoding template'); + }); + + it('outputs structured JSON API error with --json', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); + cleanupCallbacks.push(() => rmSync(dir, {recursive: true, force: true})); + process.env.BITMOVIN_API_KEY = 'test-key-create'; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + headers: {get: (name: string) => (name.toLowerCase() === 'x-request-id' ? 'req-create-1' : null)}, + text: async () => JSON.stringify({developerMessage: 'Could not parse encoding template'}), + }), + ); + const file = join(dir, 'bad.yaml'); + writeFileSync(file, 'metadata:\n type: LIVE\n'); + const capOut = captureStdout(); + const capErr = captureStderr(); + const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); + await expect(Cmd.run([file, '--json'])).rejects.toThrow(/EEXIT: 1/); + capOut.restore(); + capErr.restore(); + expect(JSON.parse(capOut.output())).toEqual({ + error: true, + httpStatusCode: 400, + message: 'Could not parse encoding template', + requestId: 'req-create-1', + }); + }); +}); + describe('encoding templates start', () => { it('starts from a stored template ID', async () => { const capErr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);