├── docs ├── .nojekyll ├── CNAME ├── logo.png ├── favicon.png ├── .markdownlint.yaml ├── _sidebar.md ├── input.md ├── retries.md ├── hypermedia.md ├── README.md ├── comparison.md └── shorthandv1.md ├── .gitignore ├── openapi ├── testdata │ ├── group-resp │ │ ├── output.yaml │ │ └── openapi.yaml │ ├── auto_config │ │ ├── openapi.yaml │ │ └── output.yaml │ ├── extensions │ │ ├── output.yaml │ │ └── openapi.yaml │ ├── long_example │ │ ├── output.yaml │ │ └── openapi.yaml │ ├── request │ │ ├── openapi.yaml │ │ └── output.yaml │ └── petstore │ │ ├── output.yaml │ │ └── openapi.yaml ├── example.go ├── openapi_test.go ├── schema.go └── schema_test.go ├── codecov.yml ├── oauth ├── authcode_test.go ├── request.go ├── clientcreds.go ├── refresh.go └── oauth.go ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── LICENSE.md ├── cli ├── autoconfig.go ├── input_test.go ├── encoding_test.go ├── readable_test.go ├── gron_test.go ├── input.go ├── content_test.go ├── api_test.go ├── logger.go ├── flag.go ├── transport_test.go ├── apiconfig_test.go ├── encoding.go ├── operation_test.go ├── param_test.go ├── edit_test.go ├── transport.go ├── auth.go ├── readable.go ├── param.go ├── links_test.go ├── operation.go ├── gron.go ├── lexer.go ├── request_test.go ├── edit.go ├── interactive_test.go ├── formatter_test.go ├── links.go └── api.go ├── main.go ├── .goreleaser.yml ├── bench_test.go ├── go.mod ├── README.md └── bulk └── file.go /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | rest.sh -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | TODO* 3 | launch.json 4 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rest-sh/restish/HEAD/docs/logo.png -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rest-sh/restish/HEAD/docs/favicon.png -------------------------------------------------------------------------------- /docs/.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | default: true 2 | 3 | # no-hard-tabs 4 | MD010: false 5 | 6 | # line-length 7 | MD013: false 8 | 9 | # commands-show-output 10 | MD014: false 11 | 12 | # no-inline-html 13 | MD033: false 14 | -------------------------------------------------------------------------------- /openapi/testdata/group-resp/output.yaml: -------------------------------------------------------------------------------- 1 | short: Test API 2 | operations: 3 | - name: get-test 4 | aliases: [] 5 | long: | 6 | ## Response 204 7 | 8 | Empty response 9 | 10 | ## Responses 400/404/422/500 (application/json) 11 | 12 | ```schema 13 | { 14 | message*: (string) 15 | } 16 | ``` 17 | method: GET 18 | uri_template: http://api.example.com/test 19 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: true 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "50...80" 8 | 9 | parsers: 10 | gcov: 11 | branch_detection: 12 | conditional: yes 13 | loop: yes 14 | method: no 15 | macro: no 16 | 17 | comment: 18 | layout: "reach,diff,flags,files,footer" 19 | behavior: default 20 | require_changes: false 21 | 22 | ignore: 23 | - main.go 24 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - [Home](/ "Restish") 2 | - [Guide](guide.md "Restish User Guide") 3 | - [Comparison](comparison.md "Comparison") 4 | - [Configuration](configuration.md "Configuring Restish") 5 | - [OpenAPI](openapi.md "OpenAPI 3 & Restish") 6 | - [Input](input.md "Restish Input") 7 | - [CLI Shorthand](shorthand.md "CLI Shorthand") 8 | - [Output](output.md "Restish Output") 9 | - [Retries & Timeouts](retries.md "Retries & Timeouts") 10 | - [Hypermedia](hypermedia.md "Hypermedia Linking in Restish") 11 | - [Bulk Management](bulk.md "Bulk Resource Management") 12 | -------------------------------------------------------------------------------- /oauth/authcode_test.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestEncodeUrlWindowsSuccess(t *testing.T) { 11 | u := "https://mydomain.auth.us-east-1.amazoncognito.com/oauth2/authorize?response_type=code&client_id=1example23456789&redirect_uri=https://www.example.com&state=abcdefg&scope=openid+profile" 12 | 13 | r := encodeUrlWindows(u) 14 | //t.Log(r) 15 | 16 | assert.NotEqual(t, u, r) 17 | assert.Contains(t, r, "^&") 18 | assert.False(t, strings.HasPrefix(r, "^&")) 19 | assert.False(t, strings.HasSuffix(r, "^&")) 20 | } 21 | -------------------------------------------------------------------------------- /openapi/testdata/auto_config/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.1.0" 2 | info: 3 | version: 1.0.0 4 | title: Test API 5 | components: 6 | securitySchemes: 7 | default: 8 | type: oauth2 9 | flows: 10 | clientCredentials: 11 | tokenUrl: https://example.com/token 12 | scopes: {} 13 | authorizationCode: 14 | authorizationUrl: https://example.com/authorize 15 | tokenUrl: https://example.com/token 16 | scopes: {} 17 | basic: 18 | type: http 19 | scheme: basic 20 | x-cli-config: 21 | security: default 22 | prompt: 23 | client_id: 24 | description: Client identifier 25 | example: abc123 26 | -------------------------------------------------------------------------------- /openapi/testdata/auto_config/output.yaml: -------------------------------------------------------------------------------- 1 | short: Test API 2 | operations: [] 3 | auth: 4 | - name: http-basic 5 | params: 6 | username: "" 7 | password: "" 8 | - name: oauth-client-credentials 9 | params: 10 | token_url: https://example.com/token 11 | client_id: "" 12 | client_secret: "" 13 | - name: oauth-authorization-code 14 | params: 15 | authorize_url: https://example.com/authorize 16 | client_id: "" 17 | token_url: https://example.com/token 18 | auto_config: 19 | prompt: 20 | client_id: 21 | description: Client identifier 22 | example: abc123 23 | exclude: false 24 | auth: 25 | name: oauth-authorization-code 26 | params: 27 | authorize_url: https://example.com/authorize 28 | client_id: "" 29 | token_url: https://example.com/token 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: {} 7 | workflow_dispatch: # Allow triggering manually. 8 | 9 | jobs: 10 | build: 11 | name: Build & Test 12 | 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, windows-latest] 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v5 21 | - name: Set up go 22 | uses: actions/setup-go@v6 23 | with: 24 | go-version: 1.24 25 | - run: go test -coverprofile=coverage.txt -covermode=atomic ./... 26 | if: matrix.os == 'ubuntu-latest' 27 | - run: go test 28 | if: matrix.os == 'windows-latest' 29 | - uses: codecov/codecov-action@v5 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | if: matrix.os == 'ubuntu-latest' 33 | -------------------------------------------------------------------------------- /openapi/testdata/extensions/output.yaml: -------------------------------------------------------------------------------- 1 | short: Test API 2 | operations: 3 | - name: getItem 4 | aliases: 5 | - get-item 6 | - getitem 7 | - gi 8 | short: "" 9 | long: | 10 | ## Argument Schema: 11 | ```schema 12 | { 13 | item-id: (string) 14 | } 15 | ``` 16 | 17 | ## Option Schema: 18 | ```schema 19 | { 20 | --query: [ 21 | (string) 22 | ] 23 | } 24 | ``` 25 | 26 | ## Response 200 (application/json) 27 | 28 | CLI-specific description override 29 | 30 | ```schema 31 | { 32 | foo: (string) 33 | } 34 | ``` 35 | method: GET 36 | uri_template: http://api.example.com/items/{item-id} 37 | path_params: 38 | - type: string 39 | name: item-id 40 | query_params: 41 | - type: "array[string]" 42 | name: q 43 | display_name: query 44 | -------------------------------------------------------------------------------- /openapi/testdata/long_example/output.yaml: -------------------------------------------------------------------------------- 1 | short: Test API 2 | operations: 3 | - name: put-item 4 | aliases: [] 5 | short: "" 6 | long: | 7 | ## Argument Schema: 8 | ```schema 9 | { 10 | item-id: (string) 11 | } 12 | ``` 13 | 14 | ## Input Example 15 | 16 | ```json 17 | { 18 | "bar": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 19 | "baz": "ccccccccccccccccccccccccccccccccccccccccccccc", 20 | "foo": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 21 | } 22 | ``` 23 | 24 | ## Request Schema (application/json) 25 | 26 | ```schema 27 | { 28 | foo: (string) 29 | } 30 | ``` 31 | 32 | ## Response 201 33 | 34 | desc 35 | method: PUT 36 | uri_template: http://api.example.com/items/{item-id} 37 | body_media_type: application/json 38 | path_params: 39 | - type: string 40 | name: item-id 41 | examples: 42 | - " /tmp/homebrew_repo_id_ed25519 30 | shell: bash 31 | 32 | - name: Build using goreleaser-cross 33 | run: | 34 | docker run --rm \ 35 | -e CGO_ENABLED=1 \ 36 | -e GITHUB_TOKEN=${{ steps.releaser-token.outputs.token }} \ 37 | -v /tmp/homebrew_repo_id_ed25519:/tmp/homebrew_repo_id_ed25519 \ 38 | -v $PWD:/workspace \ 39 | -w /workspace \ 40 | ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ 41 | release --clean --verbose 42 | -------------------------------------------------------------------------------- /cli/input_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "testing" 7 | "testing/fstest" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func WithFakeStdin(data []byte, mode fs.FileMode, f func()) { 13 | fs := fstest.MapFS{ 14 | "stdin": { 15 | Data: data, 16 | Mode: mode, 17 | }, 18 | } 19 | stdinFile, _ := fs.Open("stdin") 20 | Stdin = stdinFile 21 | defer func() { Stdin = os.Stdin }() 22 | f() 23 | } 24 | 25 | func TestInputStructuredJSON(t *testing.T) { 26 | WithFakeStdin([]byte{}, fs.ModeCharDevice, func() { 27 | body, err := GetBody("application/json", []string{"foo: 1, bar: false"}) 28 | assert.NoError(t, err) 29 | assert.Equal(t, `{"bar":false,"foo":1}`, body) 30 | }) 31 | } 32 | 33 | func TestInputStructuredYAML(t *testing.T) { 34 | WithFakeStdin([]byte{}, fs.ModeCharDevice, func() { 35 | body, err := GetBody("application/yaml", []string{"foo: 1, bar: false"}) 36 | assert.NoError(t, err) 37 | assert.Equal(t, "bar: false\nfoo: 1\n", body) 38 | }) 39 | } 40 | 41 | func TestInputBinary(t *testing.T) { 42 | WithFakeStdin([]byte("This is not JSON!"), 0, func() { 43 | body, err := GetBody("", []string{}) 44 | assert.NoError(t, err) 45 | assert.Equal(t, "This is not JSON!", body) 46 | }) 47 | } 48 | 49 | func TestInputInvalidType(t *testing.T) { 50 | WithFakeStdin([]byte{}, fs.ModeCharDevice, func() { 51 | _, err := GetBody("application/unknown", []string{"foo: 1"}) 52 | assert.Error(t, err) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /openapi/testdata/group-resp/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.1.0" 2 | info: 3 | version: 1.0.0 4 | title: Test API 5 | components: 6 | schemas: 7 | ErrorModel: 8 | type: object 9 | required: [message] 10 | properties: 11 | message: 12 | type: string 13 | paths: 14 | /test: 15 | get: 16 | operationId: get-test 17 | responses: 18 | "204": 19 | description: Empty response 20 | # The following should all be grouped together as they represent the 21 | # same error model. 22 | "400": 23 | description: desc 24 | content: 25 | application/json: 26 | schema: 27 | $ref: "#/components/schemas/ErrorModel" 28 | "404": 29 | description: desc 30 | content: 31 | application/json: 32 | schema: 33 | $ref: "#/components/schemas/ErrorModel" 34 | "422": 35 | description: desc 36 | content: 37 | application/json: 38 | schema: 39 | $ref: "#/components/schemas/ErrorModel" 40 | # Example of the same model *without* a ref. It should still get 41 | # grouped with the rest as it is functionally equivalent. 42 | "500": 43 | description: desc 44 | content: 45 | application/json: 46 | schema: 47 | type: object 48 | required: [message] 49 | properties: 50 | message: 51 | type: string 52 | -------------------------------------------------------------------------------- /cli/encoding_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "compress/gzip" 7 | "io" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/andybalholm/brotli" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func gzipEnc(data string) []byte { 16 | b := bytes.NewBuffer(nil) 17 | w := gzip.NewWriter(b) 18 | w.Write([]byte(data)) 19 | w.Close() 20 | return b.Bytes() 21 | } 22 | 23 | func deflateEnc(data string) []byte { 24 | b := bytes.NewBuffer(nil) 25 | w, _ := flate.NewWriter(b, 1) 26 | w.Write([]byte(data)) 27 | w.Close() 28 | return b.Bytes() 29 | } 30 | 31 | func brEnc(data string) []byte { 32 | b := bytes.NewBuffer(nil) 33 | w := brotli.NewWriter(b) 34 | w.Write([]byte(data)) 35 | w.Close() 36 | return b.Bytes() 37 | } 38 | 39 | var encodingTests = []struct { 40 | name string 41 | header string 42 | data []byte 43 | }{ 44 | {"none", "", []byte("hello world")}, 45 | {"gzip", "gzip", gzipEnc("hello world")}, 46 | {"deflate", "deflate", deflateEnc("hello world")}, 47 | {"brotli", "br", brEnc("hello world")}, 48 | } 49 | 50 | func TestEncodings(parent *testing.T) { 51 | for _, tt := range encodingTests { 52 | parent.Run(tt.name, func(t *testing.T) { 53 | resp := &http.Response{ 54 | Header: http.Header{ 55 | "Content-Encoding": []string{tt.header}, 56 | }, 57 | Body: io.NopCloser(bytes.NewReader(tt.data)), 58 | } 59 | 60 | err := DecodeResponse(resp) 61 | assert.NoError(t, err) 62 | 63 | data, err := io.ReadAll(resp.Body) 64 | assert.NoError(t, err) 65 | assert.Equal(t, "hello world", string(data)) 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /openapi/testdata/request/output.yaml: -------------------------------------------------------------------------------- 1 | short: Test API 2 | operations: 3 | - name: delete-items-item-id 4 | aliases: [] 5 | short: "" 6 | long: | 7 | ## Argument Schema: 8 | ```schema 9 | { 10 | item-id: (string) 11 | } 12 | ``` 13 | 14 | ## Response 204 15 | 16 | Response has no body 17 | method: DELETE 18 | uri_template: http://api.example.com/items/{item-id} 19 | path_params: 20 | - type: string 21 | name: item-id 22 | - name: put-item 23 | aliases: [] 24 | short: "" 25 | long: | 26 | ## Argument Schema: 27 | ```schema 28 | { 29 | item-id: (string) 30 | } 31 | ``` 32 | 33 | ## Option Schema: 34 | ```schema 35 | { 36 | --my-header: (string) 37 | } 38 | ``` 39 | 40 | ## Input Example 41 | 42 | ```json 43 | { 44 | "foo": "multi" 45 | } 46 | ``` 47 | 48 | ## Request Schema (application/json) 49 | 50 | ```schema 51 | { 52 | foo: (string) 53 | } 54 | ``` 55 | 56 | ## Response 200 (application/json) 57 | 58 | desc 59 | 60 | ```schema 61 | { 62 | foo: (string) 63 | } 64 | ``` 65 | method: PUT 66 | uri_template: http://api.example.com/items/{item-id} 67 | body_media_type: application/json 68 | path_params: 69 | - type: string 70 | name: item-id 71 | header_params: 72 | - type: string 73 | name: MyHeader 74 | example: abc123 75 | style: 1 76 | examples: 77 | - "foo: multi" 78 | -------------------------------------------------------------------------------- /cli/readable_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestReadableMarshal(t *testing.T) { 11 | created, _ := time.Parse(time.RFC3339, "2020-01-01T12:34:56Z") 12 | data := map[string]interface{}{ 13 | "binary": []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 14 | "created": created, 15 | "date": created.Truncate(24 * time.Hour), 16 | "id": "test", 17 | "emptyMap": map[string]interface{}{}, 18 | "emptyArray": []string{}, 19 | "nested": map[string]interface{}{ 20 | "saved": true, 21 | "self": "https://example.com/nested", 22 | }, 23 | "pointer": nil, 24 | "tags": []string{"one", "tw\"o", "three"}, 25 | "value": 123, 26 | "float": 1.2, 27 | } 28 | 29 | encoded, err := MarshalReadable(data) 30 | assert.NoError(t, err) 31 | assert.Equal(t, `{ 32 | binary: 0x00010203040506070809... 33 | created: 2020-01-01T12:34:56Z 34 | date: 2020-01-01 35 | emptyArray: [] 36 | emptyMap: {} 37 | float: 1.2 38 | id: "test" 39 | nested: { 40 | saved: true 41 | self: "https://example.com/nested" 42 | } 43 | pointer: null 44 | tags: ["one", "tw\"o", "three"] 45 | value: 123 46 | }`, string(encoded)) 47 | } 48 | 49 | func TestSingleItemWithNewlines(t *testing.T) { 50 | data := []interface{}{ 51 | map[string]interface{}{ 52 | "id": 1234, 53 | "created": "2020-08-12", 54 | }, 55 | } 56 | 57 | encoded, err := MarshalReadable(data) 58 | assert.NoError(t, err) 59 | assert.Equal(t, `[ 60 | { 61 | created: "2020-08-12" 62 | id: 1234 63 | } 64 | ]`, string(encoded)) 65 | } 66 | -------------------------------------------------------------------------------- /cli/gron_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGronMarshal(t *testing.T) { 11 | type D struct { 12 | D []map[string]any `json:"d"` 13 | } 14 | 15 | type T struct { 16 | A string `json:"a"` 17 | B int `json:"b"` 18 | C bool `json:"c"` 19 | D 20 | E bool `json:"-"` 21 | private bool 22 | } 23 | 24 | value := T{ 25 | A: "hello", 26 | B: 42, 27 | C: true, 28 | D: D{[]map[string]any{ 29 | {"e": "world & i <3 restish"}, 30 | {"f": []any{1, 2}, "g": time.Time{}, "h": []byte("foo")}, 31 | {"for": map[int]int{1: 2}}, 32 | {"dotted.name": "foo"}, 33 | {"name[with]brackets": "bar"}, 34 | {"name\"with": "quote"}, 35 | }}, 36 | private: true, 37 | } 38 | 39 | g := Gron{} 40 | b, err := g.Marshal(value) 41 | assert.NoError(t, err) 42 | assert.Equal(t, `body = {}; 43 | body.a = "hello"; 44 | body.b = 42; 45 | body.c = true; 46 | body.d = []; 47 | body.d[0] = {}; 48 | body.d[0].e = "world & i <3 restish"; 49 | body.d[1] = {}; 50 | body.d[1].f = []; 51 | body.d[1].f[0] = 1; 52 | body.d[1].f[1] = 2; 53 | body.d[1].g = "0001-01-01T00:00:00Z"; 54 | body.d[1].h = "Zm9v"; 55 | body.d[2] = {}; 56 | body.d[2].for = {}; 57 | body.d[2].for["1"] = 2; 58 | body.d[3] = {}; 59 | body.d[3]["dotted.name"] = "foo"; 60 | body.d[4] = {}; 61 | body.d[4]["name[with]brackets"] = "bar"; 62 | body.d[5] = {}; 63 | body.d[5]["name\"with"] = "quote"; 64 | `, string(b)) 65 | 66 | // Invalid types should result in an error! 67 | _, err = g.Marshal(T{ 68 | D: D{[]map[string]any{ 69 | {"foo": make(chan int)}, 70 | }}, 71 | }) 72 | assert.Error(t, err) 73 | } 74 | -------------------------------------------------------------------------------- /cli/input.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "strings" 10 | 11 | "github.com/danielgtaylor/shorthand/v2" 12 | yaml "gopkg.in/yaml.v2" 13 | ) 14 | 15 | // Stdin represents the command input, which defaults to os.Stdin. 16 | var Stdin interface { 17 | Stat() (fs.FileInfo, error) 18 | io.Reader 19 | } = os.Stdin 20 | 21 | // GetBody returns the request body if one was passed either as shorthand 22 | // arguments or via stdin. 23 | func GetBody(mediaType string, args []string) (string, error) { 24 | var body string 25 | 26 | if info, err := Stdin.Stat(); err == nil { 27 | if len(args) == 0 && (info.Mode()&os.ModeCharDevice) == 0 { 28 | // There are no args but there is data on stdin. Just read it and 29 | // pass it through as it may not be structured data we can parse or 30 | // could be binary (e.g. file uploads). 31 | b, err := io.ReadAll(Stdin) 32 | if err != nil { 33 | return "", err 34 | } 35 | return string(b), nil 36 | } 37 | } 38 | 39 | input, _, err := shorthand.GetInput(args, shorthand.ParseOptions{ 40 | EnableFileInput: true, 41 | EnableObjectDetection: true, 42 | }) 43 | if err != nil { 44 | return "", err 45 | } 46 | 47 | if input != nil { 48 | if strings.Contains(mediaType, "json") { 49 | marshalled, err := json.Marshal(input) 50 | if err != nil { 51 | return "", err 52 | } 53 | body = string(marshalled) 54 | } else if strings.Contains(mediaType, "yaml") { 55 | marshalled, err := yaml.Marshal(input) 56 | if err != nil { 57 | return "", err 58 | } 59 | body = string(marshalled) 60 | } else { 61 | return "", fmt.Errorf("not sure how to marshal %s", mediaType) 62 | } 63 | } 64 | 65 | return body, nil 66 | } 67 | -------------------------------------------------------------------------------- /cli/content_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | var contentTests = []struct { 10 | name string 11 | types []string 12 | ct ContentType 13 | data []byte 14 | pretty []byte 15 | }{ 16 | {"text", []string{"text/plain", "text/html"}, &Text{}, []byte("hello world"), nil}, 17 | {"json", []string{"application/json", "foo+json"}, &JSON{}, []byte("{\"hello\":\"world\"}\n"), []byte("{\n \"hello\": \"world\"\n}\n")}, 18 | {"yaml", []string{"application/yaml", "foo+yaml"}, &YAML{}, []byte("hello: world\n"), nil}, 19 | {"cbor", []string{"application/cbor", "foo+cbor"}, &CBOR{}, []byte("\xf6"), nil}, 20 | {"msgpack", []string{"application/msgpack", "application/x-msgpack", "application/vnd.msgpack", "foo+msgpack"}, &MsgPack{}, []byte("\x81\xa5\x68\x65\x6c\x6c\x6f\xa5\x77\x6f\x72\x6c\x64"), nil}, 21 | {"ion", []string{"application/ion", "foo+ion"}, &Ion{}, []byte("\xe0\x01\x00\xea\x0f"), []byte("null")}, 22 | } 23 | 24 | func TestContentTypes(parent *testing.T) { 25 | for _, tt := range contentTests { 26 | parent.Run(tt.name, func(t *testing.T) { 27 | for _, typ := range tt.types { 28 | assert.True(t, tt.ct.Detect(typ)) 29 | } 30 | 31 | assert.False(t, tt.ct.Detect("bad-content-type")) 32 | 33 | var data interface{} 34 | err := tt.ct.Unmarshal(tt.data, &data) 35 | assert.NoError(t, err) 36 | 37 | b, err := tt.ct.Marshal(data) 38 | assert.NoError(t, err) 39 | 40 | if tt.pretty != nil { 41 | if p, ok := tt.ct.(PrettyMarshaller); ok { 42 | b, err := p.MarshalPretty(data) 43 | assert.NoError(t, err) 44 | assert.Equal(t, tt.pretty, b) 45 | } else { 46 | t.Fatal("not a pretty marshaller") 47 | } 48 | } 49 | 50 | assert.Equal(t, tt.data, b) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /oauth/request.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/rest-sh/restish/cli" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | // tokenResponse is used to parse responses from token providers and make sure 16 | // the expiration time is set properly regardless of whether `expires_in` or 17 | // `expiry` is returned. 18 | type tokenResponse struct { 19 | TokenType string `json:"token_type"` 20 | AccessToken string `json:"access_token"` 21 | RefreshToken string `json:"refresh_token,omitempty"` 22 | ExpiresIn time.Duration `json:"expires_in"` 23 | Expiry time.Time `json:"expiry,omitempty"` 24 | } 25 | 26 | // requestToken from the given URL with the given payload. This can be used 27 | // for many different grant types and will return a parsed token. 28 | func requestToken(tokenURL, payload string) (*oauth2.Token, error) { 29 | req, err := http.NewRequest("POST", tokenURL, strings.NewReader(payload)) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 35 | 36 | cli.LogDebugRequest(req) 37 | 38 | start := time.Now() 39 | res, err := http.DefaultClient.Do(req) 40 | if err != nil { 41 | return nil, err 42 | } 43 | cli.LogDebugResponse(start, res) 44 | defer res.Body.Close() 45 | body, _ := io.ReadAll(res.Body) 46 | 47 | if res.StatusCode > 200 { 48 | return nil, fmt.Errorf("bad response from token endpoint:\n%s", body) 49 | } 50 | 51 | decoded := tokenResponse{} 52 | if err := json.Unmarshal(body, &decoded); err != nil { 53 | return nil, err 54 | } 55 | 56 | expiry := decoded.Expiry 57 | if expiry.IsZero() { 58 | expiry = time.Now().Add(decoded.ExpiresIn * time.Second) 59 | } 60 | 61 | token := &oauth2.Token{ 62 | AccessToken: decoded.AccessToken, 63 | TokenType: decoded.TokenType, 64 | RefreshToken: decoded.RefreshToken, 65 | Expiry: expiry, 66 | } 67 | 68 | return token, nil 69 | } 70 | -------------------------------------------------------------------------------- /oauth/clientcreds.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/rest-sh/restish/cli" 10 | "golang.org/x/oauth2/clientcredentials" 11 | ) 12 | 13 | // ClientCredentialsHandler implements the Client Credentials OAuth2 flow. 14 | type ClientCredentialsHandler struct{} 15 | 16 | // Parameters returns a list of OAuth2 Authorization Code inputs. 17 | func (h *ClientCredentialsHandler) Parameters() []cli.AuthParam { 18 | return []cli.AuthParam{ 19 | {Name: "client_id", Required: true, Help: "OAuth 2.0 Client ID"}, 20 | {Name: "client_secret", Required: true, Help: "OAuth 2.0 Client Secret"}, 21 | {Name: "token_url", Required: true, Help: "OAuth 2.0 token URL, e.g. https://api.example.com/oauth/token"}, 22 | {Name: "scopes", Help: "Optional scopes to request in the token"}, 23 | } 24 | } 25 | 26 | // OnRequest gets run before the request goes out on the wire. 27 | func (h *ClientCredentialsHandler) OnRequest(request *http.Request, key string, params map[string]string) error { 28 | if request.Header.Get("Authorization") == "" { 29 | if params["client_id"] == "" { 30 | return ErrInvalidProfile 31 | } 32 | 33 | if params["client_secret"] == "" { 34 | return ErrInvalidProfile 35 | } 36 | 37 | if params["token_url"] == "" { 38 | return ErrInvalidProfile 39 | } 40 | 41 | endpointParams := url.Values{} 42 | for k, v := range params { 43 | if k == "client_id" || k == "client_secret" || k == "scopes" || k == "token_url" { 44 | // Not a custom param... 45 | continue 46 | } 47 | 48 | endpointParams.Add(k, v) 49 | } 50 | 51 | source := (&clientcredentials.Config{ 52 | ClientID: params["client_id"], 53 | ClientSecret: params["client_secret"], 54 | TokenURL: params["token_url"], 55 | EndpointParams: endpointParams, 56 | Scopes: strings.Split(params["scopes"], ","), 57 | }).TokenSource(context.Background()) 58 | 59 | return TokenHandler(source, key, request) 60 | } 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /cli/api_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type overrideLoader struct { 14 | detect func(resp *http.Response) bool 15 | load func(entrypoint, spec url.URL, resp *http.Response) (API, error) 16 | locationHints func() []string 17 | } 18 | 19 | func (l *overrideLoader) Detect(resp *http.Response) bool { 20 | if l.detect != nil { 21 | return l.detect(resp) 22 | } 23 | return true 24 | } 25 | 26 | func (l *overrideLoader) Load(entrypoint url.URL, spec url.URL, resp *http.Response) (API, error) { 27 | if l.load != nil { 28 | return l.load(entrypoint, spec, resp) 29 | } 30 | return API{}, nil 31 | } 32 | func (l *overrideLoader) LocationHints() []string { 33 | if l.locationHints != nil { 34 | return l.locationHints() 35 | } 36 | return []string{} 37 | } 38 | 39 | func TestLoadFromFile(t *testing.T) { 40 | reset(false) 41 | viper.Set("rsh-no-cache", true) 42 | AddLoader(&overrideLoader{ 43 | load: func(entrypoint, spec url.URL, resp *http.Response) (API, error) { 44 | assert.Equal(t, "testdata/petstore.json", spec.String()) 45 | return API{}, nil 46 | }, 47 | }) 48 | 49 | configs["file-load-test"] = &APIConfig{ 50 | Base: "https://api.example.com", 51 | SpecFiles: []string{"testdata/petstore.json"}, 52 | } 53 | 54 | _, err := Load("https://api.example.com", &cobra.Command{}) 55 | 56 | assert.NoError(t, err) 57 | } 58 | 59 | func TestBadSpecURL(t *testing.T) { 60 | reset(false) 61 | viper.Set("rsh-no-cache", true) 62 | AddLoader(&overrideLoader{ 63 | load: func(entrypoint, spec url.URL, resp *http.Response) (API, error) { 64 | assert.Equal(t, "testdata/petstore.json", spec.String()) 65 | return API{}, nil 66 | }, 67 | }) 68 | 69 | configs["bad-spec-url-test"] = &APIConfig{ 70 | Base: "https://api.example.com", 71 | SpecFiles: []string{"http://abc{def@ghi}"}, 72 | } 73 | 74 | _, err := Load("https://api.example.com", &cobra.Command{}) 75 | assert.Error(t, err) 76 | } 77 | -------------------------------------------------------------------------------- /cli/logger.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httputil" 7 | "strings" 8 | "time" 9 | 10 | "github.com/alecthomas/chroma/quick" 11 | ) 12 | 13 | var enableVerbose bool 14 | 15 | // LogDebug logs a debug message if --rsh-verbose (-v) was passed. 16 | func LogDebug(format string, values ...interface{}) { 17 | if enableVerbose { 18 | fmt.Fprintf(Stderr, "%s %s\n", au.Index(243, "DEBUG:"), fmt.Sprintf(format, values...)) 19 | } 20 | } 21 | 22 | // LogDebugRequest logs the request in a debug message if verbose output 23 | // is enabled. 24 | func LogDebugRequest(req *http.Request) { 25 | if enableVerbose { 26 | dumped, err := httputil.DumpRequest(req, true) 27 | if err != nil { 28 | return 29 | } 30 | 31 | if useColor { 32 | sb := &strings.Builder{} 33 | quick.Highlight(sb, string(dumped), "http", "terminal256", "cli-dark") 34 | dumped = []byte(sb.String()) 35 | } 36 | 37 | LogDebug("Making request:\n%s", string(dumped)) 38 | } 39 | } 40 | 41 | // LogDebugResponse logs the response in a debug message if verbose output 42 | // is enabled. 43 | func LogDebugResponse(start time.Time, resp *http.Response) { 44 | if enableVerbose { 45 | dumped, err := httputil.DumpResponse(resp, true) 46 | if err != nil { 47 | return 48 | } 49 | 50 | if useColor { 51 | sb := &strings.Builder{} 52 | quick.Highlight(sb, string(dumped), "http", "terminal256", "cli-dark") 53 | dumped = []byte(sb.String()) 54 | } 55 | 56 | LogDebug("Got response from server in %s:\n%s", time.Since(start), string(dumped)) 57 | } 58 | } 59 | 60 | // LogInfo logs an info message. 61 | func LogInfo(format string, values ...interface{}) { 62 | fmt.Fprintf(Stderr, "%s %s\n", au.Index(74, "INFO:"), fmt.Sprintf(format, values...)) 63 | } 64 | 65 | // LogWarning logs a warning message. 66 | func LogWarning(format string, values ...interface{}) { 67 | fmt.Fprintf(Stderr, "%s %s\n", au.Index(222, "WARN:"), fmt.Sprintf(format, values...)) 68 | } 69 | 70 | // LogError logs an error message. 71 | func LogError(format string, values ...interface{}) { 72 | // TODO: stack traces? 73 | fmt.Fprintf(Stderr, "%s %s\n", au.BgIndex(204, "ERROR:").White().Bold(), fmt.Sprintf(format, values...)) 74 | } 75 | -------------------------------------------------------------------------------- /oauth/refresh.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | 7 | "github.com/rest-sh/restish/cli" 8 | "golang.org/x/oauth2" 9 | ) 10 | 11 | // RefreshTokenSource will use a refresh token to try and get a new token before 12 | // calling the original token source to get a new token. 13 | type RefreshTokenSource struct { 14 | // ClientID of the application 15 | ClientID string 16 | 17 | // TokenURL is used to fetch new tokens 18 | TokenURL string 19 | 20 | // Scopes to request when refreshing the token 21 | Scopes []string 22 | 23 | // EndpointParams are extra URL query parameters to include in the request 24 | EndpointParams *url.Values 25 | 26 | // RefreshToken from a cache, if available. If not, then the first time a 27 | // token is requested it will be loaded from the token source and this value 28 | // will get updated if it's present in the returned token. 29 | RefreshToken string 30 | 31 | // TokenSource to wrap to fetch new tokens if the refresh token is missing or 32 | // did not work to get a new token. 33 | TokenSource oauth2.TokenSource 34 | } 35 | 36 | // Token generates a new token using either a refresh token or by falling 37 | // back to the original source. 38 | func (ts *RefreshTokenSource) Token() (*oauth2.Token, error) { 39 | if ts.RefreshToken != "" { 40 | cli.LogDebug("Trying refresh token to get a new access token") 41 | refreshParams := url.Values{ 42 | "grant_type": []string{"refresh_token"}, 43 | "client_id": []string{ts.ClientID}, 44 | "refresh_token": []string{ts.RefreshToken}, 45 | "scope": []string{strings.Join(ts.Scopes, " ")}, 46 | } 47 | 48 | // Copy any endpoint-specific parameters. 49 | if ts.EndpointParams != nil { 50 | for k, v := range *ts.EndpointParams { 51 | refreshParams[k] = v 52 | } 53 | } 54 | 55 | token, err := requestToken(ts.TokenURL, refreshParams.Encode()) 56 | if err == nil { 57 | return token, err 58 | } 59 | 60 | // Couldn't refresh, try the original source again. 61 | } 62 | 63 | token, err := ts.TokenSource.Token() 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | // Update the initial token with the (possibly new) refresh token. 69 | ts.RefreshToken = token.RefreshToken 70 | 71 | return token, nil 72 | } 73 | -------------------------------------------------------------------------------- /cli/flag.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | // AddGlobalFlag will make a new global flag on the root command. 12 | func AddGlobalFlag(name, short, description string, defaultValue interface{}, multi bool) { 13 | viper.SetDefault(name, defaultValue) 14 | 15 | flags := Root.PersistentFlags() 16 | switch defaultValue.(type) { 17 | case bool: 18 | if multi { 19 | flags.BoolSliceP(name, short, viper.Get(name).([]bool), description) 20 | GlobalFlags.BoolSliceP(name, short, viper.Get(name).([]bool), description) 21 | } else { 22 | flags.BoolP(name, short, viper.GetBool(name), description) 23 | GlobalFlags.BoolP(name, short, viper.GetBool(name), description) 24 | } 25 | case time.Duration: 26 | if multi { 27 | panic(fmt.Errorf("unsupported float slice param")) 28 | } else { 29 | flags.DurationP(name, short, viper.GetDuration(name), description) 30 | GlobalFlags.DurationP(name, short, viper.GetDuration(name), description) 31 | } 32 | case int, int16, int32, int64, uint16, uint32, uint64: 33 | if multi { 34 | flags.IntSliceP(name, short, viper.Get(name).([]int), description) 35 | GlobalFlags.IntSliceP(name, short, viper.Get(name).([]int), description) 36 | } else { 37 | flags.IntP(name, short, viper.GetInt(name), description) 38 | GlobalFlags.IntP(name, short, viper.GetInt(name), description) 39 | } 40 | case float32, float64: 41 | if multi { 42 | panic(fmt.Errorf("unsupported float slice param")) 43 | } else { 44 | flags.Float64P(name, short, viper.GetFloat64(name), description) 45 | GlobalFlags.Float64P(name, short, viper.GetFloat64(name), description) 46 | } 47 | default: 48 | if multi { 49 | v := viper.Get(name) 50 | if s, ok := v.(string); ok { 51 | // Probably loaded from the environment. 52 | v = strings.Split(s, ",") 53 | viper.Set(name, v) 54 | } 55 | flags.StringArrayP(name, short, v.([]string), description) 56 | GlobalFlags.StringArrayP(name, short, v.([]string), description) 57 | } else { 58 | flags.StringP(name, short, fmt.Sprintf("%v", viper.Get(name)), description) 59 | GlobalFlags.StringP(name, short, fmt.Sprintf("%v", viper.Get(name)), description) 60 | } 61 | } 62 | 63 | viper.BindPFlag(name, flags.Lookup(name)) 64 | } 65 | -------------------------------------------------------------------------------- /cli/transport_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "gopkg.in/h2non/gock.v1" 10 | ) 11 | 12 | func TestMinCachedTransport(t *testing.T) { 13 | defer gock.Off() 14 | 15 | gock.New("http://example.com").Get("/success").Reply(200) 16 | gock.New("http://example.com").Get("/cached").Reply(200).SetHeader("cache-control", "max-age=10") 17 | gock.New("http://example.com").Get("/expires").Reply(200).SetHeader("expires", "Sun, 1 Jan 2020 12:00:00 GMT") 18 | gock.New("http://example.com").Get("/modify").Reply(200).SetHeader("cache-control", "public") 19 | gock.New("http://example.com").Get("/error").Reply(400) 20 | 21 | tx := MinCachedTransport(1 * time.Hour) 22 | 23 | // Missing cache, should get added. 24 | req, _ := http.NewRequest(http.MethodGet, "http://example.com/success", nil) 25 | resp, err := tx.RoundTrip(req) 26 | 27 | assert.NoError(t, err) 28 | assert.Equal(t, resp.StatusCode, 200) 29 | assert.Equal(t, resp.Header.Get("cache-control"), "max-age=3600") 30 | 31 | // Already-cached requests should not be touched. 32 | req, _ = http.NewRequest(http.MethodGet, "http://example.com/cached", nil) 33 | resp, err = tx.RoundTrip(req) 34 | 35 | assert.NoError(t, err) 36 | assert.Equal(t, resp.StatusCode, 200) 37 | assert.Equal(t, resp.Header.Get("cache-control"), "max-age=10") 38 | 39 | // Already-cached requests should not be touched. 40 | req, _ = http.NewRequest(http.MethodGet, "http://example.com/expires", nil) 41 | resp, err = tx.RoundTrip(req) 42 | 43 | assert.NoError(t, err) 44 | assert.Equal(t, resp.StatusCode, 200) 45 | assert.NotEmpty(t, resp.Header.Get("expires")) 46 | assert.Equal(t, resp.Header.Get("cache-control"), "") 47 | 48 | // Already-set header should be modified instead of replaced. 49 | req, _ = http.NewRequest(http.MethodGet, "http://example.com/modify", nil) 50 | resp, err = tx.RoundTrip(req) 51 | 52 | assert.NoError(t, err) 53 | assert.Equal(t, resp.StatusCode, 200) 54 | assert.Equal(t, resp.Header.Get("cache-control"), "public,max-age=3600") 55 | 56 | // Errors should not get cache headers added. 57 | req, _ = http.NewRequest(http.MethodGet, "http://example.com/error", nil) 58 | resp, err = tx.RoundTrip(req) 59 | 60 | assert.NoError(t, err) 61 | assert.Equal(t, resp.StatusCode, 400) 62 | assert.Equal(t, resp.Header.Get("cache-control"), "") 63 | } 64 | -------------------------------------------------------------------------------- /oauth/oauth.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/rest-sh/restish/cli" 8 | "golang.org/x/oauth2" 9 | ) 10 | 11 | // ErrInvalidProfile is thrown when a profile is missing or invalid. 12 | var ErrInvalidProfile = errors.New("invalid profile") 13 | 14 | // TokenHandler takes a token source, gets a token, and modifies a request to 15 | // add the token auth as a header. Uses the CLI cache to store tokens on a per- 16 | // profile basis between runs. 17 | func TokenHandler(source oauth2.TokenSource, key string, request *http.Request) error { 18 | var cached *oauth2.Token 19 | 20 | // Load any existing token from the CLI's cache file. 21 | expiresKey := key + ".expires" 22 | typeKey := key + ".type" 23 | tokenKey := key + ".token" 24 | refreshKey := key + ".refresh" 25 | 26 | expiry := cli.Cache.GetTime(expiresKey) 27 | if !expiry.IsZero() { 28 | cli.LogDebug("Loading OAuth2 token from cache.") 29 | cached = &oauth2.Token{ 30 | AccessToken: cli.Cache.GetString(tokenKey), 31 | RefreshToken: cli.Cache.GetString(refreshKey), 32 | TokenType: cli.Cache.GetString(typeKey), 33 | Expiry: expiry, 34 | } 35 | } 36 | 37 | if cached != nil { 38 | // Wrap the token source preloaded with our cached token. 39 | source = oauth2.ReuseTokenSource(cached, source) 40 | } 41 | 42 | // Get the next available token from the source. 43 | token, err := source.Token() 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if cached == nil || (token.AccessToken != cached.AccessToken) { 49 | // Token either didn't exist in the cache or has changed, so let's write 50 | // the new values to the CLI cache. 51 | cli.LogDebug("Token refreshed. Updating cache.") 52 | 53 | cli.Cache.Set(expiresKey, token.Expiry) 54 | cli.Cache.Set(typeKey, token.Type()) 55 | cli.Cache.Set(tokenKey, token.AccessToken) 56 | 57 | if token.RefreshToken != "" { 58 | // Only set the refresh token if present. This prevents overwriting it 59 | // after using a refresh token, because the newly returned token won't 60 | // have another refresh token set on it (you keep using the same one). 61 | cli.Cache.Set(refreshKey, token.RefreshToken) 62 | } 63 | 64 | // Save the cache to disk. 65 | if err := cli.Cache.WriteConfig(); err != nil { 66 | return err 67 | } 68 | } 69 | 70 | // Set the auth header so the request can be made. 71 | token.SetAuthHeader(request) 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /cli/apiconfig_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAPIContentTypes(t *testing.T) { 11 | captured := run("api content-types") 12 | assert.Contains(t, captured, "application/json") 13 | assert.Contains(t, captured, "table") 14 | assert.Contains(t, captured, "readable") 15 | } 16 | 17 | func TestAPIShow(t *testing.T) { 18 | for tn, tc := range map[string]struct { 19 | color bool 20 | want string 21 | }{ 22 | "no color": {false, "{\n \"base\": \"https://api.example.com\"\n}\n"}, 23 | "color": {true, "\x1b[38;5;247m{\x1b[0m\n \x1b[38;5;74m\"base\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"https://api.example.com\"\x1b[0m\n\x1b[38;5;247m}\x1b[0m\n"}, 24 | } { 25 | t.Run(tn, func(t *testing.T) { 26 | reset(tc.color) 27 | configs["test"] = &APIConfig{ 28 | name: "test", 29 | Base: "https://api.example.com", 30 | } 31 | captured := runNoReset("api show test") 32 | assert.Equal(t, captured, tc.want) 33 | }) 34 | } 35 | } 36 | 37 | func TestAPIClearCache(t *testing.T) { 38 | reset(false) 39 | 40 | configs["test"] = &APIConfig{ 41 | name: "test", 42 | Base: "https://api.example.com", 43 | } 44 | Cache.Set("test:default.token", "abc123") 45 | 46 | runNoReset("api clear-auth-cache test") 47 | 48 | assert.Equal(t, "", Cache.GetString("test:default.token")) 49 | } 50 | 51 | func TestAPIClearCacheProfile(t *testing.T) { 52 | reset(false) 53 | 54 | configs["test"] = &APIConfig{ 55 | name: "test", 56 | Base: "https://api.example.com", 57 | } 58 | Cache.Set("test:default.token", "abc123") 59 | Cache.Set("test:other.token", "def456") 60 | 61 | runNoReset("api clear-auth-cache test -p other") 62 | 63 | assert.Equal(t, "abc123", Cache.GetString("test:default.token")) 64 | assert.Equal(t, "", Cache.GetString("test:other.token")) 65 | } 66 | 67 | func TestAPIClearCacheMissing(t *testing.T) { 68 | reset(false) 69 | 70 | captured := runNoReset("api clear-auth-cache missing-api") 71 | assert.Contains(t, captured, "API missing-api not found") 72 | } 73 | 74 | func TestEditAPIsMissingEditor(t *testing.T) { 75 | os.Setenv("EDITOR", "") 76 | os.Setenv("VISUAL", "") 77 | exited := false 78 | editAPIs(func(code int) { 79 | exited = true 80 | }) 81 | assert.True(t, exited) 82 | } 83 | 84 | func TestEditBadCommand(t *testing.T) { 85 | os.Setenv("EDITOR", "bad-command") 86 | os.Setenv("VISUAL", "") 87 | assert.Panics(t, func() { 88 | editAPIs(func(code int) {}) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: restish 4 | 5 | before: 6 | hooks: 7 | - go mod download 8 | # TODO: Figure out how to test with CGO on all platforms. 9 | - go test -v ./... 10 | 11 | builds: 12 | - id: restish-darwin-amd64 13 | goos: 14 | - darwin 15 | goarch: 16 | - amd64 17 | env: 18 | - CGO_ENABLED=1 19 | - CC=o64-clang 20 | - CXX=o64-clang++ 21 | 22 | - id: restish-darwin-arm64 23 | goos: 24 | - darwin 25 | goarch: 26 | - arm64 27 | env: 28 | - CGO_ENABLED=1 29 | - CC=oa64-clang 30 | - CXX=oa64-clang++ 31 | 32 | - id: restish-linux-amd64 33 | goos: 34 | - linux 35 | goarch: 36 | - amd64 37 | env: 38 | - CGO_ENABLED=1 39 | - CC=x86_64-linux-gnu-gcc 40 | - CXX=x86_64-linux-gnu-g++ 41 | 42 | - id: restish-linux-arm64 43 | goos: 44 | - linux 45 | goarch: 46 | - arm64 47 | env: 48 | - CGO_ENABLED=1 49 | - CC=aarch64-linux-gnu-gcc 50 | - CXX=aarch64-linux-gnu-g++ 51 | 52 | - id: restish-windows-amd64 53 | goos: 54 | - windows 55 | goarch: 56 | - amd64 57 | env: 58 | - CGO_ENABLED=1 59 | - CC=x86_64-w64-mingw32-gcc 60 | - CXX=x86_64-w64-mingw32-g++ 61 | 62 | archives: 63 | - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" 64 | format_overrides: 65 | - goos: windows 66 | formats: 67 | - zip 68 | 69 | homebrew_casks: 70 | - name: restish 71 | homepage: https://rest.sh 72 | description: "Restish is a CLI for interacting with REST-ish HTTP APIs with some nice features built-in." 73 | repository: 74 | owner: rest-sh 75 | name: homebrew-tap 76 | branch: main 77 | git: 78 | url: "git@github.com:rest-sh/homebrew-tap.git" 79 | private_key: /tmp/homebrew_repo_id_ed25519 80 | commit_author: 81 | name: Restish Releaser 82 | email: release@rest.sh 83 | hooks: 84 | post: 85 | install: | 86 | if OS.mac? 87 | system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/restish"] 88 | end 89 | 90 | checksum: 91 | name_template: "checksums.txt" 92 | 93 | snapshot: 94 | version_template: "{{ .Tag }}-next" 95 | 96 | changelog: 97 | sort: asc 98 | filters: 99 | exclude: 100 | - "^docs:" 101 | - "^test:" 102 | -------------------------------------------------------------------------------- /cli/encoding.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "compress/flate" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/andybalholm/brotli" 12 | ) 13 | 14 | // ContentEncoding is used to encode/decode content for transfer over the wire, 15 | // for example with gzip. 16 | type ContentEncoding interface { 17 | Reader(stream io.Reader) (io.Reader, error) 18 | } 19 | 20 | // contentTypes is a list of acceptable content types 21 | var encodings = map[string]ContentEncoding{} 22 | 23 | // AddEncoding adds a new content encoding with the given name. 24 | func AddEncoding(name string, encoding ContentEncoding) { 25 | encodings[name] = encoding 26 | } 27 | 28 | func buildAcceptEncodingHeader() string { 29 | accept := []string{} 30 | 31 | for name := range encodings { 32 | accept = append(accept, name) 33 | } 34 | 35 | return strings.Join(accept, ", ") 36 | } 37 | 38 | // DecodeResponse will replace the response body with a decoding reader if needed. 39 | // Assumes the original body will be closed outside of this function. 40 | func DecodeResponse(resp *http.Response) error { 41 | contentEncoding := resp.Header.Get("content-encoding") 42 | 43 | if contentEncoding == "" { 44 | // Nothing to do! 45 | return nil 46 | } 47 | 48 | encoding := encodings[contentEncoding] 49 | 50 | if encoding == nil { 51 | return fmt.Errorf("unsupported content-encoding %s", contentEncoding) 52 | } 53 | 54 | LogDebug("Decoding response from %s", contentEncoding) 55 | 56 | reader, err := encoding.Reader(resp.Body) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | resp.Body = io.NopCloser(reader) 62 | 63 | return nil 64 | } 65 | 66 | // DeflateEncoding supports gzip-encoded response content. 67 | type DeflateEncoding struct{} 68 | 69 | // Reader returns a new reader for the stream that removes the gzip encoding. 70 | func (g DeflateEncoding) Reader(stream io.Reader) (io.Reader, error) { 71 | return flate.NewReader(stream), nil 72 | } 73 | 74 | // GzipEncoding supports gzip-encoded response content. 75 | type GzipEncoding struct{} 76 | 77 | // Reader returns a new reader for the stream that removes the gzip encoding. 78 | func (g GzipEncoding) Reader(stream io.Reader) (io.Reader, error) { 79 | return gzip.NewReader(stream) 80 | } 81 | 82 | // BrotliEncoding supports RFC 7932 Brotli content encoding. 83 | type BrotliEncoding struct{} 84 | 85 | // Reader returns a new reader for the stream that removes the brotli encoding. 86 | func (b BrotliEncoding) Reader(stream io.Reader) (io.Reader, error) { 87 | return io.Reader(brotli.NewReader(stream)), nil 88 | } 89 | -------------------------------------------------------------------------------- /openapi/testdata/petstore/output.yaml: -------------------------------------------------------------------------------- 1 | short: Swagger Petstore 2 | operations: 3 | - name: create-pets 4 | group: pets 5 | aliases: 6 | - createpets 7 | short: Create a pet 8 | long: | 9 | ## Response 201 10 | 11 | Null response 12 | 13 | ## Response default (application/json) 14 | 15 | unexpected error 16 | 17 | ```schema 18 | { 19 | code*: (integer format:int32) 20 | message*: (string) 21 | } 22 | ``` 23 | method: POST 24 | uri_template: http://api.example.com/pets 25 | - name: list-pets 26 | group: pets 27 | aliases: 28 | - listpets 29 | short: List all pets 30 | long: | 31 | ## Option Schema: 32 | ```schema 33 | { 34 | --limit: (integer format:int32) 35 | } 36 | ``` 37 | 38 | ## Response 200 (application/json) 39 | 40 | A paged array of pets 41 | 42 | Headers: Next 43 | 44 | ```schema 45 | [ 46 | { 47 | id*: (integer format:int64) 48 | name*: (string) 49 | tag: (string) 50 | } 51 | ] 52 | ``` 53 | 54 | ## Response default (application/json) 55 | 56 | unexpected error 57 | 58 | ```schema 59 | { 60 | code*: (integer format:int32) 61 | message*: (string) 62 | } 63 | ``` 64 | method: GET 65 | uri_template: http://api.example.com/pets 66 | query_params: 67 | - type: integer 68 | name: limit 69 | description: How many items to return at one time (max 100) 70 | - name: show-pet-by-id 71 | group: pets 72 | aliases: 73 | - showpetbyid 74 | short: Info for a specific pet 75 | long: | 76 | ## Argument Schema: 77 | ```schema 78 | { 79 | pet-id: (string) 80 | } 81 | ``` 82 | 83 | ## Response 200 (application/json) 84 | 85 | Expected response to a valid request 86 | 87 | ```schema 88 | { 89 | id*: (integer format:int64) 90 | name*: (string) 91 | tag: (string) 92 | } 93 | ``` 94 | 95 | ## Response default (application/json) 96 | 97 | unexpected error 98 | 99 | ```schema 100 | { 101 | code*: (integer format:int32) 102 | message*: (string) 103 | } 104 | ``` 105 | method: GET 106 | uri_template: http://api.example.com/pets/{petId} 107 | path_params: 108 | - type: string 109 | name: petId 110 | description: The id of the pet to retrieve 111 | -------------------------------------------------------------------------------- /cli/operation_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/spf13/viper" 9 | "github.com/stretchr/testify/assert" 10 | "gopkg.in/h2non/gock.v1" 11 | ) 12 | 13 | func TestOperation(t *testing.T) { 14 | defer gock.Off() 15 | 16 | gock. 17 | New("http://example2.com"). 18 | Get("/prefix/test/id1"). 19 | MatchParam("search", "foo"). 20 | MatchParam("def3", "abc"). 21 | MatchHeader("Accept", "application/json"). 22 | Reply(200). 23 | JSON(map[string]interface{}{ 24 | "hello": "world", 25 | }) 26 | 27 | op := Operation{ 28 | Name: "test", 29 | Short: "short", 30 | Long: "long", 31 | Method: http.MethodGet, 32 | URITemplate: "http://example.com/test/{id}", 33 | PathParams: []*Param{ 34 | { 35 | Type: "string", 36 | Name: "id", 37 | DisplayName: "id", 38 | Description: "desc", 39 | }, 40 | }, 41 | QueryParams: []*Param{ 42 | { 43 | Type: "string", 44 | Name: "search", 45 | DisplayName: "search", 46 | Description: "desc", 47 | }, 48 | { 49 | Type: "string", 50 | Name: "def", 51 | DisplayName: "def", 52 | Description: "desc", 53 | }, 54 | { 55 | Type: "string", 56 | Name: "def2", 57 | DisplayName: "def2", 58 | Description: "desc", 59 | Default: "", 60 | }, 61 | { 62 | Type: "string", 63 | Name: "def3", 64 | DisplayName: "def3", 65 | Description: "desc", 66 | Default: "abc", 67 | }, 68 | }, 69 | HeaderParams: []*Param{ 70 | { 71 | Type: "string", 72 | Name: "Accept", 73 | DisplayName: "Accept", 74 | Description: "desc", 75 | Default: "application/json", 76 | }, 77 | { 78 | Type: "string", 79 | Name: "Accept-Encoding", 80 | DisplayName: "Accept-Encoding", 81 | Description: "desc", 82 | Default: "gz", 83 | }, 84 | }, 85 | } 86 | 87 | cmd := op.command() 88 | 89 | viper.Reset() 90 | viper.Set("nocolor", true) 91 | viper.Set("tty", true) 92 | Init("test", "1.0.0") 93 | Defaults() 94 | capture := &strings.Builder{} 95 | Stdout = capture 96 | Stderr = capture 97 | cmd.SetOutput(Stdout) 98 | viper.Set("rsh-server", "http://example2.com/prefix") 99 | cmd.Flags().Parse([]string{"--search=foo", "--def-3=abc", "--accept=application/json"}) 100 | cmd.Run(cmd, []string{"id1"}) 101 | 102 | assert.Equal(t, "HTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n hello: \"world\"\n}\n", capture.String()) 103 | } 104 | -------------------------------------------------------------------------------- /cli/param_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/pflag" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var paramInputs = []struct { 11 | Name string 12 | Type string 13 | Style Style 14 | Explode bool 15 | Value interface{} 16 | Expected []string 17 | }{ 18 | {"bool-simple", "boolean", StyleSimple, false, true, []string{"true"}}, 19 | {"bool-form", "boolean", StyleForm, false, true, []string{"test=true"}}, 20 | {"int-simple", "integer", StyleSimple, false, 123, []string{"123"}}, 21 | {"int-form", "integer", StyleForm, false, 123, []string{"test=123"}}, 22 | {"num-simple", "number", StyleSimple, false, 123.4, []string{"123.4"}}, 23 | {"num-form", "number", StyleForm, false, 123.4, []string{"test=123.4"}}, 24 | {"str-simple", "string", StyleSimple, false, "hello", []string{"hello"}}, 25 | {"str-form", "string", StyleForm, false, "hello", []string{"test=hello"}}, 26 | {"arr-bool-simple", "array[boolean]", StyleSimple, false, []bool{true, false}, []string{"true,false"}}, 27 | {"arr-bool-form", "array[boolean]", StyleForm, false, []bool{true, false}, []string{"true,false"}}, 28 | {"arr-bool-form-explode", "array[boolean]", StyleForm, true, []bool{true, false}, []string{"true", "false"}}, 29 | {"arr-int-simple", "array[integer]", StyleSimple, false, []int{123, 456}, []string{"123,456"}}, 30 | {"arr-int-form", "array[integer]", StyleForm, false, []int{123, 456}, []string{"123,456"}}, 31 | {"arr-int-form-explode", "array[integer]", StyleForm, true, []int{123, 456}, []string{"123", "456"}}, 32 | {"arr-str-simple", "array[string]", StyleSimple, false, []string{"one", "two"}, []string{"one,two"}}, 33 | {"arr-str-form", "array[string]", StyleForm, false, []string{"one", "two"}, []string{"one,two"}}, 34 | {"arr-str-form-explode", "array[string]", StyleForm, true, []string{"one", "two"}, []string{"one", "two"}}, 35 | } 36 | 37 | func TestParamSerialize(t *testing.T) { 38 | for _, input := range paramInputs { 39 | t.Run(input.Name, func(t *testing.T) { 40 | p := Param{ 41 | Name: "test", 42 | Type: input.Type, 43 | Style: input.Style, 44 | Explode: input.Explode, 45 | } 46 | 47 | serialized := p.Serialize(input.Value) 48 | assert.Equal(t, input.Expected, serialized) 49 | }) 50 | } 51 | } 52 | 53 | func TestParamFlag(t *testing.T) { 54 | for _, input := range paramInputs { 55 | t.Run(input.Name, func(t *testing.T) { 56 | p := Param{ 57 | Name: "test", 58 | Type: input.Type, 59 | Style: input.Style, 60 | Explode: input.Explode, 61 | } 62 | 63 | flags := pflag.NewFlagSet("", pflag.PanicOnError) 64 | p.AddFlag(flags) 65 | 66 | assert.NotNil(t, flags.Lookup("test")) 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docs/input.md: -------------------------------------------------------------------------------- 1 | # Input 2 | 3 | You can set headers, query parameters, and a body for each outgoing request. 4 | 5 | ## Request parameters 6 | 7 | Request headers and query parameters are set via arguments or in the URI itself: 8 | 9 | ```bash 10 | # Pass a query param (either way) 11 | $ restish api.rest.sh/?search=foo 12 | $ restish -q search=foo api.rest.sh 13 | 14 | # Query params with an API short name 15 | $ restish example/images?cursor=abc123 16 | 17 | # Pass a header 18 | $ restish -H MyHeader:value api.rest.sh 19 | 20 | # Pass multiple 21 | $ restish -H Header1:val1 -H Header2:val2 api.rest.sh 22 | ``` 23 | 24 | ?> Note that query parameters use `=` as a delimiter while haders use `:`, just like with HTTP. 25 | 26 | ## Request body 27 | 28 | A request body can be set in two ways (or a combination of both) for requests that support bodies (e.g. `POST` / `PUT` / `PATCH`): 29 | 30 | 1. Standard input 31 | 2. CLI shorthand 32 | 33 | ### Standard input 34 | 35 | Any stream of data passed to standard input will be sent as the request body. 36 | 37 | ```bash 38 | # Set body from file 39 | $ restish PUT api.rest.sh Don't forget to set the `Content-Type` header if needed. It will default to JSON if unset. 46 | 47 | ### CLI Shorthand 48 | 49 | The [CLI Shorthand](shorthand.md) language is a convenient way of providing structured data on the commandline. It is a JSON-like syntax that enables you to easily create nested structured data. For example: 50 | 51 | ```bash 52 | $ restish POST api.rest.sh 'foo.bar[]{baz: 1, hello: world}' 53 | ``` 54 | 55 | Will send the following request: 56 | 57 | ```http 58 | POST / HTTP/2.0 59 | Content-Type: application/json 60 | Host: api.rest.sh 61 | 62 | { 63 | "foo": { 64 | "bar": [ 65 | { 66 | "baz": 1, 67 | "hello": "world" 68 | } 69 | ] 70 | } 71 | } 72 | ``` 73 | 74 | The shorthand supports nested objects, arrays, automatic type coercion, and loading data from files. See the [CLI Shorthand Syntax](shorthand.md) for more info. 75 | 76 | ### Combined body input 77 | 78 | It's also possible to use standard in as a template and replace or set values via commandline arguments, getting the best of both worlds. For example: 79 | 80 | ```bash 81 | # Use both a file and override a value 82 | $ restish POST api.rest.sh Hint: want to replace an array? Use something like `value: [item]` rather than appending. 89 | -------------------------------------------------------------------------------- /cli/edit_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "gopkg.in/h2non/gock.v1" 11 | ) 12 | 13 | func TestEditSuccess(t *testing.T) { 14 | defer gock.Off() 15 | 16 | gock.New("http://example.com"). 17 | Get("/items/foo"). 18 | Reply(http.StatusOK). 19 | SetHeader("Etag", "abc123"). 20 | JSON(map[string]interface{}{ 21 | "foo": 123, 22 | }) 23 | 24 | gock.New("http://example.com"). 25 | Put("/items/foo"). 26 | MatchHeader("If-Match", "abc123"). 27 | BodyString( 28 | `{"foo": 123, "bar": 456}`, 29 | ). 30 | Reply(http.StatusOK) 31 | 32 | os.Setenv("VISUAL", "") 33 | os.Setenv("EDITOR", "true") // dummy to just return 34 | edit("http://example.com/items/foo", []string{"bar:456"}, true, true, func(int) {}, json.Marshal, json.Unmarshal, "json") 35 | } 36 | 37 | func TestEditNonInteractiveArgsRequired(t *testing.T) { 38 | code := 999 39 | edit("http://example.com/items/foo", []string{}, false, true, func(c int) { 40 | code = c 41 | }, json.Marshal, json.Unmarshal, "json") 42 | 43 | assert.Equal(t, 1, code) 44 | } 45 | 46 | func TestEditInteractiveMissingEditor(t *testing.T) { 47 | os.Setenv("VISUAL", "") 48 | os.Setenv("EDITOR", "") 49 | code := 999 50 | edit("http://example.com/items/foo", []string{}, true, true, func(c int) { 51 | code = c 52 | }, json.Marshal, json.Unmarshal, "json") 53 | 54 | assert.Equal(t, 1, code) 55 | } 56 | 57 | func TestEditBadGet(t *testing.T) { 58 | defer gock.Off() 59 | 60 | gock.New("http://example.com"). 61 | Get("/items/foo"). 62 | Reply(http.StatusInternalServerError) 63 | 64 | code := 999 65 | edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) { 66 | code = c 67 | }, json.Marshal, json.Unmarshal, "json") 68 | 69 | assert.Equal(t, 1, code) 70 | } 71 | 72 | func TestEditNoChange(t *testing.T) { 73 | defer gock.Off() 74 | 75 | gock.New("http://example.com"). 76 | Get("/items/foo"). 77 | Reply(http.StatusOK). 78 | SetHeader("Etag", "abc123"). 79 | JSON(map[string]interface{}{ 80 | "foo": 123, 81 | }) 82 | 83 | code := 999 84 | edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) { 85 | code = c 86 | }, json.Marshal, json.Unmarshal, "json") 87 | 88 | assert.Equal(t, 0, code) 89 | } 90 | 91 | func TestEditNotObject(t *testing.T) { 92 | defer gock.Off() 93 | 94 | gock.New("http://example.com"). 95 | Get("/items/foo"). 96 | Reply(http.StatusOK). 97 | SetHeader("Etag", "abc123"). 98 | JSON([]interface{}{ 99 | 123, 100 | }) 101 | 102 | code := 999 103 | edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) { 104 | code = c 105 | }, json.Marshal, json.Unmarshal, "json") 106 | 107 | assert.Equal(t, 1, code) 108 | } 109 | -------------------------------------------------------------------------------- /openapi/testdata/petstore/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | license: 6 | name: MIT 7 | servers: 8 | - url: http://petstore.swagger.io/v1 9 | paths: 10 | /pets: 11 | get: 12 | summary: List all pets 13 | operationId: listPets 14 | tags: 15 | - pets 16 | parameters: 17 | - name: limit 18 | in: query 19 | description: How many items to return at one time (max 100) 20 | required: false 21 | schema: 22 | type: integer 23 | format: int32 24 | responses: 25 | "200": 26 | description: A paged array of pets 27 | headers: 28 | Next: 29 | description: A link to the next page of responses 30 | schema: 31 | type: string 32 | content: 33 | application/json: 34 | schema: 35 | $ref: "#/components/schemas/Pets" 36 | default: 37 | description: unexpected error 38 | content: 39 | application/json: 40 | schema: 41 | $ref: "#/components/schemas/Error" 42 | post: 43 | summary: Create a pet 44 | operationId: createPets 45 | tags: 46 | - pets 47 | responses: 48 | "201": 49 | description: Null response 50 | default: 51 | description: unexpected error 52 | content: 53 | application/json: 54 | schema: 55 | $ref: "#/components/schemas/Error" 56 | /pets/{petId}: 57 | parameters: 58 | - $ref: "#/components/parameters/petId" 59 | get: 60 | summary: Info for a specific pet 61 | operationId: showPetById 62 | tags: 63 | - pets 64 | responses: 65 | "200": 66 | description: Expected response to a valid request 67 | content: 68 | application/json: 69 | schema: 70 | $ref: "#/components/schemas/Pet" 71 | default: 72 | description: unexpected error 73 | content: 74 | application/json: 75 | schema: 76 | $ref: "#/components/schemas/Error" 77 | components: 78 | schemas: 79 | Pet: 80 | type: object 81 | required: 82 | - id 83 | - name 84 | properties: 85 | id: 86 | type: integer 87 | format: int64 88 | name: 89 | type: string 90 | tag: 91 | type: string 92 | Pets: 93 | type: array 94 | items: 95 | $ref: "#/components/schemas/Pet" 96 | Error: 97 | type: object 98 | required: 99 | - code 100 | - message 101 | properties: 102 | code: 103 | type: integer 104 | format: int32 105 | message: 106 | type: string 107 | parameters: 108 | petId: 109 | name: petId 110 | in: path 111 | description: The id of the pet to retrieve 112 | required: true 113 | schema: 114 | type: string 115 | -------------------------------------------------------------------------------- /docs/retries.md: -------------------------------------------------------------------------------- 1 | # Retries & Timeouts 2 | 3 | Restish has support for automatic retries for some types of requests, and also supports giving up after a certain amount of time. This is useful for APIs that are rate-limited or have intermittent issues. 4 | 5 | ## Automatic Retries 6 | 7 | By default, Restish will retry the following responses two times: 8 | 9 | - `408 Request Timeout` 10 | - `425 Too Early` 11 | - `429 Too Many Requests` 12 | - `500 Internal Server Error` 13 | - `502 Bad Gateway` 14 | - `503 Service Unavailable` 15 | - `504 Gateway Timeout` 16 | 17 | This is configurable via the `--rsh-retry` parameter or `RSH_RETRY` environment variable, which should be a positive integer. Set to `0` to disable retries. 18 | 19 | Here is an example of the default behavior: 20 | 21 | ```bash 22 | # Trigger retries by generating a 429 response. 23 | $ restish api.rest.sh/status/429 24 | WARN: Got 429 Too Many Requests, retrying in 1s 25 | WARN: Got 429 Too Many Requests, retrying in 1s 26 | HTTP/2.0 429 Too Many Requests 27 | Cache-Control: private 28 | Cf-Cache-Status: MISS 29 | Cf-Ray: 80e668787b3ec59c-SEA 30 | Content-Length: 0 31 | Date: Fri, 29 Sep 2023 18:49:47 GMT 32 | Server: cloudflare 33 | Vary: Accept-Encoding 34 | X-Do-App-Origin: 18871cde-e6ba-11ec-b1dc-0c42a19a82a7 35 | X-Do-Orig-Status: 429 36 | X-Varied-Accept-Encoding: deflate, gzip, br 37 | ``` 38 | 39 | By default, Restish will wait 1 second between retries. If the server responds with one of the following headers, it will be parsed and used to determine the retry delay: 40 | 41 | - `Retry-After` ([RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.1.3)) 42 | - `X-Retry-In` (as set by e.g. [Traefik](https://doc.traefik.io/traefik/middlewares/http/ratelimit/) [rate limiting](https://github.com/traefik/traefik/blob/v2.10/pkg/middlewares/ratelimiter/rate_limiter.go#L176-L177)) 43 | 44 | For example: 45 | 46 | ```bash 47 | # Trigger delayed retries with a 429 and Retry-After header. 48 | $ restish api.rest.sh/status/429?retry-after=3 49 | WARN: Got 429 Too Many Requests, retrying in 3s 50 | WARN: Got 429 Too Many Requests, retrying in 3s 51 | HTTP/2.0 429 Too Many Requests 52 | Cache-Control: private 53 | Cf-Cache-Status: MISS 54 | Cf-Ray: 80e669fbb95a283d-SEA 55 | Content-Length: 0 56 | Date: Fri, 29 Sep 2023 18:50:49 GMT 57 | Retry-After: 3 58 | Server: cloudflare 59 | Vary: Accept-Encoding 60 | X-Do-App-Origin: 18871cde-e6ba-11ec-b1dc-0c42a19a82a7 61 | X-Do-Orig-Status: 429 62 | X-Varied-Accept-Encoding: br, deflate, gzip 63 | ``` 64 | 65 | ## Request Timeouts 66 | 67 | Restish has optional timeouts you can set on outgoing requests using the `--rsh-timeout` parameter or `RSH_TIMEOUT` environment variable. This should be a duration with suffix, e.g. `1s` or `500ms`. Set to `0` to disable timeouts (which is the default). Timeouts are retried since they are often due to intermittent network issues and subsequent requests may succeed. 68 | 69 | Here is an example of a timeout: 70 | 71 | ```bash 72 | # Trigger a timeout with a ridiculously low value. 73 | $ restish api.rest.sh/ --rsh-timeout=10ms 74 | WARN: Got request timeout after 10ms, retrying 75 | WARN: Got request timeout after 10ms, retrying 76 | ERROR: Caught error: Request timed out after 10ms: Get "https://api.rest.sh/": context deadline exceeded 77 | ``` 78 | -------------------------------------------------------------------------------- /bench_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/amzn/ion-go/ion" 10 | "github.com/fxamacker/cbor/v2" 11 | "github.com/rest-sh/restish/cli" 12 | "github.com/rest-sh/restish/openapi" 13 | "github.com/shamaton/msgpack/v2" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | func BenchmarkFormats(b *testing.B) { 18 | inputs := []struct { 19 | Name string 20 | URL string 21 | }{ 22 | { 23 | Name: "small", 24 | URL: "https://github.com/OAI/OpenAPI-Specification/raw/d1cc440056f1c7bb913bcd643b15c14ee1c409f4/examples/v3.0/uspto.json", 25 | }, 26 | { 27 | Name: "large", 28 | URL: "https://github.com/github/rest-api-description/blob/83cdec7384b62ef6f54bad60270544d6fc6f22cd/descriptions/api.github.com/api.github.com.json?raw=true", 29 | }, 30 | } 31 | 32 | cli.Init("benchmark", "1.0.0") 33 | cli.Defaults() 34 | cli.AddLoader(openapi.New()) 35 | 36 | for _, t := range inputs { 37 | resp, err := http.Get(t.URL) 38 | if err != nil { 39 | panic(err) 40 | } 41 | if resp.StatusCode >= 300 { 42 | panic("non-success status from server, check URLs are still working") 43 | } 44 | 45 | dummy := &cobra.Command{} 46 | doc, err := cli.Load(t.URL, dummy) 47 | if err != nil { 48 | panic(err) 49 | } 50 | 51 | dataJSON, err := json.Marshal(doc) 52 | if err != nil { 53 | panic(err) 54 | } 55 | 56 | dataCBOR, err := cbor.Marshal(doc) 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | dataMsgPack, err := msgpack.Marshal(doc) 62 | if err != nil { 63 | panic(err) 64 | } 65 | 66 | dataIon, err := ion.MarshalBinary(doc) 67 | if err != nil { 68 | panic(err) 69 | } 70 | 71 | fmt.Printf("json: %d\ncbor: %d\nmsgp: %d\n ion: %d\n", len(dataJSON), len(dataCBOR), len(dataMsgPack), len(dataIon)) 72 | 73 | b.Run(t.Name+"-json-marshal", func(b *testing.B) { 74 | b.ReportAllocs() 75 | for n := 0; n < b.N; n++ { 76 | json.Marshal(doc) 77 | } 78 | }) 79 | 80 | b.Run(t.Name+"-json-unmarshal", func(b *testing.B) { 81 | b.ReportAllocs() 82 | for n := 0; n < b.N; n++ { 83 | var tmp cli.API 84 | json.Unmarshal(dataJSON, &tmp) 85 | } 86 | }) 87 | 88 | b.Run(t.Name+"-cbor-marshal", func(b *testing.B) { 89 | b.ReportAllocs() 90 | for n := 0; n < b.N; n++ { 91 | cbor.Marshal(doc) 92 | } 93 | }) 94 | 95 | b.Run(t.Name+"-cbor-unmarshal", func(b *testing.B) { 96 | b.ReportAllocs() 97 | for n := 0; n < b.N; n++ { 98 | var tmp cli.API 99 | cbor.Unmarshal(dataCBOR, &tmp) 100 | } 101 | }) 102 | 103 | b.Run(t.Name+"-msgpack-marshal", func(b *testing.B) { 104 | b.ReportAllocs() 105 | for n := 0; n < b.N; n++ { 106 | msgpack.Marshal(doc) 107 | } 108 | }) 109 | 110 | b.Run(t.Name+"-msgpack-unmarshal", func(b *testing.B) { 111 | b.ReportAllocs() 112 | for n := 0; n < b.N; n++ { 113 | var tmp cli.API 114 | msgpack.Unmarshal(dataMsgPack, &tmp) 115 | } 116 | }) 117 | 118 | b.Run(t.Name+"-ion-marshal", func(b *testing.B) { 119 | b.ReportAllocs() 120 | for n := 0; n < b.N; n++ { 121 | ion.MarshalBinary(doc) 122 | } 123 | }) 124 | 125 | b.Run(t.Name+"-ion-unmarshal", func(b *testing.B) { 126 | b.ReportAllocs() 127 | for n := 0; n < b.N; n++ { 128 | var tmp cli.API 129 | ion.Unmarshal(dataIon, &tmp) 130 | } 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /cli/transport.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/gbl08ma/httpcache" 12 | "github.com/gbl08ma/httpcache/diskcache" 13 | ) 14 | 15 | // cacheKey returns the cache key for req. 16 | func cacheKey(req *http.Request) string { 17 | if req.Method == http.MethodGet { 18 | return req.URL.String() 19 | } 20 | 21 | return req.Method + " " + req.URL.String() 22 | } 23 | 24 | // shouldCache returns whether a response should be manually cached. 25 | func shouldCache(resp *http.Response) bool { 26 | // Error responses should not be cached. 27 | if resp.StatusCode >= 400 { 28 | return false 29 | } 30 | 31 | // The older "Expires" header means we should not touch it. 32 | if resp.Header.Get("expires") != "" { 33 | return false 34 | } 35 | 36 | // There is a "Cache-Control" header *AND* it has a cache age set, so we 37 | // should not touch it. 38 | if strings.Contains(resp.Header.Get("cache-control"), "max-age") { 39 | return false 40 | } 41 | 42 | return true 43 | } 44 | 45 | // CachedTransport returns an HTTP transport with caching abilities. 46 | func CachedTransport() *httpcache.Transport { 47 | t := httpcache.NewTransport(diskcache.New(filepath.Join(getCacheDir(), "responses"))) 48 | t.MarkCachedResponses = false 49 | return t 50 | } 51 | 52 | type minCachedTransport struct { 53 | min time.Duration 54 | } 55 | 56 | func (m minCachedTransport) RoundTrip(req *http.Request) (*http.Response, error) { 57 | resp, err := http.DefaultClient.Do(req) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | // Automatically cache for the minimum time if the request is successful and 63 | // the response doesn't already have cache headers. 64 | if shouldCache(resp) { 65 | // Add the minimum max-age. 66 | ma := fmt.Sprintf("max-age=%d", int(m.min.Seconds())) 67 | if cc := resp.Header.Get("cache-control"); cc != "" { 68 | resp.Header.Set("cache-control", cc+","+ma) 69 | } else { 70 | resp.Header.Set("cache-control", ma) 71 | } 72 | } 73 | 74 | // HACK: httpcache expects reads rather than close, so for now we special-case 75 | // the 204 response type and do a dummy read that immediately results in 76 | // an EOF. 77 | if resp.StatusCode == http.StatusNoContent { 78 | io.ReadAll(resp.Body) 79 | } 80 | 81 | return resp, nil 82 | } 83 | 84 | // MinCachedTransport returns an HTTP transport with caching abilities and 85 | // a minimum cache duration for any responses if no cache headers are set. 86 | func MinCachedTransport(min time.Duration) *httpcache.Transport { 87 | t := CachedTransport() 88 | t.Transport = &minCachedTransport{min} 89 | return t 90 | } 91 | 92 | type invalidateCachedTransport struct { 93 | transport *httpcache.Transport 94 | } 95 | 96 | func (i invalidateCachedTransport) RoundTrip(req *http.Request) (*http.Response, error) { 97 | // Invalidate cache entry. 98 | key := cacheKey(req) 99 | i.transport.Cache.Delete(key) 100 | 101 | // Make the request. 102 | return i.transport.RoundTrip(req) 103 | } 104 | 105 | // InvalidateCachedTransport returns an HTTP transport which will not read 106 | // cached items (it deletes them) and then refreshes the cache when new items 107 | // are fetched. 108 | func InvalidateCachedTransport() http.RoundTripper { 109 | return &invalidateCachedTransport{ 110 | transport: CachedTransport(), 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /docs/hypermedia.md: -------------------------------------------------------------------------------- 1 | # Hypermedia 2 | 3 | Restish uses a standardized internal representation of hypermedia links to make navigating and querying different APIs simpler. It looks like: 4 | 5 | ```json 6 | { 7 | "rel": "next", 8 | "uri": "https://api.rest.sh/images?cursor=abc123" 9 | } 10 | ``` 11 | 12 | The URI is always resolved so you don't need to worry about absolute or relative paths. 13 | 14 | ## Automatic pagination 15 | 16 | Restish uses these standardized links to automatically handle paginated collections, returning the full collection to you whenever possible. 17 | 18 | This behavior can be disabled via the `--rsh-no-paginate` argument or `RSH_NO_PAGINATE=1` environment variable when needed. You may need to do this for large or slow collections. 19 | 20 | ```bash 21 | # Automatic paginated response returns all pages 22 | $ restish api.rest.sh/images 23 | ... 24 | [ 25 | { 26 | format: "jpeg" 27 | name: "Dragonfly macro" 28 | self: "/images/jpeg" 29 | } 30 | { 31 | format: "webp" 32 | name: "Origami under blacklight" 33 | self: "/images/webp" 34 | } 35 | { 36 | format: "gif" 37 | name: "Andy Warhol mural in Miami" 38 | self: "/images/gif" 39 | } 40 | { 41 | format: "png" 42 | name: "Station in Prague" 43 | self: "/images/png" 44 | } 45 | { 46 | format: "heic" 47 | name: "Chihuly glass in boats" 48 | self: "/images/heic" 49 | } 50 | ] 51 | ``` 52 | 53 | ```bash 54 | # Return a single page of results 55 | $ restish --rsh-no-paginate api.rest.sh/images 56 | Link: ; rel="next", ; rel="describedby" 57 | ... 58 | [ 59 | { 60 | format: "jpeg" 61 | name: "Dragonfly macro" 62 | self: "/images/jpeg" 63 | } 64 | { 65 | format: "webp" 66 | name: "Origami under blacklight" 67 | self: "/images/webp" 68 | } 69 | ] 70 | ``` 71 | 72 | ## Links command 73 | 74 | The `links` command provides a shorthand for displaying the available links. All links are normalized to include the full URL. Paginated responses may generate the same link multiple times. 75 | 76 | ```bash 77 | # Display available links 78 | $ restish links api.rest.sh/images 79 | { 80 | "describedby": [ 81 | { 82 | "rel": "describedby", 83 | "uri": "https://api.rest.sh/schemas/ImageItemList.json" 84 | }, 85 | { 86 | "rel": "describedby", 87 | "uri": "https://api.rest.sh/schemas/ImageItemList.json" 88 | }, 89 | { 90 | "rel": "describedby", 91 | "uri": "https://api.rest.sh/schemas/ImageItemList.json" 92 | } 93 | ], 94 | "next": [ 95 | { 96 | "rel": "next", 97 | "uri": "https://api.rest.sh/images?cursor=abc123" 98 | }, 99 | { 100 | "rel": "next", 101 | "uri": "https://api.rest.sh/images?cursor=def456" 102 | } 103 | ], 104 | "self-item": [ 105 | { 106 | "rel": "self-item", 107 | "uri": "https://api.rest.sh/images/jpeg" 108 | }, 109 | { 110 | "rel": "self-item", 111 | "uri": "https://api.rest.sh/images/webp" 112 | }, 113 | { 114 | "rel": "self-item", 115 | "uri": "https://api.rest.sh/images/gif" 116 | }, 117 | { 118 | "rel": "self-item", 119 | "uri": "https://api.rest.sh/images/png" 120 | }, 121 | { 122 | "rel": "self-item", 123 | "uri": "https://api.rest.sh/images/heic" 124 | } 125 | ] 126 | } 127 | ``` 128 | 129 | ```bash 130 | # Optionally filter to certain link relations 131 | $ restish links api.rest.sh/images next 132 | [ 133 | { 134 | "rel": "next", 135 | "uri": "https://api.rest.sh/images?cursor=abc123" 136 | }, 137 | { 138 | "rel": "next", 139 | "uri": "https://api.rest.sh/images?cursor=def456" 140 | } 141 | ] 142 | ``` 143 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rest-sh/restish 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/AlecAivazis/survey/v2 v2.3.6 7 | github.com/ThalesIgnite/crypto11 v1.2.5 8 | github.com/alecthomas/chroma v0.10.0 9 | github.com/alexeyco/simpletable v1.0.0 10 | github.com/amzn/ion-go v1.1.3 11 | github.com/andybalholm/brotli v1.0.4 12 | github.com/charmbracelet/glamour v0.6.0 13 | github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3 14 | github.com/danielgtaylor/mexpr v1.8.0 15 | github.com/danielgtaylor/shorthand/v2 v2.1.1 16 | github.com/eliukblau/pixterm v1.3.1 17 | github.com/fxamacker/cbor/v2 v2.4.0 18 | github.com/gbl08ma/httpcache v1.0.2 19 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 20 | github.com/gosimple/slug v1.13.1 21 | github.com/hexops/gotextdiff v1.0.3 22 | github.com/iancoleman/strcase v0.2.0 23 | github.com/logrusorgru/aurora v2.0.3+incompatible 24 | github.com/lucasjones/reggen v0.0.0-20200904144131-37ba4fa293bb 25 | github.com/mattn/go-colorable v0.1.13 26 | github.com/mattn/go-isatty v0.0.16 27 | github.com/mitchellh/mapstructure v1.5.0 28 | github.com/pb33f/libopenapi v0.22.3 29 | github.com/schollz/progressbar/v3 v3.12.2 30 | github.com/shamaton/msgpack/v2 v2.1.1 31 | github.com/spf13/afero v1.9.3 32 | github.com/spf13/cobra v1.6.1 33 | github.com/spf13/pflag v1.0.5 34 | github.com/spf13/viper v1.14.0 35 | github.com/stretchr/testify v1.10.0 36 | github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9 37 | github.com/zeebo/xxh3 v1.0.2 38 | golang.org/x/exp v0.0.0-20230321023759-10a507213a29 39 | golang.org/x/oauth2 v0.34.0 40 | golang.org/x/term v0.30.0 41 | golang.org/x/text v0.23.0 42 | gopkg.in/h2non/gock.v1 v1.1.2 43 | gopkg.in/yaml.v2 v2.4.0 44 | gopkg.in/yaml.v3 v3.0.1 45 | ) 46 | 47 | require ( 48 | github.com/aymanbagabas/go-osc52 v1.2.1 // indirect 49 | github.com/aymerick/douceur v0.2.0 // indirect 50 | github.com/bahlo/generic-list-go v0.2.0 // indirect 51 | github.com/buger/jsonparser v1.1.1 // indirect 52 | github.com/davecgh/go-spew v1.1.1 // indirect 53 | github.com/disintegration/imaging v1.6.2 // indirect 54 | github.com/dlclark/regexp2 v1.7.0 // indirect 55 | github.com/fsnotify/fsnotify v1.6.0 // indirect 56 | github.com/google/btree v1.1.2 // indirect 57 | github.com/gorilla/css v1.0.1 // indirect 58 | github.com/gosimple/unidecode v1.0.1 // indirect 59 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect 60 | github.com/hashicorp/hcl v1.0.0 // indirect 61 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 62 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 63 | github.com/klauspost/cpuid/v2 v2.0.9 // indirect 64 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 65 | github.com/magiconair/properties v1.8.6 // indirect 66 | github.com/mailru/easyjson v0.7.7 // indirect 67 | github.com/mattn/go-runewidth v0.0.14 // indirect 68 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 69 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 70 | github.com/miekg/pkcs11 v1.0.3-0.20190429190417-a667d056470f // indirect 71 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 72 | github.com/muesli/reflow v0.3.0 // indirect 73 | github.com/muesli/termenv v0.13.0 // indirect 74 | github.com/olekukonko/tablewriter v0.0.5 // indirect 75 | github.com/pelletier/go-toml v1.9.5 // indirect 76 | github.com/pelletier/go-toml/v2 v2.0.6 // indirect 77 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 78 | github.com/pkg/errors v0.9.1 // indirect 79 | github.com/pmezard/go-difflib v1.0.0 // indirect 80 | github.com/rivo/uniseg v0.4.3 // indirect 81 | github.com/speakeasy-api/jsonpath v0.6.2 // indirect 82 | github.com/spf13/cast v1.5.0 // indirect 83 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 84 | github.com/subosito/gotenv v1.4.1 // indirect 85 | github.com/thales-e-security/pool v0.0.2 // indirect 86 | github.com/twpayne/httpcache v1.0.0 // indirect 87 | github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd // indirect 88 | github.com/x448/float16 v0.8.4 // indirect 89 | github.com/yuin/goldmark v1.5.3 // indirect 90 | github.com/yuin/goldmark-emoji v1.0.1 // indirect 91 | golang.org/x/image v0.18.0 // indirect 92 | golang.org/x/net v0.38.0 // indirect 93 | golang.org/x/sys v0.31.0 // indirect 94 | gopkg.in/ini.v1 v1.67.0 // indirect 95 | launchpad.net/gocheck v0.0.0-20140225173054-000000000087 // indirect 96 | ) 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Restish Logo](https://user-images.githubusercontent.com/106826/82109918-ec5b2300-96ee-11ea-9af0-8515329d5965.png) 2 | 3 | [![Works With Restish](https://img.shields.io/badge/Works%20With-Restish-ff5f87)](https://rest.sh/) [![User Guide](https://img.shields.io/badge/Docs-Guide-5fafd7)](https://rest.sh/#/guide) [![CI](https://github.com/rest-sh/restish/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/rest-sh/restish/actions/workflows/ci.yaml) [![codecov](https://codecov.io/gh/rest-sh/restish/branch/main/graph/badge.svg)](https://codecov.io/gh/rest-sh/restish) [![Docs](https://img.shields.io/badge/godoc-reference-5fafd7)](https://pkg.go.dev/github.com/rest-sh/restish?tab=subdirectories) [![Go Report Card](https://goreportcard.com/badge/github.com/rest-sh/restish)](https://goreportcard.com/report/github.com/rest-sh/restish) 4 | 5 | [Restish](https://rest.sh/) is a CLI for interacting with [REST](https://apisyouwonthate.com/blog/rest-and-hypermedia-in-2019)-ish HTTP APIs with some nice features built-in — like always having the latest API resources, fields, and operations available when they go live on the API without needing to install or update anything. 6 | Check out [how Restish compares to cURL & HTTPie](https://rest.sh/#/comparison). 7 | 8 | See the [user guide](https://rest.sh/#/guide) for how to install Restish and get started. 9 | 10 | Features include: 11 | 12 | - HTTP/2 ([RFC 7540](https://tools.ietf.org/html/rfc7540)) with TLS by _default_ with fallback to HTTP/1.1 13 | - Generic head/get/post/put/patch/delete verbs like `curl` or [HTTPie](https://httpie.org/) 14 | - Generated commands for CLI operations, e.g. `restish my-api list-users` 15 | - Automatically discovers API descriptions 16 | - [RFC 8631](https://tools.ietf.org/html/rfc8631) `service-desc` link relation 17 | - [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation 18 | - Supported formats 19 | - OpenAPI [3.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) / [3.1](https://spec.openapis.org/oas/v3.1.0.html) and [JSON Schema](https://json-schema.org/) 20 | - Automatic configuration of API auth if advertised by the API 21 | - Shell command completion for Bash, Fish, Zsh, Powershell 22 | - Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links 23 | - API endpoint-based auth built-in with support for profiles: 24 | - HTTP Basic 25 | - API key via header or query param 26 | - OAuth2 client credentials flow (machine-to-machine, [RFC 6749](https://tools.ietf.org/html/rfc6749)) 27 | - OAuth2 authorization code (with PKCE [RFC 7636](https://tools.ietf.org/html/rfc7636)) flow 28 | - On the fly authorization through external tools for custom API signature mechanisms 29 | - Content negotiation, decoding & unmarshalling built-in: 30 | - JSON ([RFC 8259](https://tools.ietf.org/html/rfc8259), ) 31 | - YAML () 32 | - CBOR ([RFC 7049](https://tools.ietf.org/html/rfc7049), ) 33 | - MessagePack () 34 | - Amazon Ion () 35 | - Gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)), Deflate ([RFC 1951](https://datatracker.ietf.org/doc/html/rfc1951)), and Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding 36 | - Automatic retries with support for [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) and `X-Retry-In` headers when APIs are rate-limited. 37 | - Standardized [hypermedia](https://smartbear.com/learn/api-design/what-is-hypermedia/) parsing into queryable/followable response links: 38 | - HTTP Link relation headers ([RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2)) 39 | - [HAL](http://stateless.co/hal_specification.html) 40 | - [Siren](https://github.com/kevinswiber/siren) 41 | - [Terrifically Simple JSON](https://github.com/mpnally/Terrifically-Simple-JSON) 42 | - [JSON:API](https://jsonapi.org/) 43 | - Local caching that respects [RFC 7234](https://tools.ietf.org/html/rfc7234) `Cache-Control` and `Expires` headers 44 | - CLI [shorthand](https://github.com/danielgtaylor/openapi-cli-generator/tree/master/shorthand#cli-shorthand-syntax) for structured data input (e.g. for JSON) 45 | - [Shorthand query](https://github.com/danielgtaylor/shorthand#querying) response filtering & projection 46 | - Colorized prettified readable output 47 | - Fast native zero-dependency binary 48 | 49 | Articles: 50 | 51 | - [A CLI for REST APIs](https://dev.to/danielgtaylor/a-cli-for-rest-apis-part-1-104b) 52 | - [Mapping OpenAPI to the CLI](https://dev.to/danielgtaylor/mapping-openapi-to-the-cli-37pb) 53 | 54 | This project started life as a fork of [OpenAPI CLI Generator](https://github.com/danielgtaylor/openapi-cli-generator). 55 | -------------------------------------------------------------------------------- /cli/auth.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "os/exec" 11 | "strings" 12 | "syscall" 13 | 14 | "golang.org/x/term" 15 | ) 16 | 17 | // AuthParam describes an auth input parameter for an AuthHandler. 18 | type AuthParam struct { 19 | Name string 20 | Help string 21 | Required bool 22 | } 23 | 24 | // AuthHandler is used to register new authentication handlers that will apply 25 | // auth to an outgoing request as needed. 26 | type AuthHandler interface { 27 | // Parameters returns an ordered list of required and optional input 28 | // parameters for this auth handler. Used when configuring an API. 29 | Parameters() []AuthParam 30 | 31 | // OnRequest applies auth to an outgoing request before it hits the wire. 32 | OnRequest(req *http.Request, key string, params map[string]string) error 33 | } 34 | 35 | var authHandlers map[string]AuthHandler = map[string]AuthHandler{} 36 | 37 | // AddAuth registers a new named auth handler. 38 | func AddAuth(name string, h AuthHandler) { 39 | authHandlers[name] = h 40 | } 41 | 42 | // BasicAuth implements HTTP Basic authentication. 43 | type BasicAuth struct{} 44 | 45 | // Parameters define the HTTP Basic Auth parameter names. 46 | func (a *BasicAuth) Parameters() []AuthParam { 47 | return []AuthParam{ 48 | {Name: "username", Required: true}, 49 | {Name: "password", Required: true}, 50 | } 51 | } 52 | 53 | // OnRequest gets run before the request goes out on the wire. 54 | func (a *BasicAuth) OnRequest(req *http.Request, key string, params map[string]string) error { 55 | _, usernamePresent := params["username"] 56 | _, passwordPresent := params["password"] 57 | 58 | if usernamePresent && !passwordPresent { 59 | fmt.Print("password: ") 60 | inputPassword, err := term.ReadPassword(int(syscall.Stdin)) 61 | if err == nil { 62 | params["password"] = string(inputPassword) 63 | } 64 | fmt.Println() 65 | } 66 | 67 | req.SetBasicAuth(params["username"], params["password"]) 68 | return nil 69 | } 70 | 71 | // ExternalToolAuth defers authentication to a third party tool. 72 | // This avoids baking all possible authentication implementations 73 | // inside restish itself. 74 | type ExternalToolAuth struct{} 75 | 76 | // Request is used to exchange requests with the external tool. 77 | type Request struct { 78 | Method string `json:"method"` 79 | URI string `json:"uri"` 80 | Header http.Header `json:"headers"` 81 | Body string `json:"body"` 82 | } 83 | 84 | // Parameters defines the ExternalToolAuth parameter names. 85 | // A single parameter is supported and required: `commandline` which 86 | // points to the tool to call to authenticate a request. 87 | func (a *ExternalToolAuth) Parameters() []AuthParam { 88 | return []AuthParam{ 89 | {Name: "commandline", Required: true}, 90 | {Name: "omitbody", Required: false}, 91 | } 92 | } 93 | 94 | // OnRequest gets run before the request goes out on the wire. 95 | // The supplied commandline argument is ran with a JSON input 96 | // and expects a JSON output on stdout 97 | func (a *ExternalToolAuth) OnRequest(req *http.Request, key string, params map[string]string) error { 98 | commandLine := params["commandline"] 99 | omitBodyStr, omitBodyPresent := params["omitbody"] 100 | omitBody := false 101 | if omitBodyPresent && strings.EqualFold(omitBodyStr, "true") { 102 | omitBody = true 103 | } 104 | shell, shellPresent := os.LookupEnv("SHELL") 105 | if !shellPresent { 106 | shell = "/bin/sh" 107 | } 108 | cmd := exec.Command(shell, "-c", commandLine) 109 | stdin, err := cmd.StdinPipe() 110 | if err != nil { 111 | return err 112 | } 113 | 114 | bodyStr := "" 115 | if req.Body != nil && !omitBody { 116 | bodyBytes, err := io.ReadAll(req.Body) 117 | if err != nil { 118 | return err 119 | } 120 | bodyStr = string(bodyBytes) 121 | req.Body = io.NopCloser(strings.NewReader(bodyStr)) 122 | } 123 | 124 | textRequest := Request{ 125 | Method: req.Method, 126 | URI: req.URL.String(), 127 | Header: req.Header, 128 | Body: bodyStr, 129 | } 130 | requestBytes, err := json.Marshal(textRequest) 131 | if err != nil { 132 | return err 133 | } 134 | _, err = stdin.Write(requestBytes) 135 | if err != nil { 136 | return err 137 | } 138 | stdin.Close() 139 | outBytes, err := cmd.Output() 140 | if err != nil { 141 | return err 142 | } 143 | if len(outBytes) <= 0 { 144 | return nil 145 | } 146 | var requestUpdates Request 147 | err = json.Unmarshal(outBytes, &requestUpdates) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | if len(requestUpdates.URI) > 0 { 153 | req.URL, err = url.Parse(requestUpdates.URI) 154 | if err != nil { 155 | return err 156 | } 157 | } 158 | 159 | for k, vs := range requestUpdates.Header { 160 | for _, v := range vs { 161 | // A single value is supported for each header 162 | req.Header.Set(k, v) 163 | } 164 | } 165 | return nil 166 | } 167 | -------------------------------------------------------------------------------- /cli/readable.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "math" 7 | "reflect" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // MarshalReadable marshals a value into a human-friendly readable format. 15 | func MarshalReadable(v interface{}) ([]byte, error) { 16 | return marshalReadable("", v) 17 | } 18 | 19 | func marshalReadable(indent string, v interface{}) ([]byte, error) { 20 | rv := reflect.ValueOf(v) 21 | switch rv.Kind() { 22 | case reflect.Invalid: 23 | return []byte("null"), nil 24 | case reflect.Ptr: 25 | if rv.IsZero() { 26 | return []byte("null"), nil 27 | } 28 | 29 | return marshalReadable(indent, rv.Elem().Interface()) 30 | case reflect.Bool: 31 | if v.(bool) { 32 | return []byte("true"), nil 33 | } 34 | 35 | return []byte("false"), nil 36 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 37 | i := rv.Convert(reflect.TypeOf(int64(0))).Interface().(int64) 38 | return []byte(strconv.FormatInt(i, 10)), nil 39 | case reflect.Float32, reflect.Float64: 40 | // Copied from https://golang.org/src/encoding/json/encode.go 41 | f := rv.Float() 42 | abs := math.Abs(f) 43 | fmtByte := byte('f') 44 | bits := 64 45 | if rv.Kind() == reflect.Float32 { 46 | bits = 32 47 | } 48 | if abs != 0 { 49 | if bits == 64 && (abs < 1e-6 || abs >= 1e21) || bits == 32 && (float32(abs) < 1e-6 || float32(abs) >= 1e21) { 50 | fmtByte = 'e' 51 | } 52 | } 53 | b := []byte(strconv.FormatFloat(f, fmtByte, -1, bits)) 54 | if fmtByte == 'e' { 55 | // clean up e-09 to e-9 56 | n := len(b) 57 | if n >= 4 && b[n-4] == 'e' && b[n-3] == '-' && b[n-2] == '0' { 58 | b[n-2] = b[n-1] 59 | b = b[:n-1] 60 | } 61 | } 62 | return b, nil 63 | case reflect.String: 64 | // Escape quotes 65 | s := strings.Replace(v.(string), `"`, `\"`, -1) 66 | 67 | // Trim trailing newlines & add indentation 68 | s = strings.TrimRight(s, "\n") 69 | s = strings.Replace(s, "\n", "\n "+indent, -1) 70 | 71 | return []byte(`"` + s + `"`), nil 72 | case reflect.Array: 73 | return marshalReadable(indent, rv.Slice(0, rv.Len()).Interface()) 74 | case reflect.Slice: 75 | // Special case: empty slice should go in-line. 76 | if rv.Len() == 0 { 77 | return []byte("[]"), nil 78 | } 79 | 80 | // Detect binary []byte values and display the first few bytes as hex, 81 | // since that is easier to process in your head than base64. 82 | if binary, ok := v.([]byte); ok { 83 | suffix := "" 84 | if len(binary) > 10 { 85 | binary = binary[:10] 86 | suffix = "..." 87 | } 88 | return []byte("0x" + hex.EncodeToString(binary) + suffix), nil 89 | } 90 | 91 | // Otherwise, print out the slice. 92 | length := 0 93 | hasNewlines := false 94 | lines := []string{} 95 | for i := 0; i < rv.Len(); i++ { 96 | encoded, err := marshalReadable(indent+" ", rv.Index(i).Interface()) 97 | if err != nil { 98 | return nil, err 99 | } 100 | length += len(encoded) // TODO: handle multi-byte runes? 101 | if strings.Contains(string(encoded), "\n") { 102 | hasNewlines = true 103 | } 104 | lines = append(lines, string(encoded)) 105 | } 106 | 107 | s := "" 108 | if !hasNewlines && len(indent)+(len(lines)*2)+length < 80 { 109 | // Special-case: short array gets inlined like [1, 2, 3] 110 | s += "[" + strings.Join(lines, ", ") + "]" 111 | } else { 112 | s += "[\n" + indent + " " + strings.Join(lines, "\n "+indent) + "\n" + indent + "]" 113 | } 114 | 115 | return []byte(s), nil 116 | case reflect.Map: 117 | // Special case: empty map should go in-line 118 | if rv.Len() == 0 { 119 | return []byte("{}"), nil 120 | } 121 | 122 | m := "{\n" 123 | 124 | // Sort the keys 125 | keys := rv.MapKeys() 126 | stringKeys := []string{} 127 | reverse := map[string]reflect.Value{} 128 | for _, k := range keys { 129 | ks := fmt.Sprintf("%v", k) 130 | stringKeys = append(stringKeys, ks) 131 | reverse[ks] = k 132 | } 133 | 134 | sort.Strings(stringKeys) 135 | 136 | // Write out each key/value pair. 137 | for _, k := range stringKeys { 138 | v := rv.MapIndex(reverse[k]) 139 | encoded, err := marshalReadable(indent+" ", v.Interface()) 140 | if err != nil { 141 | return nil, err 142 | } 143 | m += indent + " " + k + ": " + string(encoded) + "\n" 144 | } 145 | 146 | m += indent + "}" 147 | 148 | return []byte(m), nil 149 | case reflect.Struct: 150 | if t, ok := v.(time.Time); ok { 151 | if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 && t.Nanosecond() == 0 { 152 | // Special case: date only 153 | return []byte(t.UTC().Format("2006-01-02")), nil 154 | } 155 | return []byte(t.UTC().Format(time.RFC3339Nano)), nil 156 | } 157 | 158 | // TODO: user-defined structs, go through each field. 159 | } 160 | 161 | return nil, fmt.Errorf("unknown kind %s", rv.Kind()) 162 | } 163 | -------------------------------------------------------------------------------- /cli/param.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "reflect" 7 | 8 | "github.com/iancoleman/strcase" 9 | "github.com/spf13/pflag" 10 | ) 11 | 12 | // Style is an encoding style for parameters. 13 | type Style int 14 | 15 | const ( 16 | // StyleSimple corresponds to OpenAPI 3 simple parameters 17 | StyleSimple Style = iota 18 | 19 | // StyleForm corresponds to OpenAPI 3 form parameters 20 | StyleForm 21 | ) 22 | 23 | func typeConvert(from, to interface{}) interface{} { 24 | return reflect.ValueOf(from).Convert(reflect.TypeOf(to)).Interface() 25 | } 26 | 27 | // Param represents an API operation input parameter. 28 | type Param struct { 29 | Type string `json:"type" yaml:"type"` 30 | Name string `json:"name" yaml:"name"` 31 | DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` 32 | Description string `json:"description,omitempty" yaml:"description,omitempty"` 33 | Style Style `json:"style,omitempty" yaml:"style,omitempty"` 34 | Explode bool `json:"explode,omitempty" yaml:"explide,omitempty"` 35 | Default interface{} `json:"default,omitempty" yaml:"default,omitempty"` 36 | Example interface{} `json:"example,omitempty" yaml:"example,omitempty"` 37 | } 38 | 39 | // Parse the parameter from a string input (e.g. command line argument) 40 | func (p Param) Parse(value string) (interface{}, error) { 41 | // TODO: parse based on the type, used mostly for path parameter parsing 42 | // which is almost always a string anyway. 43 | return value, nil 44 | } 45 | 46 | // Serialize the parameter based on the type/style/explode configuration. 47 | func (p Param) Serialize(value interface{}) []string { 48 | v := reflect.ValueOf(value) 49 | if v.Kind() == reflect.Ptr { 50 | v = v.Elem() 51 | value = v.Interface() 52 | } 53 | 54 | switch p.Type { 55 | case "boolean", "integer", "number", "string": 56 | switch p.Style { 57 | case StyleForm: 58 | return []string{fmt.Sprintf("%s=%v", p.Name, value)} 59 | case StyleSimple: 60 | return []string{fmt.Sprintf("%v", value)} 61 | } 62 | 63 | case "array[boolean]", "array[integer]", "array[number]", "array[string]": 64 | tmp := []string{} 65 | switch p.Style { 66 | case StyleForm: 67 | for i := 0; i < v.Len(); i++ { 68 | item := v.Index(i) 69 | if p.Explode { 70 | tmp = append(tmp, fmt.Sprintf("%v", item.Interface())) 71 | } else { 72 | if len(tmp) == 0 { 73 | tmp = append(tmp, "") 74 | } 75 | 76 | tmp[0] += fmt.Sprintf("%v", item.Interface()) 77 | if i < v.Len()-1 { 78 | tmp[0] += "," 79 | } 80 | } 81 | } 82 | case StyleSimple: 83 | tmp = append(tmp, "") 84 | for i := 0; i < v.Len(); i++ { 85 | item := v.Index(i) 86 | tmp[0] += fmt.Sprintf("%v", item.Interface()) 87 | if i < v.Len()-1 { 88 | tmp[0] += "," 89 | } 90 | } 91 | } 92 | return tmp 93 | } 94 | 95 | return nil 96 | } 97 | 98 | // OptionName returns the commandline option name for this parameter. 99 | func (p Param) OptionName() string { 100 | name := p.Name 101 | if p.DisplayName != "" { 102 | name = p.DisplayName 103 | } 104 | return strcase.ToDelimited(name, '-') 105 | } 106 | 107 | // AddFlag adds a new option flag to a command's flag set for this parameter. 108 | func (p Param) AddFlag(flags *pflag.FlagSet) interface{} { 109 | name := p.OptionName() 110 | def := p.Default 111 | 112 | switch p.Type { 113 | case "boolean": 114 | if def == nil { 115 | def = false 116 | } 117 | return flags.Bool(name, def.(bool), p.Description) 118 | case "integer": 119 | if def == nil { 120 | def = 0 121 | } 122 | return flags.Int(name, typeConvert(def, 0).(int), p.Description) 123 | case "number": 124 | if def == nil { 125 | def = 0.0 126 | } 127 | return flags.Float64(name, typeConvert(def, float64(0.0)).(float64), p.Description) 128 | case "string": 129 | if def == nil { 130 | def = "" 131 | } 132 | return flags.String(name, def.(string), p.Description) 133 | case "array[boolean]": 134 | if def == nil { 135 | def = []bool{} 136 | } 137 | return flags.BoolSlice(name, def.([]bool), p.Description) 138 | case "array[integer]": 139 | if def == nil { 140 | def = []int{} 141 | } 142 | return flags.IntSlice(name, def.([]int), p.Description) 143 | case "array[number]": 144 | log.Printf("number slice not implemented for param %s", p.Name) 145 | return nil 146 | // Float slices aren't implemented in the pflag package... 147 | // if def == nil { 148 | // def = []float64{} 149 | // } 150 | // return flags.Float64Slice(p.Name, def.([]float64), p.Description) 151 | case "array[string]": 152 | if def == nil { 153 | def = []string{} 154 | } else { 155 | tmp := []string{} 156 | for _, item := range def.([]interface{}) { 157 | tmp = append(tmp, item.(string)) 158 | } 159 | def = tmp 160 | } 161 | return flags.StringSlice(name, def.([]string), p.Description) 162 | } 163 | 164 | return nil 165 | } 166 | -------------------------------------------------------------------------------- /cli/links_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type errorLinkParser struct{} 12 | 13 | func (p errorLinkParser) ParseLinks(r *Response) error { 14 | return fmt.Errorf("error parsing links") 15 | } 16 | 17 | func TestLinkParserFailure(t *testing.T) { 18 | AddLinkParser(errorLinkParser{}) 19 | u, _ := url.Parse("https://example.com/test") 20 | r := &Response{ 21 | Links: Links{}, 22 | Headers: map[string]string{}, 23 | Body: nil, 24 | } 25 | err := ParseLinks(u, r) 26 | assert.Error(t, err) 27 | } 28 | 29 | func TestLinkHeaderParser(t *testing.T) { 30 | r := &Response{ 31 | Links: Links{}, 32 | Headers: map[string]string{ 33 | "Link": `; rel="self", ; rel="item", ; rel="item"`, 34 | }, 35 | } 36 | 37 | p := LinkHeaderParser{} 38 | err := p.ParseLinks(r) 39 | assert.NoError(t, err) 40 | assert.Equal(t, r.Links["self"][0].URI, "/self") 41 | assert.Equal(t, r.Links["item"][0].URI, "/foo") 42 | assert.Equal(t, r.Links["item"][1].URI, "/bar") 43 | 44 | // Test a bad link header 45 | r.Headers["Link"] = "bad value" 46 | err = p.ParseLinks(r) 47 | assert.Error(t, err) 48 | } 49 | 50 | func TestHALParser(t *testing.T) { 51 | r := &Response{ 52 | Links: Links{}, 53 | Body: map[string]interface{}{ 54 | "_links": map[string]interface{}{ 55 | "curies": nil, 56 | "self": map[string]interface{}{ 57 | "href": "/self", 58 | }, 59 | "item": map[string]interface{}{ 60 | "href": "/item", 61 | }, 62 | }, 63 | }, 64 | } 65 | 66 | p := HALParser{} 67 | err := p.ParseLinks(r) 68 | assert.NoError(t, err) 69 | assert.Equal(t, r.Links["self"][0].URI, "/self") 70 | assert.Equal(t, r.Links["item"][0].URI, "/item") 71 | } 72 | 73 | func TestHALParserArray(t *testing.T) { 74 | r := &Response{ 75 | Links: Links{}, 76 | Body: []interface{}{ 77 | map[string]interface{}{ 78 | "_links": map[string]interface{}{ 79 | "self": map[string]interface{}{ 80 | "href": "/one", 81 | }, 82 | }, 83 | }, 84 | map[string]interface{}{ 85 | "_links": map[string]interface{}{ 86 | "self": map[string]interface{}{ 87 | "href": "/two", 88 | }, 89 | }, 90 | }, 91 | }, 92 | } 93 | 94 | p := HALParser{} 95 | err := p.ParseLinks(r) 96 | assert.NoError(t, err) 97 | assert.Equal(t, r.Links["self"][0].URI, "/one") 98 | assert.Equal(t, r.Links["self"][1].URI, "/two") 99 | } 100 | 101 | func TestTerrificallySimpleJSONParser(t *testing.T) { 102 | r := &Response{ 103 | Links: Links{}, 104 | Body: map[string]interface{}{ 105 | "self": "/self", 106 | "things": []interface{}{ 107 | map[string]interface{}{ 108 | "self": "/foo", 109 | "name": "Foo", 110 | }, 111 | map[string]interface{}{ 112 | "self": "/bar", 113 | "name": "Bar", 114 | }, 115 | // Weird object with int keys instead of strings? Possible with binary 116 | // formats but not JSON itself. 117 | &map[int]interface{}{ 118 | 5: map[string]interface{}{ 119 | "self": "/weird", 120 | }, 121 | }, 122 | }, 123 | "other": map[string]interface{}{ 124 | "self": map[string]interface{}{ 125 | "foo": "bar", 126 | }, 127 | }, 128 | }, 129 | } 130 | 131 | p := TerrificallySimpleJSONParser{} 132 | err := p.ParseLinks(r) 133 | assert.NoError(t, err) 134 | assert.Equal(t, r.Links["self"][0].URI, "/self") 135 | assert.Equal(t, r.Links["things-item"][0].URI, "/foo") 136 | assert.Equal(t, r.Links["things-item"][1].URI, "/bar") 137 | assert.Equal(t, r.Links["5"][0].URI, "/weird") 138 | assert.NotContains(t, r.Links, "other") 139 | assert.NotContains(t, r.Links, "foo") 140 | } 141 | 142 | func TestSirenParser(t *testing.T) { 143 | r := &Response{ 144 | Links: Links{}, 145 | Body: map[string]interface{}{ 146 | "links": []map[string]interface{}{ 147 | {"rel": []string{"self"}, "href": "/self"}, 148 | {"rel": []string{"one", "two"}, "href": "/multi"}, 149 | {"rel": []string{"invalid"}}, 150 | }, 151 | }, 152 | } 153 | 154 | s := SirenParser{} 155 | err := s.ParseLinks(r) 156 | assert.NoError(t, err) 157 | assert.Equal(t, r.Links["self"][0].URI, "/self") 158 | assert.Equal(t, r.Links["one"][0].URI, "/multi") 159 | assert.Equal(t, r.Links["two"][0].URI, "/multi") 160 | } 161 | 162 | func TestJSONAPIParser(t *testing.T) { 163 | r := &Response{ 164 | Links: Links{}, 165 | Body: map[string]interface{}{ 166 | "links": map[string]interface{}{ 167 | "self": "/self", 168 | }, 169 | "data": []interface{}{ 170 | map[string]interface{}{ 171 | "links": map[string]interface{}{ 172 | "self": map[string]interface{}{ 173 | "href": "/item", 174 | }, 175 | }, 176 | }, 177 | }, 178 | }, 179 | } 180 | 181 | j := JSONAPIParser{} 182 | err := j.ParseLinks(r) 183 | assert.NoError(t, err) 184 | assert.Equal(t, r.Links["self"][0].URI, "/self") 185 | assert.Equal(t, r.Links["item"][0].URI, "/item") 186 | } 187 | -------------------------------------------------------------------------------- /cli/operation.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/gosimple/slug" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | // Operation represents an API action, e.g. list-things or create-user 17 | type Operation struct { 18 | Name string `json:"name" yaml:"name"` 19 | Group string `json:"group,omitempty" yaml:"group,omitempty"` 20 | Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"` 21 | Short string `json:"short,omitempty" yaml:"short,omitempty"` 22 | Long string `json:"long,omitempty" yaml:"long,omitempty"` 23 | Method string `json:"method,omitempty" yaml:"method,omitempty"` 24 | URITemplate string `json:"uri_template" yaml:"uri_template"` 25 | PathParams []*Param `json:"path_params,omitempty" yaml:"path_params,omitempty"` 26 | QueryParams []*Param `json:"query_params,omitempty" yaml:"query_params,omitempty"` 27 | HeaderParams []*Param `json:"header_params,omitempty" yaml:"header_params,omitempty"` 28 | BodyMediaType string `json:"body_media_type,omitempty" yaml:"body_media_type,omitempty"` 29 | Examples []string `json:"examples,omitempty" yaml:"examples,omitempty"` 30 | Hidden bool `json:"hidden,omitempty" yaml:"hidden,omitempty"` 31 | Deprecated string `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` 32 | } 33 | 34 | // command returns a Cobra command instance for this operation. 35 | func (o Operation) command() *cobra.Command { 36 | flags := map[string]interface{}{} 37 | 38 | use := slug.Make(o.Name) 39 | for _, p := range o.PathParams { 40 | use += " " + slug.Make(p.Name) 41 | } 42 | 43 | argSpec := cobra.ExactArgs(len(o.PathParams)) 44 | if o.BodyMediaType != "" { 45 | argSpec = cobra.MinimumNArgs(len(o.PathParams)) 46 | } 47 | 48 | long := o.Long 49 | 50 | examples := "" 51 | for _, ex := range o.Examples { 52 | examples += fmt.Sprintf(" %s %s %s\n", Root.CommandPath(), use, ex) 53 | } 54 | 55 | sub := &cobra.Command{ 56 | Use: use, 57 | GroupID: o.Group, 58 | Aliases: o.Aliases, 59 | Short: o.Short, 60 | Long: long, 61 | Example: examples, 62 | Args: argSpec, 63 | Hidden: o.Hidden, 64 | Deprecated: o.Deprecated, 65 | Run: func(cmd *cobra.Command, args []string) { 66 | uri := o.URITemplate 67 | 68 | for i, param := range o.PathParams { 69 | value, err := param.Parse(args[i]) 70 | if err != nil { 71 | value := param.Serialize(args[i])[0] 72 | log.Fatalf("could not parse param %s with input %s: %v", param.Name, value, err) 73 | } 74 | // Replaces URL-encoded `{`+name+`}` in the template. 75 | uri = strings.Replace(uri, "{"+param.Name+"}", fmt.Sprintf("%v", value), 1) 76 | } 77 | 78 | query := url.Values{} 79 | for _, param := range o.QueryParams { 80 | if !cmd.Flags().Changed(param.OptionName()) { 81 | // This option was not passed from the shell, so there is no need to 82 | // send it, even if it is the default or zero value. 83 | continue 84 | } 85 | 86 | flag := flags[param.Name] 87 | for _, v := range param.Serialize(flag) { 88 | query.Add(param.Name, v) 89 | } 90 | } 91 | queryEncoded := query.Encode() 92 | if queryEncoded != "" { 93 | if strings.Contains(uri, "?") { 94 | uri += "&" 95 | } else { 96 | uri += "?" 97 | } 98 | uri += queryEncoded 99 | } 100 | 101 | customServer := viper.GetString("rsh-server") 102 | if customServer != "" { 103 | // Adjust the server based on the customized input. 104 | orig, _ := url.Parse(uri) 105 | custom, _ := url.Parse(customServer) 106 | 107 | orig.Scheme = custom.Scheme 108 | orig.Host = custom.Host 109 | 110 | if custom.Path != "" && custom.Path != "/" { 111 | orig.Path = strings.TrimSuffix(custom.Path, "/") + orig.Path 112 | } 113 | 114 | uri = orig.String() 115 | } 116 | 117 | headers := http.Header{} 118 | for _, param := range o.HeaderParams { 119 | if !cmd.Flags().Changed(param.OptionName()) { 120 | // This option was not passed from the shell, so there is no need to 121 | // send it, even if it is the default or zero value. 122 | continue 123 | } 124 | 125 | for _, v := range param.Serialize(flags[param.Name]) { 126 | headers.Add(param.Name, v) 127 | } 128 | } 129 | 130 | var body io.Reader 131 | 132 | if o.BodyMediaType != "" { 133 | b, err := GetBody(o.BodyMediaType, args[len(o.PathParams):]) 134 | if err != nil { 135 | panic(err) 136 | } 137 | body = strings.NewReader(b) 138 | } 139 | 140 | req, _ := http.NewRequest(o.Method, uri, body) 141 | req.Header = headers 142 | MakeRequestAndFormat(req) 143 | }, 144 | } 145 | 146 | for _, p := range o.QueryParams { 147 | flags[p.Name] = p.AddFlag(sub.Flags()) 148 | } 149 | 150 | for _, p := range o.HeaderParams { 151 | flags[p.Name] = p.AddFlag(sub.Flags()) 152 | } 153 | 154 | return sub 155 | } 156 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ![Restish Logo](https://user-images.githubusercontent.com/106826/82109918-ec5b2300-96ee-11ea-9af0-8515329d5965.png) 2 | 3 |
4 | 5 | [![Works With Restish](https://img.shields.io/badge/Works%20With-Restish-ff5f87)](https://rest.sh/) [![User Guide](https://img.shields.io/badge/Docs-Guide-5fafd7)](https://rest.sh/#/guide) [![CI](https://github.com/rest-sh/restish/workflows/CI/badge.svg?branch=main)](https://github.com/rest-sh/restish/actions?query=workflow%3ACI+branch%3Amain++) [![codecov](https://codecov.io/gh/rest-sh/restish/branch/main/graph/badge.svg)](https://codecov.io/gh/rest-sh/restish) [![Docs](https://img.shields.io/badge/godoc-reference-5fafd7)](https://pkg.go.dev/github.com/rest-sh/restish?tab=subdirectories) [![Go Report Card](https://goreportcard.com/badge/github.com/rest-sh/restish)](https://goreportcard.com/report/github.com/rest-sh/restish) [![GitHub Likes](https://img.shields.io/github/stars/rest-sh/restish?style=social)](https://github.com/rest-sh/restish) 6 | 7 |
8 | 9 | [Restish](https://rest.sh/) is a CLI for interacting with [REST](https://apisyouwonthate.com/blog/rest-and-hypermedia-in-2019)-ish HTTP APIs with some nice features built-in, like always having the latest API resources, fields, and operations available when they go live on the API without needing to install or update anything. 10 | 11 | ## Why use this? 12 | 13 | Every API deserves a CLI for quick access and for power users to script against the service. Building CLIs from scratch is a pain. Restish provides one tool your users can install that just works for multiple APIs and is always up to date, because the interface is defined by the server. See how it [compares](/comparison.md) to cURL and HTTPie. 14 | 15 | ## Getting started 16 | 17 | Start with the [guide](/guide.md) to learn how to install and configure Restish as well as getting an overview of all of its features. 18 | 19 | ## Features 20 | 21 | - HTTP/2 ([RFC 7540](https://tools.ietf.org/html/rfc7540)) with TLS by _default_ with fallback to HTTP/1.1 22 | - Generic HEAD/GET/POST/PUT/PATCH/DELETE verbs like `curl` or [HTTPie](https://httpie.org/) 23 | - Generated commands for CLI operations, e.g. `restish my-api list-users` 24 | - Automatically discovers API descriptions 25 | - [RFC 8631](https://tools.ietf.org/html/rfc8631) `service-desc` link relation 26 | - [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation 27 | - Supported formats 28 | - OpenAPI [3.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) / [3.1](https://spec.openapis.org/oas/v3.1.0.html) and [JSON Schema](https://json-schema.org/) 29 | - Automatic configuration of API auth if advertised by the API 30 | - Shell command completion for Bash, Fish, Zsh, Powershell 31 | - Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links 32 | - API endpoint-based auth built-in with support for profiles: 33 | - HTTP Basic 34 | - API key via header or query param 35 | - OAuth2 client credentials flow (machine-to-machine, [RFC 6749](https://tools.ietf.org/html/rfc6749)) 36 | - OAuth2 authorization code (with PKCE [RFC 7636](https://tools.ietf.org/html/rfc7636)) flow 37 | - Content negotiation, decoding & unmarshalling built-in: 38 | - JSON ([RFC 8259](https://tools.ietf.org/html/rfc8259), ) 39 | - YAML () 40 | - CBOR ([RFC 7049](https://tools.ietf.org/html/rfc7049), ) 41 | - MessagePack () 42 | - Amazon Ion () 43 | - Gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)), Deflate ([RFC 1951](https://datatracker.ietf.org/doc/html/rfc1951)), and Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding 44 | - Automatic retries with support for [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) and `X-Retry-In` headers when APIs are rate-limited. 45 | - Standardized [hypermedia](https://smartbear.com/learn/api-design/what-is-hypermedia/) parsing into queryable/followable response links: 46 | - HTTP Link relation headers ([RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2)) 47 | - [HAL](http://stateless.co/hal_specification.html) 48 | - [Siren](https://github.com/kevinswiber/siren) 49 | - [Terrifically Simple JSON](https://github.com/mpnally/Terrifically-Simple-JSON) 50 | - [JSON:API](https://jsonapi.org/) 51 | - Local caching that respects [RFC 7234](https://tools.ietf.org/html/rfc7234) `Cache-Control` and `Expires` headers 52 | - Client-side bulk resource management (like git for API resources) 53 | - CLI [shorthand](https://github.com/danielgtaylor/openapi-cli-generator/tree/master/shorthand#cli-shorthand-syntax) for structured data input (e.g. for JSON) 54 | - [Shorthand query](https://github.com/danielgtaylor/shorthand#querying) response filtering & projection 55 | - Colorized prettified readable output 56 | - Fast native zero-dependency binary 57 | 58 | ## Articles 59 | 60 | - [A CLI for REST APIs](https://dev.to/danielgtaylor/a-cli-for-rest-apis-part-1-104b) 61 | - [Mapping OpenAPI to the CLI](https://dev.to/danielgtaylor/mapping-openapi-to-the-cli-37pb) 62 | -------------------------------------------------------------------------------- /bulk/file.go: -------------------------------------------------------------------------------- 1 | package bulk 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "path/filepath" 11 | "reflect" 12 | 13 | "github.com/rest-sh/restish/cli" 14 | "github.com/spf13/afero" 15 | "github.com/zeebo/xxh3" 16 | ) 17 | 18 | // reformat returns the standardized/formatted JSON representation given JSON 19 | // byte data as input. 20 | func reformat(data []byte) ([]byte, error) { 21 | // Round-trip to get consistent formatting. This is inefficient but a much 22 | // nicer experience for people with auto-formatters set up in their editor 23 | // or who may try to undo changes and get the formatting slightly off. 24 | var tmp any 25 | json.Unmarshal(data, &tmp) 26 | return cli.MarshalShort("json", true, tmp) 27 | } 28 | 29 | // hash returns a new fast 128-bit hash of the given bytes. 30 | func hash(b []byte) []byte { 31 | tmp := xxh3.Hash128(b).Bytes() 32 | return tmp[:] 33 | } 34 | 35 | // File represents a checked out file with metadata about the remote and local 36 | // version(s) of the file. 37 | type File struct { 38 | // Path is the relative path to the local file 39 | Path string `json:"path"` 40 | // URL to the remote file 41 | URL string `json:"url"` 42 | 43 | // ETag header used for conditional updates 44 | ETag string `json:"etag,omitempty"` 45 | // LastModified header used for conditional updates 46 | LastModified string `json:"last_modified,omitempty"` 47 | 48 | // VersionRemote used to compare when listing 49 | VersionRemote string `json:"version_remote,omitempty"` 50 | // VersionLocal tracks the local copy of the file 51 | VersionLocal string `json:"version_local,omitempty"` 52 | 53 | // Schema is used to describe the type of the resource, if available. 54 | Schema string `json:"schema,omitempty"` 55 | 56 | // Hash is used for detecting local changes 57 | Hash []byte `json:"hash,omitempty"` 58 | } 59 | 60 | // GetData returns the file contents. 61 | func (f *File) GetData() ([]byte, error) { 62 | return afero.ReadFile(afs, f.Path) 63 | } 64 | 65 | // IsChangedLocal returns whether a file has been modified locally. The 66 | // `ignoreDeleted` parameter sets whether deleted files are considered to be 67 | // changed or not. 68 | func (f *File) IsChangedLocal(ignoreDeleted bool) bool { 69 | if len(f.Hash) == 0 { 70 | return false 71 | } 72 | b, err := f.GetData() 73 | if err != nil { 74 | return !ignoreDeleted 75 | } 76 | 77 | b, err = reformat(b) 78 | if err != nil { 79 | cli.LogWarning("Warning unable to format %s: %s\n", f.Path, err) 80 | return false 81 | } 82 | 83 | return !bytes.Equal(hash(b), f.Hash) 84 | } 85 | 86 | // IsChangedRemote returns whether the local and remote versions mismatch. 87 | func (f *File) IsChangedRemote() bool { 88 | return f.VersionLocal != f.VersionRemote 89 | } 90 | 91 | // Fetch pulls the remote file and updates the metadata. 92 | func (f *File) Fetch() ([]byte, error) { 93 | req, _ := http.NewRequest(http.MethodGet, f.URL, nil) 94 | // TODO: conditional fetch? 95 | resp, err := cli.GetParsedResponse(req) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | if resp.Status >= http.StatusBadRequest { 101 | cli.LogError("Error fetching %s from %s\n", f.Path, f.URL) 102 | cli.Formatter.Format(resp) 103 | return nil, fmt.Errorf("error fetching %s", f.URL) 104 | } 105 | 106 | if etag := resp.Headers["Etag"]; etag != "" { 107 | f.ETag = etag 108 | } 109 | 110 | if lastModified := resp.Headers["Last-Modified"]; lastModified != "" { 111 | f.LastModified = lastModified 112 | } 113 | 114 | if db := resp.Links["describedby"]; len(db) > 0 { 115 | baseURL, _ := url.Parse(f.URL) 116 | u, _ := url.Parse(db[0].URI) 117 | f.Schema = baseURL.ResolveReference(u).String() 118 | } else { 119 | v := reflect.ValueOf(resp.Body) 120 | if v.Kind() == reflect.Map && !v.IsNil() { 121 | if s := v.MapIndex(reflect.ValueOf("$schema")); s.Kind() == reflect.String { 122 | // Assume this is not a relative URL as it lives within the doc. 123 | f.Schema = v.String() 124 | } 125 | } 126 | } 127 | 128 | b, err := cli.MarshalShort("json", true, resp.Body) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | f.VersionLocal = f.VersionRemote 134 | 135 | if err := f.WriteCached(b); err != nil { 136 | return nil, err 137 | } 138 | 139 | return b, nil 140 | } 141 | 142 | // WriteCached writes the file to disk in the special cache directory. 143 | func (f *File) WriteCached(b []byte) error { 144 | fp := path.Join(".rshbulk", f.Path) 145 | afs.MkdirAll(filepath.Dir(fp), 0700) 146 | return afero.WriteFile(afs, fp, b, 0600) 147 | } 148 | 149 | // Write writes the file to disk. This also updates the local file hash 150 | // used to determine if the file has been modified. 151 | func (f *File) Write(b []byte) error { 152 | f.Hash = hash(b) 153 | afs.MkdirAll(filepath.Dir(f.Path), 0700) 154 | return afero.WriteFile(afs, f.Path, b, 0600) 155 | } 156 | 157 | // Reset overwrites the local file with the remote contents. 158 | func (f *File) Reset() error { 159 | cached, err := afero.ReadFile(afs, path.Join(metaDir, f.Path)) 160 | if err != nil { 161 | return err 162 | } 163 | return f.Write(cached) 164 | } 165 | -------------------------------------------------------------------------------- /cli/gron.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "reflect" 9 | "sort" 10 | "strings" 11 | "time" 12 | "unicode" 13 | ) 14 | 15 | // PathBuffer builds up a path string from multiple parts. 16 | type PathBuffer struct { 17 | parts [][]byte 18 | } 19 | 20 | func (b *PathBuffer) Push(s string) { 21 | if s[0] != '[' { 22 | s = "." + s 23 | } 24 | b.parts = append(b.parts, []byte(s)) 25 | } 26 | 27 | func (b *PathBuffer) Pop() { 28 | b.parts = b.parts[:len(b.parts)-1] 29 | } 30 | 31 | func (b *PathBuffer) Bytes() []byte { 32 | return bytes.Join(b.parts, []byte{}) 33 | } 34 | 35 | // NewPathBuffer creates a new path buffer with the given underlying initial 36 | // data loaded into it. 37 | func NewPathBuffer(parts [][]byte) *PathBuffer { 38 | return &PathBuffer{parts: parts} 39 | } 40 | 41 | // validFirstRune returns true for runes that are valid 42 | // as the first rune in an identifier. 43 | func validFirstRune(r rune) bool { 44 | return unicode.In(r, unicode.Lu, unicode.Ll, unicode.Lm, unicode.Lo, unicode.Nl) || r == '$' || r == '_' 45 | } 46 | 47 | // identifier returns a JS-safe identifier string. 48 | func identifier(s string) string { 49 | valid := true 50 | for i, r := range s { 51 | if i == 0 { 52 | valid = validFirstRune(r) 53 | } else { 54 | valid = validFirstRune(r) || unicode.In(r, unicode.Mn, unicode.Mc, unicode.Nd, unicode.Pc) 55 | } 56 | if !valid { 57 | break 58 | } 59 | } 60 | if valid { 61 | return s 62 | } 63 | 64 | s = strings.ReplaceAll(s, `"`, `\"`) 65 | 66 | return fmt.Sprintf(`["%s"]`, s) 67 | } 68 | 69 | // keyStr returns a string representation of a map key. 70 | func keyStr(v reflect.Value) string { 71 | if v.Kind() == reflect.String { 72 | return v.String() 73 | } 74 | return fmt.Sprintf(`%v`, v.Interface()) 75 | } 76 | 77 | // apnd appends any number of strings or byte slices to a byte slice. 78 | func apnd(buf []byte, what ...any) []byte { 79 | for _, b := range what { 80 | if v, ok := b.([]byte); ok { 81 | buf = append(buf, v...) 82 | } else if v, ok := b.(string); ok { 83 | buf = append(buf, v...) 84 | } 85 | } 86 | return buf 87 | } 88 | 89 | func marshalGron(pb *PathBuffer, data any, isAnon bool, out []byte) ([]byte, error) { 90 | var err error 91 | 92 | v := reflect.Indirect(reflect.ValueOf(data)) 93 | switch v.Kind() { 94 | case reflect.Struct: 95 | // Special case: time.Time! 96 | if v.Type() == reflect.TypeOf(time.Time{}) { 97 | out = apnd(out, pb.Bytes(), " = \"", v.Interface().(time.Time).Format(time.RFC3339Nano), "\";\n") 98 | break 99 | } 100 | 101 | if !isAnon { 102 | // Special case: anonymous embedded structs should not result in 103 | // redefinition of the parent's base type. 104 | out = apnd(out, pb.Bytes(), " = {};\n") 105 | } 106 | 107 | // Fields are output in definition order, including embedded structs. Field 108 | // overrides are not supported and will result in multiple output 109 | // definitions. The `omitempty` tag is ignored just to make grepping 110 | // for zero values easier. 111 | for i := 0; i < v.NumField(); i++ { 112 | field := v.Field(i) 113 | ft := v.Type().Field(i) 114 | if !ft.IsExported() { 115 | // Ignore unexported (i.e. private) fields. 116 | continue 117 | } 118 | anon := false 119 | if ft.Anonymous { 120 | anon = true 121 | } else { 122 | // Try to determine the name using the standard Go rules. 123 | name := ft.Name 124 | if tag := ft.Tag.Get("json"); tag != "" { 125 | if tag == "-" { 126 | continue 127 | } 128 | name = strings.Split(tag, ",")[0] 129 | } 130 | pb.Push(identifier(name)) 131 | } 132 | if out, err = marshalGron(pb, field.Interface(), anon, out); err != nil { 133 | return nil, err 134 | } 135 | if !anon { 136 | pb.Pop() 137 | } 138 | } 139 | case reflect.Map: 140 | out = apnd(out, pb.Bytes(), " = {};\n") 141 | keys := v.MapKeys() 142 | // Maps are output in sorted alphanum order. 143 | sort.Slice(keys, func(i, j int) bool { 144 | return keyStr(keys[i]) < keyStr(keys[j]) 145 | }) 146 | for _, key := range keys { 147 | pb.Push(identifier(keyStr(key))) 148 | if out, err = marshalGron(pb, v.MapIndex(key).Interface(), false, out); err != nil { 149 | return nil, err 150 | } 151 | pb.Pop() 152 | } 153 | case reflect.Slice: 154 | // Special case: []byte 155 | if v.Type().Elem().Kind() == reflect.Uint8 { 156 | out = apnd(out, pb.Bytes(), " = \"", base64.StdEncoding.EncodeToString(v.Bytes()), "\";\n") 157 | break 158 | } 159 | 160 | out = apnd(out, pb.Bytes(), " = [];\n") 161 | for i := 0; i < v.Len(); i++ { 162 | pb.Push(fmt.Sprintf("[%d]", i)) 163 | if out, err = marshalGron(pb, v.Index(i).Interface(), false, out); err != nil { 164 | return nil, err 165 | } 166 | pb.Pop() 167 | } 168 | default: 169 | // This is a primitive type, just take the JSON representation. 170 | // The default encoder escapes '<', '>', and '&' which we don't want 171 | // since we are not a browser. Disable this with an encoder instance. 172 | // See https://stackoverflow.com/a/28596225/164268 173 | buf := &bytes.Buffer{} 174 | enc := json.NewEncoder(buf) 175 | enc.SetEscapeHTML(false) 176 | if err := enc.Encode(makeJSONSafe(data)); err != nil { 177 | return nil, err 178 | } 179 | // Note: encoder adds it's own ending newline we need to strip out. 180 | b := bytes.TrimSuffix(buf.Bytes(), []byte("\n")) 181 | out = apnd(out, pb.Bytes(), " = ", b, ";\n") 182 | } 183 | 184 | return out, nil 185 | } 186 | -------------------------------------------------------------------------------- /cli/lexer.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/alecthomas/chroma" 5 | "github.com/alecthomas/chroma/lexers" 6 | ) 7 | 8 | // ReadableLexer colorizes the output of the Readable marshaller. 9 | var ReadableLexer = lexers.Register(chroma.MustNewLazyLexer( 10 | &chroma.Config{ 11 | Name: "CLI Readable", 12 | Aliases: []string{"readable"}, 13 | NotMultiline: true, 14 | DotAll: true, 15 | }, 16 | func() chroma.Rules { 17 | return chroma.Rules{ 18 | "whitespace": { 19 | { 20 | Pattern: `\s+`, 21 | Type: chroma.Text, 22 | }, 23 | }, 24 | "scalar": { 25 | { 26 | Pattern: `(true|false|null)\b`, 27 | Type: chroma.KeywordConstant, 28 | }, 29 | { 30 | Pattern: `"?0x[0-9a-f]+(\\.\\.\\.)?"?`, 31 | Type: chroma.LiteralNumberHex, 32 | }, 33 | { 34 | Pattern: `"?[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9:+-.]+Z?)?"?`, 35 | Type: chroma.LiteralDate, 36 | }, 37 | { 38 | Pattern: `-?(0|[1-9]\d*)(\.\d+[eE](\+|-)?\d+|[eE](\+|-)?\d+|\.\d+)`, 39 | Type: chroma.LiteralNumberFloat, 40 | }, 41 | { 42 | Pattern: `-?(0|[1-9]\d*)`, 43 | Type: chroma.LiteralNumberInteger, 44 | }, 45 | { 46 | Pattern: `"([a-z]+://|/)(\\\\|\\"|[^"])+"`, 47 | Type: chroma.LiteralStringSymbol, 48 | }, 49 | { 50 | Pattern: `"(\\\\|\\"|[^"])*"`, 51 | Type: chroma.LiteralStringDouble, 52 | }, 53 | }, 54 | "objectrow": { 55 | { 56 | Pattern: `:`, 57 | Type: chroma.Punctuation, 58 | }, 59 | { 60 | Pattern: `\n`, 61 | Type: chroma.Punctuation, 62 | Mutator: chroma.Pop(1), 63 | }, 64 | { 65 | Pattern: `\}`, 66 | Type: chroma.Punctuation, 67 | Mutator: chroma.Pop(2), 68 | }, 69 | chroma.Include("value"), 70 | }, 71 | "object": { 72 | chroma.Include("whitespace"), 73 | { 74 | Pattern: `\}`, 75 | Type: chroma.EmitterFunc(indentEnd), 76 | Mutator: chroma.Pop(1), 77 | }, 78 | { 79 | Pattern: `(\\\\|\\:|[^:])+`, 80 | Type: chroma.NameTag, 81 | Mutator: chroma.Push("objectrow"), 82 | }, 83 | }, 84 | "arrayvalue": { 85 | { 86 | Pattern: `\]`, 87 | Type: chroma.EmitterFunc(indentEnd), 88 | Mutator: chroma.Pop(1), 89 | }, 90 | chroma.Include("value"), 91 | }, 92 | "value": { 93 | chroma.Include("whitespace"), 94 | { 95 | Pattern: `\{`, 96 | Type: chroma.EmitterFunc(indentStart), 97 | Mutator: chroma.Push("object"), 98 | }, 99 | { 100 | Pattern: `\[`, 101 | Type: chroma.EmitterFunc(indentStart), 102 | Mutator: chroma.Push("arrayvalue"), 103 | }, 104 | chroma.Include("scalar"), 105 | }, 106 | "root": { 107 | chroma.Include("value"), 108 | }, 109 | } 110 | }, 111 | )) 112 | 113 | // indentLevel tracks the current indentation level from `{` and `[` characters. 114 | // Making this a global is unfortunate but there doesn't appear to be any 115 | // other way to make it work. 116 | var indentLevel = 0 117 | 118 | const ( 119 | IndentLevel1 chroma.TokenType = 9000 + iota 120 | IndentLevel2 121 | IndentLevel3 122 | ) 123 | 124 | // indentStart tracks and emits an appropriate indent level token whenever 125 | // a `{` or `[` is encountered. It enables nested indent braces to be color 126 | // coded in an alternating pattern to make it easier to distinguish. 127 | func indentStart(groups []string, state *chroma.LexerState) chroma.Iterator { 128 | tokens := []chroma.Token{ 129 | {Type: chroma.TokenType(9000 + (indentLevel % 3)), Value: groups[0]}, 130 | } 131 | indentLevel++ 132 | return chroma.Literator(tokens...) 133 | } 134 | 135 | // indentEnd emits indent level tokens to match indentStart. 136 | func indentEnd(groups []string, state *chroma.LexerState) chroma.Iterator { 137 | indentLevel-- 138 | tokens := []chroma.Token{ 139 | {Type: chroma.TokenType(9000 + (indentLevel % 3)), Value: groups[0]}, 140 | } 141 | return chroma.Literator(tokens...) 142 | } 143 | 144 | // SchemaLexer colorizes schema output. 145 | var SchemaLexer = lexers.Register(chroma.MustNewLazyLexer( 146 | &chroma.Config{ 147 | Name: "CLI Schema", 148 | Aliases: []string{"schema"}, 149 | NotMultiline: true, 150 | DotAll: true, 151 | }, 152 | func() chroma.Rules { 153 | return chroma.Rules{ 154 | "whitespace": { 155 | {Pattern: `\s+`, Type: chroma.Text}, 156 | }, 157 | "value": { 158 | chroma.Include("whitespace"), 159 | { 160 | Pattern: `allOf|anyOf|oneOf`, 161 | Type: chroma.NameBuiltin, 162 | }, 163 | { 164 | Pattern: `(\()([^ )]+)`, 165 | Type: chroma.ByGroups(chroma.Text, chroma.Keyword), 166 | }, 167 | { 168 | Pattern: `([^: )]+)(:)([^ )]+)`, 169 | Type: chroma.ByGroups(chroma.String, chroma.Text, chroma.Text), 170 | }, 171 | { 172 | Pattern: `[^\n]*`, 173 | Type: chroma.Text, 174 | Mutator: chroma.Pop(1), 175 | }, 176 | }, 177 | "row": { 178 | chroma.Include("whitespace"), 179 | { 180 | Pattern: `allOf|anyOf|oneOf`, 181 | Type: chroma.NameBuiltin, 182 | }, 183 | { 184 | Pattern: `([^*:\n]+)(\*?)(:)`, 185 | Type: chroma.ByGroups(chroma.NameTag, chroma.GenericStrong, chroma.Text), 186 | Mutator: chroma.Push("value"), 187 | }, 188 | { 189 | Pattern: `(\()([^ )]+)`, 190 | Type: chroma.ByGroups(chroma.Text, chroma.Keyword), 191 | Mutator: chroma.Push("value"), 192 | }, 193 | }, 194 | "root": { 195 | chroma.Include("row"), 196 | }, 197 | } 198 | }, 199 | )) 200 | -------------------------------------------------------------------------------- /cli/request_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/spf13/viper" 11 | "github.com/stretchr/testify/assert" 12 | "gopkg.in/h2non/gock.v1" 13 | ) 14 | 15 | func TestFixAddress(t *testing.T) { 16 | assert.Equal(t, "https://example.com", fixAddress("example.com")) 17 | assert.Equal(t, "http://localhost:8000", fixAddress(":8000")) 18 | assert.Equal(t, "http://localhost:8000", fixAddress("localhost:8000")) 19 | 20 | configs["test"] = &APIConfig{ 21 | Base: "https://example.com", 22 | } 23 | assert.Equal(t, "https://example.com/foo", fixAddress("test/foo")) 24 | delete(configs, "test") 25 | } 26 | 27 | func TestRequestPagination(t *testing.T) { 28 | defer gock.Off() 29 | 30 | gock.New("http://example.com"). 31 | Get("/paginated"). 32 | Reply(http.StatusOK). 33 | // Page 1 links to page 2 34 | SetHeader("Link", "; rel=\"next\""). 35 | SetHeader("Content-Length", "7"). 36 | JSON([]interface{}{1, 2, 3}) 37 | gock.New("http://example.com"). 38 | Get("/paginated2"). 39 | Reply(http.StatusOK). 40 | // Page 2 links to page 3 41 | SetHeader("Link", "; rel=\"next\""). 42 | SetHeader("Content-Length", "5"). 43 | JSON([]interface{}{4, 5}) 44 | gock.New("http://example.com"). 45 | Get("/paginated3"). 46 | Reply(http.StatusOK). 47 | SetHeader("Content-Length", "3"). 48 | JSON([]interface{}{6}) 49 | 50 | req, _ := http.NewRequest(http.MethodGet, "http://example.com/paginated", nil) 51 | resp, err := GetParsedResponse(req) 52 | 53 | assert.NoError(t, err) 54 | assert.Equal(t, resp.Status, http.StatusOK) 55 | 56 | // Content length should be the sum of all combined. 57 | assert.Equal(t, resp.Headers["Content-Length"], "15") 58 | 59 | // Response body should be a concatenation of all pages. 60 | assert.Equal(t, []interface{}{1.0, 2.0, 3.0, 4.0, 5.0, 6.0}, resp.Body) 61 | } 62 | 63 | type authHookFailure struct{} 64 | 65 | func (a *authHookFailure) Parameters() []AuthParam { 66 | return []AuthParam{} 67 | } 68 | 69 | func (a *authHookFailure) OnRequest(req *http.Request, key string, params map[string]string) error { 70 | return errors.New("some-error") 71 | } 72 | 73 | func TestAuthHookFailure(t *testing.T) { 74 | configs["auth-hook-fail"] = &APIConfig{ 75 | Profiles: map[string]*APIProfile{ 76 | "default": { 77 | Auth: &APIAuth{ 78 | Name: "hook-fail", 79 | }, 80 | }, 81 | }, 82 | } 83 | 84 | authHandlers["hook-fail"] = &authHookFailure{} 85 | 86 | r, _ := http.NewRequest(http.MethodGet, "/test", nil) 87 | assert.PanicsWithError(t, "some-error", func() { 88 | MakeRequest(r) 89 | }) 90 | } 91 | 92 | func TestGetStatus(t *testing.T) { 93 | defer gock.Off() 94 | 95 | reset(false) 96 | lastStatus = 0 97 | 98 | gock.New("http://example.com"). 99 | Get("/"). 100 | Reply(http.StatusOK) 101 | 102 | req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil) 103 | resp, err := MakeRequest(req) 104 | 105 | assert.NoError(t, err) 106 | assert.Equal(t, resp.StatusCode, http.StatusOK) 107 | 108 | assert.Equal(t, http.StatusOK, GetLastStatus()) 109 | } 110 | 111 | func TestIgnoreStatus(t *testing.T) { 112 | defer gock.Off() 113 | 114 | reset(false) 115 | lastStatus = 0 116 | 117 | gock.New("http://example.com"). 118 | Get("/"). 119 | Reply(http.StatusOK) 120 | 121 | req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil) 122 | resp, err := MakeRequest(req, IgnoreStatus()) 123 | 124 | assert.NoError(t, err) 125 | assert.Equal(t, resp.StatusCode, http.StatusOK) 126 | 127 | assert.Equal(t, 0, GetLastStatus()) 128 | } 129 | 130 | func TestRequestRetryIn(t *testing.T) { 131 | defer gock.Off() 132 | 133 | reset(false) 134 | viper.Set("rsh-retry", 1) 135 | 136 | // Duration string value (with units) 137 | gock.New("http://example.com"). 138 | Get("/"). 139 | Times(1). 140 | Reply(http.StatusTooManyRequests). 141 | SetHeader("X-Retry-In", "1ms") 142 | 143 | gock.New("http://example.com"). 144 | Get("/"). 145 | Times(1). 146 | Reply(http.StatusOK) 147 | 148 | req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil) 149 | resp, err := MakeRequest(req) 150 | 151 | assert.NoError(t, err) 152 | assert.Equal(t, resp.StatusCode, http.StatusOK) 153 | } 154 | 155 | func TestRequestRetryAfter(t *testing.T) { 156 | defer gock.Off() 157 | 158 | reset(false) 159 | viper.Set("rsh-retry", 2) 160 | 161 | // Seconds value 162 | gock.New("http://example.com"). 163 | Put("/"). 164 | Times(1). 165 | Reply(http.StatusTooManyRequests). 166 | SetHeader("Retry-After", "0") 167 | 168 | // HTTP date value 169 | gock.New("http://example.com"). 170 | Put("/"). 171 | Times(1). 172 | Reply(http.StatusTooManyRequests). 173 | SetHeader("Retry-After", time.Now().Format(http.TimeFormat)) 174 | 175 | gock.New("http://example.com"). 176 | Put("/"). 177 | Times(1). 178 | Reply(http.StatusOK) 179 | 180 | req, _ := http.NewRequest(http.MethodPut, "http://example.com/", bytes.NewReader([]byte("hello"))) 181 | resp, err := MakeRequest(req) 182 | 183 | assert.NoError(t, err) 184 | assert.Equal(t, resp.StatusCode, http.StatusOK) 185 | } 186 | 187 | func TestRequestRetryTimeout(t *testing.T) { 188 | defer gock.Off() 189 | 190 | reset(false) 191 | viper.Set("rsh-retry", 1) 192 | viper.Set("rsh-timeout", 1*time.Millisecond) 193 | 194 | // Duration string value (with units) 195 | gock.New("http://example.com"). 196 | Get("/"). 197 | Times(2). 198 | Reply(http.StatusOK). 199 | Delay(2 * time.Millisecond) 200 | // Note: delay seems to have a bug where subsequent requests without the 201 | // delay are still delayed... For now just have it reply twice. 202 | 203 | req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil) 204 | _, err := MakeRequest(req) 205 | 206 | assert.Error(t, err) 207 | assert.ErrorContains(t, err, "timed out") 208 | } 209 | -------------------------------------------------------------------------------- /cli/edit.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | 12 | "github.com/danielgtaylor/shorthand/v2" 13 | "github.com/google/shlex" 14 | "github.com/hexops/gotextdiff" 15 | "github.com/hexops/gotextdiff/myers" 16 | "github.com/hexops/gotextdiff/span" 17 | "github.com/mattn/go-isatty" 18 | "github.com/spf13/viper" 19 | ) 20 | 21 | func panicOnErr(err error) { 22 | if err != nil { 23 | panic(err) 24 | } 25 | } 26 | 27 | // getEditor tries to find the system default text editor command. 28 | func getEditor() string { 29 | editor := os.Getenv("VISUAL") 30 | if editor == "" { 31 | editor = os.Getenv("EDITOR") 32 | } 33 | 34 | return editor 35 | } 36 | 37 | func edit(addr string, args []string, interactive, noPrompt bool, exitFunc func(int), editMarshal func(interface{}) ([]byte, error), editUnmarshal func([]byte, interface{}) error, ext string) { 38 | if !interactive && len(args) == 0 { 39 | fmt.Fprintln(os.Stderr, "No arguments passed to modify the resource. Use `-i` to enable interactive mode.") 40 | exitFunc(1) 41 | return 42 | } 43 | 44 | editor := getEditor() 45 | if interactive && editor == "" { 46 | fmt.Fprintln(os.Stderr, `Please set the VISUAL or EDITOR environment variable with your preferred editor. Examples: 47 | 48 | export VISUAL="code --wait" 49 | export EDITOR="vim"`) 50 | exitFunc(1) 51 | return 52 | } 53 | 54 | req, _ := http.NewRequest(http.MethodGet, fixAddress(addr), nil) 55 | resp, err := GetParsedResponse(req) 56 | panicOnErr(err) 57 | 58 | if resp.Status >= 400 { 59 | panicOnErr(Formatter.Format(resp)) 60 | exitFunc(1) 61 | return 62 | } 63 | 64 | // Convert from CBOR or other formats which might allow map[any]any to the 65 | // constraints of JSON (i.e. map[string]interface{}). 66 | var data interface{} = resp.Map() 67 | data = makeJSONSafe(data) 68 | 69 | filter := viper.GetString("rsh-filter") 70 | if filter == "" { 71 | filter = "body" 72 | } 73 | 74 | var logger func(format string, a ...interface{}) 75 | if enableVerbose { 76 | logger = LogDebug 77 | } 78 | filtered, _, err := shorthand.GetPath(filter, data, shorthand.GetOptions{ 79 | DebugLogger: logger, 80 | }) 81 | panicOnErr(err) 82 | data = filtered 83 | 84 | if _, ok := data.(map[string]interface{}); !ok { 85 | fmt.Fprintln(os.Stderr, "Resource didn't return an object.") 86 | exitFunc(1) 87 | return 88 | } 89 | 90 | // Save original representation for comparison later. We use JSON here for 91 | // consistency and to avoid things like YAML encoding e.g. dates and strings 92 | // differently. 93 | orig, _ := json.MarshalIndent(data, "", " ") 94 | 95 | // If available, grab any headers that can be used for conditional updates 96 | // so we don't overwrite changes made by other people while we edit. 97 | etag := resp.Headers["Etag"] 98 | lastModified := resp.Headers["Last-Modified"] 99 | 100 | // TODO: remove read-only fields? This requires: 101 | // 1. Figure out which operation the URL corresponds to. 102 | // 2. Get and then analyse the response schema for that operation. 103 | // 3. Remove corresponding fields from `data`. 104 | 105 | var modified interface{} = data 106 | 107 | if len(args) > 0 { 108 | modified, err = shorthand.Unmarshal(strings.Join(args, " "), shorthand.ParseOptions{EnableFileInput: true, EnableObjectDetection: true}, modified) 109 | panicOnErr(err) 110 | } 111 | 112 | if interactive { 113 | // Create temp file 114 | tmp, err := os.CreateTemp("", "rsh-edit*"+ext) 115 | panicOnErr(err) 116 | defer os.Remove(tmp.Name()) 117 | 118 | // TODO: should we try and detect a `describedby` link relation and insert 119 | // that as a `$schema` key into the document before editing? The schema 120 | // itself may not allow the `$schema` key... hmm. 121 | 122 | // Write the current body 123 | marshalled, err := editMarshal(modified) 124 | panicOnErr(err) 125 | tmp.Write(marshalled) 126 | tmp.Close() 127 | 128 | // Open editor and wait for exit 129 | parts, err := shlex.Split(editor) 130 | panicOnErr(err) 131 | name := parts[0] 132 | args := append(parts[1:], tmp.Name()) 133 | 134 | cmd := exec.Command(name, args...) 135 | cmd.Stdin = os.Stdin 136 | cmd.Stdout = os.Stdout 137 | cmd.Stderr = os.Stderr 138 | panicOnErr(cmd.Run()) 139 | 140 | // Read file contents 141 | b, err := os.ReadFile(tmp.Name()) 142 | panicOnErr(err) 143 | 144 | panicOnErr(editUnmarshal(b, &modified)) 145 | } 146 | 147 | modified = makeJSONSafe(modified) 148 | mod, err := json.MarshalIndent(modified, "", " ") 149 | panicOnErr(err) 150 | edits := myers.ComputeEdits(span.URIFromPath("original"), string(orig), string(mod)) 151 | 152 | if len(edits) == 0 { 153 | fmt.Fprintln(os.Stderr, "No changes made.") 154 | exitFunc(0) 155 | return 156 | } else { 157 | diff := fmt.Sprint(gotextdiff.ToUnified("original", "modified", string(orig), edits)) 158 | if useColor { 159 | d, _ := Highlight("diff", []byte(diff)) 160 | diff = string(d) 161 | } 162 | fmt.Println(diff) 163 | 164 | if !noPrompt && isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) { 165 | fmt.Printf("Continue? [Y/n] ") 166 | tmp := []byte{0} 167 | os.Stdin.Read(tmp) 168 | if tmp[0] == 'n' { 169 | exitFunc(0) 170 | return 171 | } 172 | } 173 | } 174 | 175 | // TODO: support different submission formats, e.g. based on any given 176 | // `Content-Type` header? 177 | // TODO: content-encoding for large bodies? 178 | // TODO: determine if a PATCH could be used instead? 179 | b, _ := json.Marshal(modified) 180 | req, _ = http.NewRequest(http.MethodPut, fixAddress(addr), bytes.NewReader(b)) 181 | req.Header.Set("Content-Type", "application/json") 182 | 183 | if etag != "" { 184 | req.Header.Set("If-Match", etag) 185 | } else if lastModified != "" { 186 | req.Header.Set("If-Unmodified-Since", lastModified) 187 | } 188 | 189 | MakeRequestAndFormat(req) 190 | } 191 | -------------------------------------------------------------------------------- /cli/interactive_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "gopkg.in/h2non/gock.v1" 11 | ) 12 | 13 | type mockAsker struct { 14 | t *testing.T 15 | pos int 16 | responses []string 17 | } 18 | 19 | func (a *mockAsker) askConfirm(message string, def bool, help string) bool { 20 | a.pos++ 21 | a.t.Log("confirm", a.responses[a.pos-1]) 22 | return a.responses[a.pos-1] == "y" 23 | } 24 | 25 | func (a *mockAsker) askInput(message string, def string, required bool, help string) string { 26 | a.pos++ 27 | a.t.Log("input", a.responses[a.pos-1]) 28 | return a.responses[a.pos-1] 29 | } 30 | 31 | func (a *mockAsker) askSelect(message string, options []string, def interface{}, help string) string { 32 | a.pos++ 33 | a.t.Log("select", a.responses[a.pos-1]) 34 | return a.responses[a.pos-1] 35 | } 36 | 37 | func TestInteractive(t *testing.T) { 38 | // Remove existing config if present... 39 | os.Remove(filepath.Join(getConfigDir("test"), "apis.json")) 40 | os.Remove(filepath.Join(getConfigDir("test"), "cache.json")) 41 | 42 | reset(false) 43 | 44 | defer gock.Off() 45 | 46 | gock.New("http://api.example.com").Get("/").Reply(200).JSON(map[string]interface{}{ 47 | "Hello": "World", 48 | }) 49 | 50 | gock.New("http://api.example.com").Get("/openapi.json").Reply(404) 51 | gock.New("http://api.example.com").Get("/openapi.yaml").Reply(404) 52 | 53 | mock := &mockAsker{ 54 | t: t, 55 | responses: []string{ 56 | // TODO: Add a bunch more responses for various code paths. 57 | "http://api.example.com", 58 | "Add header", 59 | "Foo", 60 | "bar", 61 | "Add query param", 62 | "search", 63 | "bar", 64 | "Set up auth", 65 | "http-basic", 66 | "u", 67 | "p", 68 | "n", 69 | "Finished with profile", 70 | "Edit profile default", 71 | "Delete header Foo", 72 | "y", 73 | "Finished with profile", 74 | "Change base URI", 75 | "http://api.example.com", 76 | "Save and exit", 77 | }, 78 | } 79 | 80 | askInitAPI(mock, Root, []string{"example"}) 81 | } 82 | 83 | type testLoader struct { 84 | API API 85 | } 86 | 87 | func (l *testLoader) LocationHints() []string { 88 | return []string{"/openapi.json"} 89 | } 90 | 91 | func (l *testLoader) Detect(resp *http.Response) bool { 92 | return true 93 | } 94 | 95 | func (l *testLoader) Load(entrypoint, spec url.URL, resp *http.Response) (API, error) { 96 | LogInfo("Loading API %s", entrypoint.String()) 97 | return l.API, nil 98 | } 99 | 100 | func TestInteractiveAutoConfig(t *testing.T) { 101 | // Remove existing config if present... 102 | os.Remove(filepath.Join(getConfigDir("test"), "apis.json")) 103 | os.Remove(filepath.Join(getConfigDir("test"), "cache.json")) 104 | 105 | reset(false) 106 | AddLoader(&testLoader{ 107 | API: API{ 108 | Short: "Swagger Petstore", 109 | Auth: []APIAuth{ 110 | { 111 | Name: "oauth-authorization-code", 112 | Params: map[string]string{ 113 | "client_id": "", 114 | "authorize_url": "https://example.com/authorize", 115 | "token_url": "https://example.com/token", 116 | }, 117 | }, 118 | }, 119 | Operations: []Operation{ 120 | { 121 | Name: "createpets", 122 | Short: "Create a pet", 123 | Long: "\n## Response 201\n\nNull response\n\n## Response default (application/json)\n\nunexpected error\n\n```schema\n{\n code*: (integer format:int32) \n message*: (string) \n}\n```\n", 124 | Method: "POST", 125 | URITemplate: "http://api.example.com/pets", 126 | PathParams: []*Param{}, 127 | QueryParams: []*Param{}, 128 | HeaderParams: []*Param{}, 129 | }, 130 | { 131 | Name: "listpets", 132 | Short: "List all pets", 133 | Long: "\n## Response 200 (application/json)\n\nA paged array of pets\n\n```schema\n[\n {\n id*: (integer format:int64) \n name*: (string) \n tag: (string) \n }\n]\n```\n\n## Response default (application/json)\n\nunexpected error\n\n```schema\n{\n code*: (integer format:int32) \n message*: (string) \n}\n```\n", 134 | Method: "GET", 135 | URITemplate: "http://api.example.com/pets", 136 | PathParams: []*Param{}, 137 | QueryParams: []*Param{ 138 | { 139 | Type: "integer", 140 | Name: "limit", 141 | Description: "How many items to return at one time (max 100)", 142 | }, 143 | }, 144 | HeaderParams: []*Param{}, 145 | }, 146 | { 147 | Name: "showpetbyid", 148 | Short: "Info for a specific pet", 149 | Long: "\n## Response 200 (application/json)\n\nExpected response to a valid request\n\n```schema\n{\n id*: (integer format:int64) \n name*: (string) \n tag: (string) \n}\n```\n\n## Response default (application/json)\n\nunexpected error\n\n```schema\n{\n code*: (integer format:int32) \n message*: (string) \n}\n```\n", 150 | Method: "GET", 151 | URITemplate: "http://api.example.com/pets/{petId}", 152 | PathParams: []*Param{ 153 | { 154 | Type: "string", 155 | Name: "petId", 156 | Description: "The id of the pet to retrieve", 157 | }, 158 | }, 159 | QueryParams: []*Param{}, 160 | HeaderParams: []*Param{}, 161 | }, 162 | }, 163 | AutoConfig: AutoConfig{ 164 | Prompt: map[string]AutoConfigVar{ 165 | "client_id": { 166 | Description: "Client identifier", 167 | Example: "abc123", 168 | }, 169 | }, 170 | Auth: APIAuth{ 171 | Name: "oauth-authorization-code", 172 | Params: map[string]string{ 173 | "client_id": "", 174 | "authorize_url": "https://example.com/authorize", 175 | "token_url": "https://example.com/token", 176 | }, 177 | }, 178 | }, 179 | }, 180 | }) 181 | defer reset(false) 182 | 183 | defer gock.Off() 184 | 185 | gock.New("http://api2.example.com").Get("/").Reply(200).JSON(map[string]interface{}{ 186 | "Hello": "World", 187 | }) 188 | 189 | gock.New("http://api2.example.com").Get("/openapi.json").Reply(200).BodyString("dummy") 190 | 191 | mock := &mockAsker{ 192 | t: t, 193 | responses: []string{ 194 | "foo", 195 | "Save and exit", 196 | }, 197 | } 198 | 199 | askInitAPI(mock, Root, []string{"autoconfig", "http://api2.example.com"}) 200 | } 201 | -------------------------------------------------------------------------------- /cli/formatter_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "testing" 7 | 8 | "github.com/spf13/viper" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestPrintable(t *testing.T) { 13 | // Printable with BOM 14 | var body interface{} = []byte("\uFEFF\t\r\n Just a tést!.$%^{}/") 15 | _, ok := printable(body) 16 | assert.True(t, ok) 17 | 18 | // Non-printable 19 | body = []byte{0} 20 | _, ok = printable(body) 21 | assert.False(t, ok) 22 | 23 | // Long printable 24 | tmp := make([]byte, 150) 25 | for i := 0; i < 150; i++ { 26 | tmp[i] = 'a' 27 | } 28 | _, ok = printable(tmp) 29 | assert.True(t, ok) 30 | 31 | // Too long 32 | tmp = make([]byte, 1000000) 33 | for i := 0; i < 1000000; i++ { 34 | tmp[i] = 'a' 35 | } 36 | _, ok = printable(tmp) 37 | assert.False(t, ok) 38 | } 39 | 40 | var img, _ = base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEklEQVR42mP8/5+hngEIGGEMADlqBP1mY/qhAAAAAElFTkSuQmCC") 41 | 42 | var formatterTests = []struct { 43 | name string 44 | tty bool 45 | color bool 46 | raw bool 47 | format string 48 | filter string 49 | headers map[string]string 50 | body any 51 | result any 52 | err string 53 | }{ 54 | { 55 | name: "body-string", 56 | tty: true, 57 | body: "string", 58 | result: " 0 \n\nstring\n", 59 | }, 60 | { 61 | name: "image", 62 | tty: true, 63 | headers: map[string]string{ 64 | "Content-Type": "image/png", 65 | }, 66 | body: img, 67 | result: []byte{0x20, 0x30, 0x20, 0xa, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d, 0x54, 0x79, 0x70, 0x65, 0x3a, 0x20, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0xa, 0xa}, 68 | }, 69 | { 70 | name: "image-empty", 71 | tty: true, 72 | headers: map[string]string{ 73 | "Content-Type": "image/png", 74 | "Content-Length": "0", 75 | }, 76 | body: []byte{}, 77 | result: []byte{0x20, 0x30, 0x20, 0xa, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3a, 0x20, 0x30, 0xa, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d, 0x54, 0x79, 0x70, 0x65, 0x3a, 0x20, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0xa, 0xa}, 78 | }, 79 | { 80 | name: "json-pretty-explicit-full", 81 | tty: true, 82 | color: true, 83 | format: "json", 84 | filter: "@", 85 | body: "string", 86 | result: "\x1b[38;5;247m{\x1b[0m\n \x1b[38;5;74m\"body\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"string\"\x1b[0m\x1b[38;5;247m,\x1b[0m\n \x1b[38;5;74m\"headers\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;247m{},\x1b[0m\n \x1b[38;5;74m\"links\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;247m{},\x1b[0m\n \x1b[38;5;74m\"proto\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"\"\x1b[0m\x1b[38;5;247m,\x1b[0m\n \x1b[38;5;74m\"status\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;172m0\x1b[0m\n\x1b[38;5;247m}\x1b[0m\n", 87 | }, 88 | { 89 | name: "json-escape", 90 | format: "json", 91 | body: " and & shouldn't get escaped", 92 | result: `" and & shouldn't get escaped"` + "\n", 93 | }, 94 | { 95 | name: "json-bytes", 96 | format: "json", 97 | filter: "body", 98 | body: []byte{0, 1, 2, 3, 4, 5}, 99 | result: "\"AAECAwQF\"\n", 100 | }, 101 | { 102 | name: "json-filter", 103 | format: "json", 104 | filter: "body.id", 105 | body: []any{ 106 | map[string]any{"id": 1}, 107 | map[string]any{"id": 2}, 108 | }, 109 | result: "[\n 1,\n 2\n]\n", 110 | }, 111 | { 112 | name: "table", 113 | format: "table", 114 | filter: "body", 115 | body: []any{ 116 | map[string]any{"id": 1, "registered": true}, 117 | map[string]any{"id": 2, "registered": false}, 118 | }, 119 | result: `╔════╤════════════╗ 120 | ║ id │ registered ║ 121 | ╟━━━━┼━━━━━━━━━━━━╢ 122 | ║ 1 │ true ║ 123 | ║ 2 │ false ║ 124 | ╚════╧════════════╝ 125 | `, 126 | }, 127 | { 128 | name: "raw-bytes", 129 | tty: true, 130 | raw: true, 131 | body: []byte{0, 1, 2, 3, 4, 5}, 132 | result: []byte{0, 1, 2, 3, 4, 5}, 133 | }, 134 | { 135 | name: "raw-filtered-value", 136 | tty: true, 137 | raw: true, 138 | filter: "body", 139 | body: "[1, 2, 3]", 140 | result: "[1, 2, 3]\n", 141 | }, 142 | { 143 | name: "raw-filtered-bytes", 144 | tty: true, 145 | raw: true, 146 | filter: "body", 147 | body: []byte{0, 1, 2, 3, 4, 5}, 148 | result: "AAECAwQF\n", 149 | }, 150 | { 151 | name: "raw-large-json-num", 152 | raw: true, 153 | filter: "body", 154 | body: []interface{}{ 155 | nil, 156 | float64(1000000000000000), 157 | float64(1.2e5), 158 | float64(1.234), 159 | float64(0.00000000000005), // This should still use scientific notation! 160 | }, 161 | result: "null\n1000000000000000\n120000\n1.234\n5e-14\n", 162 | }, 163 | { 164 | name: "redirect-bytes", 165 | body: []byte{0, 1, 2, 3}, 166 | result: []byte{0, 1, 2, 3}, 167 | }, 168 | { 169 | name: "redirect-json", 170 | body: map[string]any{"example": true}, 171 | result: `{ 172 | "example": true 173 | } 174 | `, 175 | }, 176 | { 177 | name: "redirect-explicit-full-response", 178 | body: "foo", 179 | filter: "@", 180 | result: "{\n \"body\": \"foo\",\n \"headers\": {},\n \"links\": {},\n \"proto\": \"\",\n \"status\": 0\n}\n", 181 | }, 182 | { 183 | name: "redirect-yaml", 184 | format: "yaml", 185 | body: map[string]any{"example": true}, 186 | result: "example: true\n", 187 | }, 188 | { 189 | name: "error-prefix", 190 | filter: "boby.id", // should be body.id 191 | body: map[string]any{"id": 123}, 192 | err: "filter must begin with one of", 193 | }, 194 | { 195 | name: "error-missing-dot", 196 | filter: "body{id}", // should be body.{id} 197 | body: map[string]any{"id": 123}, 198 | err: "expected '.'", 199 | }, 200 | } 201 | 202 | func TestFormatter(t *testing.T) { 203 | for _, input := range formatterTests { 204 | t.Run(input.name, func(t *testing.T) { 205 | formatter := NewDefaultFormatter(input.tty, input.color) 206 | buf := &bytes.Buffer{} 207 | Stdout = buf 208 | viper.Reset() 209 | viper.Set("rsh-raw", input.raw) 210 | viper.Set("rsh-filter", input.filter) 211 | if input.format != "" { 212 | viper.Set("rsh-output-format", input.format) 213 | } else { 214 | viper.Set("rsh-output-format", "auto") 215 | } 216 | err := formatter.Format(Response{ 217 | Headers: input.headers, 218 | Body: input.body, 219 | }) 220 | if input.err != "" { 221 | assert.Error(t, err) 222 | assert.Contains(t, err.Error(), input.err) 223 | } else { 224 | assert.NoError(t, err) 225 | if b, ok := input.result.([]byte); ok { 226 | assert.Equal(t, b, buf.Bytes()) 227 | } else { 228 | assert.Equal(t, input.result, buf.String()) 229 | } 230 | } 231 | }) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /docs/comparison.md: -------------------------------------------------------------------------------- 1 | # Comparison to other tools 2 | 3 | See how Restish compares to other tools below: 4 | 5 | | Feature | Restish | HTTPie | cURL | 6 | | ---------------------------------------------------- | ------- | ------------- | --------------- | 7 | | Implementation Language | Go | Python | C | 8 | | Fast native no-dependency binary | ✅ | ❌ | ✅ | 9 | | HEAD/GET/POST/PUT/PATCH/DELETE/OPTIONS/etc | ✅ | ✅ | ✅ | 10 | | HTTPS by default | ✅ | ❌ | ❌ | 11 | | HTTP/2 by default | ✅ | ❌ | ❌ | 12 | | OAuth2.0 token fetching/caching | ✅ | 🟠 (plugin) | ❌ | 13 | | Authentication profiles | ✅ | 🟠 (sessions) | ❌ | 14 | | Content negotiation by default | ✅ | 🟠 (encoding) | ❌ | 15 | | gzip encoding | ✅ | ✅ | ❌ | 16 | | brotli encoding | ✅ | ❌ | ❌ | 17 | | CBOR & MessagePack binary format decoding | ✅ | ❌ | ❌ | 18 | | Local cache via `Cache-Control` or `Expires` headers | ✅ | ❌ | ❌ | 19 | | Shorthand for structured data input | ✅ | ✅ | ❌ | 20 | | Loading structured data fields from files via `@` | ✅ | ✅ | ❌ | 21 | | Raw input via stdin just works | ✅ | ✅ | 🟠 (via `-d@-`) | 22 | | Per-domain configuration (e.g. default headers) | ✅ | ❌ | ❌ | 23 | | API nicknames, e.g. `github/users/repos` | ✅ | ❌ | ❌ | 24 | | OpenAPI 3 support | ✅ | ❌ | ❌ | 25 | | API documentation & examples via `--help` | ✅ | ❌ | ❌ | 26 | | Syntax highlighting | ✅ | ✅ | ❌ | 27 | | Pretty printing | ✅ | ✅ | ❌ | 28 | | Image response preview in terminal | ✅ | ❌ | ❌ | 29 | | Structured response filtering | ✅ | ❌ | ❌ | 30 | | Hypermedia link parsing | ✅ | ❌ | ❌ | 31 | | Automatic pagination for `next` link relations | ✅ | ❌ | ❌ | 32 | | URL & command shell completion hints | ✅ | ❌ | ❌ | 33 | 34 | ## Performance comparison 35 | 36 | Test were run on a Macbook Pro and averages of several requests are reported as latency can and does vary. The general takeaway is that performance is better than HTTPie and very close to cURL but with many more features. Numbers below are in seconds. 37 | 38 | | Test | Restish | Restish (cached) | HTTPie | cURL | 39 | | ------------------------------ | ------: | ---------------: | -----: | ----: | 40 | | Github list repos | 1.210 | 0.620 | 1.358 | 1.122 | 41 | | Github list repo collaborators | 0.251 | 0.049 | 0.702 | 0.212 | 42 | | Digital Ocean get account | 0.512 | no cache headers | 0.786 | 0.526 | 43 | | Get `httpbin.org/cache/60` | 0.401 | 0.025 | 0.707 | 0.371 | 44 | 45 | As the above shows, if caching is enabled at the API level then Restish can actually outperform `curl` in some scenarios. Imagine the following naive shell script where a single user might own many items and the `get-user` operation is cacheable: 46 | 47 | ```bash 48 | for ITEM_ID in $(restish my-api list-items); do 49 | USER_ID=$(restish my-api get-item $ITEM_ID -f body.user_id) 50 | # The following call is going to be cached sometimes, saving us time! 51 | EMAIL=$(restish my-api get-user $USER_ID -f body.email) 52 | echo "$ITEM_ID is owned by $EMAIL" 53 | done 54 | ``` 55 | 56 | This can be demonstrated in a very silly example with `zsh` showing how these small differences can easily compound to many second differences in how fast your scripts may run: 57 | 58 | ```bash 59 | # curl total time: 3.968s 60 | time (repeat 10 {curl https://httpbin.org/cache/60}) 61 | 62 | # HTTPie total time: 6.699s 63 | time (repeat 10 {https https://httpbin.org/cache/60}) 64 | 65 | # Restish total time: 0.702s (first request is not cached) 66 | time (repeat 10 {restish https://httpbin.org/cache/60}) 67 | ``` 68 | 69 | ## Detailed comparisons 70 | 71 | ### Passing headers & query parameters 72 | 73 | This is how you pass parameters to the API. 74 | 75 | cURL Example: 76 | 77 | ```bash 78 | curl -H Header:value 'https://api.rest.sh/?a=1&b=true' 79 | curl -H Header:value https://api.rest.sh/ -G -d a=1 -d b=true 80 | ``` 81 | 82 | HTTPie Example: 83 | 84 | ```bash 85 | https Header:value 'api.rest.sh/?a=1&b=true' 86 | https Header:value api.rest.sh/ a==1 b==true 87 | ``` 88 | 89 | Restish Example: 90 | 91 | ```bash 92 | restish -H Header:value 'api.rest.sh/?a=1&b=true' 93 | restish -H Header:value api.rest.sh/ -q a=1 -q b=true 94 | ``` 95 | 96 | ### Input shorthand 97 | 98 | This is one mechanism to generate and pass a request body to the API. 99 | 100 | cURL Example: n/a 101 | 102 | HTTPie Example: 103 | 104 | ```bash 105 | https POST api.rest.sh \ 106 | platform[name]=HTTPie \ 107 | platform[about][mission]='Make APIs simple and intuitive' \ 108 | platform[about][homepage]=httpie.io \ 109 | platform[about][stars]:=54000 \ 110 | platform[apps][]=Terminal \ 111 | platform[apps][]=Desktop \ 112 | platform[apps][]=Web \ 113 | platform[apps][]=Mobile 114 | ``` 115 | 116 | Restish equivalent: 117 | 118 | ```bash 119 | restish POST api.rest.sh \ 120 | platform.name: HTTPie, \ 121 | platform.about.mission: Make APIs simple and intuitive, \ 122 | platform.about.homepage: httpie.io, \ 123 | platform.about.stars: 54000, \ 124 | platform.apps: [Terminal, Desktop, Web, Mobile] 125 | ``` 126 | 127 | ### Getting header values 128 | 129 | How easy is it to read the output of a header in a shell environment? 130 | 131 | cURL Exmaple: 132 | 133 | ```bash 134 | curl https://api.rest.sh/ --head 2>/dev/null | grep -i Content-Length | cut -d':' -d' ' -f2 135 | ``` 136 | 137 | HTTPie Example: 138 | 139 | ```bash 140 | https --headers api.rest.sh | grep Content-Length | cut -d':' -d' ' -f2 141 | ``` 142 | 143 | Restish Example: 144 | 145 | ```bash 146 | restish api.rest.sh -f 'headers.Content-Length' -r 147 | ``` 148 | -------------------------------------------------------------------------------- /openapi/example.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "log" 5 | "maps" 6 | "strings" 7 | 8 | "github.com/lucasjones/reggen" 9 | "github.com/pb33f/libopenapi/datamodel/high/base" 10 | ) 11 | 12 | // GenExample creates a dummy example from a given schema. 13 | func GenExample(schema *base.Schema, mode schemaMode) interface{} { 14 | example, err := genExampleInternal(schema, mode, map[[32]byte]bool{}) 15 | if err != nil { 16 | log.Fatal(err) 17 | } 18 | return example 19 | } 20 | 21 | func genExampleInternal(s *base.Schema, mode schemaMode, known map[[32]byte]bool) (any, error) { 22 | inferType(s) 23 | 24 | // TODO: handle not 25 | if len(s.OneOf) > 0 { 26 | return genExampleInternal(s.OneOf[0].Schema(), mode, known) 27 | } 28 | 29 | if len(s.AnyOf) > 0 { 30 | return genExampleInternal(s.AnyOf[0].Schema(), mode, known) 31 | } 32 | 33 | if len(s.AllOf) > 0 { 34 | result := map[string]any{} 35 | for _, proxy := range s.AllOf { 36 | tmp, err := genExampleInternal(proxy.Schema(), mode, known) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if m, ok := tmp.(map[string]any); ok { 41 | maps.Copy(result, m) 42 | } 43 | } 44 | return result, nil 45 | } 46 | 47 | if s.Example != nil { 48 | return decodeYAML(s.Example) 49 | } 50 | 51 | if len(s.Examples) > 0 { 52 | return decodeYAML(s.Examples[0]) 53 | } 54 | 55 | if s.Default != nil { 56 | return decodeYAML(s.Default) 57 | } 58 | 59 | if s.Minimum != nil { 60 | if s.ExclusiveMinimum != nil && s.ExclusiveMinimum.IsA() && s.ExclusiveMinimum.A { 61 | return *s.Minimum + 1, nil 62 | } 63 | return *s.Minimum, nil 64 | } else if s.ExclusiveMinimum != nil && (s.ExclusiveMinimum.IsB()) { 65 | return s.ExclusiveMinimum.B + 1, nil 66 | } 67 | 68 | if s.Maximum != nil { 69 | if s.ExclusiveMaximum != nil && s.ExclusiveMaximum.IsA() && s.ExclusiveMaximum.A { 70 | return *s.Maximum - 1, nil 71 | } 72 | return *s.Maximum, nil 73 | } else if s.ExclusiveMaximum != nil && s.ExclusiveMaximum.IsB() { 74 | return s.ExclusiveMaximum.B - 1, nil 75 | } 76 | 77 | if s.MultipleOf != nil && *s.MultipleOf != 0 { 78 | return *s.MultipleOf, nil 79 | } 80 | 81 | if len(s.Enum) > 0 { 82 | return decodeYAML(s.Enum[0]) 83 | } 84 | 85 | if s.Pattern != "" { 86 | if g, err := reggen.NewGenerator(s.Pattern); err == nil { 87 | // We need stable/reproducible outputs, so use a constant seed. 88 | g.SetSeed(1589525091) 89 | return g.Generate(3), nil 90 | } 91 | } 92 | 93 | switch s.Format { 94 | case "date": 95 | return "2020-05-14", nil 96 | case "time": 97 | return "23:44:51-07:00", nil 98 | case "date-time": 99 | return "2020-05-14T23:44:51-07:00", nil 100 | case "duration": 101 | return "P30S", nil 102 | case "email", "idn-email": 103 | return "user@example.com", nil 104 | case "hostname", "idn-hostname": 105 | return "example.com", nil 106 | case "ipv4": 107 | return "192.0.2.1", nil 108 | case "ipv6": 109 | return "2001:db8::1", nil 110 | case "uuid": 111 | return "3e4666bf-d5e5-4aa7-b8ce-cefe41c7568a", nil 112 | case "uri", "iri": 113 | return "https://example.com/", nil 114 | case "uri-reference", "iri-reference": 115 | return "/example", nil 116 | case "uri-template": 117 | return "https://example.com/{id}", nil 118 | case "json-pointer": 119 | return "/example/0/id", nil 120 | case "relative-json-pointer": 121 | return "0/id", nil 122 | case "regex": 123 | return "ab+c", nil 124 | case "password": 125 | return "********", nil 126 | } 127 | 128 | typ := "" 129 | for _, t := range s.Type { 130 | // Find the first non-null type and use that for now. 131 | if t != "null" { 132 | typ = t 133 | break 134 | } 135 | } 136 | 137 | switch typ { 138 | case "boolean": 139 | return true, nil 140 | case "integer": 141 | return 1, nil 142 | case "number": 143 | return 1.0, nil 144 | case "string": 145 | if s.MinLength != nil && *s.MinLength > 6 { 146 | sb := strings.Builder{} 147 | for i := int64(0); i < *s.MinLength; i++ { 148 | sb.WriteRune('s') 149 | } 150 | return sb.String(), nil 151 | } 152 | 153 | if s.MaxLength != nil && *s.MaxLength < 6 { 154 | sb := strings.Builder{} 155 | for i := int64(0); i < *s.MaxLength; i++ { 156 | sb.WriteRune('s') 157 | } 158 | return sb.String(), nil 159 | } 160 | 161 | return "string", nil 162 | case "array": 163 | if s.Items != nil && s.Items.IsA() { 164 | items := s.Items.A.Schema() 165 | simple := isSimpleSchema(items) 166 | hash := items.GoLow().Hash() 167 | if simple || !known[hash] { 168 | known[hash] = true 169 | item, err := genExampleInternal(items, mode, known) 170 | if err != nil { 171 | return nil, err 172 | } 173 | known[hash] = false 174 | 175 | count := 1 176 | if s.MinItems != nil && *s.MinItems > 0 { 177 | count = int(*s.MinItems) 178 | } 179 | 180 | value := make([]any, 0, count) 181 | for i := 0; i < count; i++ { 182 | value = append(value, item) 183 | } 184 | return value, nil 185 | } 186 | 187 | return []any{nil}, nil 188 | } 189 | return "[]", nil 190 | case "object": 191 | value := map[string]any{} 192 | 193 | // Special case: object with nothing defined 194 | if s.Properties != nil && s.Properties.Len() == 0 && s.AdditionalProperties == nil { 195 | return value, nil 196 | } 197 | 198 | for name, proxy := range s.Properties.FromOldest() { 199 | prop := proxy.Schema() 200 | if prop == nil { 201 | continue 202 | } 203 | if derefOrDefault(prop.ReadOnly) && mode == modeWrite { 204 | continue 205 | } else if derefOrDefault(prop.WriteOnly) && mode == modeRead { 206 | continue 207 | } 208 | 209 | simple := isSimpleSchema(prop) 210 | hash := prop.GoLow().Hash() 211 | if simple || !known[hash] { 212 | known[hash] = true 213 | var err error 214 | value[name], err = genExampleInternal(prop, mode, known) 215 | if err != nil { 216 | return nil, err 217 | } 218 | known[hash] = false 219 | } else { 220 | value[name] = nil 221 | } 222 | } 223 | 224 | if s.AdditionalProperties != nil { 225 | ap := s.AdditionalProperties 226 | if ap != nil { 227 | if ap.IsA() && ap.A != nil { 228 | addl := ap.A.Schema() 229 | simple := isSimpleSchema(addl) 230 | hash := addl.GoLow().Hash() 231 | if simple || !known[hash] { 232 | known[hash] = true 233 | var err error 234 | value[""], err = genExampleInternal(addl, mode, known) 235 | if err != nil { 236 | return nil, err 237 | } 238 | known[hash] = false 239 | } else { 240 | value[""] = nil 241 | } 242 | } 243 | if ap.IsB() && ap.B { 244 | value[""] = nil 245 | } 246 | } 247 | } 248 | 249 | return value, nil 250 | } 251 | 252 | return nil, nil 253 | } 254 | -------------------------------------------------------------------------------- /cli/links.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "reflect" 7 | 8 | "github.com/mitchellh/mapstructure" 9 | link "github.com/tent/http-link-go" 10 | ) 11 | 12 | // Link describes a hypermedia link to another resource. 13 | type Link struct { 14 | Rel string `json:"rel"` 15 | URI string `json:"uri"` 16 | } 17 | 18 | // Links represents a map of `rel` => list of linke relations. 19 | type Links map[string][]*Link 20 | 21 | // LinkParser parses link relationships in a response. 22 | type LinkParser interface { 23 | ParseLinks(resp *Response) error 24 | } 25 | 26 | var linkParsers = []LinkParser{} 27 | 28 | // AddLinkParser adds a new link parser to create standardized link relation 29 | // objects on a parsed response. 30 | func AddLinkParser(parser LinkParser) { 31 | linkParsers = append(linkParsers, parser) 32 | } 33 | 34 | // ParseLinks uses all registered LinkParsers to parse links for a response. 35 | func ParseLinks(base *url.URL, resp *Response) error { 36 | for _, parser := range linkParsers { 37 | if err := parser.ParseLinks(resp); err != nil { 38 | return err 39 | } 40 | } 41 | 42 | for _, links := range resp.Links { 43 | for _, l := range links { 44 | p, err := url.Parse(l.URI) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | resolved := base.ResolveReference(p) 50 | l.URI = resolved.String() 51 | } 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // LinkHeaderParser parses RFC 5988 HTTP link relation headers. 58 | type LinkHeaderParser struct{} 59 | 60 | // ParseLinks processes the links in a parsed response. 61 | func (l LinkHeaderParser) ParseLinks(resp *Response) error { 62 | if resp.Headers["Link"] != "" { 63 | links, err := link.Parse(resp.Headers["Link"]) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | for _, parsed := range links { 69 | resp.Links[parsed.Rel] = append(resp.Links[parsed.Rel], &Link{ 70 | Rel: parsed.Rel, 71 | URI: parsed.URI, 72 | }) 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | 79 | // halLink represents a single link in a HAL response. 80 | type halLink struct { 81 | Href string `mapstructure:"href"` 82 | } 83 | 84 | // halBody represents the top-level HAL response body. 85 | type halBody struct { 86 | Links map[string]halLink `mapstructure:"_links"` 87 | } 88 | 89 | // HALParser parses HAL hypermedia links. Ignores curies. 90 | type HALParser struct{} 91 | 92 | // ParseLinks processes the links in a parsed response. 93 | func (h HALParser) ParseLinks(resp *Response) error { 94 | entries := []interface{}{} 95 | if l, ok := resp.Body.([]interface{}); ok { 96 | entries = l 97 | } else { 98 | entries = append(entries, resp.Body) 99 | } 100 | 101 | for _, entry := range entries { 102 | hal := halBody{} 103 | if err := mapstructure.Decode(entry, &hal); err == nil { 104 | for rel, link := range hal.Links { 105 | if rel == "curies" { 106 | // TODO: handle curies at some point? 107 | continue 108 | } 109 | 110 | resp.Links[rel] = append(resp.Links[rel], &Link{ 111 | Rel: rel, 112 | URI: link.Href, 113 | }) 114 | } 115 | } 116 | } 117 | 118 | return nil 119 | } 120 | 121 | // TerrificallySimpleJSONParser parses `self` links from JSON-like formats. 122 | type TerrificallySimpleJSONParser struct{} 123 | 124 | // ParseLinks processes the links in a parsed response. 125 | func (t TerrificallySimpleJSONParser) ParseLinks(resp *Response) error { 126 | return t.walk(resp, "self", resp.Body) 127 | } 128 | 129 | // walk the response body recursively to find any `self` links. 130 | func (t TerrificallySimpleJSONParser) walk(resp *Response, key string, value interface{}) error { 131 | v := reflect.ValueOf(value) 132 | 133 | switch v.Kind() { 134 | case reflect.Slice: 135 | for i := 0; i < v.Len(); i++ { 136 | t.walk(resp, key+"-item", v.Index(i).Interface()) 137 | } 138 | case reflect.Map: 139 | for _, k := range v.MapKeys() { 140 | kStr := "" 141 | if s, ok := k.Interface().(string); ok { 142 | kStr = s 143 | if s == "self" { 144 | // Only try to process if the value is a string. 145 | if uri, ok := v.MapIndex(k).Interface().(string); ok { 146 | // Only consider this a link if the URI is valid. If not, then 147 | // we ignore it. 148 | _, err := url.Parse(uri) 149 | if err == nil { 150 | resp.Links[key] = append(resp.Links[key], &Link{ 151 | Rel: key, 152 | URI: uri, 153 | }) 154 | continue 155 | } 156 | } 157 | } 158 | } else { 159 | kStr = fmt.Sprintf("%v", k) 160 | } 161 | 162 | t.walk(resp, kStr, v.MapIndex(k).Interface()) 163 | } 164 | case reflect.Ptr: 165 | return t.walk(resp, key, v.Elem().Interface()) 166 | } 167 | 168 | return nil 169 | } 170 | 171 | type sirenLink struct { 172 | Rel []string `mapstructure:"rel"` 173 | Href string `mapstructure:"href"` 174 | } 175 | 176 | type sirenBody struct { 177 | Links []sirenLink `mapstructure:"links"` 178 | } 179 | 180 | // SirenParser parses Siren hypermedia links. 181 | type SirenParser struct{} 182 | 183 | // ParseLinks processes the links in a parsed response. 184 | func (s SirenParser) ParseLinks(resp *Response) error { 185 | siren := sirenBody{} 186 | if err := mapstructure.Decode(resp.Body, &siren); err == nil { 187 | for _, link := range siren.Links { 188 | if link.Href == "" { 189 | continue 190 | } 191 | 192 | for _, rel := range link.Rel { 193 | resp.Links[rel] = append(resp.Links[rel], &Link{ 194 | Rel: rel, 195 | URI: link.Href, 196 | }) 197 | } 198 | } 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func getJSONAPIlinks(links map[string]interface{}, resp *Response, isItem bool) { 205 | for k, v := range links { 206 | rel := k 207 | if isItem && k == "self" { 208 | rel = "item" 209 | } 210 | 211 | if s, ok := v.(string); ok { 212 | resp.Links[rel] = append(resp.Links[rel], &Link{ 213 | Rel: rel, 214 | URI: s, 215 | }) 216 | } 217 | 218 | if m, ok := v.(map[string]interface{}); ok { 219 | if s, ok := m["href"].(string); ok { 220 | resp.Links[rel] = append(resp.Links[rel], &Link{ 221 | Rel: rel, 222 | URI: s, 223 | }) 224 | } 225 | } 226 | } 227 | } 228 | 229 | // JSONAPIParser parses JSON:API hypermedia links. 230 | type JSONAPIParser struct{} 231 | 232 | // ParseLinks processes the links in a parsed response. 233 | func (j JSONAPIParser) ParseLinks(resp *Response) error { 234 | if b, ok := resp.Body.(map[string]interface{}); ok { 235 | // Find top-level links 236 | if l, ok := b["links"].(map[string]interface{}); ok { 237 | getJSONAPIlinks(l, resp, false) 238 | } 239 | 240 | // Find collection item links 241 | if d, ok := b["data"].([]interface{}); ok { 242 | for _, item := range d { 243 | if m, ok := item.(map[string]interface{}); ok { 244 | if l, ok := m["links"].(map[string]interface{}); ok { 245 | getJSONAPIlinks(l, resp, true) 246 | } 247 | } 248 | } 249 | } 250 | } 251 | 252 | return nil 253 | } 254 | -------------------------------------------------------------------------------- /openapi/openapi_test.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "maps" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "path" 12 | "sort" 13 | "strings" 14 | "testing" 15 | "testing/iotest" 16 | 17 | v3 "github.com/pb33f/libopenapi/datamodel/high/v3" 18 | "github.com/pb33f/libopenapi/orderedmap" 19 | "github.com/stretchr/testify/assert" 20 | "github.com/stretchr/testify/require" 21 | "gopkg.in/yaml.v3" 22 | 23 | "github.com/rest-sh/restish/cli" 24 | ) 25 | 26 | // parseURL parses the input as a URL ignoring any errors 27 | func parseURL(s string) *url.URL { 28 | output, _ := url.Parse(s) 29 | return output 30 | } 31 | 32 | func TestGetBasePath(t *testing.T) { 33 | cases := []struct { 34 | name string 35 | location *url.URL 36 | servers []*v3.Server 37 | output string 38 | hasError bool 39 | }{ 40 | { 41 | name: "Should return location if server is only a path", 42 | location: parseURL("http://localhost:12345/api"), 43 | servers: []*v3.Server{{URL: "/api"}}, 44 | output: "/api", 45 | }, 46 | { 47 | name: "Should return the empty string if no matches can be found", 48 | location: parseURL("http://localhost:1245"), 49 | servers: []*v3.Server{{URL: "http://my-api.foo.bar/foo"}}, 50 | output: "", 51 | }, 52 | { 53 | name: "Should return the prefix of the matched URL 1", 54 | location: parseURL("http://my-api.foo.bar"), 55 | servers: []*v3.Server{{URL: "http://my-api.foo.bar/mount/api"}}, 56 | output: "/mount/api", 57 | }, 58 | { 59 | name: "Should return the prefix of the matched URL 2", 60 | location: parseURL("http://my-api.foo.bar/mount/api"), 61 | servers: []*v3.Server{{URL: "http://my-api.foo.bar/mount/api"}}, 62 | output: "/mount/api", 63 | }, 64 | { 65 | name: "Should use default value for expanded url parameter 1", 66 | location: parseURL("http://my-api.foo.bar"), 67 | servers: []*v3.Server{ 68 | { 69 | URL: "http://my-api.foo.bar/{mount}/api", 70 | Variables: orderedmap.From(maps.All(map[string]*v3.ServerVariable{ 71 | "mount": {Default: "point"}, 72 | })), 73 | }, 74 | }, 75 | output: "/point/api", 76 | }, 77 | { 78 | name: "Should use default value for expanded url parameter 2", 79 | location: parseURL("http://my-api.foo.bar"), 80 | servers: []*v3.Server{ 81 | {URL: "http://my-api.some.other.domain:12456"}, 82 | { 83 | URL: "http://my-api.foo.bar/{mount}/api", 84 | Variables: orderedmap.From(maps.All(map[string]*v3.ServerVariable{ 85 | "mount": {Default: "point"}, 86 | })), 87 | }, 88 | }, 89 | output: "/point/api", 90 | }, 91 | { 92 | name: "Should match with enum values over default for expanded url parameter 1", 93 | location: parseURL("http://my-api.foo.bar"), 94 | servers: []*v3.Server{ 95 | { 96 | URL: "http://my-api.foo.bar/{mount}/api", 97 | Variables: orderedmap.From(maps.All(map[string]*v3.ServerVariable{ 98 | "mount": {Default: "point", Enum: []string{"vec", "point"}}, 99 | })), 100 | }, 101 | }, 102 | output: "/vec/api", 103 | }, 104 | { 105 | name: "Should match with enum values over default for expanded url parameter 2", 106 | location: parseURL("http://my-api.foo.bar"), 107 | servers: []*v3.Server{ 108 | {URL: "http://my-api.some.other.domain:12456"}, 109 | { 110 | URL: "http://my-api.foo.bar/{mount}/api", 111 | Variables: orderedmap.From(maps.All(map[string]*v3.ServerVariable{ 112 | "mount": {Default: "point", Enum: []string{"vec", "point"}}, 113 | })), 114 | }, 115 | }, 116 | output: "/vec/api", 117 | }, 118 | { 119 | name: "Should match against all expanded parameters", 120 | location: parseURL("http://ppmy-api.foo.bar/vec"), 121 | servers: []*v3.Server{ 122 | { 123 | URL: "http://{env}my-api.foo.bar/{mount}/api", 124 | Variables: orderedmap.From(maps.All(map[string]*v3.ServerVariable{ 125 | "env": {Default: "pp"}, 126 | "mount": {Default: "point", Enum: []string{"vec", "point"}}, 127 | })), 128 | }, 129 | }, 130 | output: "/vec/api", 131 | }, 132 | { 133 | name: "Should return an error if the openapi server can't be parsed", 134 | location: parseURL("http://localhost"), 135 | servers: []*v3.Server{{URL: "http://localhost@1224:foo"}}, 136 | hasError: true, 137 | }, 138 | } 139 | 140 | for _, tc := range cases { 141 | t.Run(tc.name, func(t *testing.T) { 142 | output, err := getBasePath(tc.location, tc.servers) 143 | if !tc.hasError { 144 | if assert.NoError(t, err) { 145 | assert.Equal(t, tc.output, output) 146 | } 147 | } else { 148 | assert.Error(t, err) 149 | } 150 | }) 151 | } 152 | } 153 | 154 | func TestDetectViaHeader(t *testing.T) { 155 | resp := http.Response{ 156 | Header: http.Header{}, 157 | } 158 | resp.Header.Set("Content-Type", "application/vnd.oai.openapi;version=3.0") 159 | 160 | loader := New() 161 | assert.True(t, loader.Detect(&resp)) 162 | } 163 | 164 | func TestDetectViaBody(t *testing.T) { 165 | resp := http.Response{ 166 | Header: http.Header{}, 167 | Body: io.NopCloser(strings.NewReader("openapi: 3.1")), 168 | } 169 | 170 | loader := New() 171 | assert.True(t, loader.Detect(&resp)) 172 | } 173 | 174 | func TestLocationHints(t *testing.T) { 175 | assert.Contains(t, New().LocationHints(), "/openapi.json") 176 | } 177 | 178 | func TestBrokenRequest(t *testing.T) { 179 | base, _ := url.Parse("http://api.example.com") 180 | spec, _ := url.Parse("/openapi.yaml") 181 | 182 | resp := &http.Response{ 183 | Body: io.NopCloser(iotest.ErrReader(fmt.Errorf("request closed"))), 184 | } 185 | 186 | _, err := New().Load(*base, *spec, resp) 187 | assert.Error(t, err) 188 | } 189 | 190 | func TestEmptyDocument(t *testing.T) { 191 | base, _ := url.Parse("http://api.example.com") 192 | spec, _ := url.Parse("/openapi.yaml") 193 | 194 | resp := &http.Response{ 195 | Body: io.NopCloser(strings.NewReader("")), 196 | } 197 | 198 | _, err := New().Load(*base, *spec, resp) 199 | assert.Error(t, err) 200 | } 201 | 202 | func TestUnsupported(t *testing.T) { 203 | base, _ := url.Parse("http://api.example.com") 204 | spec, _ := url.Parse("/openapi.yaml") 205 | 206 | resp := &http.Response{ 207 | Body: io.NopCloser(strings.NewReader(`swagger: 2.0`)), 208 | } 209 | 210 | _, err := New().Load(*base, *spec, resp) 211 | assert.Error(t, err) 212 | } 213 | 214 | func TestLoader(t *testing.T) { 215 | entries, err := os.ReadDir("testdata") 216 | require.NoError(t, err) 217 | 218 | for _, entry := range entries { 219 | t.Run(entry.Name(), func(t *testing.T) { 220 | input, err := os.ReadFile(path.Join("testdata", entry.Name(), "openapi.yaml")) 221 | require.NoError(t, err) 222 | 223 | outBytes, err := os.ReadFile(path.Join("testdata", entry.Name(), "output.yaml")) 224 | require.NoError(t, err) 225 | 226 | var output cli.API 227 | require.NoError(t, yaml.Unmarshal(outBytes, &output)) 228 | 229 | base, _ := url.Parse("http://api.example.com") 230 | spec, _ := url.Parse("http://api.example.com/openapi.yaml") 231 | 232 | resp := &http.Response{ 233 | Body: io.NopCloser(bytes.NewReader(input)), 234 | } 235 | 236 | api, err := New().Load(*base, *spec, resp) 237 | assert.NoError(t, err) 238 | 239 | sort.Slice(api.Operations, func(i, j int) bool { 240 | return strings.Compare(api.Operations[i].Name, api.Operations[j].Name) < 0 241 | }) 242 | 243 | assert.Equal(t, output, api) 244 | }) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /openapi/schema.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/pb33f/libopenapi/datamodel/high/base" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type schemaMode int 15 | 16 | const ( 17 | modeRead schemaMode = iota 18 | modeWrite 19 | ) 20 | 21 | // inferType fixes missing type if it is missing & can be inferred 22 | func inferType(s *base.Schema) { 23 | if len(s.Type) == 0 { 24 | if s.Items != nil { 25 | s.Type = []string{"array"} 26 | } 27 | if (s.Properties != nil && s.Properties.Len() > 0) || s.AdditionalProperties != nil { 28 | s.Type = []string{"object"} 29 | } 30 | } 31 | } 32 | 33 | // isSimpleSchema returns whether this schema is a scalar or array as these 34 | // can't be circular references. Objects result in `false` and that triggers 35 | // circular ref checks. 36 | func isSimpleSchema(s *base.Schema) bool { 37 | if len(s.Type) == 0 { 38 | return true 39 | } 40 | 41 | return s.Type[0] != "object" 42 | } 43 | 44 | func renderSchema(s *base.Schema, indent string, mode schemaMode) string { 45 | return renderSchemaInternal(s, indent, mode, map[[32]byte]bool{}) 46 | } 47 | 48 | func derefOrDefault[T any](v *T) T { 49 | if v != nil { 50 | return *v 51 | } 52 | var d T 53 | return d 54 | } 55 | 56 | func renderSchemaInternal(s *base.Schema, indent string, mode schemaMode, known map[[32]byte]bool) string { 57 | doc := s.Title 58 | if doc == "" { 59 | doc = s.Description 60 | } 61 | 62 | inferType(s) 63 | 64 | // TODO: handle not 65 | for _, of := range []struct { 66 | label string 67 | schemas []*base.SchemaProxy 68 | }{ 69 | {label: "allOf", schemas: s.AllOf}, 70 | {label: "oneOf", schemas: s.OneOf}, 71 | {label: "anyOf", schemas: s.AnyOf}, 72 | } { 73 | if len(of.schemas) > 0 { 74 | out := of.label + "{\n" 75 | for _, possible := range of.schemas { 76 | sch := possible.Schema() 77 | simple := isSimpleSchema(sch) 78 | hash := sch.GoLow().Hash() 79 | if simple || !known[hash] { 80 | known[hash] = true 81 | out += indent + " " + renderSchemaInternal(possible.Schema(), indent+" ", mode, known) + "\n" 82 | known[hash] = false 83 | continue 84 | } 85 | out += indent + " \n" 86 | } 87 | return out + indent + "}" 88 | } 89 | } 90 | 91 | // TODO: list type alternatives somehow? 92 | typ := "" 93 | for _, t := range s.Type { 94 | // Find the first non-null type and use that for now. 95 | if t != "null" { 96 | typ = t 97 | break 98 | } 99 | } 100 | 101 | switch typ { 102 | case "boolean", "integer", "number", "string": 103 | tags := []string{} 104 | 105 | // TODO: handle more validators 106 | if s.Nullable != nil && *s.Nullable { 107 | tags = append(tags, "nullable:true") 108 | } 109 | 110 | if s.Minimum != nil { 111 | key := "min" 112 | if s.ExclusiveMinimum != nil && s.ExclusiveMinimum.IsA() && s.ExclusiveMinimum.A { 113 | key = "exclusiveMin" 114 | } 115 | tags = append(tags, fmt.Sprintf("%s:%g", key, *s.Minimum)) 116 | } else if s.ExclusiveMinimum != nil && s.ExclusiveMinimum.IsB() { 117 | tags = append(tags, fmt.Sprintf("exclusiveMin:%g", s.ExclusiveMinimum.B)) 118 | } 119 | 120 | if s.Maximum != nil { 121 | key := "max" 122 | if s.ExclusiveMaximum != nil && s.ExclusiveMaximum.IsA() && s.ExclusiveMaximum.A { 123 | key = "exclusiveMax" 124 | } 125 | tags = append(tags, fmt.Sprintf("%s:%g", key, *s.Maximum)) 126 | } else if s.ExclusiveMaximum != nil && s.ExclusiveMaximum.IsB() { 127 | tags = append(tags, fmt.Sprintf("exclusiveMax:%g", s.ExclusiveMaximum.B)) 128 | } 129 | 130 | if s.MultipleOf != nil && *s.MultipleOf != 0 { 131 | tags = append(tags, fmt.Sprintf("multiple:%g", *s.MultipleOf)) 132 | } 133 | 134 | if s.Default != nil { 135 | def, err := yaml.Marshal(s.Default) 136 | if err != nil { 137 | log.Fatal(err) 138 | } 139 | tags = append(tags, fmt.Sprintf("default:%v", string(bytes.TrimSpace(def)))) 140 | } 141 | 142 | if s.Format != "" { 143 | tags = append(tags, fmt.Sprintf("format:%v", s.Format)) 144 | } 145 | 146 | if s.Pattern != "" { 147 | tags = append(tags, fmt.Sprintf("pattern:%s", s.Pattern)) 148 | } 149 | 150 | if s.MinLength != nil && *s.MinLength != 0 { 151 | tags = append(tags, fmt.Sprintf("minLen:%d", *s.MinLength)) 152 | } 153 | 154 | if s.MaxLength != nil && *s.MaxLength != 0 { 155 | tags = append(tags, fmt.Sprintf("maxLen:%d", *s.MaxLength)) 156 | } 157 | 158 | if len(s.Enum) > 0 { 159 | enums := []string{} 160 | for _, e := range s.Enum { 161 | ev, err := yaml.Marshal(e) 162 | if err != nil { 163 | log.Fatal(err) 164 | } 165 | enums = append(enums, fmt.Sprintf("%v", string(bytes.TrimSpace(ev)))) 166 | } 167 | 168 | tags = append(tags, fmt.Sprintf("enum:%s", strings.Join(enums, ","))) 169 | } 170 | 171 | tagStr := "" 172 | if len(tags) > 0 { 173 | tagStr = " " + strings.Join(tags, " ") 174 | } 175 | 176 | if doc != "" { 177 | doc = " " + doc 178 | } 179 | return fmt.Sprintf("(%s%s)%s", strings.Join(s.Type, "|"), tagStr, doc) 180 | case "array": 181 | if s.Items != nil && s.Items.IsA() { 182 | items := s.Items.A.Schema() 183 | simple := isSimpleSchema(items) 184 | hash := items.GoLow().Hash() 185 | if simple || !known[hash] { 186 | known[hash] = true 187 | arr := "[\n " + indent + renderSchemaInternal(items, indent+" ", mode, known) + "\n" + indent + "]" 188 | known[hash] = false 189 | return arr 190 | } 191 | 192 | return "[]" 193 | } 194 | return "[]" 195 | case "object": 196 | // Special case: object with nothing defined 197 | if (s.Properties == nil || s.Properties.Len() == 0) && s.AdditionalProperties == nil { 198 | return "(object)" 199 | } 200 | 201 | var obj strings.Builder 202 | obj.WriteString("{\n") 203 | 204 | keys := slices.Sorted(s.Properties.KeysFromOldest()) 205 | 206 | for _, name := range keys { 207 | propVal := s.Properties.Value(name) 208 | prop := propVal.Schema() 209 | if prop == nil { 210 | if err := propVal.GetBuildError(); err != nil { 211 | log.Fatal(err) 212 | } 213 | continue 214 | } 215 | 216 | if derefOrDefault(prop.ReadOnly) && mode == modeWrite { 217 | continue 218 | } else if derefOrDefault(prop.WriteOnly) && mode == modeRead { 219 | continue 220 | } 221 | 222 | if slices.Contains(s.Required, name) { 223 | name += "*" 224 | } 225 | 226 | simple := isSimpleSchema(prop) 227 | hash := prop.GoLow().Hash() 228 | if simple || !known[hash] { 229 | known[hash] = true 230 | obj.WriteString(indent + " " + name + ": " + renderSchemaInternal(prop, indent+" ", mode, known) + "\n") 231 | known[hash] = false 232 | } else { 233 | obj.WriteString(indent + " " + name + ": \n") 234 | } 235 | } 236 | 237 | if s.AdditionalProperties != nil { 238 | ap := s.AdditionalProperties 239 | if ap != nil { 240 | if ap.IsA() && ap.A != nil { 241 | addl := ap.A.Schema() 242 | simple := isSimpleSchema(addl) 243 | hash := addl.GoLow().Hash() 244 | if simple || !known[hash] { 245 | known[hash] = true 246 | obj.WriteString(indent + " " + ": " + renderSchemaInternal(addl, indent+" ", mode, known) + "\n") 247 | } else { 248 | obj.WriteString(indent + " : \n") 249 | } 250 | } 251 | if ap.IsB() && ap.B { 252 | obj.WriteString(indent + " : \n") 253 | } 254 | } 255 | } 256 | 257 | obj.WriteString(indent + "}") 258 | return obj.String() 259 | } 260 | 261 | return "" 262 | } 263 | -------------------------------------------------------------------------------- /docs/shorthandv1.md: -------------------------------------------------------------------------------- 1 | # CLI Shorthand v1 2 | 3 | ?> This page describes the shorthand version 1 used by Restish 0.14.0 and older. 4 | 5 | Restish comes with an optional contextual shorthand syntax for passing structured data into calls that require a body (i.e. `POST`, `PUT`, `PATCH`). While you can always pass full JSON or other documents through `stdin`, you can also specify or modify them by hand as arguments to the command using this shorthand syntax. 6 | 7 | ?> Note: for the examples below, you may need to escape or quote the values depending on your shell & settings. Instead of `foo.bar[].baz: 1`, use `'foo.bar[].baz: 1'`. If using `zsh` you can prefix a command with `noglob` to ignore `?` and `[]`. 8 | 9 | For example: 10 | 11 | ```bash 12 | # Make an HTTP POST with a JSON body 13 | $ restish POST api.rest.sh foo.bar[].baz: 1, .hello: world 14 | ``` 15 | 16 | Would result in the following body contents being sent on the wire (assuming a JSON content type): 17 | 18 | ```json 19 | { 20 | "foo": { 21 | "bar": [ 22 | { 23 | "baz": 1, 24 | "hello": "world" 25 | } 26 | ] 27 | } 28 | } 29 | ``` 30 | 31 | The shorthand syntax supports the following features, described in more detail with examples below: 32 | 33 | - Automatic type coercion & forced strings 34 | - Nested object creation 35 | - Object property grouping 36 | - Nested array creation 37 | - Appending to arrays 38 | - Both object and array backreferences 39 | - Loading property values from files 40 | - Supports structured, forced string, and base64 data 41 | 42 | ## Alternatives & inspiration 43 | 44 | The built-in CLI shorthand syntax is not the only one you can use to generate data for CLI commands. Here are some alternatives: 45 | 46 | - [jo](https://github.com/jpmens/jo) 47 | - [jarg](https://github.com/jdp/jarg) 48 | 49 | For example, the shorthand example given above could be rewritten as: 50 | 51 | ```bash 52 | $ jo -p foo=$(jo -p bar=$(jo -a $(jo baz=1 hello=world))) | restish POST api.rest.sh 53 | ``` 54 | 55 | The built-in shorthand syntax implementation described herein uses those and the following for inspiration: 56 | 57 | - [YAML](http://yaml.org/) 58 | - [W3C HTML JSON Forms](https://www.w3.org/TR/html-json-forms/) 59 | - [jq](https://stedolan.github.io/jq/) 60 | - [JMESPath](http://jmespath.org/) 61 | 62 | It seems reasonable to ask, why create a new syntax? 63 | 64 | 1. Built-in. No extra executables required. 65 | 2. No need to use sub-shells to build complex structured data. 66 | 3. Syntax is closer to YAML & JSON and mimics how we do queries using tools like `jq` and `jmespath`. 67 | 68 | ## Features in depth 69 | 70 | You can use the `j` executable from the [CLI Shorthand](https://github.com/danielgtaylor/shorthand) project to try out the shorthand format examples below. Examples are shown in JSON, but the shorthand parses into structured data that can be marshalled as other formats, like YAML or CBOR if you prefer. 71 | 72 | ```bash 73 | $ go install github.com/danielgtaylor/shorthand/cmd/j@latest 74 | ``` 75 | 76 | Also feel free to use this tool to generate structured data for input to other commands. 77 | 78 | ### Keys & values 79 | 80 | At its most basic, a structure is built out of key & value pairs. They are separated by commas: 81 | 82 | ```bash 83 | $ j hello: world, question: how are you? 84 | { 85 | "hello": "world", 86 | "question": "how are you?" 87 | } 88 | ``` 89 | 90 | ### Types & type coercion 91 | 92 | Well-known values like `null`, `true`, and `false` get converted to their respective types automatically. Numbers also get converted. Similar to YAML, anything that doesn't fit one of those is treated as a string. If needed, you can disable this automatic coercion by forcing a value to be treated as a string with the `~` operator. **Note**: the `~` modifier must come _directly after_ the colon. 93 | 94 | ```bash 95 | # With coercion 96 | $ j empty: null, bool: true, num: 1.5, string: hello 97 | { 98 | "bool": true, 99 | "empty": null, 100 | "num": 1.5, 101 | "string": "hello" 102 | } 103 | 104 | # As strings 105 | $ j empty:~ null, bool:~ true, num:~ 1.5, string:~ hello 106 | { 107 | "bool": "true", 108 | "empty": "null", 109 | "num": "1.5", 110 | "string": "hello" 111 | } 112 | 113 | # Passing the empty string 114 | $ j blank:~ 115 | { 116 | "blank": "" 117 | } 118 | 119 | # Passing a tilde using whitespace 120 | $ j foo: ~/Documents 121 | { 122 | "foo": "~/Documents" 123 | } 124 | 125 | # Passing a tilde using forced strings 126 | $ j foo:~~/Documents 127 | { 128 | "foo": "~/Documents" 129 | } 130 | ``` 131 | 132 | ### Objects 133 | 134 | Nested objects use a `.` separator when specifying the key. 135 | 136 | ```bash 137 | $ j foo.bar.baz: 1 138 | { 139 | "foo": { 140 | "bar": { 141 | "baz": 1 142 | } 143 | } 144 | } 145 | ``` 146 | 147 | Properties of nested objects can be grouped by placing them inside `{` and `}`. 148 | 149 | ```bash 150 | $ j foo.bar{id: 1, count.clicks: 5} 151 | { 152 | "foo": { 153 | "bar": { 154 | "count": { 155 | "clicks": 5 156 | }, 157 | "id": 1 158 | } 159 | } 160 | } 161 | ``` 162 | 163 | ### Arrays 164 | 165 | Simple arrays use a `,` between values. Nested arrays use square brackets `[` and `]` to specify the zero-based index to insert an item. Use a blank index to append to the array. 166 | 167 | ```bash 168 | # Array shorthand 169 | $ j a: 1, 2, 3 170 | { 171 | "a": [ 172 | 1, 173 | 2, 174 | 3 175 | ] 176 | } 177 | 178 | # Nested arrays 179 | $ j a[0][2][0]: 1 180 | { 181 | "a": [ 182 | [ 183 | null, 184 | null, 185 | [ 186 | 1 187 | ] 188 | ] 189 | ] 190 | } 191 | 192 | # Appending arrays 193 | $ j a[]: 1, a[]: 2, a[]: 3 194 | { 195 | "a": [ 196 | 1, 197 | 2, 198 | 3 199 | ] 200 | } 201 | ``` 202 | 203 | ### Backreferences 204 | 205 | Since the shorthand syntax is context-aware, it is possible to use the current context to reference back to the most recently used object or array when creating new properties or items. 206 | 207 | ```bash 208 | # Backref with object properties 209 | $ j foo.bar: 1, .baz: 2 210 | { 211 | "foo": { 212 | "bar": 1, 213 | "baz": 2 214 | } 215 | } 216 | 217 | # Backref with array appending 218 | $ j foo.bar[]: 1, []: 2, []: 3 219 | { 220 | "foo": { 221 | "bar": [ 222 | 1, 223 | 2, 224 | 3 225 | ] 226 | } 227 | } 228 | 229 | # Easily build complex structures 230 | $ j name: foo, tags[]{id: 1, count.clicks: 5, .sales: 1}, []{id: 2, count.clicks: 8, .sales: 2} 231 | { 232 | "name": "foo", 233 | "tags": [ 234 | { 235 | "count": { 236 | "clicks": 5, 237 | "sales": 1 238 | }, 239 | "id": 1 240 | }, 241 | { 242 | "count": { 243 | "clicks": 8, 244 | "sales": 2 245 | }, 246 | "id": 2 247 | } 248 | ] 249 | } 250 | ``` 251 | 252 | ### Loading from files 253 | 254 | Sometimes a field makes more sense to load from a file than to be specified on the commandline. The `@` preprocessor and `~` & `%` modifiers let you load structured data, strings, and base64-encoded data into values. 255 | 256 | ```bash 257 | # Load a file's value as a parameter 258 | $ j foo: @hello.txt 259 | { 260 | "foo": "hello, world" 261 | } 262 | 263 | # Load structured data 264 | $ j foo: @hello.json 265 | { 266 | "foo": { 267 | "hello": "world" 268 | } 269 | } 270 | 271 | # Force loading a string 272 | $ j foo: @~hello.json 273 | { 274 | "foo": "{\n \"hello\": \"world\"\n}" 275 | } 276 | 277 | # Load as base 64 data 278 | $ j foo: @%hello.json 279 | { 280 | "foo": "ewogICJoZWxsbyI6ICJ3b3JsZCIKfQ==" 281 | } 282 | ``` 283 | 284 | Remember, it's possible to disable this behavior with the string modifier `~`: 285 | 286 | ```bash 287 | $ j twitter:~ @user 288 | { 289 | "twitter": "@user" 290 | } 291 | ``` 292 | -------------------------------------------------------------------------------- /cli/api.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/fxamacker/cbor/v2" 15 | "github.com/spf13/cobra" 16 | "github.com/spf13/viper" 17 | "golang.org/x/text/cases" 18 | "golang.org/x/text/language" 19 | ) 20 | 21 | // API represents an abstracted API description used to build CLI commands 22 | // around available resources, operations, and links. An API is produced by 23 | // a Loader and cached by the CLI in-between runs when possible. 24 | type API struct { 25 | RestishVersion string `json:"restish_version" yaml:"restish_version"` 26 | Short string `json:"short" yaml:"short"` 27 | Long string `json:"long,omitempty" yaml:"long,omitempty"` 28 | Operations []Operation `json:"operations,omitempty" yaml:"operations,omitempty"` 29 | Auth []APIAuth `json:"auth,omitempty" yaml:"auth,omitempty"` 30 | AutoConfig AutoConfig `json:"auto_config,omitempty" yaml:"auto_config,omitempty"` 31 | } 32 | 33 | // Merge two APIs together. Takes the description if none is set and merges 34 | // operations. Ignores auth - if that differs then create another API instead. 35 | func (a *API) Merge(other API) { 36 | if a.Short == "" { 37 | a.Short = other.Short 38 | } 39 | 40 | if a.Long == "" { 41 | a.Long = other.Long 42 | } 43 | 44 | a.Operations = append(a.Operations, other.Operations...) 45 | } 46 | 47 | var loaders []Loader 48 | 49 | // Loader is used to detect and load an API spec, turning it into CLI commands. 50 | type Loader interface { 51 | LocationHints() []string 52 | Detect(resp *http.Response) bool 53 | Load(entrypoint, spec url.URL, resp *http.Response) (API, error) 54 | } 55 | 56 | // AddLoader adds a new API spec loader to the CLI. 57 | func AddLoader(loader Loader) { 58 | loaders = append(loaders, loader) 59 | } 60 | 61 | func setupRootFromAPI(root *cobra.Command, api *API) { 62 | if root.Short == "" { 63 | root.Short = api.Short 64 | } 65 | 66 | if root.Long == "" { 67 | root.Long = api.Long 68 | } 69 | 70 | for _, op := range api.Operations { 71 | if op.Group != "" && !root.ContainsGroup(op.Group) { 72 | groupName := fmt.Sprintf("%s Commands:", cases.Title(language.Und, cases.NoLower).String(op.Group)) 73 | group := &cobra.Group{ID: op.Group, Title: groupName} 74 | root.AddGroup(group) 75 | } 76 | root.AddCommand(op.command()) 77 | } 78 | } 79 | 80 | func load(root *cobra.Command, entrypoint, spec url.URL, resp *http.Response, name string, loader Loader) (API, error) { 81 | api, err := loader.Load(entrypoint, spec, resp) 82 | if err != nil { 83 | return API{}, err 84 | } 85 | 86 | setupRootFromAPI(root, &api) 87 | return api, nil 88 | } 89 | 90 | func cacheAPI(name string, api *API) { 91 | if name == "" { 92 | return 93 | } 94 | 95 | Cache.Set(name+".expires", time.Now().Add(24*time.Hour)) 96 | Cache.WriteConfig() 97 | 98 | b, err := cbor.Marshal(api) 99 | if err != nil { 100 | LogError("Could not marshal API cache %s", err) 101 | } 102 | filename := filepath.Join(getCacheDir(), name+".cbor") 103 | if err := os.WriteFile(filename, b, 0o600); err != nil { 104 | LogError("Could not write API cache %s", err) 105 | } 106 | } 107 | 108 | // Load will hydrate the command tree for an API, possibly refreshing the 109 | // API spec if the cache is out of date. 110 | func Load(entrypoint string, root *cobra.Command) (API, error) { 111 | start := time.Now() 112 | defer func() { 113 | LogDebug("API loading took %s", time.Since(start)) 114 | }() 115 | uris := []string{} 116 | 117 | if !strings.HasSuffix(entrypoint, "/") { 118 | entrypoint += "/" 119 | } 120 | 121 | uri, err := url.Parse(entrypoint) 122 | if err != nil { 123 | return API{}, err 124 | } 125 | 126 | name, config := findAPI(entrypoint) 127 | desc := API{} 128 | found := false 129 | 130 | // See if there is a cache we can quickly load. 131 | expires := Cache.GetTime(name + ".expires") 132 | if !viper.GetBool("rsh-no-cache") && !expires.IsZero() && expires.After(time.Now()) { 133 | var cached API 134 | filename := filepath.Join(getCacheDir(), name+".cbor") 135 | if data, err := os.ReadFile(filename); err == nil { 136 | if err := cbor.Unmarshal(data, &cached); err == nil { 137 | if cached.RestishVersion == root.Version { 138 | setupRootFromAPI(root, &cached) 139 | return cached, nil 140 | } 141 | } 142 | } 143 | } 144 | 145 | fromFileOrUrl := func(uri string) ([]byte, error) { 146 | uriLower := strings.ToLower(uri) 147 | if strings.Index(uriLower, "http") == 0 { 148 | resp, err := http.Get(uri) 149 | if err != nil { 150 | return []byte{}, err 151 | } 152 | return io.ReadAll(resp.Body) 153 | } else { 154 | return os.ReadFile(os.ExpandEnv(uri)) 155 | } 156 | } 157 | if name != "" && len(config.SpecFiles) > 0 { 158 | // Load the local files 159 | for _, filename := range config.SpecFiles { 160 | resp := &http.Response{ 161 | Proto: "HTTP/1.1", 162 | StatusCode: 200, 163 | } 164 | 165 | body, err := fromFileOrUrl(filename) 166 | if err != nil { 167 | return API{}, err 168 | } 169 | 170 | // No need to check error, it was checked above in `fromFileOrUrl`. 171 | uriSpec, _ := url.Parse(filename) 172 | 173 | for _, l := range loaders { 174 | // Reset the body 175 | resp.Body = io.NopCloser(bytes.NewReader(body)) 176 | 177 | if l.Detect(resp) { 178 | found = true 179 | resp.Body = io.NopCloser(bytes.NewReader(body)) 180 | tmp, err := load(root, *uri, *uriSpec, resp, name, l) 181 | if err != nil { 182 | return API{}, err 183 | } 184 | desc.Merge(tmp) 185 | break 186 | } 187 | } 188 | } 189 | 190 | if found { 191 | desc.RestishVersion = root.Version 192 | cacheAPI(name, &desc) 193 | return desc, nil 194 | } 195 | } 196 | 197 | LogDebug("Checking API entrypoint %s", entrypoint) 198 | req, err := http.NewRequest(http.MethodGet, entrypoint, nil) 199 | if err != nil { 200 | return API{}, err 201 | } 202 | 203 | // We already cache the parsed API specs, no need to cache the 204 | // server response. 205 | // We will almost never be in a situation where we don't want to use 206 | // the parsed API cache, but do want to use a cached response from 207 | // the server. 208 | client := &http.Client{Transport: InvalidateCachedTransport()} 209 | httpResp, err := MakeRequest(req, WithClient(client), IgnoreCLIParams()) 210 | if err != nil { 211 | return API{}, err 212 | } 213 | defer httpResp.Body.Close() 214 | 215 | resp, err := ParseResponse(httpResp) 216 | if err != nil { 217 | return API{}, err 218 | } 219 | 220 | // Start with known link relations for API descriptions. 221 | for _, l := range resp.Links["service-desc"] { 222 | uris = append(uris, l.URI) 223 | } 224 | for _, l := range resp.Links["describedby"] { 225 | uris = append(uris, l.URI) 226 | } 227 | 228 | // Try hints from loaders next. These are likely places for API descriptions 229 | // to be on the server, like e.g. `/openapi.json`. 230 | for _, l := range loaders { 231 | uris = append(uris, l.LocationHints()...) 232 | } 233 | 234 | uris = append(uris, uri.String()) 235 | 236 | for _, checkURI := range uris { 237 | parsed, err := url.Parse(checkURI) 238 | if err != nil { 239 | return API{}, err 240 | } 241 | resolved := uri.ResolveReference(parsed) 242 | LogDebug("Checking %s", resolved) 243 | 244 | req, err := http.NewRequest(http.MethodGet, resolved.String(), nil) 245 | if err != nil { 246 | return API{}, err 247 | } 248 | 249 | resp, err := MakeRequest(req, WithClient(client), IgnoreCLIParams()) 250 | if err != nil { 251 | return API{}, err 252 | } 253 | if err := DecodeResponse(resp); err != nil { 254 | return API{}, err 255 | } 256 | defer resp.Body.Close() 257 | body, err := io.ReadAll(resp.Body) 258 | if err != nil { 259 | return API{}, err 260 | } 261 | 262 | for _, l := range loaders { 263 | // Reset the body 264 | resp.Body = io.NopCloser(bytes.NewReader(body)) 265 | 266 | if l.Detect(resp) { 267 | resp.Body = io.NopCloser(bytes.NewReader(body)) 268 | 269 | // Override the operation base path if requested, otherwise 270 | // default to the API entrypoint. 271 | opsBase := uri 272 | if config.OperationBase != "" { 273 | opsBase = uri.ResolveReference(&url.URL{Path: config.OperationBase}) 274 | } 275 | api, err := load(root, *opsBase, *resolved, resp, name, l) 276 | if err == nil { 277 | cacheAPI(name, &api) 278 | } 279 | return api, err 280 | } 281 | } 282 | } 283 | 284 | return API{}, fmt.Errorf("could not detect API type: %s", entrypoint) 285 | } 286 | -------------------------------------------------------------------------------- /openapi/schema_test.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/pb33f/libopenapi/datamodel" 8 | "github.com/pb33f/libopenapi/datamodel/high/base" 9 | "github.com/pb33f/libopenapi/datamodel/low" 10 | lowbase "github.com/pb33f/libopenapi/datamodel/low/base" 11 | "github.com/pb33f/libopenapi/index" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | var schemaTests = []struct { 18 | name string 19 | mode schemaMode 20 | in string 21 | inSpecVersion string 22 | out string 23 | }{ 24 | { 25 | name: "guess-array", 26 | in: `items: {type: string}`, 27 | inSpecVersion: datamodel.OAS3, 28 | out: "[\n (string)\n]", 29 | }, 30 | { 31 | name: "guess-object", 32 | in: `additionalProperties: true`, 33 | inSpecVersion: datamodel.OAS3, 34 | out: "{\n : \n}", 35 | }, 36 | { 37 | name: "nullable", 38 | in: `{type: boolean, nullable: true}`, 39 | inSpecVersion: datamodel.OAS3, 40 | out: "(boolean nullable:true)", 41 | }, 42 | { 43 | name: "nullable31", 44 | in: `{type: [null, boolean]}`, 45 | inSpecVersion: datamodel.OAS31, 46 | out: "(null|boolean)", 47 | }, 48 | { 49 | name: "min", 50 | in: `{type: number, minimum: 5}`, 51 | inSpecVersion: datamodel.OAS3, 52 | out: "(number min:5)", 53 | }, 54 | { 55 | name: "exclusive-min", 56 | in: `{type: number, minimum: 5, exclusiveMinimum: true}`, 57 | inSpecVersion: datamodel.OAS3, 58 | out: "(number exclusiveMin:5)", 59 | }, 60 | { 61 | name: "exclusive-min-31", 62 | in: `{type: number, exclusiveMinimum: 5}`, 63 | inSpecVersion: datamodel.OAS31, 64 | out: "(number exclusiveMin:5)", 65 | }, 66 | { 67 | name: "max", 68 | in: `{type: number, maximum: 5}`, 69 | inSpecVersion: datamodel.OAS3, 70 | out: "(number max:5)", 71 | }, 72 | { 73 | name: "exclusive-max", 74 | in: `{type: number, maximum: 5, exclusiveMaximum: true}`, 75 | inSpecVersion: datamodel.OAS3, 76 | out: "(number exclusiveMax:5)", 77 | }, 78 | { 79 | name: "exclusive-max-31", 80 | in: `{type: number, exclusiveMaximum: 5}`, 81 | inSpecVersion: datamodel.OAS31, 82 | out: "(number exclusiveMax:5)", 83 | }, 84 | { 85 | name: "multiple-of", 86 | in: `{type: number, multipleOf: 5}`, 87 | inSpecVersion: datamodel.OAS3, 88 | out: "(number multiple:5)", 89 | }, 90 | { 91 | name: "default-scalar", 92 | in: `{type: number, default: 5.0}`, 93 | inSpecVersion: datamodel.OAS3, 94 | out: "(number default:5.0)", 95 | }, 96 | { 97 | name: "string-format", 98 | in: `{type: string, format: date}`, 99 | inSpecVersion: datamodel.OAS3, 100 | out: "(string format:date)", 101 | }, 102 | { 103 | name: "string-pattern", 104 | in: `{type: string, pattern: "^[a-z]+$"}`, 105 | inSpecVersion: datamodel.OAS3, 106 | out: "(string pattern:^[a-z]+$)", 107 | }, 108 | { 109 | name: "string-min-length", 110 | in: `{type: string, minLength: 5}`, 111 | inSpecVersion: datamodel.OAS3, 112 | out: "(string minLen:5)", 113 | }, 114 | { 115 | name: "string-max-length", 116 | in: `{type: string, maxLength: 5}`, 117 | inSpecVersion: datamodel.OAS3, 118 | out: "(string maxLen:5)", 119 | }, 120 | { 121 | name: "string-enum", 122 | in: `{type: string, enum: [one, two]}`, 123 | inSpecVersion: datamodel.OAS3, 124 | out: "(string enum:one,two)", 125 | }, 126 | { 127 | name: "empty-array", 128 | in: `{type: array}`, 129 | inSpecVersion: datamodel.OAS3, 130 | out: "[]", 131 | }, 132 | { 133 | name: "array", 134 | in: `{type: array, items: {type: number}}`, 135 | inSpecVersion: datamodel.OAS3, 136 | out: "[\n (number)\n]", 137 | }, 138 | { 139 | name: "object-empty", 140 | in: `{type: object}`, 141 | inSpecVersion: datamodel.OAS3, 142 | out: "(object)", 143 | }, 144 | { 145 | name: "object-prop-null", 146 | in: `{type: object, properties: {foo: null}}`, 147 | inSpecVersion: datamodel.OAS3, 148 | out: "{\n foo: \n}", 149 | }, 150 | { 151 | name: "object", 152 | in: `{type: object, properties: {foo: {type: string}, bar: {type: integer}}, required: [foo]}`, 153 | inSpecVersion: datamodel.OAS3, 154 | out: "{\n bar: (integer)\n foo*: (string)\n}", 155 | }, 156 | { 157 | name: "object-read-only", 158 | mode: modeRead, 159 | in: `{type: object, properties: {foo: {type: string, readOnly: true}, bar: {type: string, writeOnly: true}}}`, 160 | inSpecVersion: datamodel.OAS3, 161 | out: "{\n foo: (string)\n}", 162 | }, 163 | { 164 | name: "object-write-only", 165 | mode: modeWrite, 166 | in: `{type: object, properties: {foo: {type: string, readOnly: true}, bar: {type: string, writeOnly: true}}}`, 167 | inSpecVersion: datamodel.OAS3, 168 | out: "{\n bar: (string)\n}", 169 | }, 170 | { 171 | name: "object-additional-props-bool", 172 | in: `{type: object, additionalProperties: true}`, 173 | inSpecVersion: datamodel.OAS3, 174 | out: "{\n : \n}", 175 | }, 176 | { 177 | name: "object-additional-props-scehma", 178 | in: `{type: object, additionalProperties: {type: string}}`, 179 | inSpecVersion: datamodel.OAS3, 180 | out: "{\n : (string)\n}", 181 | }, 182 | { 183 | name: "all-of", 184 | in: `{allOf: [{type: object, properties: {a: {type: string}}}, {type: object, properties: {foo: {type: string}, bar: {type: number, description: desc}}}]}`, 185 | inSpecVersion: datamodel.OAS3, 186 | out: "allOf{\n {\n a: (string)\n }\n {\n bar: (number) desc\n foo: (string)\n }\n}", 187 | }, 188 | { 189 | name: "one-of", 190 | in: `{oneOf: [{type: boolean}, {type: object, properties: {foo: {type: string}, bar: {type: number, description: desc}}}]}`, 191 | inSpecVersion: datamodel.OAS3, 192 | out: "oneOf{\n (boolean)\n {\n bar: (number) desc\n foo: (string)\n }\n}", 193 | }, 194 | { 195 | name: "any-of", 196 | in: `{anyOf: [{type: boolean}, {type: object, properties: {foo: {type: string}, bar: {type: number, description: desc}}}]}`, 197 | inSpecVersion: datamodel.OAS3, 198 | out: "anyOf{\n (boolean)\n {\n bar: (number) desc\n foo: (string)\n }\n}", 199 | }, 200 | { 201 | name: "recusive-prop", 202 | in: `{type: object, properties: {person: {type: object, properties: {friend: {$ref: "#/properties/person"}}}}}`, 203 | inSpecVersion: datamodel.OAS3, 204 | out: "{\n person: {\n friend: \n }\n}", 205 | }, 206 | { 207 | name: "recusive-array", 208 | in: `{type: object, properties: {person: {type: object, properties: {friend: {type: array, items: {$ref: "#/properties/person"}}}}}}`, 209 | inSpecVersion: datamodel.OAS3, 210 | out: "{\n person: {\n friend: []\n }\n}", 211 | }, 212 | { 213 | name: "recusive-additional-props", 214 | in: `{type: object, properties: {person: {type: object, properties: {friend: {type: object, additionalProperties: {$ref: "#/properties/person"}}}}}}`, 215 | inSpecVersion: datamodel.OAS3, 216 | out: "{\n person: {\n friend: {\n : \n }\n }\n}", 217 | }, 218 | } 219 | 220 | func TestSchema(t *testing.T) { 221 | for _, example := range schemaTests { 222 | t.Run(example.name, func(t *testing.T) { 223 | var rootNode yaml.Node 224 | var ls lowbase.Schema 225 | 226 | require.NoError(t, yaml.Unmarshal([]byte(example.in), &rootNode)) 227 | require.NoError(t, low.BuildModel(rootNode.Content[0], &ls)) 228 | 229 | specIndex := index.NewSpecIndex(&rootNode) 230 | 231 | if example.inSpecVersion == datamodel.OAS3 { 232 | inf, err := datamodel.ExtractSpecInfo([]byte(`openapi: 3.0.1`)) 233 | require.NoError(t, err) 234 | specIndex.GetConfig().SpecInfo = inf 235 | } else { 236 | inf, err := datamodel.ExtractSpecInfo([]byte(`openapi: 3.1.0`)) 237 | require.NoError(t, err) 238 | specIndex.GetConfig().SpecInfo = inf 239 | } 240 | require.NoError(t, ls.Build(context.Background(), rootNode.Content[0], specIndex)) 241 | 242 | // spew.Dump(ls) 243 | 244 | s := base.NewSchema(&ls) 245 | assert.Equal(t, example.out, renderSchema(s, "", example.mode)) 246 | }) 247 | } 248 | } 249 | --------------------------------------------------------------------------------