From 9192d36d7a45e99f5f05082ea06fe076e0e9e8e7 Mon Sep 17 00:00:00 2001 From: Dominik Madarasz Date: Fri, 30 Oct 2026 07:46:25 +0100 Subject: [PATCH] fix: allow jobs that render a template URL The API renders either an uploaded template (template.id) or a template URL (template.src, with template.name), but the vendored spec marked id as required and the CLI refused any job without it. The JobTemplate schema is synced from nexrender-api (id optional, name documented) and the generated client regenerated. Validation now asks for exactly one of template.id or template.src, and for template.name with template.src. Found by Vanguard. Co-Authored-By: Claude Opus 5.5 --- internal/api/api.gen.go | 36 +++++++++++++++++++++++++++-------- internal/validate/job.go | 18 ++++++++++++++++-- internal/validate/job_test.go | 23 ++++++++++++++++++++++ openapi/openapi.json | 13 +++++++------ 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index b674671..eebb495 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -560,7 +560,7 @@ type JobCreation struct { Type *JobCreationSettingsType `json:"type,omitempty"` } `json:"settings,omitempty"` - // Template Template configuration defining the After Effects project and composition to render + // Template Template configuration defining the After Effects project and composition to render. Provide exactly one of `id` (an uploaded template) or `src` (a template URL); sending both, or neither, is rejected. Template JobTemplate `json:"template"` // Upload Custom upload configuration for output files to external storage providers @@ -662,15 +662,18 @@ type JobCreationResponse struct { // JobCreationResponseStatus 'queued' for regular jobs, 'pending' for parent jobs waiting on children type JobCreationResponseStatus string -// JobTemplate Template configuration defining the After Effects project and composition to render +// JobTemplate Template configuration defining the After Effects project and composition to render. Provide exactly one of `id` (an uploaded template) or `src` (a template URL); sending both, or neither, is rejected. type JobTemplate struct { // Composition Name of the specific composition within the template to render (optional for .mogrt) Composition *string `json:"composition"` - // Id Unique template identifier in ULID format (26 characters, uppercase alphanumeric) - Id string `json:"id"` + // Id Unique template identifier in ULID format (26 characters, uppercase alphanumeric). Use instead of `src`. + Id *string `json:"id,omitempty"` + + // Name Project file name. Required with `src`: for a .zip, the path of the .aep inside the archive (for example `project/main.aep`); otherwise the file name of the .aep or .mogrt. With `id`, it defaults to the name detected when the template was uploaded. + Name *string `json:"name,omitempty"` - // Src Source URL to the After Effects template (.aep, .mogrt, or .zip file) + // Src Use instead of `id`. Public HTTP(S), native s3:/gs:, inline data:, or a file: URL inside the current job work directory for an After Effects template (.aep, .mogrt, or .zip file). The worker rejects paths and symlinks escaping that directory and non-public network destinations. Requires `name`. Src *string `json:"src,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` } @@ -1517,6 +1520,14 @@ func (a *JobTemplate) UnmarshalJSON(b []byte) error { delete(object, "id") } + if raw, found := object["name"]; found { + err = json.Unmarshal(raw, &a.Name) + if err != nil { + return fmt.Errorf("error reading 'name': %w", err) + } + delete(object, "name") + } + if raw, found := object["src"]; found { err = json.Unmarshal(raw, &a.Src) if err != nil { @@ -1551,9 +1562,18 @@ func (a JobTemplate) MarshalJSON() ([]byte, error) { } } - object["id"], err = json.Marshal(a.Id) - if err != nil { - return nil, fmt.Errorf("error marshaling 'id': %w", err) + if a.Id != nil { + object["id"], err = json.Marshal(a.Id) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) + } + } + + if a.Name != nil { + object["name"], err = json.Marshal(a.Name) + if err != nil { + return nil, fmt.Errorf("error marshaling 'name': %w", err) + } } if a.Src != nil { diff --git a/internal/validate/job.go b/internal/validate/job.go index 2967281..b5cd56e 100644 --- a/internal/validate/job.go +++ b/internal/validate/job.go @@ -14,8 +14,15 @@ func JobPayload(data []byte) (api.JobCreation, error) { if err := json.Unmarshal(data, &payload); err != nil { return api.JobCreation{}, clierr.Validation("job payload is not valid JSON: "+err.Error(), "Run nexrender schema createJob --json to inspect the request schema.") } - if strings.TrimSpace(payload.Template.Id) == "" { - return api.JobCreation{}, clierr.Validation("template.id is required", "Select a template with nexrender template list --json.") + id := trimmed(payload.Template.Id) + src := trimmed(payload.Template.Src) + switch { + case id == "" && src == "": + return api.JobCreation{}, clierr.Validation("template.id or template.src is required", "Select an uploaded template with nexrender template list --json, or put a template URL in template.src.") + case id != "" && src != "": + return api.JobCreation{}, clierr.Validation("template.id and template.src cannot be used together", "Keep template.id for an uploaded template, or template.src for a template URL.") + case src != "" && trimmed(payload.Template.Name) == "": + return api.JobCreation{}, clierr.Validation("template.name is required with template.src", "Set it to the .aep or .mogrt file name, or to the .aep path inside a .zip.") } if payload.Preview != nil && *payload.Preview && payload.Settings != nil { return api.JobCreation{}, clierr.Validation("preview and settings cannot be used together", "Remove settings for a preview job, or set preview to false.") @@ -31,3 +38,10 @@ func JobPayload(data []byte) (api.JobCreation, error) { } return payload, nil } + +func trimmed(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} diff --git a/internal/validate/job_test.go b/internal/validate/job_test.go index aaab508..622206b 100644 --- a/internal/validate/job_test.go +++ b/internal/validate/job_test.go @@ -16,3 +16,26 @@ func TestJobPayloadRules(t *testing.T) { t.Fatal("expected short codec to fail") } } + +func TestJobPayloadTemplateSource(t *testing.T) { + cases := []struct { + name string + payload string + valid bool + }{ + {"uploaded template", `{"template":{"id":"tpl","composition":"Main"}}`, true}, + {"template URL", `{"template":{"src":"https://nx1-assets-eu.nexrender.com/example/Test%20AEP.zip","name":"LTExample.aep","composition":"Final 2"}}`, true}, + {"neither", `{"template":{"composition":"Main"}}`, false}, + {"both", `{"template":{"id":"tpl","src":"https://nx1-assets-eu.nexrender.com/example/Test%20AEP.zip","name":"LTExample.aep","composition":"Main"}}`, false}, + {"src without name", `{"template":{"src":"https://nx1-assets-eu.nexrender.com/example/Test%20AEP.zip","composition":"Final 2"}}`, false}, + } + for _, tc := range cases { + _, err := JobPayload([]byte(tc.payload)) + if tc.valid && err != nil { + t.Errorf("%s: unexpected error %v", tc.name, err) + } + if !tc.valid && err == nil { + t.Errorf("%s: expected a validation error", tc.name) + } + } +} diff --git a/openapi/openapi.json b/openapi/openapi.json index 04c4af8..0df4401 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -425,16 +425,20 @@ }, "JobTemplate": { "type": "object", - "description": "Template configuration defining the After Effects project and composition to render", + "description": "Template configuration defining the After Effects project and composition to render. Provide exactly one of `id` (an uploaded template) or `src` (a template URL); sending both, or neither, is rejected.", "properties": { "id": { "type": "string", "pattern": "^[A-Z0-9]{26}$", - "description": "Unique template identifier in ULID format (26 characters, uppercase alphanumeric)" + "description": "Unique template identifier in ULID format (26 characters, uppercase alphanumeric). Use instead of `src`." }, "src": { "type": "string", - "description": "Source URL to the After Effects template (.aep, .mogrt, or .zip file)" + "description": "Use instead of `id`. Public HTTP(S), native s3:/gs:, inline data:, or a file: URL inside the current job work directory for an After Effects template (.aep, .mogrt, or .zip file). The worker rejects paths and symlinks escaping that directory and non-public network destinations. Requires `name`." + }, + "name": { + "type": "string", + "description": "Project file name. Required with `src`: for a .zip, the path of the .aep inside the archive (for example `project/main.aep`); otherwise the file name of the .aep or .mogrt. With `id`, it defaults to the name detected when the template was uploaded." }, "composition": { "type": "string", @@ -442,9 +446,6 @@ "nullable": true } }, - "required": [ - "id" - ], "additionalProperties": true }, "JobCreation": {