cli/exp_api.go
Rama main · réplica de lectura del repositorio original.
package cli
import (
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
// apiMethods are the HTTP methods accepted as the optional first argument of
// `coder exp api`. Any other first argument is treated as the path.
var apiMethods = []string{
http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodOptions,
}
// apiCmd makes an authenticated request to an arbitrary Coder API path and
// writes the raw response body to stdout. Request items follow httpie syntax.
func (r *RootCmd) apiCmd() *serpent.Command {
var input string
cmd := &serpent.Command{
Use: "api [METHOD] <path> [REQUEST_ITEM ...]",
Short: "Make an authenticated HTTP request to the Coder API and print the response.",
Long: "The path is relative to /api/v2/, with or without a leading slash. " +
"Paths that start with api/ or /api/ are used as given, for example /api/experimental/chats. " +
"Paths cannot contain . or .. segments.\n\n" +
"METHOD is optional and defaults to GET, or POST when the request has data items or --input.\n\n" +
"Request items use httpie syntax:\n" +
" Name:value request header\n" +
" Name: remove a request header, such as the default Content-Type\n" +
" name==value query parameter\n" +
" name=value JSON string field\n" +
" name:=json raw JSON field: number, bool, null, array, or object\n" +
" name=@file JSON string field read from a file, or - for stdin\n" +
" name:=@file raw JSON field read from a file, or - for stdin\n\n" +
"Data items build a JSON object body. Escape a separator character in a key with a backslash, " +
"for example foo\\==bar. Escapes also apply to file paths, so on Windows use forward slashes, " +
"for example name=@C:/data/file.json. Use --input to send a raw body instead of data items.\n\n" +
"Headers from the global --header and --header-command flags are sent in addition to header items.\n\n" +
FormatExamples(
Example{
Description: "Get the authenticated user",
Command: "coder exp api users/me",
},
Example{
Description: "List users with a search query",
Command: "coder exp api users q==status:active limit==10",
},
Example{
Description: "List your unread inbox notifications",
Command: "coder exp api notifications/inbox read_status==unread",
},
Example{
Description: "Search the audit log for deleted workspaces",
Command: "coder exp api audit 'q==resource_type:workspace action:delete' limit==25",
},
Example{
Description: "Create a user in an organization, reading the password from stdin",
Command: `printf '%s' "$PASSWORD" | coder exp api users username=alice email=alice@example.com password=@- 'organization_ids:=["<org-id>"]'`,
},
Example{
Description: "Send a JSON body from stdin",
Command: `echo '{"name":"new-name"}' | coder exp api PATCH workspaces/<id> --input -`,
},
),
Middleware: serpent.RequireRangeArgs(1, -1),
Handler: func(inv *serpent.Invocation) error {
method, rawPath, rawItems := splitAPIArgs(inv.Args)
if rawPath == "" {
return xerrors.Errorf("missing path after method %s", method)
}
path, err := apiRequestPath(rawPath)
if err != nil {
return err
}
// Checked before parsing so that no file or stdin is read for a
// request that is rejected.
if input != "" && hasAPIDataItems(rawItems) {
return xerrors.New("--input cannot be combined with data items")
}
items, err := parseAPIRequestItems(rawItems, inv.Stdin)
if err != nil {
return err
}
if method == "" {
method = http.MethodGet
if input != "" || len(items.data) > 0 {
method = http.MethodPost
}
}
var body any
switch {
case input == "-":
body = inv.Stdin
case input != "":
f, err := os.Open(input)
if err != nil {
return xerrors.Errorf("open input: %w", err)
}
defer f.Close()
body = f
case len(items.data) > 0:
body = items.data
}
client, err := r.InitClient(inv)
if err != nil {
return err
}
if apiURLIsInsecure(client.URL) {
cliui.Warn(inv.Stderr, "The session token is sent over unencrypted HTTP to "+client.URL.Host+".",
"Use an https:// URL for the deployment to protect the token.")
}
resp, err := client.Request(inv.Context(), method, path, body, func(req *http.Request) {
// Append to the raw query so a query already in the path is
// sent exactly as written.
if encoded := items.query.Encode(); encoded != "" {
if req.URL.RawQuery != "" {
req.URL.RawQuery += "&"
}
req.URL.RawQuery += encoded
}
for k, vs := range items.headers {
// A Name: item with no value removes the header.
if len(vs) == 0 {
req.Header.Del(k)
continue
}
req.Header[k] = vs
}
})
if err != nil {
return xerrors.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return writeAPIError(inv.Stdout, resp)
}
if _, err := io.Copy(inv.Stdout, resp.Body); err != nil {
return xerrors.Errorf("read response body: %w", err)
}
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Flag: "input",
Description: "The file to use as the raw request body, or - to read from stdin. Cannot be combined with data items.",
Value: serpent.StringOf(&input),
},
}
return cmd
}
// splitAPIArgs separates the optional method, the path, and the request
// items. The first argument is a method only when it matches a known HTTP
// method, so paths such as "users" are never mistaken for one. The returned
// path is empty when only a method was given.
func splitAPIArgs(args []string) (method, path string, items []string) {
first := strings.ToUpper(args[0])
for _, m := range apiMethods {
if first == m {
if len(args) < 2 {
return m, "", nil
}
return m, args[1], args[2:]
}
}
return "", args[0], args[1:]
}
// writeAPIError prints the error body unmodified so scripts can inspect it,
// then returns the Coder error built from the response.
func writeAPIError(w io.Writer, resp *http.Response) error {
raw, err := io.ReadAll(resp.Body)
if err != nil {
return xerrors.Errorf("read response body: %w", err)
}
if _, err := w.Write(raw); err != nil {
return xerrors.Errorf("write response body: %w", err)
}
resp.Body = io.NopCloser(bytes.NewReader(raw))
apiErr := codersdk.ReadBodyAsError(resp)
if sdkErr, ok := codersdk.AsError(apiErr); ok && resp.StatusCode == http.StatusNotFound && sdkErr.Message == "Route not found." {
sdkErr.Helper = "Check the path against the API reference at https://coder.com/docs/reference/api."
}
return apiErr
}
// apiRequestPath turns a user-supplied path into a path under /api/ on the
// configured deployment. Absolute URLs and dot segments are rejected so the
// session token is only sent to API routes on that deployment, never to
// another host or to a workspace app proxied by it.
func apiRequestPath(raw string) (string, error) {
pathPart, _, _ := strings.Cut(raw, "?")
if strings.Contains(pathPart, "://") || strings.HasPrefix(pathPart, "//") {
return "", xerrors.Errorf("path %q must not include a scheme or host; requests are always sent to the configured Coder URL", raw)
}
p := strings.TrimPrefix(raw, "/")
if p != "api" && !strings.HasPrefix(p, "api/") {
p = "api/v2/" + p
}
p = "/" + p
u, err := url.Parse(p)
if err != nil {
return "", xerrors.Errorf("parse path: %w", err)
}
// u.Path is decoded, so percent-encoded dot segments are rejected too.
for _, segment := range strings.Split(u.Path, "/") {
if segment == "." || segment == ".." {
return "", xerrors.Errorf("path %q must not contain . or .. segments", raw)
}
}
return p, nil
}
// apiURLIsInsecure reports whether requests to u send the session token in
// clear text over a network. Loopback addresses are exempt so local
// development deployments do not warn.
func apiURLIsInsecure(u *url.URL) bool {
if u == nil || u.Scheme != "http" {
return false
}
host := u.Hostname()
if host == "localhost" {
return false
}
ip := net.ParseIP(host)
return ip == nil || !ip.IsLoopback()
}
// apiItemSeparators lists request item separators from longest to shortest,
// so the longest separator wins when several match at the same position.
// ==@ and :@ are matched only to reject them, because httpie reads their
// values from a file and this command does not support that yet.
var apiItemSeparators = []string{":=@", "==@", "=@", "==", ":=", ":@", "=", ":"}
// apiEscapable holds the characters a backslash escapes in a request item.
// A backslash before any other character is kept literally, as in httpie.
const apiEscapable = ":=@"
type apiRequestItems struct {
headers http.Header
query url.Values
data map[string]any
}
// parseAPIRequestItems parses httpie-style request items and reads the file
// or stdin sources that data items refer to.
func parseAPIRequestItems(raw []string, stdin io.Reader) (apiRequestItems, error) {
items := apiRequestItems{
headers: http.Header{},
query: url.Values{},
data: map[string]any{},
}
readStdin := false
for _, arg := range raw {
key, sep, value, ok := splitAPIRequestItem(arg)
if !ok {
return items, xerrors.Errorf("invalid request item %q: expected Name:value, name==value, name=value, name:=json, name=@file, or name:=@file", arg)
}
if key == "" {
return items, xerrors.Errorf("invalid request item %q: missing name", arg)
}
switch sep {
case ":":
key = http.CanonicalHeaderKey(key)
if key == "Host" {
return items, xerrors.Errorf("invalid request item %q: the Host header cannot be set; requests are always sent to the configured Coder URL", arg)
}
value = strings.TrimSpace(value)
if value == "" {
// An empty list removes the header and any earlier values for
// it, as Name: does in httpie.
items.headers[key] = []string{}
continue
}
items.headers.Add(key, value)
continue
case "==":
items.query.Add(key, value)
continue
case "==@", ":@":
return items, xerrors.Errorf("invalid request item %q: reading a query parameter or header from a file is not supported; escape the @ with a backslash to send it literally", arg)
}
// httpie reserves [ in field names for nested JSON, so reject it
// rather than send a literal key that a later version would change.
if strings.Contains(key, "[") {
return items, xerrors.Errorf("invalid request item %q: nested JSON field names are not supported; use name:=json to send a nested value", arg)
}
if _, exists := items.data[key]; exists {
return items, xerrors.Errorf("duplicate data field %q", key)
}
if sep == "=@" || sep == ":=@" {
if value == "-" {
if readStdin {
return items, xerrors.Errorf("request item %q: only one request item can read stdin", arg)
}
readStdin = true
}
b, err := readAPIItemFile(value, stdin)
if err != nil {
return items, xerrors.Errorf("request item %q: %w", arg, err)
}
value = string(b)
}
switch sep {
case "=", "=@":
items.data[key] = value
case ":=", ":=@":
if !json.Valid([]byte(value)) {
return items, xerrors.Errorf("request item %q: value is not valid JSON", arg)
}
items.data[key] = json.RawMessage(value)
}
}
return items, nil
}
// hasAPIDataItems reports whether any request item adds a field to the JSON
// body. It does not read any file or stdin source.
func hasAPIDataItems(raw []string) bool {
for _, arg := range raw {
_, sep, _, ok := splitAPIRequestItem(arg)
if !ok {
continue
}
switch sep {
case "=", ":=", "=@", ":=@":
return true
}
}
return false
}
func readAPIItemFile(name string, stdin io.Reader) ([]byte, error) {
if name == "-" {
b, err := io.ReadAll(stdin)
if err != nil {
return nil, xerrors.Errorf("read stdin: %w", err)
}
return b, nil
}
b, err := os.ReadFile(name)
if err != nil {
return nil, xerrors.Errorf("read file: %w", err)
}
return b, nil
}
// splitAPIRequestItem finds the first unescaped position where a separator
// matches, takes the longest separator at that position, and returns the
// unescaped key and value around it.
func splitAPIRequestItem(arg string) (key, sep, value string, ok bool) {
type char struct {
r rune
escaped bool
}
runes := []rune(arg)
chars := make([]char, 0, len(runes))
for i := 0; i < len(runes); i++ {
if runes[i] == '\\' && i+1 < len(runes) && strings.ContainsRune(apiEscapable, runes[i+1]) {
chars = append(chars, char{r: runes[i+1], escaped: true})
i++
continue
}
chars = append(chars, char{r: runes[i]})
}
join := func(cs []char) string {
rs := make([]rune, len(cs))
for i, c := range cs {
rs[i] = c.r
}
return string(rs)
}
matches := func(at int, s string) bool {
for j, r := range []rune(s) {
if at+j >= len(chars) || chars[at+j].escaped || chars[at+j].r != r {
return false
}
}
return true
}
for i := range chars {
for _, s := range apiItemSeparators {
if matches(i, s) {
return join(chars[:i]), s, join(chars[i+len([]rune(s)):]), true
}
}
}
return "", "", "", false
}