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
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: 91 additions & 36 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 @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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
}
Expand Down
Loading