Skip to content
Open
3 changes: 3 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20260819-161936.yaml
Original file line number Diff line number Diff line change
@@ -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
127 changes: 90 additions & 37 deletions internal/commands/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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{},
}
}

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -430,15 +449,43 @@ func RunAPI(ctx context.Context, opts *Opts) error {
}

opts.URL.RawQuery = query.Encode()
method := inferMethod(opts.Method, len(opts.Attributes) > 0 || len(opts.Relationships) > 0, opts.InputRequest != "")

// 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 {
Comment thread
shwetamurali marked this conversation as resolved.
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, "/"))
}
if linkages, haveSchema = relationshipLinkages(oas, method, specPath); haveSchema {
names := make([]string, 0, len(linkages))
for name := range linkages {
names = append(names, name)
}
logger.Debug("resolved relationship linkages from schema", "method", method, "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", "method", method, "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 != "")

requestHeaders, err := parseHeaders(opts.Headers)
if err != nil {
return err
Expand Down Expand Up @@ -628,7 +675,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
Expand All @@ -645,7 +692,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
}

Expand All @@ -656,19 +703,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
}
Expand Down
Loading