diff --git a/.changes/unreleased/ENHANCEMENTS-20260819-161936.yaml b/.changes/unreleased/ENHANCEMENTS-20260819-161936.yaml new file mode 100644 index 0000000..6d9b1c8 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20260819-161936.yaml @@ -0,0 +1,3 @@ +kind: ENHANCEMENTS +body: "Added a `-r name=id` flag to `api` and `create` for setting JSON:API relationships without a full request body. The relationship's linkage type is inferred from the schema (e.g. `-r project=prj-...` links to type `projects`); ambiguous or unknown relationships can be pinned with `-r name:type=id`, and to-many relationships accept comma-separated ids" +time: 2026-08-19T16:19:36.000000-04:00 diff --git a/internal/commands/api/api.go b/internal/commands/api/api.go index 1125e95..8925458 100644 --- a/internal/commands/api/api.go +++ b/internal/commands/api/api.go @@ -44,22 +44,27 @@ const ( // Opts stores the options parsed from flags for the API command. type Opts struct { - IO iostreams.IOStreams - Output *format.Outputter - Client *client.Client - Quiet bool - DryRun bool - Headers []string - URL *url.URL - Attributes map[string]string - Query map[string]string - PathParams map[string]string - InputRequest string - Method string - ResourceType string - All bool - PageSize int - PageNumber int + IO iostreams.IOStreams + Output *format.Outputter + Client *client.Client + Quiet bool + DryRun bool + Headers []string + URL *url.URL + Attributes map[string]string + Relationships map[string]string + Query map[string]string + PathParams map[string]string + InputRequest string + Method string + ResourceType string + All bool + PageSize int + PageNumber int + + // Schema, when set, is consulted to infer relationship linkage types and + // cardinality for -r. Nil falls back to the embedded spec. + Schema openapi.Schema // Authorizer, when set, can permit a noninteractive DELETE based on an // active exec session. Nil in tests that don't exercise session behavior. @@ -70,13 +75,14 @@ type Opts struct { // maps/slices initialized to empty values. func NewOpts(io iostreams.IOStreams, output *format.Outputter, apiClient *client.Client) *Opts { return &Opts{ - IO: io, - Output: output, - Client: apiClient, - Headers: []string{}, - Attributes: map[string]string{}, - Query: map[string]string{}, - PathParams: map[string]string{}, + IO: io, + Output: output, + Client: apiClient, + Headers: []string{}, + Attributes: map[string]string{}, + Relationships: map[string]string{}, + Query: map[string]string{}, + PathParams: map[string]string{}, } } @@ -172,6 +178,14 @@ func NewCmdAPI(inv *cmd.Invocation) *cmd.Command { Repeatable: true, Value: flagvalue.SimpleMap(nil, &opts.Attributes), }, + { + Name: "relationship", + Shorthand: "r", + DisplayValue: "NAME=ID", + Description: "Relationship for JSON:API request bodies as name=id (repeatable). Implies POST method. The linkage type is inferred from the schema; override an unresolved one with name:type=id. Comma-separate ids for to-many relationships.", + Repeatable: true, + Value: flagvalue.SimpleMap(nil, &opts.Relationships), + }, { Name: "field", Shorthand: "f", @@ -207,6 +221,10 @@ func NewCmdAPI(inv *cmd.Invocation) *cmd.Command { Preamble: "Create a project using attributes", Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s api /projects -a name=myproject`, version.Name), }, + { + Preamble: "Create a workspace in a project (relationship type inferred from the schema)", + Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s api /organizations/{organization}/workspaces -a name=foo -r project=prj-12dff4673ab9`, version.Name), + }, { Preamble: "Add remote state consumer", Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s api /workspaces/{workspace}/remote-state-consumers -p 'workspace=my-workspace' -i '{ "data": [ @@ -270,6 +288,7 @@ func NewCmdAPI(inv *cmd.Invocation) *cmd.Command { opts.URL = resolvedURL opts.Client = apiClient + opts.Schema = oas opts.Quiet = inv.IsQuiet() opts.DryRun = inv.IsDryRun() @@ -431,13 +450,43 @@ func RunAPI(ctx context.Context, opts *Opts) error { opts.URL.RawQuery = query.Encode() + // Resolve relationship linkage types and cardinality from the schema. The + // embedded spec is used when no schema was injected (e.g. by the create + // command or tests). + var linkages map[string]linkage + var haveSchema bool + if len(opts.Relationships) > 0 { + oas := opts.Schema + if oas == nil { + oas = openapi.LoadEmbeddedSchema() + } + // Spec paths are relative to the API version base (e.g. /api/v2), which + // the resolved request URL carries as a prefix; trim it before matching. + specPath := opts.URL.Path + if opts.Client != nil && opts.Client.BaseURL != nil { + specPath = strings.TrimPrefix(specPath, strings.TrimRight(opts.Client.BaseURL.Path, "/")) + } + linkages, haveSchema = relationshipLinkages(oas, specPath) + if haveSchema { + names := make([]string, 0, len(linkages)) + for name := range linkages { + names = append(names, name) + } + logger.Debug("resolved relationship linkages from schema", "path", specPath, "relationships", names) + } else { + // Not fatal: the request can still be built if every -r carries an + // explicit name:type=id. Otherwise buildRelationships returns a clear error. + logger.Debug("no relationship linkages resolved from schema; explicit types required for -r", "path", specPath) + } + } + // Construct a request - body, contentType, err := buildRequestBody(opts.URL.Path, opts.InputRequest, opts.Attributes, opts.ResourceType, opts.IO.In()) + body, contentType, err := buildRequestBody(opts.URL.Path, opts.InputRequest, opts.Attributes, opts.Relationships, opts.ResourceType, linkages, haveSchema, opts.IO.In()) if err != nil { return err } - method := inferMethod(opts.Method, len(opts.Attributes) > 0, opts.InputRequest != "") + method := inferMethod(opts.Method, len(opts.Attributes) > 0 || len(opts.Relationships) > 0, opts.InputRequest != "") requestHeaders, err := parseHeaders(opts.Headers) if err != nil { @@ -628,7 +677,7 @@ func parseTypedValue(raw string) any { return raw } -func buildRequestBody(path, input string, attrs map[string]string, resourceType string, stdin io.Reader) ([]byte, string, error) { +func buildRequestBody(path, input string, attrs, rels map[string]string, resourceType string, linkages map[string]linkage, haveSchema bool, stdin io.Reader) ([]byte, string, error) { if input != "" { var data []byte var err error @@ -645,7 +694,7 @@ func buildRequestBody(path, input string, attrs map[string]string, resourceType return data, "application/vnd.api+json", nil } - if len(attrs) == 0 { + if len(attrs) == 0 && len(rels) == 0 { return nil, "", nil } @@ -656,19 +705,25 @@ func buildRequestBody(path, input string, attrs map[string]string, resourceType return nil, "", errors.New("could not infer resource type from path; use --type") } - attributes := make(map[string]any, len(attrs)) - for key, value := range attrs { - attributes[key] = parseTypedValue(value) + data := map[string]any{"type": resourceType} + + if len(attrs) > 0 { + attributes := make(map[string]any, len(attrs)) + for key, value := range attrs { + attributes[key] = parseTypedValue(value) + } + data["attributes"] = attributes } - body := map[string]any{ - "data": map[string]any{ - "type": resourceType, - "attributes": attributes, - }, + if len(rels) > 0 { + relationships, err := buildRelationships(rels, linkages, haveSchema) + if err != nil { + return nil, "", err + } + data["relationships"] = relationships } - encoded, err := json.Marshal(body) + encoded, err := json.Marshal(map[string]any{"data": data}) if err != nil { return nil, "", err } diff --git a/internal/commands/api/relationships.go b/internal/commands/api/relationships.go new file mode 100644 index 0000000..1b18b5e --- /dev/null +++ b/internal/commands/api/relationships.go @@ -0,0 +1,321 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package api + +import ( + "fmt" + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/hashicorp/tfctl-cli/internal/pkg/openapi" +) + +// linkage describes a JSON:API relationship's linkage as declared by the +// schema: the resource type its members point to, and whether it is to-many. +// +// Type is the single pinned linkage type, or "" when the schema allows several +// (e.g. locked-by → users|teams|runs). Types always lists every candidate the +// schema allows (one element when pinned); it drives the "pin a type" hint for +// ambiguous relationships. +type linkage struct { + Type string + Types []string + ToMany bool +} + +// relationshipLinkages returns the settable relationships declared on the POST +// request body for the operation whose templated path matches the concrete +// requestPath, keyed by relationship name. +// +// Relationships whose linkage type the schema pins to a single value carry that +// type; ambiguous ones (a type enum with more than one member, e.g. locked-by → +// users|teams|runs) are included with an empty Type and their candidates in +// Types, so the caller can tell the user a relationship is real-but-ambiguous +// (pin a type) rather than unknown. Links-only relationships (no data) are +// omitted, as they cannot be set via a linkage. +// +// ok is false when no schema was available, no POST operation matched the path, +// or the operation declares no settable relationships. +func relationshipLinkages(oas openapi.Schema, requestPath string) (result map[string]linkage, ok bool) { + if oas == nil { + return nil, false + } + + tmpl := matchTemplatePath(oas, requestPath) + if tmpl == "" { + return nil, false + } + + pathItem, err := oas.PathByPath(tmpl) + if err != nil || pathItem.Post == nil { + return nil, false + } + rels := requestBodyRelationships(pathItem.Post) + if rels == nil { + return nil, false + } + + out := make(map[string]linkage, len(rels.Properties)) + for name, ref := range rels.Properties { + if ref.Value == nil { + continue + } + data := ref.Value.Properties["data"] + if data == nil || data.Value == nil { + continue // links-only relationship: not settable via a linkage + } + + target := data.Value + toMany := false + if data.Value.Items != nil { + toMany = true + target = data.Value.Items.Value + } + if target == nil { + continue + } + + types := enumStrings(schemaTypeEnum(target)) + switch len(types) { + case 0: + continue // no type constraint: not a resolvable linkage + case 1: + out[name] = linkage{Type: types[0], Types: types, ToMany: toMany} + default: + // Ambiguous: keep it (with an empty Type) so the caller requires an + // explicit name:type=id and can name the candidates. + out[name] = linkage{Types: types, ToMany: toMany} + } + } + + if len(out) == 0 { + return nil, false + } + return out, true +} + +// requestBodyRelationships returns the schema of the JSON:API "relationships" +// object in an operation's request body, or nil if it has none. +func requestBodyRelationships(op *openapi3.Operation) *openapi3.Schema { + if op.RequestBody == nil || op.RequestBody.Value == nil { + return nil + } + media := op.RequestBody.Value.Content["application/vnd.api+json"] + if media == nil || media.Schema == nil || media.Schema.Value == nil { + return nil + } + data := media.Schema.Value.Properties["data"] + if data == nil || data.Value == nil { + return nil + } + rels := data.Value.Properties["relationships"] + if rels == nil || rels.Value == nil { + return nil + } + return rels.Value +} + +// schemaTypeEnum returns the enum values constraining a JSON:API identifier's +// "type" field, following allOf/oneOf composition to reach the identifier +// schema. To-one linkages wrap the identifier in an allOf; to-many ones expose +// it directly under the array items. +// +// oneOf branches are unioned rather than short-circuited: a relationship +// modeled as oneOf:[{type:users},{type:teams}] must surface as >1 type so the +// caller treats it as ambiguous instead of silently picking the first branch. +// (No relationship in today's spec is modeled this way; this is defensive.) +func schemaTypeEnum(s *openapi3.Schema) []any { + if s == nil { + return nil + } + if p, ok := s.Properties["type"]; ok && p.Value != nil && len(p.Value.Enum) > 0 { + return p.Value.Enum + } + for _, sub := range s.AllOf { + if sub.Value != nil { + if e := schemaTypeEnum(sub.Value); e != nil { + return e + } + } + } + var union []any + seen := make(map[string]bool) + for _, sub := range s.OneOf { + if sub.Value == nil { + continue + } + for _, v := range schemaTypeEnum(sub.Value) { + if str, ok := v.(string); ok { + if seen[str] { + continue + } + seen[str] = true + } + union = append(union, v) + } + } + return union +} + +// enumStrings keeps the non-empty string members of a "type" enum, discarding +// any non-string or empty values. +func enumStrings(enum []any) []string { + out := make([]string, 0, len(enum)) + for _, v := range enum { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } + } + return out +} + +// matchTemplatePath returns the templated spec path whose shape matches the +// given concrete path (e.g. /organizations/acme/workspaces matches +// /organizations/{organization_name}/workspaces), or "" if none matches. A spec +// segment matches when it equals the concrete segment or is a {placeholder}. +func matchTemplatePath(oas openapi.Schema, concrete string) string { + want := splitPathSegments(concrete) + for _, key := range oas.Paths().Keys() { + have := splitPathSegments(key) + if len(have) != len(want) { + continue + } + matched := true + for i, seg := range have { + if strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") { + continue + } + if seg != want[i] { + matched = false + break + } + } + if matched { + return key + } + } + return "" +} + +func splitPathSegments(p string) []string { + return strings.FieldsFunc(strings.Trim(p, "/"), func(r rune) bool { return r == '/' }) +} + +// buildRelationships constructs the JSON:API "relationships" object from the -r +// flag values. Each flag key is a relationship name, optionally suffixed with an +// explicit ":type" override (name:type=id); without the override the linkage +// type and cardinality are resolved from the schema. Ids for a to-many +// relationship are comma-separated. +func buildRelationships(rels map[string]string, linkages map[string]linkage, haveSchema bool) (map[string]any, error) { + if len(rels) == 0 { + return nil, nil + } + + out := make(map[string]any, len(rels)) + seen := make(map[string]string, len(rels)) // relationship name -> raw flag key that set it + for rawKey, rawVal := range rels { + name, explicitType, _ := strings.Cut(rawKey, ":") + if name == "" { + return nil, fmt.Errorf("relationship name is empty in %q", rawKey) + } + if prev, dup := seen[name]; dup { + return nil, fmt.Errorf("relationship %q specified more than once (as %q and %q); provide it once", name, prev, rawKey) + } + seen[name] = rawKey + + lk, known := linkages[name] + + // Determine the linkage type. An explicit name:type=id override always + // wins; otherwise the schema must resolve the type unambiguously. + typ := explicitType + toManyKnown := false + var toMany bool + switch { + case typ != "": + // The schema still tells us the cardinality when it knows the + // relationship, even for an ambiguous or unpinned type. + if known { + toMany, toManyKnown = lk.ToMany, true + } + case !known: + return nil, unknownRelationshipError(name, linkages, haveSchema) + case lk.Type == "": + return nil, ambiguousRelationshipError(name, lk.Types) + default: + typ, toMany, toManyKnown = lk.Type, lk.ToMany, true + } + + ids := splitIDs(rawVal) + if len(ids) == 0 { + return nil, fmt.Errorf("relationship %q has no id", name) + } + + // Cardinality comes from the schema when known; otherwise infer it from + // the number of ids supplied. + if !toManyKnown { + toMany = len(ids) > 1 + } + + if !toMany { + if len(ids) > 1 { + return nil, fmt.Errorf("relationship %q is to-one but got %d ids: %s", name, len(ids), strings.Join(ids, ", ")) + } + out[name] = map[string]any{"data": identifier(typ, ids[0])} + continue + } + + data := make([]any, 0, len(ids)) + for _, id := range ids { + data = append(data, identifier(typ, id)) + } + out[name] = map[string]any{"data": data} + } + return out, nil +} + +func identifier(typ, id string) map[string]any { + return map[string]any{"type": typ, "id": id} +} + +// splitIDs splits a comma-separated id list, trimming whitespace and dropping +// empty entries. +func splitIDs(raw string) []string { + parts := strings.Split(raw, ",") + ids := make([]string, 0, len(parts)) + for _, p := range parts { + if id := strings.TrimSpace(p); id != "" { + ids = append(ids, id) + } + } + return ids +} + +// ambiguousRelationshipError explains that a relationship is real but its +// linkage type is not pinned by the schema, so the user must choose one. +func ambiguousRelationshipError(name string, types []string) error { + sorted := append([]string(nil), types...) + sort.Strings(sorted) + return fmt.Errorf("relationship %q maps to multiple types (%s); pin one with -r %s:=", + name, strings.Join(sorted, ", "), name) +} + +// unknownRelationshipError explains that a relationship name is not declared for +// this endpoint, listing the valid names when a schema was consulted or pointing +// at the explicit override when it was not. +func unknownRelationshipError(name string, linkages map[string]linkage, haveSchema bool) error { + override := fmt.Sprintf("-r %s:=", name) + if !haveSchema || len(linkages) == 0 { + return fmt.Errorf("could not infer the resource type for relationship %q; specify it explicitly with %s", name, override) + } + + names := make([]string, 0, len(linkages)) + for n := range linkages { + names = append(names, n) + } + sort.Strings(names) + return fmt.Errorf("unknown relationship %q for this endpoint; valid relationships: %s (or override with %s)", + name, strings.Join(names, ", "), override) +} diff --git a/internal/commands/api/relationships_test.go b/internal/commands/api/relationships_test.go new file mode 100644 index 0000000..19aa7f7 --- /dev/null +++ b/internal/commands/api/relationships_test.go @@ -0,0 +1,243 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package api + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/openapi" +) + +func TestMatchTemplatePath(t *testing.T) { + t.Parallel() + + oas := openapi.LoadEmbeddedSchema() + + require.Equal(t, + "/organizations/{organization_name}/workspaces", + matchTemplatePath(oas, "/organizations/acme/workspaces"), + ) + // A placeholder segment matches any concrete value. + require.Equal(t, + "/organizations/{organization_name}", + matchTemplatePath(oas, "/organizations/acme"), + ) + // A path the spec does not describe. + require.Equal(t, "", matchTemplatePath(oas, "/nope/not/a/real/path")) +} + +func TestRelationshipLinkages_FromEmbeddedSchema(t *testing.T) { + t.Parallel() + + linkages, ok := relationshipLinkages(openapi.LoadEmbeddedSchema(), "/organizations/acme/workspaces") + require.True(t, ok) + + // To-one linkage: the key differs from the type, which is exactly why we + // read the type from the schema rather than the flag key. + require.Equal(t, linkage{Type: "projects", Types: []string{"projects"}, ToMany: false}, linkages["project"]) + require.Equal(t, linkage{Type: "agent-pools", Types: []string{"agent-pools"}, ToMany: false}, linkages["agent-pool"]) + + // To-many linkage. + require.Equal(t, linkage{Type: "workspace-outputs", Types: []string{"workspace-outputs"}, ToMany: true}, linkages["outputs"]) + + // Ambiguous (type enum has several members, e.g. users|teams|runs): kept + // with an empty Type so the caller requires an explicit type, but its + // candidates are preserved to name them in the error. + lockedBy, ok := linkages["locked-by"] + require.True(t, ok, "ambiguous relationship should still be reported") + require.Empty(t, lockedBy.Type, "ambiguous relationship has no single pinned type") + require.Greater(t, len(lockedBy.Types), 1, "ambiguous relationship lists its candidates") + + // Links-only relationships (no data linkage) are omitted. + _, linksOnly := linkages["remote-state-consumers"] + require.False(t, linksOnly, "links-only relationship should be omitted") +} + +func TestRelationshipLinkages_NoSchemaOrNoMatch(t *testing.T) { + t.Parallel() + + _, ok := relationshipLinkages(nil, "/organizations/acme/workspaces") + require.False(t, ok) + + _, ok = relationshipLinkages(openapi.LoadEmbeddedSchema(), "/nope/not/real") + require.False(t, ok) +} + +func TestBuildRelationships(t *testing.T) { + t.Parallel() + + linkages := map[string]linkage{ + "project": {Type: "projects", ToMany: false}, + "outputs": {Type: "workspace-outputs", ToMany: true}, + } + + t.Run("schema-inferred to-one", func(t *testing.T) { + t.Parallel() + got, err := buildRelationships(map[string]string{"project": "prj-1"}, linkages, true) + require.NoError(t, err) + require.Equal(t, map[string]any{ + "project": map[string]any{"data": map[string]any{"type": "projects", "id": "prj-1"}}, + }, got) + }) + + t.Run("schema-inferred to-many, comma-separated ids", func(t *testing.T) { + t.Parallel() + got, err := buildRelationships(map[string]string{"outputs": "wsout-1, wsout-2"}, linkages, true) + require.NoError(t, err) + require.Equal(t, map[string]any{ + "outputs": map[string]any{"data": []any{ + map[string]any{"type": "workspace-outputs", "id": "wsout-1"}, + map[string]any{"type": "workspace-outputs", "id": "wsout-2"}, + }}, + }, got) + }) + + t.Run("explicit name:type=id override", func(t *testing.T) { + t.Parallel() + // "locked-by" is ambiguous in the schema, so the user pins the type. + got, err := buildRelationships(map[string]string{"locked-by:users": "user-1"}, linkages, true) + require.NoError(t, err) + require.Equal(t, map[string]any{ + "locked-by": map[string]any{"data": map[string]any{"type": "users", "id": "user-1"}}, + }, got) + }) + + t.Run("ambiguous relationship names its candidate types", func(t *testing.T) { + t.Parallel() + ambiguous := map[string]linkage{ + "locked-by": {Types: []string{"users", "teams", "runs"}, ToMany: false}, + } + _, err := buildRelationships(map[string]string{"locked-by": "user-1"}, ambiguous, true) + require.Error(t, err) + // Distinct from the "unknown relationship" message: it names the types + // and points at the explicit override. + assert.Contains(t, err.Error(), "maps to multiple types") + assert.Contains(t, err.Error(), "runs, teams, users") // sorted + assert.Contains(t, err.Error(), "locked-by:=") + }) + + t.Run("same relationship specified twice errors", func(t *testing.T) { + t.Parallel() + _, err := buildRelationships( + map[string]string{"project": "prj-1", "project:projects": "prj-2"}, + linkages, true, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), `relationship "project" specified more than once`) + }) + + t.Run("unknown relationship with schema lists valid names", func(t *testing.T) { + t.Parallel() + _, err := buildRelationships(map[string]string{"projects": "prj-1"}, linkages, true) + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown relationship "projects"`) + assert.Contains(t, err.Error(), "valid relationships:") + assert.Contains(t, err.Error(), "project") + }) + + t.Run("unknown relationship without schema advises explicit type", func(t *testing.T) { + t.Parallel() + _, err := buildRelationships(map[string]string{"whatever": "x-1"}, nil, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "could not infer the resource type") + assert.Contains(t, err.Error(), "whatever:=") + }) + + t.Run("to-one with multiple ids errors", func(t *testing.T) { + t.Parallel() + _, err := buildRelationships(map[string]string{"project": "prj-1,prj-2"}, linkages, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "is to-one but got 2 ids") + }) + + t.Run("empty id errors", func(t *testing.T) { + t.Parallel() + _, err := buildRelationships(map[string]string{"project": " "}, linkages, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "has no id") + }) + + t.Run("unknown relationship with explicit type and multiple ids infers to-many", func(t *testing.T) { + t.Parallel() + got, err := buildRelationships(map[string]string{"widgets:widgets": "w-1,w-2"}, nil, false) + require.NoError(t, err) + require.Equal(t, map[string]any{ + "widgets": map[string]any{"data": []any{ + map[string]any{"type": "widgets", "id": "w-1"}, + map[string]any{"type": "widgets", "id": "w-2"}, + }}, + }, got) + }) +} + +// TestRunAPI_RelationshipInfersTypeAndPost exercises the full path: -r implies +// POST, and the linkage type is read from the embedded schema for the matched +// operation. +func TestRunAPI_RelationshipInfersTypeAndPost(t *testing.T) { + t.Parallel() + + server, recorder := newAPITestServer(map[string]http.HandlerFunc{ + "POST /api/v2/organizations/acme/workspaces": func(w http.ResponseWriter, _ *http.Request) { + writeJSONAPIResponse(w, http.StatusCreated, map[string]any{ + "data": map[string]any{"id": "ws-1", "type": "workspaces"}, + }) + }, + }) + defer server.Close() + + io := iostreams.Test() + err := RunAPI(context.Background(), newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/organizations/acme/workspaces") + opts.Attributes = map[string]string{"name": "foo"} + opts.Relationships = map[string]string{"project": "prj-12dff4673ab9"} + })) + require.NoError(t, err) + + require.Equal(t, "POST", recorder.Last().Method) + assertJSONBodyEqual(t, map[string]any{ + "data": map[string]any{ + "type": "workspaces", + "attributes": map[string]any{"name": "foo"}, + "relationships": map[string]any{ + "project": map[string]any{ + "data": map[string]any{"type": "projects", "id": "prj-12dff4673ab9"}, + }, + }, + }, + }, recorder.Last().JSONBody(t)) +} + +// TestRunAPI_RelationshipOnlyBody confirms a relationship-only request still +// produces a valid data envelope (no attributes key). +func TestRunAPI_RelationshipOnlyBody(t *testing.T) { + t.Parallel() + + server, recorder := newAPITestServer(map[string]http.HandlerFunc{ + "POST /api/v2/organizations/acme/workspaces": func(w http.ResponseWriter, _ *http.Request) { + writeJSONAPIResponse(w, http.StatusCreated, map[string]any{ + "data": map[string]any{"id": "ws-1", "type": "workspaces"}, + }) + }, + }) + defer server.Close() + + io := iostreams.Test() + err := RunAPI(context.Background(), newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/organizations/acme/workspaces") + opts.Relationships = map[string]string{"project": "prj-1"} + })) + require.NoError(t, err) + + body := recorder.Last().JSONBody(t) + data := nestedMap(t, body, "data") + _, hasAttrs := data["attributes"] + require.False(t, hasAttrs, "no attributes key expected for relationship-only body") + require.Contains(t, data, "relationships") +} diff --git a/internal/commands/create/create.go b/internal/commands/create/create.go index fff135c..d0f9cf3 100644 --- a/internal/commands/create/create.go +++ b/internal/commands/create/create.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" "github.com/hashicorp/tfctl-cli/internal/pkg/flagvalue" "github.com/hashicorp/tfctl-cli/internal/pkg/heredoc" + "github.com/hashicorp/tfctl-cli/internal/pkg/openapi" "github.com/hashicorp/tfctl-cli/internal/pkg/resource" "github.com/hashicorp/tfctl-cli/version" ) @@ -43,11 +44,13 @@ func NewCmdCreate(inv *cmd.Invocation) *cmd.Command { LongHelp: heredoc.New(inv.IO, heredoc.WithPreserveNewlines()).Mustf(` The {{ template "mdCodeOrBold" "%s create" }} command creates a new resource via the API. - Provide attributes using {{ template "mdCodeOrBold" "-a key=value" }} (repeatable) or a raw request body with {{ template "mdCodeOrBold" "-i" }}. + Provide attributes using {{ template "mdCodeOrBold" "-a key=value" }} (repeatable), relationships using + {{ template "mdCodeOrBold" "-r name=id" }} (repeatable), or a raw request body with {{ template "mdCodeOrBold" "-i" }}. Use {{ template "mdCodeOrBold" "-i -" }} to read the request body from stdin. - Note: {{ template "mdCodeOrBold" "-a" }} only sets data.attributes. Resources that require a relationships block - (e.g. variable sets, policy sets) must use {{ template "mdCodeOrBold" "-i" }} with a full JSON:API request body. + {{ template "mdCodeOrBold" "-r" }} sets data.relationships. The linkage type is inferred from the schema + (e.g. {{ template "mdCodeOrBold" "-r project=prj-..." }} links to type "projects"); override an unresolved + one with {{ template "mdCodeOrBold" "name:type=id" }}, and comma-separate ids for to-many relationships. `, version.Name), Args: cmd.PositionalArguments{ Autocomplete: complete.PredictSet(resource.CreatableNames()...), @@ -74,6 +77,14 @@ func NewCmdCreate(inv *cmd.Invocation) *cmd.Command { Repeatable: true, Value: flagvalue.SimpleMap(nil, &opts.Attributes), }, + { + Name: "relationship", + Shorthand: "r", + DisplayValue: "NAME=ID", + Description: "Relationship for the JSON:API request body as name=id (repeatable). The linkage type is inferred from the schema; override an unresolved one with name:type=id. Comma-separate ids for to-many relationships.", + Repeatable: true, + Value: flagvalue.SimpleMap(nil, &opts.Relationships), + }, { Name: "input", Shorthand: "i", @@ -92,6 +103,10 @@ func NewCmdCreate(inv *cmd.Invocation) *cmd.Command { Preamble: "Create a workspace from a JSON file", Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s create workspace -i @workspace.json`, version.Name), }, + { + Preamble: "Create a workspace in a project (relationship type inferred from the schema)", + Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s create workspace -a name=my-workspace -r project=prj-12dff4673ab9`, version.Name), + }, { Preamble: "Create a project with inline JSON", Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s create project -i '{"data":{"type":"projects","attributes":{"name":"my-project"}}}'`, version.Name), @@ -103,6 +118,10 @@ func NewCmdCreate(inv *cmd.Invocation) *cmd.Command { opts.ProfileOrganization = inv.Profile.DefaultOrganization opts.Args = args + // Reuse the process-cached schema for -r linkage inference instead of + // having RunAPI re-parse the embedded spec on every invocation. + opts.Schema = openapi.SchemaFactory(inv) + client, err := inv.NewAPIClient() if err != nil { return fmt.Errorf("failed to create API client: %w", err) @@ -130,12 +149,12 @@ func runCreate(ctx context.Context, opts *Opts) error { return fmt.Errorf("create is not supported for %s", res.Type) } - if len(opts.Attributes) == 0 && opts.InputRequest == "" { - return fmt.Errorf("provide attributes with -a key=value or a request body with -i") + if len(opts.Attributes) == 0 && len(opts.Relationships) == 0 && opts.InputRequest == "" { + return fmt.Errorf("provide attributes with -a key=value, relationships with -r name=id, or a request body with -i") } - if len(opts.Attributes) > 0 && opts.InputRequest != "" { - return fmt.Errorf("cannot use both -a (attributes) and -i (input body); choose one") + if opts.InputRequest != "" && (len(opts.Attributes) > 0 || len(opts.Relationships) > 0) { + return fmt.Errorf("cannot use -i (input body) together with -a (attributes) or -r (relationships); choose one") } org := cmdutil.ResolveOrganization(opts.ProfileOrganization, opts.Organization) @@ -156,11 +175,14 @@ func runCreate(ctx context.Context, opts *Opts) error { apiOpts.DryRun = opts.DryRun apiOpts.InputRequest = opts.InputRequest apiOpts.Attributes = opts.Attributes - - // ResourceType is only needed for the attribute path (api builds the JSON:API - // envelope from it). On the -i branch the user supplies the full body, so the - // type is unused — but setting it is harmless and keeps diagnostic logging accurate. - if len(opts.Attributes) > 0 { + apiOpts.Relationships = opts.Relationships + apiOpts.Schema = opts.Schema + + // ResourceType names data.type when api builds the JSON:API envelope from + // attributes and/or relationships. On the -i branch the user supplies the full + // body, so the type is unused — but setting it is harmless and keeps diagnostic + // logging accurate. + if len(opts.Attributes) > 0 || len(opts.Relationships) > 0 { apiOpts.ResourceType = res.Type } diff --git a/internal/commands/create/create_test.go b/internal/commands/create/create_test.go index a01b63a..68b7dab 100644 --- a/internal/commands/create/create_test.go +++ b/internal/commands/create/create_test.go @@ -129,7 +129,7 @@ func TestRunCreate(t *testing.T) { err := runCreate(inv.ShutdownCtx, opts) require.Error(t, err) - assert.Contains(t, err.Error(), "provide attributes with -a key=value or a request body with -i") + assert.Contains(t, err.Error(), "provide attributes with -a key=value, relationships with -r name=id, or a request body with -i") }) t.Run("create unsupported resource type", func(t *testing.T) { @@ -204,7 +204,7 @@ func TestRunCreate(t *testing.T) { err := runCreate(inv.ShutdownCtx, opts) require.Error(t, err) - assert.Contains(t, err.Error(), "cannot use both -a (attributes) and -i (input body)") + assert.Contains(t, err.Error(), "cannot use -i (input body) together with -a (attributes) or -r (relationships)") }) t.Run("explicit org flag overrides profile", func(t *testing.T) {