Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <id>
bitmovin encoding templates delete <id>
Expand Down
84 changes: 78 additions & 6 deletions src/commands/encoding/templates/create.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,97 @@
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}),
};

static override flags = {
...BaseCommand.baseFlags,
name: Flags.string({description: 'Template name', required: true}),
};

async run(): Promise<void> {
const {args, flags} = await this.parse(EncodingTemplateCreate);
const content = readFileSync(args.file, 'utf-8');
const templatePayload: Record<string, unknown> = {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<string, string> = {
'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 ?? '<unknown id>'}`);
await this.outputData(result);
}
}
2 changes: 1 addition & 1 deletion src/commands/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ bitmovin config set organization <org-id>
bitmovin encoding templates start ./template.yaml --watch # Start encoding from YAML template
bitmovin encoding templates list # List stored templates
bitmovin encoding templates get <id> # 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 <id>
bitmovin encoding templates validate ./template.yaml # Validate YAML against schema
\`\`\`
Expand Down
20 changes: 16 additions & 4 deletions src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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';
124 changes: 124 additions & 0 deletions test/commands/encoding-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ afterEach(() => {

vi.unstubAllGlobals();
delete process.env.BM_CLI_TEST_HOME;
delete process.env.BITMOVIN_API_KEY;
});

const mockTemplates = [
Expand All @@ -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} {
Expand All @@ -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();
Expand Down Expand Up @@ -113,6 +138,105 @@ describe('encoding templates delete', () => {
});
});

describe('encoding templates create', () => {
function setupCreate(): {dir: string; fetchMock: ReturnType<typeof vi.fn>} {
const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-'));
cleanupCallbacks.push(() => rmSync(dir, {recursive: true, force: true}));
process.env.BITMOVIN_API_KEY = 'test-key-create';
Comment thread
dweinber marked this conversation as resolved.
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<string, string>;
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);
Expand Down
Loading