├── examples ├── dummyjson.com │ ├── advanced │ │ ├── products │ │ │ ├── get.ain │ │ │ ├── get-by-id.ain │ │ │ ├── delete.ain │ │ │ ├── update.ain │ │ │ └── add.ain │ │ ├── paginate.ain │ │ ├── auth.ain │ │ ├── base.ain │ │ ├── get-token.ain │ │ └── README.md │ ├── simple │ │ ├── get-product-by-id.ain │ │ ├── get-user-by-id.ain │ │ ├── get-users.ain │ │ ├── get-products.ain │ │ └── README.md │ └── README.md └── README.md ├── test └── e2e │ ├── templates │ ├── cmdparams │ │ ├── .envv │ │ ├── ok-ain-dash-v-prints-version.ain │ │ ├── ok-default-env-file-is-read.ain │ │ └── ok-vars-set-but-only-if-not-defined.ain │ ├── escaping │ │ ├── return-quoted-comment.sh │ │ ├── return-unquoted-exec.sh │ │ ├── return-unquoted-envvar.sh │ │ ├── return-unquoted-comment.sh │ │ ├── ok-escaping-end-of-envvars-and-executables.ain │ │ └── ok-escaping-and-returned-values.ain │ ├── ok-httpie-plain-get.ain │ ├── nok-empty-file-no-host-no-backend.ain │ ├── ok-curl-plain-get.ain │ ├── ok-wget-plain-get.ain │ ├── ok-env-inside-executable.ain │ ├── nok-unterminated-env.ain │ ├── nok-several-lines-under-method.ain │ ├── ok-url-encoding-host-and-qps.ain │ ├── nok-executable-in-config-not-expanded.ain │ ├── nok-executable-tokenizing-errors-stop-execution.ain │ ├── ok-executable-inside-env-var.ain │ ├── ok-multirow-envs-and-executables.ain │ ├── ok-executables-useless-in-config.ain │ ├── nok-env-fatals-include-quotes.ain │ ├── nok-executables-fatals-include-quotes.ain │ ├── ok-envvars-allowed-in-config.ain │ ├── ok-quotes-in-executables.ain │ └── nok-unterminated-exec-and-quote-sequence.ain │ └── e2e_test.go ├── .gitignore ├── grammars ├── vim │ ├── ftdetect │ │ └── ain.vim │ └── syntax │ │ └── ain.vim ├── textmate │ ├── .vscodeignore │ ├── language-configuration.json │ ├── package.json │ └── syntaxes │ │ └── ain.tmLanguage.json └── tests.ain ├── assets └── show-and-tell.gif ├── go.mod ├── check-ain-version.sh ├── internal ├── pkg │ ├── parse │ │ ├── host.go │ │ ├── body.go │ │ ├── query.go │ │ ├── headers.go │ │ ├── method.go │ │ ├── backendopts.go │ │ ├── fatals_test.go │ │ ├── backend.go │ │ ├── envvars.go │ │ ├── executable_test.go │ │ ├── config.go │ │ ├── fatals.go │ │ ├── envvars_test.go │ │ ├── querystring.go │ │ ├── executable.go │ │ ├── sections_test.go │ │ ├── capture.go │ │ ├── tokenize.go │ │ ├── sections.go │ │ ├── assemble.go │ │ └── tokenize_test.go │ ├── data │ │ ├── data.go │ │ └── backendinput.go │ ├── disk │ │ ├── env.go │ │ ├── filename.go │ │ ├── read.go │ │ └── gentemplate.go │ ├── utils │ │ ├── leven_test.go │ │ ├── leven.go │ │ ├── utils_test.go │ │ └── utils.go │ └── call │ │ ├── curl.go │ │ ├── httpie.go │ │ ├── call.go │ │ └── wget.go └── app │ └── ain │ └── cmdparams.go ├── .github └── dependabot.yml ├── go.sum ├── .vscode └── launch.json ├── LICENSE ├── .goreleaser.yml ├── Taskfile.yaml ├── cmd └── ain │ └── main.go └── README.md /examples/dummyjson.com/advanced/products/get.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | products 3 | -------------------------------------------------------------------------------- /test/e2e/templates/cmdparams/.envv: -------------------------------------------------------------------------------- 1 | CMDPARAMSTEST="cmdparams test:1" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /templates/ 2 | ain-body* 3 | dist/ 4 | .env 5 | main 6 | /cov/ 7 | -------------------------------------------------------------------------------- /grammars/vim/ftdetect/ain.vim: -------------------------------------------------------------------------------- 1 | autocmd BufRead,BufNewFile *.ain setfiletype ain 2 | -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/products/get-by-id.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | products/${ID} 3 | -------------------------------------------------------------------------------- /test/e2e/templates/escaping/return-quoted-comment.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | printf '`#' 3 | -------------------------------------------------------------------------------- /test/e2e/templates/escaping/return-unquoted-exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | printf '$(exec)' 3 | -------------------------------------------------------------------------------- /test/e2e/templates/escaping/return-unquoted-envvar.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | printf '${ENV}' 3 | -------------------------------------------------------------------------------- /assets/show-and-tell.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonaslu/ain/HEAD/assets/show-and-tell.gif -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/products/delete.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | products/${ID} 3 | 4 | [Method] 5 | DELETE 6 | -------------------------------------------------------------------------------- /grammars/textmate/.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | .gitignore 4 | vsc-extension-quickstart.md 5 | -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/paginate.ain: -------------------------------------------------------------------------------- 1 | [Query] 2 | limit=${LIMIT} 3 | skip=$(bash -c 'if [-z "$SKIP" ]; then echo "0"; else echo "$SKIP"; fi') 4 | -------------------------------------------------------------------------------- /test/e2e/templates/escaping/return-unquoted-comment.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | printf '# I won\'t be included, since # becomes a comment in the template' 3 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-httpie-plain-get.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://mock.httpstatus.io/202 3 | 4 | [Backend] 5 | httpie 6 | 7 | # stdout: 8 | # 202 Accepted 9 | -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/auth.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | auth/ 3 | 4 | [Headers] 5 | Authorization: Bearer $(bash -c 'ain base.ain get-token.ain | jq -r .accessToken') 6 | -------------------------------------------------------------------------------- /test/e2e/templates/nok-empty-file-no-host-no-backend.ain: -------------------------------------------------------------------------------- 1 | # stderr: | 2 | # No mandatory [Host] section found 3 | # No mandatory [Backend] section found 4 | # exitcode: 1 5 | -------------------------------------------------------------------------------- /examples/dummyjson.com/simple/get-product-by-id.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://dummyjson.com/product/${ID} 3 | 4 | [Backend] 5 | curl 6 | 7 | [BackendOptions] 8 | -sS # suppress curl progress bar 9 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-curl-plain-get.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://mock.httpstatus.io/200 3 | 4 | [Backend] 5 | curl 6 | 7 | [Backendoptions] 8 | -sS 9 | 10 | # stdout: 11 | # 200 OK 12 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-wget-plain-get.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://mock.httpstatus.io/201 3 | 4 | [Backend] 5 | wget 6 | 7 | [Backendoptions] 8 | -q 9 | 10 | # stdout: 11 | # 201 Created 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jonaslu/ain 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/hashicorp/go-envparse v0.1.0 7 | github.com/pkg/errors v0.9.1 8 | gopkg.in/yaml.v3 v3.0.1 9 | ) 10 | -------------------------------------------------------------------------------- /test/e2e/templates/cmdparams/ok-ain-dash-v-prints-version.ain: -------------------------------------------------------------------------------- 1 | # Here so yaml parsing stops on the line below 2 | 3 | # args: 4 | # - -v 5 | # stdout: | 6 | # Ain 1.6.0 (develop) linux/amd64 7 | # 8 | -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/base.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://dummyjson.com/ 3 | 4 | [Backend] 5 | curl 6 | 7 | [Config] 8 | Timeout=5 9 | 10 | [BackendOptions] 11 | -sS # no progress bar for curl 12 | -------------------------------------------------------------------------------- /examples/dummyjson.com/simple/get-user-by-id.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://dummyjson.com/users/${ID} 3 | 4 | [Backend] 5 | wget 6 | 7 | [BackendOptions] 8 | -q # quiet option (no debug prints when making the call) 9 | -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/products/update.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | products/${ID} 3 | 4 | [Headers] 5 | Content-Type: 'application/json' 6 | 7 | [Method] 8 | PATCH 9 | 10 | [Body] 11 | { 12 | "title": "placeholder" 13 | } 14 | -------------------------------------------------------------------------------- /check-ain-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | MAIN_VERSION=v$(grep "version =" cmd/ain/main.go | cut -f 4 -d " " | tr -d "\"") 3 | if [ "$MAIN_VERSION" != "$1" ]; then 4 | echo "Tag version and version in main.go are not the same" 5 | exit 1 6 | fi 7 | -------------------------------------------------------------------------------- /examples/dummyjson.com/simple/get-users.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://dummyjson.com/users 3 | 4 | [Query] 5 | limit=10 6 | skip=0 7 | 8 | [Backend] 9 | wget 10 | 11 | [BackendOptions] 12 | -q # quiet option (no debug prints when making the call) 13 | -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/products/add.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | products/add 3 | 4 | [Method] 5 | POST 6 | 7 | [Headers] 8 | Content-Type: 'application/json' 9 | 10 | [Body] 11 | { 12 | "title": "New title", 13 | # Add other fields here 14 | } 15 | -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/get-token.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | auth/login 3 | 4 | [Method] 5 | Post 6 | 7 | [Headers] 8 | Content-Type: application/json 9 | 10 | [Body] 11 | { 12 | "username": "emilys", 13 | "password": "emilyspass", 14 | "expiresInMins": 30 15 | } 16 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-env-inside-executable.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | curl 6 | 7 | [Headers] 8 | Test: $(printf ${VAR}) 9 | 10 | # env: 11 | # - VAR=1 12 | # args: 13 | # - -p 14 | # stdout: | 15 | # curl -H 'Test: 1' \ 16 | # 'localhost' 17 | -------------------------------------------------------------------------------- /internal/pkg/parse/host.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | func (s *sectionedTemplate) getHost() string { 4 | var host string 5 | for _, hostSourceMarker := range *s.getNamedSection(hostSection) { 6 | host = host + hostSourceMarker.lineContents 7 | } 8 | 9 | return host 10 | } 11 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | This folder contains examples of using ain. You can use them as tutorials or as starting points for your own projects. 3 | 4 | The examples are organized by the api used: 5 | * [dummmyjson.com](https://github.com/jonaslu/ain/tree/main/examples/dummyjson.com) 6 | -------------------------------------------------------------------------------- /test/e2e/templates/nok-unterminated-env.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | ${VAR 3 | 4 | [Backend] 5 | 6 | # stderr: | 7 | # Fatal error in file: $filename 8 | # Missing closing bracket for environment variable: ${VAR on line 2: 9 | # 1 [Host] 10 | # 2 > ${VAR 11 | # 3 12 | # exitcode: 1 13 | -------------------------------------------------------------------------------- /examples/dummyjson.com/simple/get-products.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://dummyjson.com/product 3 | 4 | [Backend] 5 | curl 6 | 7 | [Query] 8 | limit=10 9 | skip=$(bash -c 'if [-z "$SKIP"]; then echo "0"; else echo $SKIP; fi') 10 | 11 | [BackendOptions] 12 | -sS # suppress curl progress bar 13 | -------------------------------------------------------------------------------- /internal/pkg/parse/body.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | func (s *sectionedTemplate) getBody() []string { 4 | var body []string 5 | for _, bodySourceMarker := range *s.getNamedSection(bodySection) { 6 | body = append(body, bodySourceMarker.lineContents) 7 | } 8 | 9 | return body 10 | } 11 | -------------------------------------------------------------------------------- /internal/pkg/parse/query.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | func (s *sectionedTemplate) getQuery() []string { 4 | var query []string 5 | 6 | for _, querySourceMarker := range *s.getNamedSection(querySection) { 7 | query = append(query, querySourceMarker.lineContents) 8 | } 9 | 10 | return query 11 | } 12 | -------------------------------------------------------------------------------- /internal/pkg/parse/headers.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | func (s *sectionedTemplate) getHeaders() []string { 4 | var headers []string 5 | 6 | for _, headerSourceMarker := range *s.getNamedSection(headersSection) { 7 | headers = append(headers, headerSourceMarker.lineContents) 8 | } 9 | 10 | return headers 11 | } 12 | -------------------------------------------------------------------------------- /test/e2e/templates/nok-several-lines-under-method.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Method] 5 | POST 6 | PUT 7 | 8 | [Backend] 9 | wget 10 | 11 | # stderr: | 12 | # Fatal error in file: $filename 13 | # Found several lines under [Method] on line 5: 14 | # 4 [Method] 15 | # 5 > POST 16 | # 6 PUT 17 | # exitcode: 1 18 | -------------------------------------------------------------------------------- /examples/dummyjson.com/README.md: -------------------------------------------------------------------------------- 1 | # dummyjson.com 2 | 3 | Contains two versions of the same API: 4 | * One [simple](https://github.com/jonaslu/ain/tree/main/examples/dummyjson.com/simple) layout when just starting out using an API. 5 | * One [advanced](https://github.com/jonaslu/ain/tree/main/examples/dummyjson.com/advanced) where everything has been structured to minimize duplication. 6 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-url-encoding-host-and-qps.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | https://localhost:8080/download/file/dir with%20spaces # %20= 3 | 4 | [Query] 5 | filename=filename with %24$ in it # %24=$ 6 | 7 | [Backend] 8 | curl 9 | 10 | # args: 11 | # - -p 12 | # stdout: | 13 | # curl 'https://localhost:8080/download/file/dir%20with%20spaces?filename=filename+with+%24%24+in+it' 14 | -------------------------------------------------------------------------------- /test/e2e/templates/nok-executable-in-config-not-expanded.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | wget 6 | 7 | [Config] 8 | Timeout=$(printf 1) 9 | 10 | # env: 11 | # - VAR=1 12 | # stderr: | 13 | # Fatal error in file: $filename 14 | # Malformed timeout value, must be digit > 0 on line 8: 15 | # 7 [Config] 16 | # 8 > Timeout=$(printf 1) 17 | # 9 18 | # exitcode: 1 19 | -------------------------------------------------------------------------------- /test/e2e/templates/nok-executable-tokenizing-errors-stop-execution.ain: -------------------------------------------------------------------------------- 1 | $(unclosed 2 | 3 | $(unclosed 4 | 5 | # stderr: | 6 | # Fatal errors in file: $filename 7 | # Missing closing parenthesis for executable: $(unclosed on line 1: 8 | # 1 > $(unclosed 9 | # 2 10 | # 11 | # Missing closing parenthesis for executable: $(unclosed on line 3: 12 | # 2 13 | # 3 > $(unclosed 14 | # 4 15 | # 16 | # exitcode: 1 17 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-executable-inside-env-var.ain: -------------------------------------------------------------------------------- 1 | ${VAR} 2 | 3 | # This proves envvars can return executables 4 | # Note that \\ is needed to escape the line break as input to printf 5 | 6 | # env: 7 | # - "VAR=[Host]\n$(printf localhost)\n$(printf [Backend])\ncurl\n$(printf '[Headers]\\n')$(printf 'Goat:')$(printf ' yes!')" 8 | # args: 9 | # - -p 10 | # stdout: | 11 | # curl -H 'Goat: yes!' \ 12 | # 'localhost' 13 | -------------------------------------------------------------------------------- /test/e2e/templates/cmdparams/ok-default-env-file-is-read.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | httpie 6 | 7 | [Headers] 8 | ${CMDPARAMSTEST} 9 | 10 | # This proves that a custom .env file is picked up 11 | # since the CMDPARAMSTEST is defined there 12 | 13 | # args: 14 | # - -p 15 | # - -e 16 | # - templates/cmdparams/.envv 17 | # stdout: | 18 | # http '--ignore-stdin' \ 19 | # 'localhost' \ 20 | # 'cmdparams test:1' \ 21 | # 22 | -------------------------------------------------------------------------------- /internal/pkg/parse/method.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | func (s *sectionedTemplate) getMethod() string { 4 | methodSourceMarkers := *s.getNamedSection(methodSection) 5 | 6 | if len(methodSourceMarkers) == 0 { 7 | return "" 8 | } 9 | 10 | if len(methodSourceMarkers) > 1 { 11 | s.setFatalMessage("Found several lines under [Method]", methodSourceMarkers[0].sourceLineIndex) 12 | return "" 13 | } 14 | 15 | return methodSourceMarkers[0].lineContents 16 | } 17 | -------------------------------------------------------------------------------- /test/e2e/templates/cmdparams/ok-vars-set-but-only-if-not-defined.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | curl 6 | 7 | [Headers] 8 | var1: ${VAR1} 9 | var2: ${VAR2} 10 | 11 | # This proves that --vars overwrites any set environment vars 12 | 13 | # env: 14 | # - "VAR1=1" 15 | # args: 16 | # - -p 17 | # afterargs: 18 | # - "--vars" 19 | # - "VAR1=aah" 20 | # - "VAR2=2" 21 | # stdout: | 22 | # curl -H 'var1: aah' \ 23 | # -H 'var2: 2' \ 24 | # 'localhost' 25 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-multirow-envs-and-executables.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Headers] 5 | $(printf "Header: 1\nHeader:") 2 6 | ${VAR}4 7 | 8 | [Backend] 9 | httpie 10 | 11 | # Proves that returned results with multilines push content down 12 | 13 | # env: 14 | # - "VAR=Header: 3\nHeader: " 15 | # args: 16 | # - -p 17 | # stdout: | 18 | # http '--ignore-stdin' \ 19 | # 'localhost' \ 20 | # 'Header: 1' \ 21 | # 'Header: 2' \ 22 | # 'Header: 3' \ 23 | # 'Header: 4' \ 24 | # 25 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-executables-useless-in-config.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost:300$(printf 1) 3 | 4 | [Query] 5 | goat=1 6 | yak=2 7 | 8 | [Config] 9 | $(bash -c 'exit 1') 10 | Timeout=1 11 | QueryDelim=$(yekal) 12 | 13 | [Backend] 14 | curl 15 | 16 | # This proves that executables are not run in the [Config] section 17 | # as bash -c exit 1 would cause a fatal if run. 18 | # And the QueryDelim is picked up verbatim. 19 | 20 | # args: 21 | # - -p 22 | # stdout: | 23 | # curl 'localhost:3001?goat=1$(yekal)yak=2' 24 | -------------------------------------------------------------------------------- /test/e2e/templates/nok-env-fatals-include-quotes.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | `${VAR} ${VAR} # comment 6 | 7 | # This proves that expanded fatals retains the quoting 8 | # but the content has it removed (see the error-message lacks a `) 9 | 10 | # env: 11 | # - VAR=1 12 | # stderr: | 13 | # Fatal error in file: $filename 14 | # Unknown backend ${var} 1 on line 5: 15 | # 4 [Backend] 16 | # 5 > `${VAR} ${VAR} # comment 17 | # 6 18 | # Expanded context: 19 | # 5 > `${VAR} 1 # comment 20 | # exitcode: 1 21 | -------------------------------------------------------------------------------- /test/e2e/templates/nok-executables-fatals-include-quotes.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | `$(nope) $(printf 1) `# # comment 6 | 7 | # This proves that expanded fatals retains the quoting 8 | # but the content has it removed (see the error-message lacks a `) 9 | 10 | # stderr: | 11 | # Fatal error in file: $filename 12 | # Unknown backend $(nope) 1 # on line 5: 13 | # 4 [Backend] 14 | # 5 > `$(nope) $(printf 1) `# # comment 15 | # 6 16 | # Expanded context: 17 | # 5 > `$(nope) 1 `# # comment 18 | # 19 | # exitcode: 1 20 | -------------------------------------------------------------------------------- /grammars/textmate/language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "#", 4 | }, 5 | // symbols that are auto closed when typing 6 | "autoClosingPairs": [ 7 | ["{", "}"], 8 | ["[", "]"], 9 | ["(", ")"], 10 | ["\"", "\""], 11 | ["'", "'"] 12 | ], 13 | // // symbols that can be used to surround a selection 14 | "surroundingPairs": [ 15 | ["{", "}"], 16 | ["[", "]"], 17 | ["(", ")"], 18 | ["\"", "\""], 19 | ["'", "'"] 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-envvars-allowed-in-config.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | httpie 6 | 7 | [Config] 8 | Timeout=${VAR} 9 | 10 | # Setting var to -1 will result in an error, proving that the 11 | # envvar is expanded in a config section to it's (invalid) value 12 | 13 | # env: 14 | # - VAR=-1 15 | # stderr: | 16 | # Fatal error in file: $filename 17 | # Timeout interval must be greater than 0 on line 8: 18 | # 7 [Config] 19 | # 8 > Timeout=${VAR} 20 | # 9 21 | # Expanded context: 22 | # 8 > Timeout=-1 23 | # exitcode: 1 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /test/e2e/templates/ok-quotes-in-executables.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | http://localhost 3 | 4 | [Headers] 5 | DoubleInSingle: $(printf '"') 6 | SingleInDouble: $(printf "'") 7 | DoubleInDouble: $(printf "\"") 8 | SingleInSingle: $(printf '\'') 9 | 10 | [Backend] 11 | curl 12 | 13 | # Tests escaping quoting inside quotes. Only 14 | # applicable inside executables. 15 | 16 | # args: 17 | # - -p 18 | # stdout: | 19 | # curl -H 'DoubleInSingle: "' \ 20 | # -H 'SingleInDouble: '"'"'' \ 21 | # -H 'DoubleInDouble: "' \ 22 | # -H 'SingleInSingle: '"'"'' \ 23 | # 'http://localhost' 24 | -------------------------------------------------------------------------------- /test/e2e/templates/escaping/ok-escaping-end-of-envvars-and-executables.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | curl 6 | 7 | [Headers] 8 | Escapingendofvars: ${VAR`}} 9 | Escapingendofexec: $(printf `)) 10 | Quotesnoescape: $(printf ")") 11 | Literalvarbacktick: ${VAR\`} 12 | Literalexecbackick: $(printf \`) 13 | 14 | # env: 15 | # - "VAR}={" 16 | # - "VAR`=1" 17 | # args: 18 | # - -p 19 | # stdout: | 20 | # curl -H 'Escapingendofvars: {' \ 21 | # -H 'Escapingendofexec: )' \ 22 | # -H 'Quotesnoescape: )' \ 23 | # -H 'Literalvarbacktick: 1' \ 24 | # -H 'Literalexecbackick: `' \ 25 | # 'localhost' 26 | -------------------------------------------------------------------------------- /grammars/textmate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ain", 3 | "displayName": "ain", 4 | "description": "TextMate grammar for .ain files", 5 | "version": "0.0.1", 6 | "engines": { 7 | "vscode": "^1.101.0" 8 | }, 9 | "categories": [ 10 | "Programming Languages" 11 | ], 12 | "contributes": { 13 | "languages": [{ 14 | "id": "ain", 15 | "aliases": ["ain", "ain"], 16 | "extensions": [".ain"], 17 | "configuration": "./language-configuration.json" 18 | }], 19 | "grammars": [{ 20 | "language": "ain", 21 | "scopeName": "source.ain", 22 | "path": "./syntaxes/ain.tmLanguage.json" 23 | }] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/e2e/templates/nok-unterminated-exec-and-quote-sequence.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | $(printf 3 | $(printf "yegga) 4 | $(printf 'gogga) 5 | 6 | # stderr: | 7 | # Fatal errors in file: $filename 8 | # Missing closing parenthesis for executable: $(printf on line 2: 9 | # 1 [Host] 10 | # 2 > $(printf 11 | # 3 $(printf "yegga) 12 | # 13 | # Unterminated quote sequence for executable: $(printf "yegga) on line 3: 14 | # 2 $(printf 15 | # 3 > $(printf "yegga) 16 | # 4 $(printf 'gogga) 17 | # 18 | # Unterminated quote sequence for executable: $(printf 'gogga) on line 4: 19 | # 3 $(printf "yegga) 20 | # 4 > $(printf 'gogga) 21 | # 5 22 | # exitcode: 1 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY= 2 | github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc= 3 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 4 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 5 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 6 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 7 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 8 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 9 | -------------------------------------------------------------------------------- /internal/pkg/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "net/url" 5 | ) 6 | 7 | const TimeoutNotSet = -1 8 | 9 | type Config struct { 10 | Timeout int32 11 | QueryDelim *string 12 | } 13 | 14 | func NewConfig() Config { 15 | return Config{Timeout: TimeoutNotSet} 16 | } 17 | 18 | type BackendInput struct { 19 | Host *url.URL 20 | Body []string 21 | Method string 22 | Headers []string 23 | 24 | Backend string 25 | BackendOptions [][]string 26 | 27 | PrintCommand bool 28 | LeaveTempFile bool 29 | 30 | TempFileName string 31 | } 32 | 33 | type TimeoutContextValueKey struct{} 34 | 35 | type BackendOutput struct { 36 | Stderr string 37 | Stdout string 38 | ExitCode int 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach to Delve", 9 | "type": "go", 10 | "request": "attach", 11 | "mode": "remote", 12 | "remotePath": "${workspaceFolder}", 13 | "port": 2345, 14 | "host": "127.0.0.1" 15 | }, 16 | { 17 | "name": "VS code textmate", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "args": ["--extensionDevelopmentPath=${workspaceFolder}/grammars/textmate"] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /internal/pkg/parse/backendopts.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jonaslu/ain/internal/pkg/utils" 7 | ) 8 | 9 | func (s *sectionedTemplate) getBackendOptions() [][]string { 10 | var backendOptions [][]string 11 | 12 | for _, backedOptionSourceMarker := range *s.getNamedSection(backendOptionsSection) { 13 | tokenizedBackendOpts, err := utils.TokenizeLine(backedOptionSourceMarker.lineContents) 14 | if err != nil { 15 | // !! TODO !! Can parse all messages don't have to return 16 | s.setFatalMessage(fmt.Sprintf("Could not parse backend-option %s", err.Error()), backedOptionSourceMarker.sourceLineIndex) 17 | return backendOptions 18 | } 19 | 20 | backendOptions = append(backendOptions, tokenizedBackendOpts) 21 | } 22 | 23 | return backendOptions 24 | } 25 | -------------------------------------------------------------------------------- /internal/pkg/disk/env.go: -------------------------------------------------------------------------------- 1 | package disk 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/hashicorp/go-envparse" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func ReadEnvFile(path string, errorOnMissingFile bool) error { 11 | file, err := os.Open(path) 12 | 13 | if os.IsNotExist(err) { 14 | if errorOnMissingFile { 15 | return errors.New("cannot open .env-file " + path) 16 | } 17 | 18 | return nil 19 | } 20 | 21 | if err != nil { 22 | return errors.Wrap(err, "error loading .env-file "+path) 23 | } 24 | 25 | if file != nil { 26 | res, err := envparse.Parse(file) 27 | if err != nil { 28 | return errors.Wrap(err, "error parsing .env-file "+path) 29 | } 30 | 31 | for envVarKey, envVarValue := range res { 32 | if _, exists := os.LookupEnv(envVarKey); !exists { 33 | if err := os.Setenv(envVarKey, envVarValue); err != nil { 34 | return errors.Wrap(err, "error setting env value from .env-file "+path) 35 | } 36 | } 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/pkg/utils/leven_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_LevenshteinDistance(t *testing.T) { 8 | tests := map[string]struct { 9 | str1 string 10 | str2 string 11 | expectedCost int 12 | }{ 13 | "empty string to empty string": { 14 | "", 15 | "", 16 | 0, 17 | }, 18 | "empty string to str2": { 19 | "", 20 | "abc", 21 | 3, 22 | }, 23 | "empty string to str1": { 24 | "abc", 25 | "", 26 | 3, 27 | }, 28 | "one insertion": { 29 | "a", 30 | "ab", 31 | 1, 32 | }, 33 | "one deletion": { 34 | "ab", 35 | "a", 36 | 1, 37 | }, 38 | "one change": { 39 | "a", 40 | "b", 41 | 1, 42 | }, 43 | } 44 | 45 | for testCase, testData := range tests { 46 | actualCost := LevenshteinDistance(testData.str1, testData.str2) 47 | if actualCost != testData.expectedCost { 48 | t.Errorf("Cost of %s failed, got: %d expected: %d", testCase, actualCost, testData.expectedCost) 49 | t.Fail() 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/pkg/parse/fatals_test.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import "testing" 4 | 5 | func Test_getLineWithNumberAndContent(t *testing.T) { 6 | tests := map[string]struct { 7 | lineIndex int 8 | lineContents string 9 | addCaret bool 10 | expected string 11 | }{ 12 | "Content and caret": { 13 | lineIndex: 1, 14 | lineContents: "test", 15 | addCaret: true, 16 | expected: "1 > test", 17 | }, 18 | "Content and no caret": { 19 | lineIndex: 2, 20 | lineContents: "test", 21 | addCaret: false, 22 | expected: "2 test", 23 | }, 24 | "No content no three space column": { 25 | lineIndex: 3, 26 | lineContents: "", 27 | addCaret: true, 28 | expected: "3", 29 | }, 30 | } 31 | 32 | for name, args := range tests { 33 | t.Run(name, func(t *testing.T) { 34 | if got := getLineWithNumberAndContent(args.lineIndex, args.lineContents, args.addCaret); got != args.expected { 35 | t.Errorf("getLineWithNumberAndContent() = %v, want %v", got, args.expected) 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/pkg/disk/filename.go: -------------------------------------------------------------------------------- 1 | package disk 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/jonaslu/ain/internal/pkg/utils" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | func GetTemplateFilenames(cmdParamTemplateFileNames []string) ([]string, error) { 12 | fi, err := os.Stdin.Stat() 13 | if err != nil { 14 | return nil, errors.Wrap(err, "could not stat stdin") 15 | } 16 | 17 | if (fi.Mode() & os.ModeCharDevice) == 0 { 18 | fileNameBytes, err := io.ReadAll(os.Stdin) 19 | if err != nil { 20 | return nil, errors.Wrap(err, "could not read pipe stdin") 21 | } 22 | 23 | localTemplateFilenamesViaPipe, err := utils.TokenizeLine(string(fileNameBytes)) 24 | if err != nil { 25 | return nil, errors.Wrap(err, "could not read template name(s) from pipe") 26 | } 27 | 28 | if len(localTemplateFilenamesViaPipe) == 0 { 29 | return nil, errors.New("pipe input did not contain any template names") 30 | } 31 | 32 | cmdParamTemplateFileNames = append(cmdParamTemplateFileNames, localTemplateFilenamesViaPipe...) 33 | } 34 | 35 | return cmdParamTemplateFileNames, nil 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jonas Lundberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/pkg/parse/backend.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/jonaslu/ain/internal/pkg/call" 8 | "github.com/jonaslu/ain/internal/pkg/utils" 9 | ) 10 | 11 | func (s *sectionedTemplate) getBackend() string { 12 | backendSourceMarkers := *s.getNamedSection(backendSection) 13 | if len(backendSourceMarkers) == 0 { 14 | return "" 15 | } 16 | 17 | if len(backendSourceMarkers) > 1 { 18 | s.setFatalMessage("Found several lines under [Backend]", backendSourceMarkers[0].sourceLineIndex) 19 | return "" 20 | } 21 | 22 | backendSourceMarker := backendSourceMarkers[0] 23 | backend := strings.ToLower(backendSourceMarker.lineContents) 24 | 25 | if !call.ValidBackend(backend) { 26 | for backendName := range call.ValidBackends { 27 | if utils.LevenshteinDistance(backend, backendName) < 3 { 28 | s.setFatalMessage(fmt.Sprintf("Unknown backend: %s. Did you mean %s", backend, backendName), backendSourceMarker.sourceLineIndex) 29 | return "" 30 | } 31 | } 32 | 33 | s.setFatalMessage(fmt.Sprintf("Unknown backend %s", backend), backendSourceMarker.sourceLineIndex) 34 | return "" 35 | } 36 | 37 | return backend 38 | } 39 | -------------------------------------------------------------------------------- /internal/pkg/utils/leven.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // So, there are other more effective solutions. 4 | // But I get this one, because I've made it. 5 | func LevenshteinDistance(str1, str2 string) int { 6 | // [ s t r 1] 7 | // [ 0 1 2 3 4] 8 | // [ 0 ] 9 | // [s 1 ] 10 | // [t 2 ] 11 | // [r 3 ] 12 | // [2 4 ] 13 | 14 | str1len := len(str1) 15 | str2len := len(str2) 16 | 17 | matrix := make([][]int, str2len+1) 18 | 19 | // Fill downwards and set the cost of changing str2 20 | // to the empty string 21 | for i := 0; i <= str2len; i++ { 22 | matrix[i] = make([]int, str1len+1) 23 | matrix[i][0] = i 24 | } 25 | 26 | // Fill leftwards cost of changing str1 27 | // to the empty string 28 | for i := 0; i <= str1len; i++ { 29 | matrix[0][i] = i 30 | } 31 | 32 | // Go down str2 one row at a time and fill left 33 | // the cost of changing str1 to the substring of str2 34 | for i := 1; i <= str2len; i++ { 35 | for j := 1; j <= str1len; j++ { 36 | if str1[j-1] == str2[i-1] { 37 | matrix[i][j] = matrix[i-1][j-1] 38 | continue 39 | } 40 | 41 | min := matrix[i-1][j] 42 | if matrix[i][j-1] < min { 43 | min = matrix[i][j-1] 44 | } 45 | 46 | if matrix[i-1][j-1] < min { 47 | min = matrix[i-1][j-1] 48 | } 49 | 50 | matrix[i][j] = min + 1 51 | } 52 | } 53 | 54 | return matrix[str2len][str1len] 55 | } 56 | -------------------------------------------------------------------------------- /examples/dummyjson.com/simple/README.md: -------------------------------------------------------------------------------- 1 | Simple example of when just starting out with an API using data from [https://dummyjson.com](https://dummyjson.com/docs). 2 | 3 | # Follow along 4 | To follow along you need ain and one of [curl](https://curl.se/), [wget](https://www.gnu.org/software/wget/) or [httpie](https://httpie.io/) installed. Then copy / clone out the *.ain files in this folder to your local drive. 5 | 6 | Change the backend under the `[Backend]` section in the templates to whatever you have installed on your computer. 7 | 8 | ```bash 9 | ain get-users.ain # Get the first 10 users 10 | ID=1 ain get-users-by-id.ain # Get user with id 1 11 | 12 | ain get-products.ain # Get the first 10 products 13 | SKIP=10 get-products.ain # Get the next 10 products 14 | 15 | ID=3 ain get-product-by-id.ain # Get product with id 3 16 | ``` 17 | 18 | # Layout explanation 19 | The layout is intentionally flat and contains duplication of 20 | the base URL, backend and backend options. There is one file per API-call: getting all users, one user by id, all products and one product by id. 21 | 22 | Using ain should be a gradual process. There is plenty of duplication between the files which is ok since it's more important to try out the API than to structure it properly. 23 | 24 | You modify the API-call by changing values in the files or commenting out lines. Once the duplication starts to get troublesome ain let's you gradually refactor and extract common parts. 25 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | # you may remove this if you don't need go generate 8 | - ./check-ain-version.sh {{ .Tag }} 9 | builds: 10 | - main: ./cmd/ain/main.go 11 | ldflags: 12 | - -s -w -X main.gitSha={{.ShortCommit}} 13 | env: 14 | - CGO_ENABLED=0 15 | goos: 16 | - linux 17 | - windows 18 | - darwin 19 | archives: 20 | - id: default 21 | name_template: >- 22 | {{- .ProjectName }}_ 23 | {{- .Version }}_ 24 | {{- if eq .Os "darwin"}}mac_os 25 | {{- else }}{{ tolower .Os }}{{ end }}_ 26 | {{- if eq .Arch "amd64" }}x86_64 27 | {{- else if eq .Arch "386" }}i386 28 | {{- else }}{{ .Arch }}{{ end }} 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | files: 33 | - assets/* 34 | - README.md 35 | - LICENSE 36 | wrap_in_directory: true 37 | scoops: 38 | - repository: 39 | owner: jonaslu 40 | name: scoop-tools 41 | homepage: "https://github.com/jonaslu/ain" 42 | description: "Ain is a terminal API client. It's an alternative to postman, paw or insomnia." 43 | license: MIT 44 | release: 45 | draft: true 46 | checksum: 47 | name_template: 'checksums.txt' 48 | snapshot: 49 | name_template: "{{ .Version }}" 50 | changelog: 51 | sort: asc 52 | filters: 53 | exclude: 54 | - '^docs:' 55 | - '^test:' 56 | -------------------------------------------------------------------------------- /internal/pkg/data/backendinput.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func (bi *BackendInput) CreateBodyTempFile() error { 11 | if len(bi.Body) == 0 { 12 | return nil 13 | } 14 | 15 | tempFileDir := "" 16 | 17 | if bi.PrintCommand { 18 | cwd, err := os.Getwd() 19 | if err != nil { 20 | return errors.Wrap(err, "could not get current working dir, cannot store any body temp-file") 21 | } 22 | 23 | tempFileDir = cwd 24 | } 25 | 26 | bodyStr := strings.Join(bi.Body, "\n") 27 | 28 | tmpFile, err := os.CreateTemp(tempFileDir, "ain-body") 29 | if err != nil { 30 | return errors.Wrap(err, "could not create tempfile") 31 | } 32 | 33 | if _, err := tmpFile.Write([]byte(bodyStr)); err != nil { 34 | // This also returns an error, but the first is more significant 35 | // so ignore this, it's only a temp-file that will be deleted eventually 36 | _ = tmpFile.Close() 37 | 38 | return errors.Wrap(err, "could not write to tempfile") 39 | } 40 | 41 | bi.TempFileName = tmpFile.Name() 42 | 43 | return nil 44 | } 45 | 46 | func (bi *BackendInput) RemoveBodyTempFile(forceDeletion bool) error { 47 | if bi.TempFileName == "" { 48 | return nil 49 | } 50 | 51 | if !forceDeletion && bi.LeaveTempFile { 52 | return nil 53 | } 54 | 55 | err := os.Remove(bi.TempFileName) 56 | bi.TempFileName = "" 57 | 58 | if err != nil { 59 | return errors.Wrap(err, "could not remove file with [Body] contents") 60 | } 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /grammars/tests.ain: -------------------------------------------------------------------------------- 1 | # Headings 2 | ## Ok 3 | [hOst] 4 | [HOST] # Weird casing alone and with comments 5 | [config] 6 | [hOst] 7 | [quEry] 8 | [heaDers] 9 | [methOd] 10 | [bODY] 11 | [bACKEND] 12 | [backendoptions] 13 | 14 | ## Nok 15 | [host] \`# # escaped comment 16 | [host] [host] # two on a line 17 | [config] but not text 18 | or before [config] 19 | `[headers] # literal section header as text 20 | 21 | # Envvars 22 | ${VAR1} text ${VAR2} # two on a line 23 | ${VAR`}Z} text # escaped end bracket 24 | `${VAR} # escaped envvar 25 | \`${VAR} # literal backtick before envvar 26 | 27 | # Executables 28 | $(exec arg1) text $(exec arg2) # two on a line 29 | $(exec `) ab) # escaped end parenthesis 30 | $(exec ab "aljfk()" cd 'alkj()' ef arg2) # parenthesis inside quoted strings 31 | $(exec '"' ab '"' 123 "\"" cd '\'' ef) # escaping single and double quotes 32 | $(exec ${VAR} ab `${VAR} cd \`${VAR} ef) # envvars works as outside within executables 33 | $(exec "${VAR} ab `${VAR} cd \`${VAR}" ef) # envvars works as outside within double quotes in executables 34 | $(exec '${VAR} ab `${VAR} cd \`${VAR}' ef) # envvars works as outside within single quotes in executables 35 | `$(exec) # escaped executable 36 | \`$(exec) # literal backtick before executable 37 | 38 | # Comments 39 | # I'm a comment and I'm ok 40 | text `# not a comment # but I am 41 | text \`# Literal backtick before a comment 42 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | tasks: 4 | build: 5 | desc: Build ain as a binary in the root folder 6 | cmds: 7 | - go build -o ain cmd/ain/main.go 8 | 9 | build:release: 10 | desc: Builds all release binaries using goreleaser 11 | cmds: 12 | - goreleaser release --clean --snapshot 13 | 14 | run: 15 | desc: runs develop version of ain with arguments 16 | cmds: 17 | - go run cmd/ain/main.go {{.CLI_ARGS}} 18 | 19 | run:stdsplit: 20 | desc: runs develop version of ain and annotates stdout, stderr 21 | cmds: 22 | - "go run cmd/ain/main.go {{.CLI_ARGS}} > >(sed 's/^/(o): /') 2> >(sed 's/^/(e): /' >&2)" 23 | 24 | test: 25 | desc: Run tests 26 | cmds: 27 | - go test ./... 28 | 29 | test:e2e:files: 30 | desc: Run e2e tests for files specified as arguments 31 | cmds: 32 | - go test test/e2e/e2e_test.go -- {{.CLI_ARGS}} 33 | 34 | test:cover: 35 | desc: Run tests with coverage 36 | env: 37 | E2EGOCOVERDIR: "{{.PWD}}/cov/e2e" 38 | cmds: 39 | - rm -r {{.PWD}}/cov/ 40 | - mkdir -p {{.PWD}}/cov/unit {{.PWD}}/cov/e2e 41 | - go test -cover ./... -args -test.gocoverdir="{{.PWD}}/cov/unit" 42 | - go tool covdata textfmt -i=./cov/unit,./cov/e2e -o cov/profile.out 43 | - go tool cover -html=cov/profile.out -o cov/coverage.html 44 | - xdg-open cov/coverage.html 45 | 46 | update:docs: 47 | desc: Update README.md toc 48 | cmds: 49 | - npx doctoc --github --notitle --maxlevel=2 --update-only README.md 50 | 51 | debug:dlv: 52 | desc: Run ain with delve debugger 53 | cmds: 54 | - dlv debug --headless --api-version=2 --listen=:2345 cmd/ain/main.go -- {{.CLI_ARGS}} 55 | -------------------------------------------------------------------------------- /test/e2e/templates/escaping/ok-escaping-and-returned-values.ain: -------------------------------------------------------------------------------- 1 | [Host] 2 | localhost 3 | 4 | [Backend] 5 | curl 6 | 7 | [Headers] 8 | # The line below proves that quoting the symbols returns the literal string 9 | EscapingSymbols: `${VAR} `$(exec) `# # comment 10 | 11 | # The line below proves that quoting is needed on execs in envvars, otherwise they're expanded 12 | EnvWithExec: ${EXECNOUOTEISEXPANDED} ${EXECQUOTEDNOTEXPANDED} 13 | 14 | # The line below proves that neither envvars nor executables needs quoting when returned from an executable 15 | Exec: $(templates/escaping/return-unquoted-envvar.sh) $(templates/escaping/return-unquoted-exec.sh) 16 | 17 | # The line below proves that comments need escaping in envvars, lest it becomes a comment 18 | QuotedCommentAndNoQuotedCommentEnv: ${QUOTEDCOMMENT} ${UNQUOTEDCOMMENT} 19 | 20 | # The line below proves that comments need escaping in envvars, lest it becomes a comment 21 | QuotedCommentAndNoQuotedCommentExec: $(templates/escaping/return-quoted-comment.sh) $(templates/escaping/return-unquoted-comment.sh) 22 | 23 | `[Headers] # New supported way with backtick 24 | \`[Headers] # Literal backtick before the text [Headers] 25 | \[Headers] # Old legacy way that will be removed 26 | 27 | # env: 28 | # - EXECNOUOTEISEXPANDED=$(printf 1) 29 | # - EXECQUOTEDNOTEXPANDED=`$(iwouldfail) 30 | # - QUOTEDCOMMENT=`# comment 31 | # - UNQUOTEDCOMMENT=# $(fail) 32 | # args: 33 | # - -p 34 | # stdout: | 35 | # curl -H 'EscapingSymbols: ${VAR} $(exec) #' \ 36 | # -H 'EnvWithExec: 1 $(iwouldfail)' \ 37 | # -H 'Exec: ${ENV} $(exec)' \ 38 | # -H 'QuotedCommentAndNoQuotedCommentEnv: # comment' \ 39 | # -H 'QuotedCommentAndNoQuotedCommentExec: #' \ 40 | # -H '[Headers]' \ 41 | # -H '`[Headers]' \ 42 | # -H '[Headers]' \ 43 | # 'localhost' 44 | -------------------------------------------------------------------------------- /internal/pkg/parse/envvars.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/jonaslu/ain/internal/pkg/utils" 9 | ) 10 | 11 | const maximumLevenshteinDistance = 2 12 | const maximumNumberOfSuggestions = 3 13 | 14 | func formatMissingEnvVarErrorMessage(missingEnvVar string) string { 15 | suggestions := []string{} 16 | missingEnvVarLen := len(missingEnvVar) 17 | 18 | for _, envKeyValue := range os.Environ() { 19 | key := strings.SplitN(envKeyValue, "=", 2)[0] 20 | strLength := missingEnvVarLen - len(key) 21 | if strLength < 0 { 22 | strLength = -strLength 23 | } 24 | 25 | if strLength > maximumLevenshteinDistance { 26 | continue 27 | } 28 | 29 | if utils.LevenshteinDistance(missingEnvVar, key) <= maximumLevenshteinDistance { 30 | suggestions = append(suggestions, key) 31 | 32 | if len(suggestions) >= maximumNumberOfSuggestions { 33 | break 34 | } 35 | } 36 | } 37 | 38 | if len(suggestions) > 0 { 39 | return fmt.Sprintf("Cannot find value for variable %s. Did you mean %s", missingEnvVar, strings.Join(suggestions, " or ")) 40 | } 41 | 42 | return fmt.Sprintf("Cannot find value for variable %s", missingEnvVar) 43 | } 44 | 45 | func (s *sectionedTemplate) substituteEnvVars() { 46 | s.expandTemplateLines(tokenizeEnvVars, func(c token) (string, string) { 47 | envVarKey := c.content 48 | if envVarKey == "" { 49 | return "", "Empty variable" 50 | } 51 | 52 | // I'll try anything that is not empty, if the user can't set (such as a variable with spaces in bash) it we can't find it anyway. 53 | // https://stackoverflow.com/questions/2821043/allowed-characters-in-linux-environment-variable-names 54 | value, exists := os.LookupEnv(envVarKey) 55 | 56 | if !exists { 57 | return "", formatMissingEnvVarErrorMessage(envVarKey) 58 | } 59 | 60 | if value == "" { 61 | return "", fmt.Sprintf("Value for variable %s is empty", envVarKey) 62 | } 63 | 64 | return value, "" 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /grammars/vim/syntax/ain.vim: -------------------------------------------------------------------------------- 1 | " ~/.vim/syntax/ain.vim 2 | if exists("b:current_syntax") 3 | finish 4 | endif 5 | 6 | " Headings 7 | syntax match ainHeading /^\s*\[\(config\|host\|query\|headers\|method\|body\|backend\|backendoptions\)\]\s*\ze\(\s*#\|\s*$\)\c/ 8 | highlight link ainHeading Keyword 9 | 10 | " Escapes 11 | syntax match ainEscape /\\`/ 12 | syntax match ainEnvvarEscape /`\${/ 13 | syntax match ainEscape /`\$(/ 14 | syntax match ainEscape /`#/ 15 | highlight link ainEscape Normal 16 | highlight link ainEnvvarEscape Normal 17 | 18 | " Envvars: ${VAR} 19 | syntax region ainEnvvar start=+\${+ end=+}+ contains=ainEnvvarEndEscape 20 | syntax match ainEnvvarEndEscape /`}/ contained 21 | highlight link ainEnvvar Identifier 22 | highlight link ainEnvvarEndEscape Identifier 23 | 24 | syntax match ainEscapeContained /\\`/ contained 25 | syntax match ainEnvvarEscapeContained /`\${/ contained 26 | 27 | " Executables: $(command) 28 | syntax region ainExec start=+\$(+ end=+)+ contains=ainEscapeContained,ainEnvvarEscapeContained,ainEnvvar,ainExecEscape,ainSQ,ainDQ 29 | syntax match ainExecEscape /`)/ contained 30 | highlight link ainExec Type 31 | highlight link ainExecEscape Type 32 | highlight link ainEnvvarEscapeContained Type 33 | highlight link ainEscapeContained Type 34 | 35 | " Single-quoted strings inside executables 36 | syntax region ainSQ start=+'+ end=+'+ contains=ainEscapeContained,ainEnvvarEscapeContained,ainEnvvar,ainSQEscape contained 37 | syntax match ainSQEscape /\\'/ contained 38 | highlight link ainSQ String 39 | highlight link ainSQEscape String 40 | highlight link ainEnvvarEscapeContained String 41 | highlight link ainEscapeContained String 42 | 43 | " Double-quoted strings inside executables 44 | syntax region ainDQ start=+"+ end=+"+ contains=ainEscapeContained,ainEnvvarEscapeContained,ainEnvvar,ainDQEscape contained 45 | syntax match ainDQEscape /\\"/ contained 46 | highlight link ainDQ String 47 | highlight link ainDQEscape String 48 | highlight link ainEnvvarEscapeContained String 49 | highlight link ainEscapeContained String 50 | 51 | " Comments # comment 52 | syntax match ainComment /#.*/ 53 | highlight link ainComment Comment 54 | 55 | let b:current_syntax = "ain" 56 | -------------------------------------------------------------------------------- /internal/pkg/parse/executable_test.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func Test_sectionedTemplate_insertExecutableOutputGoodCases(t *testing.T) { 10 | tests := map[string]struct { 11 | inputTemplate string 12 | executableResults *[]executableOutput 13 | expectedResult []expandedSourceMarker 14 | }{ 15 | "Executable output inserted": { 16 | inputTemplate: "$(cmd)", 17 | executableResults: &[]executableOutput{{ 18 | cmdOutput: "cmd output", 19 | fatalMessage: "", 20 | }}, 21 | expectedResult: []expandedSourceMarker{{ 22 | content: "cmd output", 23 | fatalContent: "cmd output", 24 | comment: "", 25 | sourceLineIndex: 0, 26 | expanded: true, 27 | }}}, 28 | "Escaped quoting kept in fatal context": { 29 | inputTemplate: "$(cmd1) `$(cmd2)", 30 | executableResults: &[]executableOutput{{ 31 | cmdOutput: "cmd1 output", 32 | fatalMessage: "", 33 | }}, 34 | expectedResult: []expandedSourceMarker{{ 35 | content: "cmd1 output $(cmd2)", 36 | fatalContent: "cmd1 output `$(cmd2)", 37 | comment: "", 38 | sourceLineIndex: 0, 39 | expanded: true, 40 | }}, 41 | }, 42 | } 43 | for name, test := range tests { 44 | s := newSectionedTemplate(test.inputTemplate, "") 45 | 46 | if s.insertExecutableOutput(test.executableResults); s.hasFatalMessages() { 47 | t.Errorf("Got unexpected fatals, %s ", s.getFatalMessages()) 48 | } else { 49 | if !reflect.DeepEqual(test.expectedResult, s.expandedTemplateLines) { 50 | t.Errorf("Test: %s. Expected %v, got: %v", name, test.expectedResult, s.expandedTemplateLines) 51 | } 52 | } 53 | } 54 | } 55 | 56 | func Test_sectionedTemplate_insertExecutableOutputBadCases(t *testing.T) { 57 | tests := map[string]struct { 58 | inputTemplate string 59 | executableResults *[]executableOutput 60 | expectedFatalMessage string 61 | }{ 62 | "Executable fatal returned": { 63 | inputTemplate: "$(cmd)", 64 | executableResults: &[]executableOutput{{ 65 | cmdOutput: "", 66 | fatalMessage: "This is the fatal message", 67 | }}, 68 | expectedFatalMessage: "This is the fatal message", 69 | }, 70 | } 71 | for name, test := range tests { 72 | s := newSectionedTemplate(test.inputTemplate, "") 73 | s.insertExecutableOutput(test.executableResults) 74 | 75 | if len(s.fatals) != 1 { 76 | t.Errorf("Test: %s. Wrong number of fatals", name) 77 | } 78 | 79 | if !strings.Contains(s.fatals[0], test.expectedFatalMessage) { 80 | t.Errorf("Test: %s. Unexpected error message: %s", name, s.fatals[0]) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/pkg/call/curl.go: -------------------------------------------------------------------------------- 1 | package call 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "strings" 7 | 8 | "github.com/jonaslu/ain/internal/pkg/data" 9 | "github.com/jonaslu/ain/internal/pkg/utils" 10 | ) 11 | 12 | type curl struct { 13 | backendInput *data.BackendInput 14 | binaryName string 15 | } 16 | 17 | func newCurlBackend(backendInput *data.BackendInput, binaryName string) backend { 18 | return &curl{ 19 | backendInput: backendInput, 20 | binaryName: binaryName, 21 | } 22 | } 23 | 24 | func (curl *curl) getHeaderArguments(escape bool) [][]string { 25 | args := [][]string{} 26 | for _, header := range curl.backendInput.Headers { 27 | headerVal := header 28 | if escape { 29 | headerVal = utils.EscapeForShell(header) 30 | } 31 | 32 | args = append(args, []string{"-H", headerVal}) 33 | } 34 | 35 | return args 36 | } 37 | 38 | func (curl *curl) getMethodArgument(escape bool) []string { 39 | if curl.backendInput.Method != "" { 40 | methodCapitalized := strings.ToUpper(curl.backendInput.Method) 41 | if escape { 42 | methodCapitalized = utils.EscapeForShell(methodCapitalized) 43 | } 44 | 45 | return []string{"-X", methodCapitalized} 46 | } 47 | 48 | return []string{} 49 | } 50 | 51 | func (curl *curl) getBodyArgument() []string { 52 | if curl.backendInput.TempFileName != "" { 53 | return []string{"-d", "@" + curl.backendInput.TempFileName} 54 | } 55 | 56 | return []string{} 57 | } 58 | 59 | func (curl *curl) getAsCmd(ctx context.Context) *exec.Cmd { 60 | args := []string{} 61 | for _, backendOpt := range curl.backendInput.BackendOptions { 62 | args = append(args, backendOpt...) 63 | } 64 | 65 | args = append(args, curl.getMethodArgument(false)...) 66 | for _, headerArgs := range curl.getHeaderArguments(false) { 67 | args = append(args, headerArgs...) 68 | } 69 | 70 | args = append(args, curl.getBodyArgument()...) 71 | args = append(args, curl.backendInput.Host.String()) 72 | 73 | return exec.CommandContext(ctx, curl.binaryName, args...) 74 | } 75 | 76 | func (curl *curl) getAsString() string { 77 | args := [][]string{} 78 | 79 | for _, optionLine := range curl.backendInput.BackendOptions { 80 | lineArguments := []string{} 81 | for _, option := range optionLine { 82 | lineArguments = append(lineArguments, utils.EscapeForShell(option)) 83 | } 84 | args = append(args, lineArguments) 85 | } 86 | 87 | args = append(args, curl.getMethodArgument(true)) 88 | args = append(args, curl.getHeaderArguments(true)...) 89 | 90 | args = append(args, curl.getBodyArgument()) 91 | args = append(args, []string{ 92 | utils.EscapeForShell(curl.backendInput.Host.String()), 93 | }) 94 | 95 | cmdAsString := curl.binaryName + " " + utils.PrettyPrintStringsForShell(args) 96 | 97 | return cmdAsString 98 | } 99 | -------------------------------------------------------------------------------- /internal/pkg/parse/config.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/jonaslu/ain/internal/pkg/data" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | var timeoutConfigRe = regexp.MustCompile(`(?i)\s*timeout\s*=\s*(-?\d+)?`) 13 | var queryDelimRe = regexp.MustCompile(`(?i)\s*querydelim\s*=\s*(.*)`) 14 | 15 | func parseQueryDelim(configStr string) (bool, string, error) { 16 | queryDelimMatch := queryDelimRe.FindStringSubmatch(configStr) 17 | if len(queryDelimMatch) != 2 { 18 | return false, "", nil 19 | } 20 | 21 | queryDelim := queryDelimMatch[1] 22 | if strings.Contains(queryDelim, " ") { 23 | return true, "", errors.New("Delimiter cannot contain space") 24 | } 25 | 26 | return true, queryDelimMatch[1], nil 27 | } 28 | 29 | func parseTimeoutConfig(configStr string) (bool, int32, error) { 30 | timeoutMatch := timeoutConfigRe.FindStringSubmatch(configStr) 31 | if len(timeoutMatch) != 2 { 32 | return false, 0, nil 33 | } 34 | 35 | timeoutIntervalStr := timeoutMatch[1] 36 | if timeoutIntervalStr == "" { 37 | return true, 0, errors.New("Malformed timeout value, must be digit > 0") 38 | } 39 | 40 | timeoutIntervalInt64, err := strconv.ParseInt(timeoutIntervalStr, 10, 32) 41 | 42 | if err != nil { 43 | return true, 0, errors.Wrap(err, "Could not parse timeout [Config] interval") 44 | } 45 | 46 | if timeoutIntervalInt64 < 1 { 47 | return true, 0, errors.New("Timeout interval must be greater than 0") 48 | } 49 | 50 | return true, int32(timeoutIntervalInt64), nil 51 | } 52 | 53 | func (s *sectionedTemplate) getConfig() data.Config { 54 | config := data.NewConfig() 55 | 56 | for _, configLine := range *s.getNamedSection(configSection) { 57 | if isTimeoutConfig, timeoutValue, err := parseTimeoutConfig(configLine.lineContents); isTimeoutConfig { 58 | if config.Timeout > 0 { 59 | // !! TODO !! Can have Query delimiter set n times 60 | s.setFatalMessage("Timeout config set twice", configLine.sourceLineIndex) 61 | return config 62 | } 63 | 64 | if err != nil { 65 | s.setFatalMessage(err.Error(), configLine.sourceLineIndex) 66 | return config 67 | } 68 | 69 | config.Timeout = timeoutValue 70 | continue 71 | } 72 | 73 | if isQueryDelim, queryDelimValue, err := parseQueryDelim(configLine.lineContents); isQueryDelim { 74 | if config.QueryDelim != nil { 75 | // !! TODO !! Can have Query delimiter set n times 76 | s.setFatalMessage("Query delimiter set twice", configLine.sourceLineIndex) 77 | return config 78 | } 79 | 80 | if err != nil { 81 | s.setFatalMessage(err.Error(), configLine.sourceLineIndex) 82 | return config 83 | } 84 | 85 | config.QueryDelim = &queryDelimValue 86 | continue 87 | } 88 | } 89 | 90 | return config 91 | } 92 | -------------------------------------------------------------------------------- /internal/pkg/parse/fatals.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | func getLineWithNumberAndContent(lineIndex int, lineContents string, addCaret bool) string { 9 | line := strconv.Itoa(lineIndex) 10 | 11 | if lineContents != "" { 12 | if addCaret { 13 | line = line + " > " 14 | } else { 15 | line = line + " " 16 | } 17 | 18 | line = line + lineContents 19 | } 20 | 21 | return line 22 | } 23 | 24 | func (s *sectionedTemplate) setFatalMessage(msg string, expandedSourceLineIndex int) { 25 | var templateContext []string 26 | 27 | expandedTemplateLine := s.expandedTemplateLines[expandedSourceLineIndex] 28 | 29 | errorLine := expandedTemplateLine.sourceLineIndex 30 | lineBefore := errorLine - 1 31 | if lineBefore >= 0 { 32 | templateContext = append(templateContext, getLineWithNumberAndContent(lineBefore+1, s.rawTemplateLines[lineBefore], false)) 33 | } 34 | 35 | templateContext = append(templateContext, getLineWithNumberAndContent(errorLine+1, s.rawTemplateLines[errorLine], true)) 36 | 37 | lineAfter := errorLine + 1 38 | if lineAfter < len(s.rawTemplateLines) { 39 | templateContext = append(templateContext, getLineWithNumberAndContent(lineAfter+1, s.rawTemplateLines[lineAfter], false)) 40 | } 41 | 42 | message := msg + " on line " + strconv.Itoa(errorLine+1) + ":\n" 43 | message = message + strings.Join(templateContext, "\n") 44 | 45 | if expandedTemplateLine.expanded { 46 | expandedMsg := "\nExpanded context:" 47 | beforeLine, nextLine := expandedSourceLineIndex-1, expandedSourceLineIndex+1 48 | 49 | if beforeLine > -1 && s.expandedTemplateLines[beforeLine].expanded { 50 | expandedMsg = expandedMsg + "\n" + getLineWithNumberAndContent(s.expandedTemplateLines[beforeLine].sourceLineIndex+1, s.expandedTemplateLines[beforeLine].String(), false) 51 | } 52 | 53 | expandedMsg = expandedMsg + "\n" + getLineWithNumberAndContent(expandedTemplateLine.sourceLineIndex+1, expandedTemplateLine.String(), true) 54 | 55 | if nextLine < len(s.expandedTemplateLines) && s.expandedTemplateLines[nextLine].expanded { 56 | expandedMsg = expandedMsg + "\n" + getLineWithNumberAndContent(s.expandedTemplateLines[nextLine].sourceLineIndex+1, s.expandedTemplateLines[nextLine].String(), false) 57 | } 58 | 59 | message = message + expandedMsg 60 | } 61 | 62 | s.fatals = append(s.fatals, message) 63 | } 64 | 65 | func (s *sectionedTemplate) getFatalMessages() string { 66 | fatalMessage := "Fatal error" 67 | if len(s.fatals) > 1 { 68 | fatalMessage = fatalMessage + "s" 69 | } 70 | 71 | fatalMessage = fatalMessage + " in file: " + s.filename + "\n" 72 | 73 | return fatalMessage + strings.Join(s.fatals, "\n\n") 74 | } 75 | 76 | func (s *sectionedTemplate) hasFatalMessages() bool { 77 | return len(s.fatals) > 0 78 | } 79 | -------------------------------------------------------------------------------- /internal/pkg/call/httpie.go: -------------------------------------------------------------------------------- 1 | package call 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "strings" 7 | 8 | "github.com/jonaslu/ain/internal/pkg/data" 9 | "github.com/jonaslu/ain/internal/pkg/utils" 10 | ) 11 | 12 | type httpie struct { 13 | backendInput *data.BackendInput 14 | binaryName string 15 | } 16 | 17 | func prependIgnoreStdin(backendInput *data.BackendInput) { 18 | var foundIgnoreStdin bool 19 | 20 | for _, backendOptionLine := range backendInput.BackendOptions { 21 | for _, backendOption := range backendOptionLine { 22 | if backendOption == "--ignore-stdin" { 23 | foundIgnoreStdin = true 24 | break 25 | } 26 | } 27 | } 28 | 29 | if !foundIgnoreStdin { 30 | backendInput.BackendOptions = append([][]string{{"--ignore-stdin"}}, backendInput.BackendOptions...) 31 | } 32 | } 33 | 34 | func newHttpieBackend(backendInput *data.BackendInput, binaryName string) backend { 35 | prependIgnoreStdin(backendInput) 36 | return &httpie{ 37 | backendInput: backendInput, 38 | binaryName: binaryName, 39 | } 40 | } 41 | 42 | func (httpie *httpie) getMethodArgument() string { 43 | return strings.ToUpper(httpie.backendInput.Method) 44 | } 45 | 46 | func (httpie *httpie) getBodyArgument() []string { 47 | if httpie.backendInput.TempFileName != "" { 48 | return []string{"@" + httpie.backendInput.TempFileName} 49 | } 50 | 51 | return []string{} 52 | } 53 | 54 | func (httpie *httpie) getAsCmd(ctx context.Context) *exec.Cmd { 55 | args := []string{} 56 | for _, backendOpt := range httpie.backendInput.BackendOptions { 57 | args = append(args, backendOpt...) 58 | } 59 | 60 | if httpie.backendInput.Method != "" { 61 | args = append(args, httpie.getMethodArgument()) 62 | } 63 | 64 | args = append(args, httpie.backendInput.Host.String()) 65 | args = append(args, httpie.backendInput.Headers...) 66 | args = append(args, httpie.getBodyArgument()...) 67 | 68 | httpCmd := exec.CommandContext(ctx, httpie.binaryName, args...) 69 | return httpCmd 70 | } 71 | 72 | func (httpie *httpie) getAsString() string { 73 | args := [][]string{} 74 | for _, optionLine := range httpie.backendInput.BackendOptions { 75 | lineArguments := []string{} 76 | for _, option := range optionLine { 77 | lineArguments = append(lineArguments, utils.EscapeForShell(option)) 78 | } 79 | args = append(args, lineArguments) 80 | } 81 | 82 | if httpie.backendInput.Method != "" { 83 | args = append(args, []string{utils.EscapeForShell(httpie.getMethodArgument())}) 84 | } 85 | 86 | args = append(args, []string{utils.EscapeForShell(httpie.backendInput.Host.String())}) 87 | 88 | for _, header := range httpie.backendInput.Headers { 89 | args = append(args, []string{utils.EscapeForShell(header)}) 90 | } 91 | 92 | args = append(args, httpie.getBodyArgument()) 93 | 94 | output := httpie.binaryName + " " + utils.PrettyPrintStringsForShell(args) 95 | 96 | return output 97 | } 98 | -------------------------------------------------------------------------------- /internal/pkg/parse/envvars_test.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func Test_sectionedTemplate_expandEnvVars_GoodCases(t *testing.T) { 11 | tests := map[string]struct { 12 | beforeTest func() 13 | inputTemplate string 14 | expectedResult []expandedSourceMarker 15 | }{ 16 | "Substitution works": { 17 | beforeTest: func() { 18 | os.Setenv("VAR1", "value1") 19 | os.Setenv("VAR2", "value2") 20 | }, 21 | inputTemplate: "${VAR1} ${VAR2}", 22 | expectedResult: []expandedSourceMarker{{ 23 | content: "value1 value2", 24 | fatalContent: "value1 value2", 25 | comment: "", 26 | sourceLineIndex: 0, 27 | expanded: true, 28 | }}}, 29 | "Fatal context keeps quoted envvars": { 30 | beforeTest: func() { 31 | os.Setenv("VAR1", "value1") 32 | }, 33 | inputTemplate: "${VAR1} `${VAR2}", 34 | expectedResult: []expandedSourceMarker{{ 35 | content: "value1 ${VAR2}", 36 | fatalContent: "value1 `${VAR2}", 37 | comment: "", 38 | sourceLineIndex: 0, 39 | expanded: true, 40 | }}, 41 | }, 42 | } 43 | for name, test := range tests { 44 | test.beforeTest() 45 | s := newSectionedTemplate(test.inputTemplate, "") 46 | 47 | if s.substituteEnvVars(); s.hasFatalMessages() { 48 | t.Errorf("Got unexpected fatals, %s ", s.getFatalMessages()) 49 | } else { 50 | if !reflect.DeepEqual(test.expectedResult, s.expandedTemplateLines) { 51 | t.Errorf("Test: %s. Expected %v, got: %v", name, test.expectedResult, s.expandedTemplateLines) 52 | } 53 | } 54 | } 55 | } 56 | 57 | func Test_sectionedTemplate_expandEnvVars_BadCases(t *testing.T) { 58 | tests := map[string]struct { 59 | beforeTest func() 60 | input string 61 | expectedFatalMessage string 62 | }{ 63 | "Empty variable": { 64 | beforeTest: func() {}, 65 | input: "${}", 66 | expectedFatalMessage: "Empty variable", 67 | }, 68 | "Cannot find value for variable": { 69 | beforeTest: func() { 70 | os.Unsetenv("VAR") 71 | }, 72 | input: "${VAR}", 73 | expectedFatalMessage: "Cannot find value for variable VAR", 74 | }, 75 | "Value for variable is empty": { 76 | beforeTest: func() { 77 | os.Setenv("VAR", "") 78 | }, 79 | input: "${VAR}", 80 | expectedFatalMessage: "Value for variable VAR is empty", 81 | }, 82 | } 83 | 84 | for name, test := range tests { 85 | test.beforeTest() 86 | s := newSectionedTemplate(test.input, "") 87 | s.substituteEnvVars() 88 | 89 | if len(s.fatals) != 1 { 90 | t.Errorf("Test: %s. Wrong number of fatals", name) 91 | } 92 | 93 | if !strings.Contains(s.fatals[0], test.expectedFatalMessage) { 94 | t.Errorf("Test: %s. Unexpected error message: %s", name, s.fatals[0]) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/pkg/call/call.go: -------------------------------------------------------------------------------- 1 | package call 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os/exec" 7 | 8 | "github.com/jonaslu/ain/internal/pkg/data" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type backendConstructor struct { 13 | BinaryName string 14 | constructor func(*data.BackendInput, string) backend 15 | } 16 | 17 | var ValidBackends = map[string]backendConstructor{ 18 | "curl": { 19 | BinaryName: "curl", 20 | constructor: newCurlBackend, 21 | }, 22 | "httpie": { 23 | BinaryName: "http", 24 | constructor: newHttpieBackend, 25 | }, 26 | "wget": { 27 | BinaryName: "wget", 28 | constructor: newWgetBackend, 29 | }, 30 | } 31 | 32 | type backend interface { 33 | getAsCmd(context.Context) *exec.Cmd 34 | getAsString() string 35 | } 36 | 37 | func getBackend(backendInput *data.BackendInput) (backend, error) { 38 | requestedBackend := backendInput.Backend 39 | 40 | if backendConstructor, exists := ValidBackends[requestedBackend]; exists { 41 | return backendConstructor.constructor(backendInput, backendConstructor.BinaryName), nil 42 | } 43 | 44 | return nil, errors.Errorf("Unknown backend: %s", requestedBackend) 45 | } 46 | 47 | func ValidBackend(backendName string) bool { 48 | if _, exists := ValidBackends[backendName]; exists { 49 | return true 50 | } 51 | 52 | return false 53 | } 54 | 55 | type Call struct { 56 | backendInput *data.BackendInput 57 | backend backend 58 | forceRemoveTempFile bool 59 | } 60 | 61 | func Setup(backendInput *data.BackendInput) (*Call, error) { 62 | call := Call{backendInput: backendInput} 63 | 64 | backend, err := getBackend(backendInput) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | call.backend = backend 70 | 71 | if err := backendInput.CreateBodyTempFile(); err != nil { 72 | return nil, err 73 | } 74 | 75 | return &call, nil 76 | } 77 | 78 | func (c *Call) CallAsString() string { 79 | return c.backend.getAsString() 80 | } 81 | 82 | func (c *Call) CallAsCmd(ctx context.Context) (*data.BackendOutput, error) { 83 | backendCmd := c.backend.getAsCmd(ctx) 84 | 85 | var stdout, stderr bytes.Buffer 86 | backendCmd.Stdout = &stdout 87 | backendCmd.Stderr = &stderr 88 | 89 | err := backendCmd.Run() 90 | 91 | c.forceRemoveTempFile = err != nil 92 | 93 | backendOutput := &data.BackendOutput{ 94 | Stderr: stderr.String(), 95 | Stdout: stdout.String(), 96 | ExitCode: backendCmd.ProcessState.ExitCode(), 97 | } 98 | 99 | if ctx.Err() == context.DeadlineExceeded { 100 | err = errors.Errorf("Backend-call: %s timed out after %d seconds", 101 | c.backendInput.Backend, 102 | ctx.Value(data.TimeoutContextValueKey{})) 103 | 104 | return backendOutput, err 105 | } 106 | 107 | if err != nil { 108 | return backendOutput, errors.Wrapf(err, "Error running: %s", c.backendInput.Backend) 109 | } 110 | 111 | return backendOutput, nil 112 | } 113 | 114 | func (c *Call) Teardown() error { 115 | return c.backendInput.RemoveBodyTempFile(c.forceRemoveTempFile) 116 | } 117 | -------------------------------------------------------------------------------- /internal/pkg/parse/querystring.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "net/url" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/jonaslu/ain/internal/pkg/data" 9 | ) 10 | 11 | const defaultQueryDelim = "&" 12 | const queryKeyValueDelim = "=" 13 | 14 | var rawHostKeyValueDelimRegexp = regexp.MustCompile(queryKeyValueDelim) 15 | var querySectionKeyValueDelimRegexp = regexp.MustCompile(`\s*` + queryKeyValueDelim + `\s*`) 16 | 17 | func isHex(currentChar byte) bool { 18 | if '0' <= currentChar && currentChar <= '9' || 19 | 'a' <= currentChar && currentChar <= 'f' || 20 | 'A' <= currentChar && currentChar <= 'F' { 21 | return true 22 | } 23 | 24 | return false 25 | } 26 | 27 | // Borrowed from net/url in the go standard library 28 | const upperHex = "0123456789ABCDEF" 29 | 30 | func queryEscape(queryString string) string { 31 | var result strings.Builder 32 | result.Grow(len(queryString)) 33 | 34 | for i := 0; i < len(queryString); i++ { 35 | currentChar := queryString[i] 36 | 37 | if 'a' <= currentChar && currentChar <= 'z' || 38 | 'A' <= currentChar && currentChar <= 'Z' || 39 | '0' <= currentChar && currentChar <= '9' || 40 | currentChar == '+' || 41 | currentChar == '%' && i+2 < len(queryString) && isHex(queryString[i+1]) && isHex(queryString[i+2]) { 42 | 43 | result.WriteByte(currentChar) 44 | } else { 45 | if currentChar == ' ' { 46 | result.WriteByte('+') 47 | } else { 48 | result.WriteByte('%') 49 | result.WriteByte(upperHex[currentChar>>4]) 50 | result.WriteByte(upperHex[currentChar&15]) 51 | } 52 | } 53 | } 54 | 55 | return result.String() 56 | } 57 | 58 | func encodeKeyValues(keyValues []string, queryDelim string, queryKeyValueDelimRegexp *regexp.Regexp) string { 59 | var encodedKeyValuePairs []string 60 | 61 | for _, keyValuePairStr := range keyValues { 62 | var encodedKeyValuePair string 63 | 64 | keyValuePair := queryKeyValueDelimRegexp.Split(keyValuePairStr, 2) 65 | if len(keyValuePair) == 2 { 66 | encodedKeyValuePair = strings.Join( 67 | []string{ 68 | queryEscape(keyValuePair[0]), 69 | queryEscape(keyValuePair[1]), 70 | }, 71 | queryKeyValueDelim, 72 | ) 73 | } else { 74 | encodedKeyValuePair = queryEscape(keyValuePairStr) 75 | } 76 | 77 | encodedKeyValuePairs = append(encodedKeyValuePairs, encodedKeyValuePair) 78 | } 79 | 80 | return strings.Join(encodedKeyValuePairs, queryDelim) 81 | } 82 | 83 | func addQueryString(host *url.URL, query []string, config data.Config) { 84 | if host.RawQuery == "" && len(query) == 0 { 85 | return 86 | } 87 | 88 | queryDelim := defaultQueryDelim 89 | if config.QueryDelim != nil { 90 | queryDelim = *config.QueryDelim 91 | } 92 | 93 | queryParts := []string{} 94 | if host.RawQuery != "" { 95 | if queryDelim == "" { 96 | queryParts = append(queryParts, queryEscape(host.RawQuery)) 97 | } else { 98 | rawHostKeyValues := strings.Split(host.RawQuery, queryDelim) 99 | queryParts = append(queryParts, encodeKeyValues(rawHostKeyValues, queryDelim, rawHostKeyValueDelimRegexp)) 100 | } 101 | } 102 | 103 | if len(query) > 0 { 104 | queryParts = append(queryParts, encodeKeyValues(query, queryDelim, querySectionKeyValueDelimRegexp)) 105 | } 106 | 107 | host.RawQuery = strings.Join(queryParts, queryDelim) 108 | } 109 | -------------------------------------------------------------------------------- /internal/pkg/disk/read.go: -------------------------------------------------------------------------------- 1 | package disk 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/jonaslu/ain/internal/pkg/utils" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | const fallbackEditor = "vim" 13 | 14 | func captureEditorOutput(tempFile *os.File) (string, error) { 15 | editorEnvVarName := "VISUAL" 16 | editorEnvStr := os.Getenv(editorEnvVarName) 17 | 18 | if editorEnvStr == "" { 19 | editorEnvVarName = "EDITOR" 20 | editorEnvStr = os.Getenv(editorEnvVarName) 21 | } 22 | 23 | if editorEnvStr == "" { 24 | _, err := exec.LookPath(fallbackEditor) 25 | if err != nil { 26 | return "", errors.New("cannot find the fallback editor vim on the $PATH. Cannot edit file.") 27 | } 28 | 29 | editorEnvVarName = fallbackEditor 30 | editorEnvStr = fallbackEditor 31 | } 32 | 33 | editorCmdAndArgs, err := utils.TokenizeLine(editorEnvStr) 34 | if err != nil { 35 | return "", errors.Wrapf(err, "cannot parse $%s environment variable", editorEnvVarName) 36 | } 37 | 38 | editorArgs := append(editorCmdAndArgs[1:], tempFile.Name()) 39 | 40 | cmd := exec.Command(editorCmdAndArgs[0], editorArgs...) 41 | tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) 42 | if err != nil { 43 | return "", errors.Wrap(err, "can't open /dev/tty") 44 | } 45 | 46 | cmd.Stdin = tty 47 | cmd.Stdout = tty 48 | cmd.Stderr = tty 49 | 50 | err = cmd.Run() 51 | if err != nil { 52 | return "", errors.Wrapf(err, "error running $%s %s", editorEnvVarName, cmd.String()) 53 | } 54 | 55 | _, err = tempFile.Seek(0, 0) 56 | if err != nil { 57 | return "", errors.Wrap(err, "cannot seek template temp-file to 0") 58 | } 59 | 60 | tempFileContents, err := io.ReadAll(tempFile) 61 | if err != nil { 62 | return "", errors.Wrap(err, "cannot read from template temp-file") 63 | } 64 | 65 | return string(tempFileContents), nil 66 | } 67 | 68 | func readEditedRawTemplateString(sourceTemplateFileName string) (string, error) { 69 | rawTemplateString, err := os.Open(sourceTemplateFileName) 70 | if err != nil { 71 | return "", errors.Wrapf(err, "cannot open source template file %s", sourceTemplateFileName) 72 | } 73 | 74 | // .ini formats it like ini file in some editors 75 | tempFile, err := os.CreateTemp("", "ain*.ini") 76 | if err != nil { 77 | return "", errors.Wrap(err, "cannot create template temp-file") 78 | } 79 | 80 | defer func() { 81 | if removeErr := os.Remove(tempFile.Name()); removeErr != nil { 82 | wrappedRemoveErr := errors.Wrapf(removeErr, "could not remove template temp-file %s\nPlease delete it manually.", tempFile.Name()) 83 | 84 | if err != nil { 85 | err = utils.CascadeErrorMessage(err, wrappedRemoveErr) 86 | } else { 87 | err = wrappedRemoveErr 88 | } 89 | } 90 | }() 91 | 92 | _, err = io.Copy(tempFile, rawTemplateString) 93 | if err != nil { 94 | return "", errors.Wrap(err, "cannot copy source template file to temp-file") 95 | } 96 | 97 | return captureEditorOutput(tempFile) 98 | } 99 | 100 | func ReadRawTemplateString(templateFileName string, editFile bool) (string, error) { 101 | if editFile { 102 | return readEditedRawTemplateString(templateFileName) 103 | } 104 | 105 | fileContents, err := os.ReadFile(templateFileName) 106 | if err != nil { 107 | return "", errors.Wrapf(err, "could not read template file %s", templateFileName) 108 | } 109 | 110 | return string(fileContents), nil 111 | 112 | } 113 | -------------------------------------------------------------------------------- /examples/dummyjson.com/advanced/README.md: -------------------------------------------------------------------------------- 1 | # dummyjson.com advanced example 2 | Example of an advanced layout of an API using data from [https://dummyjson.com](https://dummyjson.com/docs) 3 | 4 | # Follow along 5 | To follow along you need ain and one of [curl](https://curl.se/), [wget](https://www.gnu.org/software/wget/) or [httpie](https://httpie.io/) installed. Then copy / clone out the *.ain files in this folder to your local drive. 6 | 7 | Change the backend under the `[Backend]` section in the templates to whatever you have installed on your computer. 8 | 9 | Getting an JWT token: 10 | ```bash 11 | ain base.ain get-token.ain # Gets the JWT token which is then used when calling auth endpoints 12 | ``` 13 | 14 | Products: 15 | ```bash 16 | ain base.ain products/get.ain # Get the 30 first products 17 | 18 | LIMIT=10 SKIP=30 ain base.ain.ain products/get.ain paginate.ain # Get the next 10 products after the initial 30 19 | 20 | ain base.ain products/add.ain! # Add a product via one-off edited data 21 | ID=1 ain base.ain products/get-by-id.ain # Get product with ID 1 22 | ID=2 ain base.ain products/update.ain! # Update product 2 with one-off data (adding a ! after the file to edit it in-place) 23 | ID=3 ain base.ain products/delete.ain # Delete product 3 24 | 25 | ain base.ain auth.ain products/get.ain # Get the first 30 products, with an Authorization Bearer: header 26 | ``` 27 | 28 | # Layout explanation 29 | The layout have been selected to contain minimal repetition. 30 | 31 | ## base.ain 32 | Contains everything that is common to all calls to this API: a base-url, the preferred backend (curl in this example), any backend-options, common headers and timeouts. 33 | 34 | 35 | ## get-token.ain 36 | Most REST APIs will contain calls that require authentication typically via an `Authorization: Bearer `. 37 | 38 | Working with ain you can work out the proper call the authorization endpoint and then extract the token out as a separate step, before you integrate it into the authorized API call. 39 | 40 | `ain base.ain get-token.ain` will return the whole JWT payload. 41 | 42 | ## auth.ain 43 | Now that we have a way of getting the JWT token, we can invoke ain from ain and use jq to extract the Bearer token. Then we insert it into an `Authorization: Bearer` header. 44 | 45 | This can be made a simple or advanced as you'd like. Since it's returned via an executable you can easily make a shell-script to check the expiration of any existing JWT, or request a new token via a refresh token, before calling a token endpoint. Or you can hit the token endpoint every time. 46 | 47 | ## paginate.ain 48 | Most REST endpoints have some pagination and these are usually supplied as query-parameters. This file contains both an limit and an offset and can be included with the call to any endpoint. Since query parameters are applied after the URL has been assembled the file itself can go anywhere file-list. 49 | 50 | Supported parameters are: 51 | ```bash 52 | LIMIT=n # LIMIT mandatory 53 | SKIP=n # SKIP is optional via bash if / else 54 | ``` 55 | 56 | ## products/ 57 | All files concerning products are grouped into a folder called products/. 58 | 59 | ```bash 60 | products/add.ain 61 | products/delete.ain 62 | products/get-by-id.ain 63 | products/get.ain 64 | products/update.ain 65 | ``` 66 | 67 | Composing these files with base.ain (and auth.ain and paginate.ain if need be) makes the resulting command-line readable: 68 | 69 | ```bash 70 | ID=1 ain base.ain products/get-by-id.ain # Gets product with id 1 71 | ``` 72 | -------------------------------------------------------------------------------- /internal/pkg/call/wget.go: -------------------------------------------------------------------------------- 1 | package call 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/jonaslu/ain/internal/pkg/data" 10 | "github.com/jonaslu/ain/internal/pkg/utils" 11 | ) 12 | 13 | type wget struct { 14 | backendInput *data.BackendInput 15 | binaryName string 16 | } 17 | 18 | var outputToStdoutRegexp = regexp.MustCompile(`-\w*O\s*-`) 19 | 20 | func prependOutputToStdin(backendInput *data.BackendInput) { 21 | var foundOutputToStdin bool 22 | 23 | for _, backendOptionLine := range backendInput.BackendOptions { 24 | backendOptions := strings.Join(backendOptionLine, " ") 25 | if outputToStdoutRegexp.MatchString(backendOptions) { 26 | foundOutputToStdin = true 27 | break 28 | } 29 | } 30 | 31 | if !foundOutputToStdin { 32 | backendInput.BackendOptions = append([][]string{{"-O-"}}, backendInput.BackendOptions...) 33 | } 34 | } 35 | 36 | func newWgetBackend(backendInput *data.BackendInput, binaryName string) backend { 37 | prependOutputToStdin(backendInput) 38 | return &wget{ 39 | backendInput: backendInput, 40 | binaryName: binaryName, 41 | } 42 | } 43 | 44 | func (wget *wget) getHeaderArguments(escape bool) []string { 45 | args := []string{} 46 | for _, header := range wget.backendInput.Headers { 47 | if escape { 48 | args = append(args, "--header="+utils.EscapeForShell(header)) 49 | } else { 50 | args = append(args, "--header="+header) 51 | } 52 | } 53 | 54 | return args 55 | } 56 | 57 | func (wget *wget) getMethodArgument(escape bool) string { 58 | if wget.backendInput.Method != "" { 59 | methodCapitalized := strings.ToUpper(wget.backendInput.Method) 60 | 61 | if escape { 62 | return "--method=" + utils.EscapeForShell(methodCapitalized) 63 | } 64 | 65 | return "--method=" + methodCapitalized 66 | } 67 | 68 | return "" 69 | } 70 | 71 | func (wget *wget) getBodyArgument() []string { 72 | if wget.backendInput.TempFileName != "" { 73 | return []string{"--body-file=" + wget.backendInput.TempFileName} 74 | } 75 | 76 | return []string{} 77 | } 78 | 79 | func (wget *wget) getAsCmd(ctx context.Context) *exec.Cmd { 80 | args := []string{} 81 | for _, backendOpt := range wget.backendInput.BackendOptions { 82 | args = append(args, backendOpt...) 83 | } 84 | 85 | if wget.backendInput.Method != "" { 86 | args = append(args, wget.getMethodArgument(false)) 87 | } 88 | 89 | args = append(args, wget.getHeaderArguments(false)...) 90 | args = append(args, wget.getBodyArgument()...) 91 | 92 | args = append(args, wget.backendInput.Host.String()) 93 | 94 | wgetCmd := exec.CommandContext(ctx, wget.binaryName, args...) 95 | return wgetCmd 96 | } 97 | 98 | func (wget *wget) getAsString() string { 99 | args := [][]string{} 100 | 101 | for _, optionLine := range wget.backendInput.BackendOptions { 102 | lineArguments := []string{} 103 | for _, option := range optionLine { 104 | lineArguments = append(lineArguments, utils.EscapeForShell(option)) 105 | } 106 | args = append(args, lineArguments) 107 | } 108 | 109 | if wget.backendInput.Method != "" { 110 | args = append(args, []string{wget.getMethodArgument(true)}) 111 | } 112 | 113 | for _, header := range wget.getHeaderArguments(true) { 114 | args = append(args, []string{header}) 115 | } 116 | 117 | args = append(args, wget.getBodyArgument()) 118 | 119 | args = append(args, []string{ 120 | utils.EscapeForShell(wget.backendInput.Host.String()), 121 | }) 122 | 123 | output := wget.binaryName + " " + utils.PrettyPrintStringsForShell(args) 124 | 125 | return output 126 | } 127 | -------------------------------------------------------------------------------- /grammars/textmate/syntaxes/ain.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", 3 | "name": "ain", 4 | "patterns": [ 5 | { 6 | "include": "#headings" 7 | }, 8 | { 9 | "include": "#escapes" 10 | }, 11 | { 12 | "include": "#envvars" 13 | }, 14 | { 15 | "include": "#executables" 16 | }, 17 | { 18 | "include": "#comments" 19 | } 20 | ], 21 | "repository": { 22 | "headings": { 23 | "patterns": [ 24 | { 25 | "name": "entity.name.tag.section.ain", 26 | "match": "(?i)^\\s*\\[(config|host|query|headers|method|body|backend|backendoptions)\\]\\s*(?=(? 0 { 98 | starterTemplate = strings.ReplaceAll(starterTemplate, "{{BackendOptions}}", strings.Join(usefulBackendOptions, "\n")) 99 | } else { 100 | starterTemplate = strings.ReplaceAll(starterTemplate, "[BackendOptions]\n", "") 101 | starterTemplate = strings.ReplaceAll(starterTemplate, "{{BackendOptions}}\n\n", "") 102 | } 103 | 104 | if len(templateFileNames) == 0 { 105 | _, err := fmt.Fprintln(os.Stdout, starterTemplate) 106 | return err 107 | } 108 | 109 | for _, filename := range templateFileNames { 110 | _, err := os.Stat(filename) 111 | 112 | if !os.IsNotExist(err) { 113 | return errors.Errorf("cannot write basic template. File already exists %s", filename) 114 | } 115 | 116 | err = os.WriteFile(filename, []byte(starterTemplate), 0644) 117 | 118 | if err != nil { 119 | return errors.Wrapf(err, "could not write basic template to file %s", filename) 120 | } 121 | } 122 | 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /internal/pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | const quoteEscapeRune = '\\' 11 | 12 | var quoteRunes = [...]rune{'"', '\''} 13 | 14 | const unterminatedQuoteErrorMessageContext = 3 15 | 16 | func TokenizeLine(commandLine string) ([]string, error) { 17 | var tokenizedLines []string 18 | var lastQuoteRune rune 19 | var lastQuotePos int 20 | 21 | commandLineRune := []rune(commandLine) 22 | 23 | var builder strings.Builder 24 | builder.Grow(len(commandLine)) 25 | 26 | NextRune: 27 | for i := 0; i < len(commandLineRune); i++ { 28 | headRune := commandLineRune[i] 29 | 30 | // Nothing has been collected, discard spaces 31 | if lastQuoteRune == 0 && unicode.IsSpace(headRune) && builder.Len() == 0 { 32 | continue 33 | } 34 | 35 | // Quoting is turned on 36 | if lastQuoteRune > 0 { 37 | // Escaped quote \" - write only the quote and carry on 38 | if headRune == quoteEscapeRune && i < len(commandLineRune)-1 && commandLineRune[i+1] == lastQuoteRune { 39 | builder.WriteRune(lastQuoteRune) 40 | i = i + 1 41 | continue 42 | } 43 | 44 | // Turns quoting off 45 | if headRune == lastQuoteRune { 46 | lastQuoteRune = 0 47 | continue 48 | } 49 | 50 | builder.WriteRune(headRune) 51 | continue 52 | } 53 | 54 | // Quoting not turned on, look for any escaped quote 55 | if headRune == quoteEscapeRune && i < len(commandLineRune)-1 { 56 | for _, quoteRune := range quoteRunes { 57 | if commandLineRune[i+1] == quoteRune { 58 | builder.WriteRune(quoteRune) 59 | i = i + 1 60 | continue NextRune 61 | } 62 | } 63 | } 64 | 65 | // Check for start of quoting 66 | for _, quoteRune := range quoteRunes { 67 | if headRune == quoteRune { 68 | lastQuoteRune = quoteRune 69 | lastQuotePos = i 70 | continue NextRune 71 | } 72 | } 73 | 74 | // We're not quoting and we are on a word boundary 75 | if unicode.IsSpace(headRune) && lastQuoteRune == 0 { 76 | tokenizedLines = append(tokenizedLines, builder.String()) 77 | builder.Reset() 78 | continue 79 | } 80 | 81 | builder.WriteRune(headRune) 82 | } 83 | 84 | if lastQuoteRune > 0 { 85 | context := Ellipsize( 86 | lastQuotePos-unterminatedQuoteErrorMessageContext, 87 | lastQuotePos+unterminatedQuoteErrorMessageContext+1, 88 | commandLine, 89 | ) 90 | 91 | return nil, errors.Errorf("Unterminated quote sequence: %s", context) 92 | } 93 | 94 | if builder.Len() > 0 { 95 | tokenizedLines = append(tokenizedLines, builder.String()) 96 | } 97 | 98 | return tokenizedLines, nil 99 | } 100 | 101 | const threeCharacters = 3 102 | 103 | func Ellipsize(from, to int, str string) string { 104 | var preContext, postContext string 105 | preContextIndex := from 106 | 107 | if preContextIndex <= threeCharacters { 108 | preContextIndex = 0 109 | preContext = "" 110 | } else { 111 | preContext = "..." 112 | } 113 | 114 | postContextIndex := to 115 | if postContextIndex >= (len(str) - threeCharacters) { 116 | postContextIndex = len(str) 117 | postContext = "" 118 | } else { 119 | postContext = "..." 120 | } 121 | 122 | return preContext + str[preContextIndex:postContextIndex] + postContext 123 | } 124 | 125 | func CascadeErrorMessage(err1, err2 error) error { 126 | if err2 != nil { 127 | return errors.Errorf("%v\nThe error caused an additional error:\n%v", err1, err2) 128 | } 129 | 130 | return err1 131 | } 132 | 133 | func EscapeForShell(unsafeString string) string { 134 | return "'" + strings.ReplaceAll(unsafeString, `'`, `'"'"'`) + "'" 135 | } 136 | 137 | func PrettyPrintStringsForShell(args [][]string) string { 138 | output := "" 139 | 140 | for i, arg := range args { 141 | if len(arg) == 0 { 142 | continue 143 | } 144 | 145 | output = output + strings.Join(arg, " ") 146 | if i+1 < len(args) { 147 | output = output + " \\\n " 148 | } 149 | } 150 | 151 | return output 152 | } 153 | -------------------------------------------------------------------------------- /cmd/ain/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "runtime" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/jonaslu/ain/internal/app/ain" 13 | "github.com/jonaslu/ain/internal/pkg/call" 14 | "github.com/jonaslu/ain/internal/pkg/disk" 15 | "github.com/jonaslu/ain/internal/pkg/parse" 16 | ) 17 | 18 | var version = "1.6.0" 19 | var gitSha = "develop" 20 | 21 | const bashSignalCaughtBase = 128 22 | 23 | func printErrorAndExit(err error) { 24 | formattedError := fmt.Sprintf("Error: %s", err.Error()) 25 | fmt.Fprintln(os.Stderr, formattedError) 26 | os.Exit(1) 27 | } 28 | 29 | func checkSignalRaisedAndExit(ctx context.Context, signalRaised os.Signal) { 30 | if ctx.Err() == context.Canceled { 31 | if sigValue, ok := signalRaised.(syscall.Signal); ok { 32 | os.Exit(bashSignalCaughtBase + int(sigValue)) 33 | } 34 | 35 | os.Exit(1) 36 | } 37 | } 38 | 39 | func main() { 40 | cmdParams := ain.NewCmdParams() 41 | 42 | if cmdParams.ShowVersion { 43 | fmt.Printf("Ain %s (%s) %s/%s\n", version, gitSha, runtime.GOOS, runtime.GOARCH) 44 | return 45 | } 46 | 47 | if err := cmdParams.SetEnvVarsAndFilenames(); err != nil { 48 | printErrorAndExit(err) 49 | } 50 | 51 | if cmdParams.GenerateEmptyTemplate { 52 | if err := disk.GenerateEmptyTemplates(cmdParams.TemplateFileNames); err != nil { 53 | printErrorAndExit(err) 54 | } 55 | 56 | return 57 | } 58 | 59 | for _, envVars := range cmdParams.EnvVars { 60 | varName := envVars[0] 61 | value := envVars[1] 62 | os.Setenv(varName, value) 63 | } 64 | 65 | if err := disk.ReadEnvFile(cmdParams.EnvFile, cmdParams.EnvFile != ".env"); err != nil { 66 | printErrorAndExit(err) 67 | } 68 | 69 | localTemplateFileNames, err := disk.GetTemplateFilenames(cmdParams.TemplateFileNames) 70 | if err != nil { 71 | printErrorAndExit(err) 72 | } 73 | 74 | if len(localTemplateFileNames) == 0 { 75 | printErrorAndExit(fmt.Errorf("missing template file name(s)\n\nTry 'ain -h' for more information")) 76 | } 77 | 78 | cancelCtx, cancel := context.WithCancel(context.Background()) 79 | var signalRaised os.Signal 80 | 81 | go func() { 82 | sigs := make(chan os.Signal, 1) 83 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 84 | 85 | signalRaised = <-sigs 86 | cancel() 87 | }() 88 | 89 | assembledCtx, backendInput, fatal, err := parse.Assemble(cancelCtx, localTemplateFileNames) 90 | if err != nil { 91 | checkSignalRaisedAndExit(assembledCtx, signalRaised) 92 | 93 | printErrorAndExit(err) 94 | } 95 | 96 | if fatal != "" { 97 | // Is this valid? 98 | checkSignalRaisedAndExit(assembledCtx, signalRaised) 99 | 100 | fmt.Fprintln(os.Stderr, fatal) 101 | os.Exit(1) 102 | } 103 | 104 | backendInput.PrintCommand = cmdParams.PrintCommand 105 | 106 | call, err := call.Setup(backendInput) 107 | if err != nil { 108 | printErrorAndExit(err) 109 | } 110 | 111 | if cmdParams.PrintCommand { 112 | // Tempfile always left when calling as string 113 | fmt.Fprint(os.Stdout, call.CallAsString()) 114 | return 115 | } 116 | 117 | var errors []string 118 | backendInput.LeaveTempFile = cmdParams.LeaveTmpFile 119 | backendOutput, err := call.CallAsCmd(assembledCtx) 120 | 121 | teardownErr := call.Teardown() 122 | if teardownErr != nil { 123 | errors = append(errors, teardownErr.Error()) 124 | } 125 | 126 | if err != nil && assembledCtx.Err() != context.Canceled { 127 | errors = append(errors, err.Error()) 128 | } 129 | 130 | if len(errors) > 0 { 131 | errorMsg := "Error" 132 | if len(errors) > 1 { 133 | errorMsg += "s:\n" 134 | } else { 135 | errorMsg += ": " 136 | } 137 | 138 | errorMsg += strings.Join(errors, "\n") + "\n" 139 | fmt.Fprintln(os.Stderr, errorMsg) 140 | } 141 | 142 | if backendOutput != nil { 143 | // It's customary to print stderr first 144 | // to get the users attention on the error 145 | fmt.Fprint(os.Stderr, backendOutput.Stderr) 146 | fmt.Fprint(os.Stdout, backendOutput.Stdout) 147 | } 148 | 149 | checkSignalRaisedAndExit(assembledCtx, signalRaised) 150 | 151 | if assembledCtx.Err() == context.DeadlineExceeded || teardownErr != nil { 152 | os.Exit(1) 153 | } 154 | 155 | os.Exit(backendOutput.ExitCode) 156 | } 157 | -------------------------------------------------------------------------------- /internal/pkg/parse/executable.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os/exec" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/jonaslu/ain/internal/pkg/data" 12 | "github.com/jonaslu/ain/internal/pkg/utils" 13 | ) 14 | 15 | type executableAndArgs struct { 16 | executableCmd string 17 | args []string 18 | } 19 | 20 | type executableOutput struct { 21 | cmdOutput string 22 | fatalMessage string 23 | } 24 | 25 | func (s *sectionedTemplate) captureExecutableAndArgs() []executableAndArgs { 26 | executables := []executableAndArgs{} 27 | 28 | for expandedTemplateLineIndex, expandedTemplateLine := range s.expandedTemplateLines { 29 | if expandedTemplateLine.consumed { 30 | continue 31 | } 32 | 33 | executableTokens, fatal := tokenizeExecutables(expandedTemplateLine.content) 34 | if fatal != "" { 35 | s.setFatalMessage(fatal, expandedTemplateLine.sourceLineIndex) 36 | } 37 | 38 | if s.hasFatalMessages() { 39 | // No need to keep expanding - we're going to exit after this 40 | // returns. Try to tokenize all lines to get any extra errors, 41 | // but don't do any extra work 42 | continue 43 | } 44 | 45 | for _, token := range executableTokens { 46 | if token.tokenType != executableToken { 47 | continue 48 | } 49 | 50 | executableAndArgsStr := token.content 51 | if executableAndArgsStr == "" { 52 | s.setFatalMessage("Empty executable", expandedTemplateLineIndex) 53 | continue 54 | } 55 | 56 | tokenizedExecutableLine, err := utils.TokenizeLine(executableAndArgsStr) 57 | if err != nil { 58 | s.setFatalMessage(err.Error(), expandedTemplateLineIndex) 59 | continue 60 | } 61 | 62 | executable := tokenizedExecutableLine[0] 63 | 64 | executables = append(executables, executableAndArgs{ 65 | executableCmd: executable, 66 | args: tokenizedExecutableLine[1:], 67 | }) 68 | } 69 | } 70 | 71 | return executables 72 | } 73 | 74 | func callExecutables(ctx context.Context, config data.Config, executables []executableAndArgs) []executableOutput { 75 | executableResults := make([]executableOutput, len(executables)) 76 | 77 | wg := sync.WaitGroup{} 78 | for i, executable := range executables { 79 | go func(resultIndex int, executable executableAndArgs) { 80 | defer wg.Done() 81 | 82 | var stdout, stderr bytes.Buffer 83 | 84 | cmd := exec.CommandContext(ctx, executable.executableCmd, executable.args...) 85 | cmd.Stdout = &stdout 86 | cmd.Stderr = &stderr 87 | 88 | err := cmd.Run() 89 | if ctx.Err() == context.DeadlineExceeded { 90 | executableResults[resultIndex].fatalMessage = fmt.Sprintf("Executable %s timed out after %d seconds", cmd.String(), config.Timeout) 91 | } 92 | 93 | if ctx.Err() != nil { 94 | // Can't return an error, we're in a go-routine 95 | return 96 | } 97 | 98 | stdoutStr := stdout.String() 99 | 100 | if err != nil { 101 | stderrStr := stderr.String() 102 | 103 | executableOutput := "" 104 | if stdoutStr != "" || stderrStr != "" { 105 | executableOutput = "\n" + strings.TrimSpace(strings.Join([]string{ 106 | strings.TrimSpace(stdoutStr), 107 | strings.TrimSpace(stderrStr), 108 | }, " ")) 109 | } 110 | 111 | executableResults[resultIndex].fatalMessage = fmt.Sprintf("Executable %s error: %v%s", cmd.String(), err, executableOutput) 112 | return 113 | } 114 | 115 | if stdoutStr == "" { 116 | executableResults[resultIndex].fatalMessage = fmt.Sprintf("Executable %s\nCommand produced no stdout output", cmd.String()) 117 | return 118 | } 119 | 120 | executableResults[resultIndex].cmdOutput = stdoutStr 121 | }(i, executable) 122 | 123 | wg.Add(1) 124 | } 125 | 126 | wg.Wait() 127 | 128 | return executableResults 129 | } 130 | 131 | func (s *sectionedTemplate) insertExecutableOutput(executableResults *[]executableOutput) { 132 | if len(*executableResults) == 0 { 133 | return 134 | } 135 | 136 | nextExecutableResult := (*executableResults)[0] 137 | 138 | s.expandTemplateLines(tokenizeExecutables, func(c token) (string, string) { 139 | fatalMessage := nextExecutableResult.fatalMessage 140 | output := nextExecutableResult.cmdOutput 141 | 142 | // > 1 because we have already processed the head of the list. 143 | // Hence at least two elements left, where the [1:] element is the 144 | // next item we're trying to consume. 145 | if len(*executableResults) > 1 { 146 | *executableResults = (*executableResults)[1:] 147 | nextExecutableResult = (*executableResults)[0] 148 | } 149 | 150 | return output, fatalMessage 151 | }) 152 | } 153 | -------------------------------------------------------------------------------- /internal/pkg/parse/sections_test.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func Test_sectionedTemplate_getTextContent(t *testing.T) { 10 | tests := map[string]struct { 11 | content string 12 | comment string 13 | expectedResult string 14 | }{ 15 | "Escaped comments unescaped": { 16 | content: "text `# no comment", 17 | comment: "", 18 | expectedResult: "text # no comment", 19 | }, 20 | "Quote unescaped if comment on line": { 21 | content: "text \\`", 22 | comment: "# comment", 23 | expectedResult: "text `", 24 | }, 25 | "Quote left untouched if no comment": { 26 | content: "text \\`", 27 | comment: "", 28 | expectedResult: "text \\`", 29 | }, 30 | } 31 | 32 | for name, test := range tests { 33 | s := expandedSourceMarker{ 34 | content: test.content, 35 | comment: test.comment, 36 | } 37 | 38 | result := s.getTextContent() 39 | if test.expectedResult != result { 40 | t.Errorf("Test: %s. Expected %v, got: %v", name, result, test.expectedResult) 41 | } 42 | } 43 | } 44 | 45 | func Test_sectionedTemplate_expandTemplateLinesGoodCases(t *testing.T) { 46 | // Converts 🐐 to a comment (#) 47 | // Converts 🐷 to a newline 48 | echoIterator := func(c token) (string, string) { 49 | c.content = strings.ReplaceAll(c.content, "🐐", "#") 50 | c.content = strings.ReplaceAll(c.content, "🐷", "\n") 51 | 52 | return c.content, "" 53 | } 54 | 55 | tests := map[string]struct { 56 | inputTemplate string 57 | expectedResult []expandedSourceMarker 58 | }{ 59 | "Simple envvar substitution before comment": { 60 | inputTemplate: "${VAR} text # comment", 61 | expectedResult: []expandedSourceMarker{{ 62 | content: "VAR text ", 63 | fatalContent: "VAR text ", 64 | comment: "# comment", 65 | sourceLineIndex: 0, 66 | expanded: true, 67 | }}}, 68 | "Double envvar substitution before comment": { 69 | inputTemplate: "${VAR1} ${VAR2} # comment", 70 | expectedResult: []expandedSourceMarker{{ 71 | content: "VAR1 VAR2 ", 72 | fatalContent: "VAR1 VAR2 ", 73 | comment: "# comment", 74 | sourceLineIndex: 0, 75 | expanded: true, 76 | }}}, 77 | "Single envvar substitution with comment disables rest of line": { 78 | inputTemplate: "${VAR1 🐐 comment1} ${VAR2} # comment2", 79 | expectedResult: []expandedSourceMarker{{ 80 | content: "VAR1 ", 81 | fatalContent: "VAR1 ", 82 | comment: "# comment1 ${VAR2} # comment2", 83 | sourceLineIndex: 0, 84 | expanded: true, 85 | }}}, 86 | "Single envvar with newline pushes rest of line one row below": { 87 | inputTemplate: "${VAR1🐷} ${VAR2} # comment", 88 | expectedResult: []expandedSourceMarker{{ 89 | content: "VAR1", 90 | fatalContent: "VAR1", 91 | comment: "", 92 | sourceLineIndex: 0, 93 | expanded: true, 94 | }, { 95 | content: " VAR2 ", 96 | fatalContent: " VAR2 ", 97 | comment: "# comment", 98 | sourceLineIndex: 0, 99 | expanded: true, 100 | }}}, 101 | "Single envvar with newline and comment pushes rest of line one row below": { 102 | inputTemplate: "${VAR1 🐐 comment1🐷} ${VAR2} # comment2", 103 | expectedResult: []expandedSourceMarker{{ 104 | content: "VAR1 ", 105 | fatalContent: "VAR1 ", 106 | comment: "# comment1", 107 | sourceLineIndex: 0, 108 | expanded: true, 109 | }, { 110 | content: " VAR2 ", 111 | fatalContent: " VAR2 ", 112 | comment: "# comment2", 113 | sourceLineIndex: 0, 114 | expanded: true, 115 | }}}, 116 | "Single envvar with newline, comment, newline and comment pushes rest of line one row below and disables": { 117 | inputTemplate: "${VAR1 🐐 comment1🐷🐐} ${VAR2} # comment2", 118 | expectedResult: []expandedSourceMarker{{ 119 | content: "VAR1 ", 120 | fatalContent: "VAR1 ", 121 | comment: "# comment1", 122 | sourceLineIndex: 0, 123 | expanded: true, 124 | }, { 125 | content: "", 126 | fatalContent: "", 127 | comment: "# ${VAR2} # comment2", 128 | sourceLineIndex: 0, 129 | expanded: true, 130 | }}}, 131 | } 132 | 133 | for name, test := range tests { 134 | s := newSectionedTemplate(test.inputTemplate, "") 135 | 136 | if s.expandTemplateLines(tokenizeEnvVars, echoIterator); s.hasFatalMessages() { 137 | t.Errorf("Test: %s. Got unexpected fatals, %s ", name, s.getFatalMessages()) 138 | } else { 139 | if !reflect.DeepEqual(test.expectedResult, s.expandedTemplateLines) { 140 | t.Errorf("Test: %s. Expected %v, got: %v", name, test.expectedResult, s.expandedTemplateLines) 141 | } 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/e2e/e2e_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "gopkg.in/yaml.v3" 16 | ) 17 | 18 | const testBinaryPath = "./ain_test" 19 | 20 | type testDirectives struct { 21 | Env []string 22 | Args []string 23 | AfterArgs []string `yaml:"afterargs"` 24 | Stderr string 25 | Stdout string 26 | ExitCode int 27 | } 28 | 29 | func addBarsBeforeNewlines(s string) string { 30 | bars := "" 31 | for _, c := range s { 32 | if c == '\n' { 33 | bars += "|" 34 | } 35 | bars += string(c) 36 | } 37 | 38 | return bars + "|" 39 | } 40 | 41 | func buildGoBinary() error { 42 | args := []string{"build"} 43 | if os.Getenv("E2EGOCOVERDIR") != "" { 44 | args = append(args, "-cover") 45 | } 46 | 47 | args = append(args, "-o", testBinaryPath, "../../cmd/ain/main.go") 48 | 49 | cmd := exec.Command("go", args...) 50 | 51 | err := cmd.Run() 52 | if err != nil { 53 | return errors.New("could not build binary") 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func runTest(filename string, templateContents []byte) error { 60 | lines := strings.Split(string(templateContents), "\n") 61 | idx := len(lines) 62 | 63 | directives := []string{} 64 | 65 | for idx > 0 { 66 | idx-- 67 | 68 | line := string(lines[idx]) 69 | if line == "" && len(directives) == 0 { 70 | continue 71 | } 72 | 73 | if !strings.HasPrefix(line, "# ") { 74 | break 75 | } 76 | 77 | trimmedPrefixLine := strings.TrimPrefix(line, "# ") 78 | 79 | directives = append([]string{trimmedPrefixLine}, directives...) 80 | } 81 | 82 | testDirectives := testDirectives{} 83 | err := yaml.Unmarshal([]byte(strings.Join(directives, "\n")), &testDirectives) 84 | if err != nil { 85 | return errors.New("Could not unmarshal yaml") 86 | } 87 | 88 | testDirectives.Stdout = strings.ReplaceAll(testDirectives.Stdout, "$filename", filename) 89 | testDirectives.Stderr = strings.ReplaceAll(testDirectives.Stderr, "$filename", filename) 90 | 91 | var stdout, stderr bytes.Buffer 92 | 93 | // !! TODO !! Get timeout from yaml or default to 1 seconds 94 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 95 | defer cancel() 96 | 97 | totalArgs := append(testDirectives.Args, filename) 98 | totalArgs = append(totalArgs, testDirectives.AfterArgs...) 99 | 100 | cmd := exec.CommandContext(ctx, "./ain_test", totalArgs...) 101 | cmd.Env = testDirectives.Env 102 | cmd.Env = append(cmd.Env, "PATH="+os.Getenv("PATH")) 103 | if os.Getenv("E2EGOCOVERDIR") != "" { 104 | cmd.Env = append(cmd.Env, "GOCOVERDIR="+os.Getenv("E2EGOCOVERDIR")) 105 | } 106 | 107 | cmd.Stdout = &stdout 108 | cmd.Stderr = &stderr 109 | 110 | err = cmd.Start() 111 | if err != nil { 112 | return errors.New("could not start command") 113 | } 114 | 115 | err = cmd.Wait() 116 | if err != nil && err.(*exec.ExitError) == nil { 117 | return errors.New("Could not wait for command") 118 | } 119 | 120 | if ctx.Err() == context.DeadlineExceeded { 121 | return errors.New("timed out") 122 | } 123 | 124 | if ctx.Err() != nil { 125 | return errors.New("context error") 126 | } 127 | 128 | if stderr.String() != testDirectives.Stderr { 129 | return fmt.Errorf("stderr %s did not match %s", addBarsBeforeNewlines(stderr.String()), addBarsBeforeNewlines(testDirectives.Stderr)) 130 | } 131 | 132 | if stdout.String() != testDirectives.Stdout { 133 | return fmt.Errorf("stdout %s did not match %s", addBarsBeforeNewlines(stdout.String()), addBarsBeforeNewlines(testDirectives.Stdout)) 134 | } 135 | 136 | exitCode := cmd.ProcessState.ExitCode() 137 | if exitCode != testDirectives.ExitCode { 138 | return errors.New("exit code did not match") 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func runOneTest(fileName string, t *testing.T) error { 145 | testContents, err := os.ReadFile(fileName) 146 | if err != nil { 147 | return fmt.Errorf("could not read file: %s", err) 148 | } 149 | 150 | t.Run(fileName, func(t *testing.T) { 151 | err := runTest(fileName, testContents) 152 | if err != nil { 153 | t.Errorf("%s: %v", fileName, err) 154 | } 155 | }) 156 | 157 | return nil 158 | } 159 | 160 | func readTestFiles(templateFolder string) ([]string, error) { 161 | files, err := os.ReadDir(templateFolder) 162 | 163 | if err != nil { 164 | return nil, errors.New("could not read directory") 165 | } 166 | 167 | testFilePaths := []string{} 168 | 169 | for _, file := range files { 170 | if file.IsDir() { 171 | subFolderPath := templateFolder + "/" + file.Name() 172 | subFolderDirs, err := readTestFiles(subFolderPath) 173 | 174 | if err != nil { 175 | return nil, errors.Join(errors.New("could not read subfolder"+subFolderPath), err) 176 | } 177 | 178 | testFilePaths = append(testFilePaths, subFolderDirs...) 179 | continue 180 | } 181 | 182 | fileName := templateFolder + "/" + file.Name() 183 | if !strings.HasSuffix(fileName, ".ain") { 184 | continue 185 | } 186 | 187 | testFilePaths = append(testFilePaths, fileName) 188 | 189 | } 190 | 191 | return testFilePaths, nil 192 | } 193 | 194 | func Test_main(t *testing.T) { 195 | if err := buildGoBinary(); err != nil { 196 | t.Fatalf("Could not build binary") 197 | return 198 | } 199 | 200 | defer os.Remove(testBinaryPath) 201 | 202 | if len(flag.Args()) > 0 { 203 | for _, testToRun := range flag.Args() { 204 | if err := runOneTest(testToRun, t); err != nil { 205 | t.Fatalf("Could not run test %s, error: %s", testToRun, err) 206 | return 207 | } 208 | } 209 | 210 | return 211 | } 212 | 213 | files, err := readTestFiles("templates") 214 | if err != nil { 215 | t.Fatalf("Could not read test templates") 216 | return 217 | } 218 | 219 | for _, fileName := range files { 220 | if err := runOneTest(fileName, t); err != nil { 221 | t.Fatalf("Could not run test %s, error: %s", fileName, err) 222 | return 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /internal/app/ain/cmdparams.go: -------------------------------------------------------------------------------- 1 | package ain 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | const varsFlagStr = "--vars" 10 | 11 | func printUsage(appName string, flags []flag) { 12 | w := os.Stderr 13 | 14 | introMsg := `Ain is an HTTP API client. It reads template files to make the HTTP call. 15 | These can be given on the command line or sent over a pipe. 16 | 17 | Project home page: https://github.com/jonaslu/ain` 18 | 19 | fmt.Fprintf(w, "%s\n\nusage: %s [OPTIONS] ["+varsFlagStr+" VAR=VALUE ...] \n", introMsg, appName) 20 | fmt.Fprintf(w, "\nOPTIONS:\n") 21 | for _, f := range flags { 22 | fmt.Fprintf(w, " %-22s %s\n", f.flagName, f.usage) 23 | } 24 | 25 | fmt.Fprintf(w, "\nARGUMENTS:\n") 26 | fmt.Fprintf(w, " [!] One or more template files to process. Required\n") 27 | fmt.Fprintf(w, " "+varsFlagStr+" VAR=VALUE [...] Values for environment variables, set after file(s)\n") 28 | } 29 | 30 | type flagConsumer func([]string) (found bool, restArgs []string, error error) 31 | 32 | type flag struct { 33 | flagName string 34 | usage string 35 | flagConsumer flagConsumer 36 | } 37 | 38 | func makeBoolConsumer(flagName string, val *bool) flagConsumer { 39 | return func(args []string) (bool, []string, error) { 40 | if args[0] == flagName { 41 | *val = true 42 | return true, args[1:], nil 43 | } 44 | 45 | return false, args, nil 46 | } 47 | } 48 | 49 | func makeStringConsumer(flagName string, val *string) flagConsumer { 50 | return func(args []string) (bool, []string, error) { 51 | if args[0] == flagName { 52 | if len(args) < 2 { 53 | return false, args, fmt.Errorf("flag %s requires an argument", flagName) 54 | } 55 | 56 | *val = args[1] 57 | 58 | return true, args[2:], nil 59 | } 60 | 61 | return false, args, nil 62 | } 63 | } 64 | 65 | func makeRedefinedGuardConsumer(name string, flagConsumer flagConsumer) flagConsumer { 66 | consumed := false 67 | return func(args []string) (bool, []string, error) { 68 | consumerConsumed, restArgs, err := flagConsumer(args) 69 | 70 | if consumerConsumed && consumed { 71 | return false, restArgs, fmt.Errorf("flag %s passed twice", name) 72 | } 73 | 74 | consumed = consumerConsumed 75 | 76 | return consumed, restArgs, err 77 | } 78 | } 79 | 80 | func makeFlag(flagName, usage string, flagConsumer flagConsumer) flag { 81 | return flag{ 82 | flagName: flagName, 83 | usage: usage, 84 | flagConsumer: flagConsumer, 85 | } 86 | } 87 | 88 | func makeBoolFlag(flagName, usage string, val *bool) flag { 89 | return makeFlag(flagName, usage, makeRedefinedGuardConsumer(flagName, makeBoolConsumer(flagName, val))) 90 | } 91 | 92 | func makeStringFlag(flagName, usage string, val *string) flag { 93 | return makeFlag(flagName, usage, makeRedefinedGuardConsumer(flagName, makeStringConsumer(flagName, val))) 94 | } 95 | 96 | func NewCmdParams() *CmdParams { 97 | var leaveTmpFile, printCommand, showVersion, generateEmptyTemplate, showHelp bool 98 | envFile := ".env" 99 | 100 | flags := []flag{} 101 | 102 | appName := os.Args[0] 103 | restArgs := os.Args[1:] 104 | 105 | flags = append(flags, makeBoolFlag("-p", "Print command to the terminal instead of executing", &printCommand)) 106 | flags = append(flags, makeStringFlag("-e", "Path to .env file", &envFile)) 107 | flags = append(flags, makeBoolFlag("-l", "Leave any body-files", &leaveTmpFile)) 108 | flags = append(flags, makeBoolFlag("-b", "Generate basic template files(s)", &generateEmptyTemplate)) 109 | flags = append(flags, makeBoolFlag("-v", "Show version and exit", &showVersion)) 110 | flags = append(flags, makeBoolFlag("-h", "Show help and exit", &showHelp)) 111 | 112 | for { 113 | if len(restArgs) == 0 { 114 | break 115 | } 116 | 117 | arg := restArgs[0] 118 | 119 | if arg == varsFlagStr { 120 | break 121 | } 122 | 123 | if strings.HasPrefix(arg, "-") { 124 | flagFound := false 125 | 126 | for _, flag := range flags { 127 | consumed, consumedRestArgs, err := flag.flagConsumer(restArgs) 128 | if err != nil { 129 | fmt.Fprintf(os.Stderr, "%s: %s\n", appName, err) 130 | os.Exit(1) 131 | } 132 | 133 | restArgs = consumedRestArgs 134 | if consumed { 135 | flagFound = true 136 | break 137 | } 138 | } 139 | 140 | if !flagFound { 141 | fmt.Fprintf(os.Stderr, "%s: unknown flag: %s\n", appName, arg) 142 | os.Exit(1) 143 | } 144 | 145 | continue 146 | } 147 | 148 | // No more flags 149 | break 150 | } 151 | 152 | if showHelp { 153 | printUsage(appName, flags) 154 | os.Exit(0) 155 | } 156 | 157 | return &CmdParams{ 158 | restArgs: restArgs, 159 | LeaveTmpFile: leaveTmpFile, 160 | PrintCommand: printCommand, 161 | ShowVersion: showVersion, 162 | GenerateEmptyTemplate: generateEmptyTemplate, 163 | EnvFile: envFile, 164 | } 165 | } 166 | 167 | func (c *CmdParams) SetEnvVarsAndFilenames() error { 168 | collectVars := false 169 | vars := []string{} 170 | 171 | for _, arg := range c.restArgs { 172 | if arg == varsFlagStr { 173 | collectVars = true 174 | continue 175 | } 176 | 177 | if collectVars { 178 | vars = append(vars, arg) 179 | } else { 180 | c.TemplateFileNames = append(c.TemplateFileNames, arg) 181 | } 182 | } 183 | 184 | for _, v := range vars { 185 | varName, value, found := strings.Cut(v, "=") 186 | if !found { 187 | return fmt.Errorf("invalid environment variable format, (missing =): %s", v) 188 | } 189 | c.EnvVars = append(c.EnvVars, []string{varName, value}) 190 | } 191 | 192 | if collectVars && len(c.EnvVars) == 0 { 193 | return fmt.Errorf(varsFlagStr + " passed but no environment variables arguments found") 194 | } 195 | 196 | return nil 197 | } 198 | 199 | type CmdParams struct { 200 | restArgs []string 201 | 202 | LeaveTmpFile bool 203 | PrintCommand bool 204 | ShowVersion bool 205 | GenerateEmptyTemplate bool 206 | EnvFile string 207 | EnvVars [][]string 208 | TemplateFileNames []string 209 | } 210 | -------------------------------------------------------------------------------- /internal/pkg/parse/capture.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | type capturedSection struct { 10 | heading string 11 | headingSourceLineIndex int 12 | sectionLines *[]sourceMarker 13 | } 14 | 15 | func getSectionHeading(templateLineTextTrimmed string) string { 16 | templateLineTextTrimmedLower := strings.ToLower(templateLineTextTrimmed) 17 | for _, knownSectionHeader := range allSectionHeaders { 18 | if templateLineTextTrimmedLower == knownSectionHeader { 19 | return knownSectionHeader 20 | } 21 | } 22 | 23 | return "" 24 | } 25 | 26 | func (s *sectionedTemplate) checkValidHeadings(capturedSections []capturedSection) { 27 | // Keeps "header": [1,5,7] <- Name of heading and on what lines in the file 28 | headingDefinitionSourceLines := map[string][]int{} 29 | 30 | for _, capturedSection := range capturedSections { 31 | if len(*capturedSection.sectionLines) == 0 { 32 | // !! TODO !! Can I use capturedSectionLine or so 33 | s.setFatalMessage(fmt.Sprintf("Empty %s section", capturedSection.heading), capturedSection.headingSourceLineIndex) 34 | } 35 | 36 | headingDefinitionSourceLines[capturedSection.heading] = append(headingDefinitionSourceLines[capturedSection.heading], capturedSection.headingSourceLineIndex) 37 | } 38 | 39 | for heading, headingSourceLineIndexes := range headingDefinitionSourceLines { 40 | if len(headingSourceLineIndexes) == 1 { 41 | continue 42 | } 43 | 44 | for _, headingSourceLineIndex := range headingSourceLineIndexes[1:] { 45 | s.setFatalMessage(fmt.Sprintf("Section %s on line %d redeclared", heading, headingSourceLineIndexes[0]+1), headingSourceLineIndex) 46 | } 47 | } 48 | } 49 | 50 | func containsSectionHeader(sectionHeading string, wantedSectionHeadings []string) bool { 51 | for _, val := range wantedSectionHeadings { 52 | if sectionHeading == val { 53 | return true 54 | } 55 | } 56 | 57 | return false 58 | } 59 | 60 | func compactBodySection(currentSectionLines *[]sourceMarker) { 61 | firstNonEmptyLine := 0 62 | for ; firstNonEmptyLine < len(*currentSectionLines); firstNonEmptyLine++ { 63 | if (*currentSectionLines)[firstNonEmptyLine].lineContents != "" { 64 | break 65 | } 66 | } 67 | 68 | lastNonEmptyLine := len(*currentSectionLines) - 1 69 | for ; lastNonEmptyLine > firstNonEmptyLine; lastNonEmptyLine-- { 70 | if (*currentSectionLines)[lastNonEmptyLine].lineContents != "" { 71 | break 72 | } 73 | } 74 | 75 | *currentSectionLines = (*currentSectionLines)[firstNonEmptyLine : lastNonEmptyLine+1] 76 | } 77 | 78 | func unescapeSectionHeading(templateLineTextTrimmed, templateLineText string) string { 79 | templateLineTextTrimmedLower := strings.ToLower(templateLineTextTrimmed) 80 | 81 | // !! DEPRECATE !! Old way (e g \[Body]) 82 | if strings.HasPrefix(templateLineTextTrimmed, `\`) { 83 | for _, knownSectionHeader := range allSectionHeaders { 84 | if templateLineTextTrimmedLower == `\`+knownSectionHeader { 85 | return strings.Replace(templateLineText, `\`, "", 1) 86 | } 87 | } 88 | } 89 | 90 | if strings.HasPrefix(templateLineTextTrimmed, "`") { 91 | for _, knownSectionHeader := range allSectionHeaders { 92 | if templateLineTextTrimmedLower == "`"+knownSectionHeader { 93 | return strings.Replace(templateLineText, "`", "", 1) 94 | } 95 | } 96 | } 97 | 98 | if strings.HasPrefix(templateLineTextTrimmed, "\\`") { 99 | for _, knownSectionHeader := range allSectionHeaders { 100 | if templateLineTextTrimmedLower == "\\`"+knownSectionHeader { 101 | return strings.Replace(templateLineText, "\\`", "`", 1) 102 | } 103 | } 104 | } 105 | 106 | return templateLineText 107 | } 108 | 109 | func (s *sectionedTemplate) setCapturedSections(wantedSectionHeadings ...string) { 110 | capturedSections := []capturedSection{} 111 | 112 | var currentSectionHeader string 113 | var currentSectionLines *[]sourceMarker 114 | 115 | for expandedSourceIndex, _ := range s.expandedTemplateLines { 116 | expandedTemplateLine := &s.expandedTemplateLines[expandedSourceIndex] 117 | 118 | templateLineText := expandedTemplateLine.getTextContent() 119 | templateLineTextTrimmed := strings.TrimSpace(templateLineText) 120 | 121 | if currentSectionHeader != "" { 122 | expandedTemplateLine.consumed = true 123 | } 124 | 125 | // Discard empty lines, except if it's the [Body] section 126 | if currentSectionHeader != bodySection && templateLineTextTrimmed == "" { 127 | continue 128 | } 129 | 130 | if sectionHeading := getSectionHeading(templateLineTextTrimmed); sectionHeading != "" { 131 | // Compact [Body] section 132 | if currentSectionHeader == bodySection { 133 | compactBodySection(currentSectionLines) 134 | } 135 | 136 | if !containsSectionHeader(sectionHeading, wantedSectionHeadings) { 137 | currentSectionLines = nil 138 | currentSectionHeader = "" 139 | continue 140 | } 141 | 142 | currentSectionHeader = sectionHeading 143 | currentSectionLines = &[]sourceMarker{} 144 | 145 | capturedSections = append(capturedSections, capturedSection{ 146 | heading: sectionHeading, 147 | headingSourceLineIndex: expandedSourceIndex, 148 | sectionLines: currentSectionLines, 149 | }) 150 | 151 | expandedTemplateLine.consumed = true 152 | 153 | continue 154 | } 155 | 156 | // Not a section we're interested in 157 | if currentSectionLines == nil { 158 | continue 159 | } 160 | 161 | templateLineText = unescapeSectionHeading(templateLineTextTrimmed, templateLineText) 162 | 163 | sourceMarker := sourceMarker{ 164 | sourceLineIndex: expandedSourceIndex, 165 | } 166 | 167 | if currentSectionHeader == bodySection { 168 | sourceMarker.lineContents = strings.TrimRightFunc(templateLineText, func(r rune) bool { return unicode.IsSpace(r) }) 169 | } else { 170 | sourceMarker.lineContents = strings.TrimSpace(templateLineText) 171 | } 172 | 173 | *currentSectionLines = append(*currentSectionLines, sourceMarker) 174 | } 175 | 176 | if currentSectionHeader == bodySection { 177 | compactBodySection(currentSectionLines) 178 | } 179 | 180 | if s.checkValidHeadings(capturedSections); s.hasFatalMessages() { 181 | return 182 | } 183 | 184 | for _, capturedSection := range capturedSections { 185 | s.sections[capturedSection.heading] = capturedSection.sectionLines 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /internal/pkg/parse/tokenize.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type tokenType int 9 | 10 | const ( 11 | errorToken = 0 12 | commentToken = 1 13 | textToken = 2 14 | executableToken = 3 15 | envVarToken = 4 16 | ) 17 | 18 | type token struct { 19 | tokenType tokenType 20 | content string 21 | // Used in formatting fatals - contains the 22 | // original untokenized line (for keeping escaped 23 | // tokens which we loose when removing the escaping). 24 | fatalContent string 25 | } 26 | 27 | const ( 28 | commentPrefix = "#" 29 | envVarPrefix = "${" 30 | executablePrefix = "$(" 31 | ) 32 | 33 | func isStartOfToken(tokenTypePrefix, prev, rest string) bool { 34 | return strings.HasPrefix(rest, tokenTypePrefix) && (!strings.HasSuffix(prev, "`") || strings.HasSuffix(prev, "\\`")) 35 | } 36 | 37 | func splitTextOnComment(input string) (string, string) { 38 | inputRunes := []rune(input) 39 | 40 | currentContent := "" 41 | idx := 0 42 | 43 | for idx < len(inputRunes) { 44 | rest := string(inputRunes[idx:]) 45 | prev := string(inputRunes[:idx]) 46 | 47 | if isStartOfToken(commentPrefix, prev, rest) { 48 | return currentContent, rest 49 | } 50 | 51 | currentContent += string(inputRunes[idx]) 52 | idx++ 53 | } 54 | 55 | return currentContent, "" 56 | } 57 | 58 | func unescapeEnvVars(content string, hasNextToken bool) string { 59 | content = strings.ReplaceAll(content, "`"+envVarPrefix, envVarPrefix) 60 | 61 | // Handle escaped backtick at the end 62 | if hasNextToken && strings.HasSuffix(content, "\\`") { 63 | content = strings.TrimSuffix(content, "\\`") + "`" 64 | } 65 | 66 | return content 67 | } 68 | 69 | // tokenizeEnvVars does not handle comments, input 70 | // is the content of an expandedSectionLine 71 | func tokenizeEnvVars(input string) ([]token, string) { 72 | result := []token{} 73 | inputRunes := []rune(input) 74 | 75 | currentContent := "" 76 | isEnvVar := false 77 | idx := 0 78 | 79 | for idx < len(inputRunes) { 80 | rest := string(inputRunes[idx:]) 81 | prev := string(inputRunes[:idx]) 82 | 83 | if !isEnvVar && isStartOfToken(envVarPrefix, prev, rest) { 84 | if len(currentContent) > 0 { 85 | result = append(result, token{ 86 | tokenType: textToken, 87 | content: unescapeEnvVars(currentContent, true), 88 | fatalContent: currentContent, 89 | }) 90 | 91 | currentContent = "" 92 | } 93 | 94 | idx += len(envVarPrefix) 95 | isEnvVar = true 96 | continue 97 | } 98 | 99 | if isEnvVar && isStartOfToken("}", prev, rest) { 100 | unescapedContent := strings.ReplaceAll(currentContent, "`}", "}") 101 | 102 | if strings.HasSuffix(unescapedContent, "\\`") { 103 | unescapedContent = strings.TrimSuffix(unescapedContent, "\\`") + "`" 104 | } 105 | 106 | result = append(result, token{ 107 | tokenType: envVarToken, 108 | content: unescapedContent, 109 | fatalContent: envVarPrefix + currentContent + "}", 110 | }) 111 | 112 | isEnvVar = false 113 | currentContent = "" 114 | 115 | idx += 1 116 | continue 117 | } 118 | 119 | currentContent += string(inputRunes[idx : idx+1]) 120 | idx += 1 121 | } 122 | 123 | if isEnvVar { 124 | return nil, fmt.Sprintf("Missing closing bracket for environment variable: %s%s", envVarPrefix, currentContent) 125 | } 126 | 127 | if len(currentContent) > 0 { 128 | result = append(result, token{ 129 | tokenType: textToken, 130 | content: unescapeEnvVars(currentContent, false), 131 | fatalContent: currentContent, 132 | }) 133 | } 134 | 135 | return result, "" 136 | } 137 | 138 | func unescapeExecutables(content string, hasNextToken bool) string { 139 | content = strings.ReplaceAll(content, "`"+executablePrefix, executablePrefix) 140 | 141 | if hasNextToken && strings.HasSuffix(content, "\\`") { 142 | content = strings.TrimSuffix(content, "\\`") + "`" 143 | } 144 | 145 | return content 146 | } 147 | 148 | func tokenizeExecutables(input string) ([]token, string) { 149 | result := []token{} 150 | inputRunes := []rune(input) 151 | 152 | var executableQuoteRune rune 153 | var executableQuoteEnd int 154 | executableStartIdx := -1 155 | 156 | currentContent := "" 157 | idx := 0 158 | 159 | for idx < len(inputRunes) { 160 | rest := string(inputRunes[idx:]) 161 | prev := string(inputRunes[:idx]) 162 | 163 | if executableStartIdx == -1 && isStartOfToken(executablePrefix, prev, rest) { 164 | if len(currentContent) > 0 { 165 | result = append(result, token{ 166 | tokenType: textToken, 167 | content: unescapeExecutables(currentContent, true), 168 | fatalContent: currentContent, 169 | }) 170 | 171 | currentContent = "" 172 | } 173 | 174 | executableStartIdx = idx 175 | 176 | idx += len(envVarPrefix) 177 | continue 178 | } 179 | 180 | if executableStartIdx >= 0 { 181 | nextRune := []rune(rest)[0] 182 | switch nextRune { 183 | case '"', '\'': 184 | if executableQuoteRune == 0 { 185 | executableQuoteRune = nextRune 186 | 187 | unescapedContentTillNow := currentContent[executableQuoteEnd:] 188 | currentContent = currentContent[:executableQuoteEnd] + strings.ReplaceAll(unescapedContentTillNow, "`)", ")") 189 | } else if !strings.HasSuffix(prev, `\`) && executableQuoteRune == nextRune { 190 | executableQuoteRune = 0 191 | executableQuoteEnd = len(currentContent) - 1 192 | } 193 | } 194 | 195 | if executableQuoteRune == 0 && isStartOfToken(")", prev, rest) { 196 | unescapedContentTillNow := currentContent[executableQuoteEnd:] 197 | currentContent = currentContent[:executableQuoteEnd] + strings.ReplaceAll(unescapedContentTillNow, "`)", ")") 198 | executableQuoteEnd = 0 199 | 200 | if strings.HasSuffix(currentContent, "\\`") { 201 | currentContent = strings.TrimSuffix(currentContent, "\\`") + "`" 202 | } 203 | 204 | result = append(result, token{ 205 | tokenType: executableToken, 206 | content: currentContent, 207 | fatalContent: string(inputRunes[executableStartIdx : idx+1]), 208 | }) 209 | 210 | executableStartIdx = -1 211 | currentContent = "" 212 | 213 | idx += 1 214 | continue 215 | } 216 | } 217 | 218 | currentContent += string(inputRunes[idx : idx+1]) 219 | idx += 1 220 | } 221 | 222 | if executableStartIdx >= 0 { 223 | if executableQuoteRune != 0 { 224 | return nil, fmt.Sprintf("Unterminated quote sequence for executable: %s", string(inputRunes[executableStartIdx:])) 225 | } 226 | return nil, fmt.Sprintf("Missing closing parenthesis for executable: %s", string(inputRunes[executableStartIdx:])) 227 | } 228 | 229 | if len(currentContent) > 0 { 230 | result = append(result, token{ 231 | tokenType: textToken, 232 | content: unescapeExecutables(currentContent, false), 233 | fatalContent: currentContent, 234 | }) 235 | } 236 | 237 | return result, "" 238 | } 239 | -------------------------------------------------------------------------------- /internal/pkg/parse/sections.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | type sourceMarker struct { 10 | lineContents string 11 | sourceLineIndex int 12 | } 13 | 14 | type expandedSourceMarker struct { 15 | content string 16 | fatalContent string 17 | comment string 18 | sourceLineIndex int 19 | expanded bool 20 | consumed bool 21 | } 22 | 23 | func (e expandedSourceMarker) String() string { 24 | return e.fatalContent + e.comment 25 | } 26 | 27 | func (e expandedSourceMarker) getTextContent() string { 28 | textContent := strings.ReplaceAll(e.content, "`"+commentPrefix, commentPrefix) 29 | 30 | if e.comment != "" && strings.HasSuffix(textContent, "\\`") { 31 | textContent = strings.TrimSuffix(textContent, "\\`") + "`" 32 | } 33 | 34 | return textContent 35 | } 36 | 37 | const ( 38 | configSection = "[config]" 39 | hostSection = "[host]" 40 | querySection = "[query]" 41 | headersSection = "[headers]" 42 | methodSection = "[method]" 43 | bodySection = "[body]" 44 | backendSection = "[backend]" 45 | backendOptionsSection = "[backendoptions]" 46 | // As above, so below 47 | // If you add one here then add it to the slice below. 48 | // AND IF 49 | // it should be included when capturing executables (i e not Config 50 | // as it's parsed before running executables) add it to the 51 | // second slice below 52 | ) 53 | 54 | var allSectionHeaders = []string{ 55 | configSection, 56 | hostSection, 57 | querySection, 58 | headersSection, 59 | methodSection, 60 | bodySection, 61 | backendSection, 62 | backendOptionsSection, 63 | } 64 | 65 | var sectionsAllowingExecutables = []string{ 66 | hostSection, 67 | querySection, 68 | headersSection, 69 | methodSection, 70 | bodySection, 71 | backendSection, 72 | backendOptionsSection, 73 | } 74 | 75 | type sectionedTemplate struct { 76 | // sourceMarker.SourceLineIndex points to the expandedTemplateLines slice 77 | sections map[string]*[]sourceMarker 78 | 79 | // sourceMarker.SourceLineIndex points to the rawTemplateLines slice 80 | expandedTemplateLines []expandedSourceMarker 81 | rawTemplateLines []string 82 | 83 | filename string 84 | fatals []string 85 | } 86 | 87 | func (s *sectionedTemplate) getNamedSection(sectionHeader string) *[]sourceMarker { 88 | if section, exists := s.sections[sectionHeader]; exists { 89 | return section 90 | } 91 | 92 | return &[]sourceMarker{} 93 | } 94 | 95 | func splitValueOnNewlines(value string, currentLine expandedSourceMarker) (splitLines []expandedSourceMarker, lastLine expandedSourceMarker) { 96 | value = strings.ReplaceAll(value, "\r\n", "\n") 97 | newLines := strings.Split(value, "\n") 98 | 99 | valueText, valueComment := splitTextOnComment(newLines[0]) 100 | 101 | currentLine.content += valueText 102 | currentLine.fatalContent += valueText 103 | currentLine.comment = valueComment 104 | 105 | for _, newLine := range newLines[1:] { 106 | splitLines = append(splitLines, currentLine) 107 | currentLine = expandedSourceMarker{sourceLineIndex: currentLine.sourceLineIndex, expanded: true} 108 | 109 | valueText, valueComment := splitTextOnComment(newLine) 110 | 111 | currentLine.content = valueText 112 | currentLine.fatalContent = valueText 113 | currentLine.comment = valueComment 114 | } 115 | 116 | return splitLines, currentLine 117 | } 118 | 119 | func (s *sectionedTemplate) processLineTokens( 120 | tokens, 121 | fatalTokens []token, 122 | tokenIterator func(t token) (string, string), 123 | previousLine expandedSourceMarker, 124 | ) []expandedSourceMarker { 125 | currentLine := expandedSourceMarker{sourceLineIndex: previousLine.sourceLineIndex, expanded: previousLine.expanded} 126 | expandedLines := []expandedSourceMarker{} 127 | 128 | // Range over the lines tokens 129 | for tokenIdx, token := range tokens { 130 | if token.tokenType == textToken { 131 | // Remove the escaping of `${ - because now it's ok to return 132 | // `${ and it'll be verbatim this from now on. So if a script 133 | // (or an env-var) contains that sequence it should not be erased 134 | // anymore. 135 | currentLine.content += token.content 136 | currentLine.fatalContent += fatalTokens[tokenIdx].fatalContent 137 | 138 | continue 139 | } 140 | 141 | value, fatal := tokenIterator(token) 142 | if fatal != "" { 143 | s.setFatalMessage(fatal, previousLine.sourceLineIndex) 144 | continue 145 | } 146 | 147 | if s.hasFatalMessages() { 148 | // Fatals relates to the current expanded lines, 149 | // and not the new we're making. Avoid the computation 150 | // below but keep iterating over tokens so 151 | // we report all fatals on this line 152 | continue 153 | } 154 | 155 | currentLine.expanded = true 156 | 157 | // Append any split lines and set the current line to the last 158 | var splitLines []expandedSourceMarker 159 | splitLines, currentLine = splitValueOnNewlines(value, currentLine) 160 | expandedLines = append(expandedLines, splitLines...) 161 | 162 | // If a comment was inserted the rest of the line now becomes part of that comment 163 | if currentLine.comment != "" { 164 | for _, restToken := range tokens[tokenIdx+1:] { 165 | // Use fatalContent because this keeps escaped characters 166 | currentLine.comment += restToken.fatalContent 167 | } 168 | break 169 | } 170 | } 171 | 172 | currentLine.comment += previousLine.comment 173 | expandedLines = append(expandedLines, currentLine) 174 | 175 | return expandedLines 176 | } 177 | 178 | func (s *sectionedTemplate) expandTemplateLines( 179 | tokenizer func(string) ([]token, string), 180 | tokenIterator func(t token) (string, string), 181 | ) { 182 | expandedLines := []expandedSourceMarker{} 183 | 184 | for _, currentLine := range s.expandedTemplateLines { 185 | if currentLine.consumed { 186 | expandedLines = append(expandedLines, currentLine) 187 | continue 188 | } 189 | 190 | tokens, fatal := tokenizer(currentLine.content) 191 | if fatal != "" { 192 | s.setFatalMessage(fatal, currentLine.sourceLineIndex) 193 | continue 194 | } 195 | 196 | fatalTokens, fatal := tokenizer(currentLine.fatalContent) 197 | if fatal != "" { 198 | fmt.Fprintf(os.Stderr, "Internal error tokenizing fatals: %s\n", fatal) 199 | os.Exit(1) 200 | } 201 | 202 | // One token might return several lines 203 | expandedLinesFromTokens := s.processLineTokens( 204 | tokens, 205 | fatalTokens, 206 | tokenIterator, 207 | currentLine, 208 | ) 209 | 210 | expandedLines = append(expandedLines, expandedLinesFromTokens...) 211 | } 212 | 213 | if !s.hasFatalMessages() { 214 | s.expandedTemplateLines = expandedLines 215 | } 216 | } 217 | 218 | func newSectionedTemplate(rawTemplateString, filename string) *sectionedTemplate { 219 | rawTemplateLines := strings.Split(strings.ReplaceAll(rawTemplateString, "\r\n", "\n"), "\n") 220 | 221 | expandedTemplateLines := []expandedSourceMarker{} 222 | 223 | for sourceIndex, rawTemplateLine := range rawTemplateLines { 224 | content, comment := splitTextOnComment(rawTemplateLine) 225 | 226 | expandedTemplateLines = append(expandedTemplateLines, expandedSourceMarker{ 227 | content: content, 228 | fatalContent: content, 229 | comment: comment, 230 | 231 | sourceLineIndex: sourceIndex, 232 | expanded: false, 233 | }) 234 | } 235 | 236 | sectionedTemplate := sectionedTemplate{ 237 | sections: map[string]*[]sourceMarker{}, 238 | expandedTemplateLines: expandedTemplateLines, 239 | rawTemplateLines: rawTemplateLines, 240 | filename: filename, 241 | } 242 | 243 | return §ionedTemplate 244 | } 245 | -------------------------------------------------------------------------------- /internal/pkg/parse/assemble.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | "time" 9 | 10 | "github.com/jonaslu/ain/internal/pkg/data" 11 | "github.com/jonaslu/ain/internal/pkg/disk" 12 | ) 13 | 14 | const editFileSuffix = "!" 15 | 16 | func getAllSectionedTemplates(filenames []string) ([]*sectionedTemplate, error) { 17 | allSectionedTemplates := []*sectionedTemplate{} 18 | 19 | for _, filename := range filenames { 20 | editFile := false 21 | 22 | if strings.HasSuffix(filename, editFileSuffix) { 23 | editFile = true 24 | filename = strings.TrimSuffix(filename, editFileSuffix) 25 | } 26 | 27 | rawTemplateString, err := disk.ReadRawTemplateString(filename, editFile) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | allSectionedTemplates = append(allSectionedTemplates, newSectionedTemplate(rawTemplateString, filename)) 33 | } 34 | 35 | return allSectionedTemplates, nil 36 | } 37 | 38 | func getConfig(allSectionedTemplates []*sectionedTemplate) (data.Config, []string) { 39 | configFatals := []string{} 40 | config := data.NewConfig() 41 | 42 | for i := len(allSectionedTemplates) - 1; i >= 0; i-- { 43 | sectionedTemplate := allSectionedTemplates[i] 44 | 45 | if sectionedTemplate.setCapturedSections(configSection); sectionedTemplate.hasFatalMessages() { 46 | configFatals = append(configFatals, sectionedTemplate.getFatalMessages()) 47 | break 48 | } 49 | 50 | localConfig := sectionedTemplate.getConfig() 51 | if sectionedTemplate.hasFatalMessages() { 52 | configFatals = append(configFatals, sectionedTemplate.getFatalMessages()) 53 | break 54 | } 55 | 56 | if config.Timeout == data.TimeoutNotSet { 57 | config.Timeout = localConfig.Timeout 58 | } 59 | 60 | if config.QueryDelim == nil { 61 | config.QueryDelim = localConfig.QueryDelim 62 | } 63 | 64 | if config.Timeout > data.TimeoutNotSet && config.QueryDelim != nil { 65 | break 66 | } 67 | } 68 | 69 | return config, configFatals 70 | } 71 | 72 | func substituteEnvVars(allSectionedTemplates []*sectionedTemplate) []string { 73 | substituteEnvVarsFatals := []string{} 74 | 75 | for _, sectionedTemplate := range allSectionedTemplates { 76 | if sectionedTemplate.substituteEnvVars(); sectionedTemplate.hasFatalMessages() { 77 | substituteEnvVarsFatals = append(substituteEnvVarsFatals, sectionedTemplate.getFatalMessages()) 78 | } 79 | } 80 | 81 | return substituteEnvVarsFatals 82 | } 83 | 84 | func substituteExecutables(ctx context.Context, config data.Config, allSectionedTemplates []*sectionedTemplate) ([]string, error) { 85 | substituteExecutablesFatals := []string{} 86 | allExecutableAndArgs := []executableAndArgs{} 87 | 88 | for _, sectionedTemplate := range allSectionedTemplates { 89 | allExecutableAndArgs = append(allExecutableAndArgs, sectionedTemplate.captureExecutableAndArgs()...) 90 | 91 | if sectionedTemplate.hasFatalMessages() { 92 | substituteExecutablesFatals = append(substituteExecutablesFatals, sectionedTemplate.getFatalMessages()) 93 | } 94 | } 95 | 96 | if len(substituteExecutablesFatals) > 0 { 97 | return substituteExecutablesFatals, nil 98 | } 99 | 100 | allExecutablesOutput := callExecutables(ctx, config, allExecutableAndArgs) 101 | if ctx.Err() == context.Canceled { 102 | return nil, ctx.Err() 103 | } 104 | 105 | for _, sectionedTemplate := range allSectionedTemplates { 106 | if sectionedTemplate.insertExecutableOutput(&allExecutablesOutput); sectionedTemplate.hasFatalMessages() { 107 | substituteExecutablesFatals = append(substituteExecutablesFatals, sectionedTemplate.getFatalMessages()) 108 | } 109 | } 110 | 111 | return substituteExecutablesFatals, nil 112 | } 113 | 114 | type allSectionRows struct { 115 | host string 116 | backend string 117 | method string 118 | headers []string 119 | query []string 120 | body []string 121 | backendOptions [][]string 122 | } 123 | 124 | func getAllSectionRows(allSectionedTemplates []*sectionedTemplate) (allSectionRows, []string) { 125 | allSectionRowsFatals := []string{} 126 | allSectionRows := allSectionRows{} 127 | 128 | for _, sectionedTemplate := range allSectionedTemplates { 129 | if sectionedTemplate.setCapturedSections(sectionsAllowingExecutables...); sectionedTemplate.hasFatalMessages() { 130 | allSectionRowsFatals = append(allSectionRowsFatals, sectionedTemplate.getFatalMessages()) 131 | continue 132 | } 133 | 134 | allSectionRows.host = allSectionRows.host + sectionedTemplate.getHost() 135 | allSectionRows.headers = append(allSectionRows.headers, sectionedTemplate.getHeaders()...) 136 | allSectionRows.query = append(allSectionRows.query, sectionedTemplate.getQuery()...) 137 | allSectionRows.backendOptions = append(allSectionRows.backendOptions, sectionedTemplate.getBackendOptions()...) 138 | 139 | if localBackend := sectionedTemplate.getBackend(); localBackend != "" { 140 | allSectionRows.backend = localBackend 141 | } 142 | 143 | if localMethod := sectionedTemplate.getMethod(); localMethod != "" { 144 | allSectionRows.method = localMethod 145 | } 146 | 147 | if localBody := sectionedTemplate.getBody(); len(localBody) > 0 { 148 | allSectionRows.body = localBody 149 | } 150 | 151 | if sectionedTemplate.hasFatalMessages() { 152 | allSectionRowsFatals = append(allSectionRowsFatals, sectionedTemplate.getFatalMessages()) 153 | } 154 | } 155 | 156 | return allSectionRows, allSectionRowsFatals 157 | } 158 | 159 | func getBackendInput(allSectionRows allSectionRows, config data.Config) (*data.BackendInput, []string) { 160 | backendInputFatals := []string{} 161 | backendInput := data.BackendInput{} 162 | 163 | if allSectionRows.host == "" { 164 | backendInputFatals = append(backendInputFatals, "No mandatory [Host] section found") 165 | } else { 166 | hostUrl, err := url.Parse(allSectionRows.host) 167 | 168 | if err != nil { 169 | backendInputFatals = append(backendInputFatals, fmt.Sprintf("[Host] has illegal url: %s, error: %v", allSectionRows.host, err)) 170 | } else { 171 | addQueryString(hostUrl, allSectionRows.query, config) 172 | backendInput.Host = hostUrl 173 | } 174 | } 175 | 176 | if allSectionRows.backend == "" { 177 | backendInputFatals = append(backendInputFatals, "No mandatory [Backend] section found") 178 | } 179 | 180 | backendInput.Method = allSectionRows.method 181 | backendInput.Body = allSectionRows.body 182 | backendInput.Headers = allSectionRows.headers 183 | backendInput.Backend = allSectionRows.backend 184 | backendInput.BackendOptions = allSectionRows.backendOptions 185 | 186 | return &backendInput, backendInputFatals 187 | } 188 | 189 | func Assemble(ctx context.Context, filenames []string) (context.Context, *data.BackendInput, string, error) { 190 | allSectionedTemplates, err := getAllSectionedTemplates(filenames) 191 | if err != nil { 192 | return ctx, nil, "", err 193 | } 194 | 195 | if substituteEnvVarsFatals := substituteEnvVars(allSectionedTemplates); len(substituteEnvVarsFatals) > 0 { 196 | return ctx, nil, strings.Join(substituteEnvVarsFatals, "\n\n"), nil 197 | } 198 | 199 | config, configFatals := getConfig(allSectionedTemplates) 200 | if len(configFatals) > 0 { 201 | return ctx, nil, strings.Join(configFatals, "\n\n"), nil 202 | } 203 | 204 | if config.Timeout != data.TimeoutNotSet { 205 | ctx, _ = context.WithTimeout(ctx, time.Duration(config.Timeout)*time.Second) 206 | ctx = context.WithValue(ctx, data.TimeoutContextValueKey{}, config.Timeout) 207 | } 208 | 209 | substituteExecutablesFatals, err := substituteExecutables(ctx, config, allSectionedTemplates) 210 | if err != nil { 211 | return ctx, nil, "", err 212 | } 213 | 214 | if len(substituteExecutablesFatals) > 0 { 215 | return ctx, nil, strings.Join(substituteExecutablesFatals, "\n\n"), nil 216 | } 217 | 218 | allSectionRows, allSectionRowsFatals := getAllSectionRows(allSectionedTemplates) 219 | if len(allSectionRowsFatals) > 0 { 220 | return ctx, nil, strings.Join(allSectionRowsFatals, "\n\n"), nil 221 | } 222 | 223 | backendInput, backendInputFatals := getBackendInput(allSectionRows, config) 224 | if len(backendInputFatals) > 0 { 225 | // Since we no longer have a sectionedTemplate errors 226 | // are no longer linked to a file and we separate 227 | // with one newline 228 | return ctx, nil, strings.Join(backendInputFatals, "\n"), nil 229 | } 230 | 231 | return ctx, backendInput, "", nil 232 | } 233 | -------------------------------------------------------------------------------- /internal/pkg/parse/tokenize_test.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func Test_isStartOfContentType(t *testing.T) { 9 | tests := map[string]struct { 10 | prefix, prev, rest string 11 | expectedResult bool 12 | }{ 13 | "Regular comment": { 14 | prefix: "#", 15 | prev: "goat", 16 | rest: "# yak", 17 | expectedResult: true, 18 | }, 19 | "Escaped comment": { 20 | prefix: "#", 21 | prev: "fire `", 22 | rest: "# no comment", 23 | expectedResult: false, 24 | }, 25 | "Escaped backtick followed by comment": { 26 | prefix: "#", 27 | prev: "\\`", 28 | rest: "# is comment", 29 | expectedResult: true, 30 | }, 31 | } 32 | 33 | for name, test := range tests { 34 | result := isStartOfToken(test.prefix, test.prev, test.rest) 35 | if !reflect.DeepEqual(test.expectedResult, result) { 36 | t.Errorf("Test: %s, Expected: %v, Got: %v", name, test.expectedResult, result) 37 | } 38 | } 39 | } 40 | 41 | func Test_splitTextOnComment(t *testing.T) { 42 | tests := map[string]struct { 43 | input string 44 | expectedContent string 45 | expectedComment string 46 | }{ 47 | "Only content": { 48 | input: "abc 123", 49 | expectedContent: "abc 123", 50 | expectedComment: "", 51 | }, 52 | "Only comment": { 53 | input: "# uh comment yo", 54 | expectedContent: "", 55 | expectedComment: "# uh comment yo", 56 | }, 57 | "Mixed content and comment": { 58 | input: "abc123 # uh comment yo # more", 59 | expectedContent: "abc123 ", 60 | expectedComment: "# uh comment yo # more", 61 | }, 62 | "Escaped comment": { 63 | input: "abc123 `# still content", 64 | expectedContent: "abc123 `# still content", 65 | expectedComment: "", 66 | }, 67 | "Backtick followed by comment": { 68 | input: "abc123 \\`# comment", 69 | expectedContent: "abc123 \\`", 70 | expectedComment: "# comment", 71 | }, 72 | } 73 | 74 | for name, test := range tests { 75 | content, comment := splitTextOnComment(test.input) 76 | if content != test.expectedContent { 77 | t.Errorf("Test: %s, Expected content: %v, Got: %v", name, test.expectedContent, content) 78 | } 79 | 80 | if comment != test.expectedComment { 81 | t.Errorf("Test: %s, Expected comment: %v, Got: %v", name, test.expectedComment, comment) 82 | } 83 | } 84 | } 85 | 86 | func Test_tokenizeEnvVarsGoodCases(t *testing.T) { 87 | tests := map[string]struct { 88 | input string 89 | expectedTokens []token 90 | }{ 91 | "Empty input": { 92 | input: "", 93 | expectedTokens: []token{}, 94 | }, 95 | "Only text": { 96 | input: "teeext", 97 | expectedTokens: []token{{ 98 | tokenType: textToken, 99 | content: "teeext", 100 | fatalContent: "teeext", 101 | }}, 102 | }, 103 | "Only envvars": { 104 | input: "${VAR1}${VAR2}", 105 | expectedTokens: []token{ 106 | { 107 | tokenType: envVarToken, 108 | content: "VAR1", 109 | fatalContent: "${VAR1}", 110 | }, 111 | { 112 | tokenType: envVarToken, 113 | content: "VAR2", 114 | fatalContent: "${VAR2}", 115 | }, 116 | }, 117 | }, 118 | "Text and envvars (comments are not handled)": { 119 | input: "Ugh ${VAR1}", 120 | expectedTokens: []token{{ 121 | tokenType: textToken, 122 | content: "Ugh ", 123 | fatalContent: "Ugh ", 124 | }, { 125 | tokenType: envVarToken, 126 | content: "VAR1", 127 | fatalContent: "${VAR1}", 128 | }}, 129 | }, 130 | "Escaped envvars converted to text": { 131 | input: "`${VAR1}\\`${VAR2}", 132 | expectedTokens: []token{ 133 | { 134 | tokenType: textToken, 135 | content: "${VAR1}`", 136 | fatalContent: "`${VAR1}\\`", 137 | }, 138 | { 139 | tokenType: envVarToken, 140 | content: "VAR2", 141 | fatalContent: "${VAR2}", 142 | }, 143 | }, 144 | }, 145 | "Escaped backtick is literal at end of input": { 146 | input: "${VAR1}\\`", 147 | expectedTokens: []token{ 148 | { 149 | tokenType: envVarToken, 150 | content: "VAR1", 151 | fatalContent: "${VAR1}", 152 | }, 153 | { 154 | tokenType: textToken, 155 | content: "\\`", 156 | fatalContent: "\\`", 157 | }, 158 | }, 159 | }, 160 | "Escaped end bracket in envvar": { 161 | input: "${VAR1`}}", 162 | expectedTokens: []token{{ 163 | tokenType: envVarToken, 164 | content: "VAR1}", 165 | fatalContent: "${VAR1`}}", 166 | }}, 167 | }, 168 | "Escaped backtick last in envvar": { 169 | input: "${ENV\\`}", 170 | expectedTokens: []token{{ 171 | tokenType: envVarToken, 172 | content: "ENV`", 173 | fatalContent: "${ENV\\`}", 174 | }}, 175 | }, 176 | } 177 | 178 | for name, test := range tests { 179 | tokens, fatal := tokenizeEnvVars(test.input) 180 | if fatal != "" { 181 | t.Errorf("Test: %s, Unexpected fatal: %s", name, fatal) 182 | } 183 | 184 | if !reflect.DeepEqual(test.expectedTokens, tokens) { 185 | t.Errorf("Test: %s, Expected tokens: %v, Got: %v", name, test.expectedTokens, tokens) 186 | } 187 | } 188 | } 189 | 190 | func Test_tokenizeEnvVarsBadCases(t *testing.T) { 191 | tests := map[string]struct { 192 | input string 193 | expectedFatal string 194 | }{ 195 | "Missing closing bracket for envvar": { 196 | input: "${VAR ${VAR", 197 | expectedFatal: "Missing closing bracket for environment variable: ${VAR ${VAR", 198 | }, 199 | } 200 | 201 | for name, test := range tests { 202 | _, fatal := tokenizeEnvVars(test.input) 203 | if fatal != test.expectedFatal { 204 | t.Errorf("Test: %s, Expected fatal: %v, Got: %v", name, test.expectedFatal, fatal) 205 | } 206 | } 207 | } 208 | 209 | func Test_tokenizeExecutablesGoodCases(t *testing.T) { 210 | tests := map[string]struct { 211 | input string 212 | expectedTokens []token 213 | }{ 214 | "Empty input": { 215 | input: "", 216 | expectedTokens: []token{}, 217 | }, 218 | "Only text": { 219 | input: "teeext", 220 | expectedTokens: []token{{ 221 | tokenType: textToken, 222 | content: "teeext", 223 | fatalContent: "teeext", 224 | }}, 225 | }, 226 | "Only executables": { 227 | input: "$(cmd1 arg1 arg2)$(cmd2 arg3 arg4)", 228 | expectedTokens: []token{{ 229 | tokenType: executableToken, 230 | content: "cmd1 arg1 arg2", 231 | fatalContent: "$(cmd1 arg1 arg2)", 232 | }, { 233 | tokenType: executableToken, 234 | content: "cmd2 arg3 arg4", 235 | fatalContent: "$(cmd2 arg3 arg4)", 236 | }}, 237 | }, 238 | "Text and executables (comments are not handled)": { 239 | input: "text $(cmd1 arg1 arg2)", 240 | expectedTokens: []token{ 241 | { 242 | tokenType: textToken, 243 | content: "text ", 244 | fatalContent: "text ", 245 | }, 246 | { 247 | tokenType: executableToken, 248 | content: "cmd1 arg1 arg2", 249 | fatalContent: "$(cmd1 arg1 arg2)", 250 | }, 251 | }, 252 | }, 253 | "Escaped executables converted to text": { 254 | input: "`$(cmd1)\\`$(cmd2)", 255 | expectedTokens: []token{{ 256 | tokenType: textToken, 257 | content: "$(cmd1)`", 258 | fatalContent: "`$(cmd1)\\`", 259 | }, { 260 | tokenType: executableToken, 261 | content: "cmd2", 262 | fatalContent: "$(cmd2)", 263 | }}, 264 | }, 265 | "Escaped backtick is literal at end of input": { 266 | input: "$(cmd1)\\`", 267 | expectedTokens: []token{{ 268 | tokenType: executableToken, 269 | content: "cmd1", 270 | fatalContent: "$(cmd1)", 271 | }, { 272 | tokenType: textToken, 273 | content: "\\`", 274 | fatalContent: "\\`", 275 | }}, 276 | }, 277 | "Escaped end parenthesis inside executable": { 278 | input: "$(echo `)yo`)\\`)", 279 | expectedTokens: []token{{ 280 | tokenType: executableToken, 281 | content: "echo )yo)`", 282 | fatalContent: "$(echo `)yo`)\\`)", 283 | }}, 284 | }, 285 | "Executable no need to escape ) when quoting": { 286 | input: "$(echo \")\\\")\"`) '))')", 287 | expectedTokens: []token{{ 288 | tokenType: executableToken, 289 | content: "echo \")\\\")\") '))'", 290 | fatalContent: "$(echo \")\\\")\"`) '))')", 291 | }}, 292 | }, 293 | } 294 | 295 | for name, test := range tests { 296 | tokens, fatal := tokenizeExecutables(test.input) 297 | if fatal != "" { 298 | t.Errorf("Test: %s, Unexpected fatal: %s", name, fatal) 299 | } 300 | 301 | if !reflect.DeepEqual(test.expectedTokens, tokens) { 302 | t.Errorf("Test: %s, Expected tokens: %v, Got: %v", name, test.expectedTokens, tokens) 303 | } 304 | } 305 | } 306 | 307 | func Test_tokenizeExecutablesBadCases(t *testing.T) { 308 | tests := map[string]struct { 309 | input string 310 | expectedFatal string 311 | }{ 312 | "Missing closing parenthesis for executable": { 313 | input: "$(cmd1 $(cmd2", 314 | expectedFatal: "Missing closing parenthesis for executable: $(cmd1 $(cmd2", 315 | }, 316 | "Unterminated quote sequence single quote": { 317 | input: "$(node -e 'console.log(\"Yo yo\"))", 318 | expectedFatal: "Unterminated quote sequence for executable: $(node -e 'console.log(\"Yo yo\"))", 319 | }, 320 | "Unterminated quote sequence double quote": { 321 | input: "$(node -e \"console.log('Yo yo'))", 322 | expectedFatal: "Unterminated quote sequence for executable: $(node -e \"console.log('Yo yo'))", 323 | }, 324 | } 325 | 326 | for name, test := range tests { 327 | _, fatal := tokenizeExecutables(test.input) 328 | if fatal != test.expectedFatal { 329 | t.Errorf("Test: %s, Expected fatal: %v, Got: %v", name, test.expectedFatal, fatal) 330 | } 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Introduction 4 | Ain is a terminal HTTP API client. It's an alternative to postman, paw or insomnia. 5 | 6 | ![Show and tell](assets/show-and-tell.gif?raw=true) 7 | 8 | * Flexible organization of API:s using files and folders ([examples](https://github.com/jonaslu/ain/tree/main/examples)). 9 | * Use shell-scripts and executables for common tasks. 10 | * Put things that change in environment variables or .env-files. 11 | * Handles url-encoding. 12 | * Share the resulting [curl](https://curl.se/), [wget](https://www.gnu.org/software/wget/) or [httpie](https://httpie.io/) command-line. 13 | * Pipe the API output for further processing. 14 | * Tries hard to be helpful when there are errors. 15 | 16 | Ain was built to enable scripting of input and further processing of output via pipes. It targets users who work with many API:s using a simple file format. It uses [curl](https://curl.se/), [wget](https://www.gnu.org/software/wget/) or [httpie](https://httpie.io/) to make the actual calls. 17 | 18 | ⭐ Please leave a star if you find it useful! ⭐ 19 | 20 | # Table of contents 21 | 22 | 23 | 24 | 25 | - [Pre-requisites](#pre-requisites) 26 | - [Installation](#installation) 27 | - [If you have go installed](#if-you-have-go-installed) 28 | - [Via homebrew](#via-homebrew) 29 | - [Via scoop](#via-scoop) 30 | - [Via the AUR (Arch Linux)](#via-the-aur-arch-linux) 31 | - [Download binaries yourself](#download-binaries-yourself) 32 | - [Syntax highlight](#syntax-highlight) 33 | - [Vim plug](#vim-plug) 34 | - [Quick start](#quick-start) 35 | - [Important concepts](#important-concepts) 36 | - [Template files](#template-files) 37 | - [Running ain](#running-ain) 38 | - [Supported sections](#supported-sections) 39 | - [[Host]](#host) 40 | - [[Query]](#query) 41 | - [[Headers]](#headers) 42 | - [[Method]](#method) 43 | - [[Body]](#body) 44 | - [[Config]](#config) 45 | - [[Backend]](#backend) 46 | - [[BackendOptions]](#backendoptions) 47 | - [Variables](#variables) 48 | - [Executables](#executables) 49 | - [Fatals](#fatals) 50 | - [Quoting](#quoting) 51 | - [Escaping](#escaping) 52 | - [URL-encoding](#url-encoding) 53 | - [Sharing is caring](#sharing-is-caring) 54 | - [Handling line endings](#handling-line-endings) 55 | - [Troubleshooting](#troubleshooting) 56 | - [Ain in a bigger context](#ain-in-a-bigger-context) 57 | - [Contributing](#contributing) 58 | - [Commit messages](#commit-messages) 59 | - [Testing](#testing) 60 | 61 | 62 | 63 | # Pre-requisites 64 | You need [curl](https://curl.se/), [wget](https://www.gnu.org/software/wget/) or [httpie](https://httpie.io/) installed and available on your `$PATH`. To test this run `ain -b`. This will generate a basic starter template listing what backends are available on your system in the [[Backend]](#backend) section. It will select one and leave the others commented out. 65 | 66 | You can also check manually what backends you have installed by opening a shell and type `curl`, `wget` or `http` (add the suffix .exe to those commands if you're on windows). Any output from the command means it's installed. 67 | 68 | On linux or mac one of the three is likely to already be installed. The others are available in your package manager or [homebrew](https://brew.sh). 69 | 70 | If you're on windows curl.exe is installed if it's windows 10 build 17063 or higher. Otherwise you can get the binaries via [scoop](https://scoop.sh), [chocolatey](https://chocolatey.org/) or download them yourself. Ain uses curl.exe and cannot use the curl cmd-let powershell builtin. 71 | 72 | # Installation 73 | 74 | ## If you have go installed 75 | You need go 1.13 or higher. Using `go install`: 76 | ``` 77 | go install github.com/jonaslu/ain/cmd/ain@latest 78 | ``` 79 | 80 | ## Via homebrew 81 | Using the package-manager [homebrew](https://brew.sh) 82 | ``` 83 | brew install ain 84 | ``` 85 | 86 | ## Via scoop 87 | Using the windows package-manager [scoop](https://scoop.sh) 88 | ``` 89 | scoop bucket add jonaslu_tools https://github.com/jonaslu/scoop-tools.git 90 | scoop install ain 91 | ``` 92 | 93 | ## Via the AUR (Arch Linux) 94 | From arch linux [AUR](https://aur.archlinux.org/) using [yay](https://github.com/Jguer/yay) 95 | ``` 96 | yay -S ain-bin 97 | ``` 98 | 99 | ## Download binaries yourself 100 | Install it so it's available on your `$PATH`: 101 | [https://github.com/jonaslu/ain/releases](https://github.com/jonaslu/ain/releases) 102 | 103 | # Syntax highlight 104 | Ain comes with it's own syntax highlight for vim. 105 | 106 | ## Vim plug 107 | Using [vim plug](https://github.com/junegunn/vim-plug) 108 | 109 | ``` 110 | Plug 'jonaslu/ain', { 'rtp': 'grammars/vim' } 111 | ``` 112 | 113 | ### Manual install 114 | 115 | # Quick start 116 | Ain comes with a built in basic template that you can use as a starting point. Ain checks what backends (that's [curl](https://curl.se/), [wget](https://www.gnu.org/software/wget/) or [httpie](https://httpie.io/)) are available on your system and inserts them into the [[Backend]](#backend) section of the generated template. One will be selected and the rest commented out so the template is runnable directly. 117 | 118 | Run: 119 | ``` 120 | ain -b basic-template.ain 121 | ``` 122 | 123 | The command above will output a starter-template to the file `basic-template.ain`. 124 | The basic template calls the / GET http endpoint on localhost with the `Content-Type: application/json`. 125 | 126 | To run the template specify a `PORT` variable: 127 | ``` 128 | ain basic-template.ain --vars PORT=8080 129 | ``` 130 | 131 | See help for all options: `ain -h` and check out the [examples](https://github.com/jonaslu/ain/tree/main/examples). 132 | 133 | # Important concepts 134 | * Templates: Files containing what, how and where to make the API call. By convention has the file suffix `.ain`. 135 | * Sections: Label in a file grouping the API parameters. 136 | * Variables: Things that vary as inputs in a template file. 137 | * Executables: Enables using the output of a command in a template file. 138 | * Backends: The thing that makes the API call ([curl](https://curl.se/), [wget](https://www.gnu.org/software/wget/) or [httpie](https://httpie.io/)). 139 | * Fatals: Error in parsing the template files (it's your fault). 140 | 141 | # Template files 142 | Ain assembles data in template files to build the API-call. Ain parses the data following labels called [sections](#supported-sections) in each template file. Here's a full example: 143 | ``` 144 | [Host] # The URL. Appends across files. Mandatory 145 | http://localhost:${PORT}/api/blog/post 146 | 147 | [Query] # Query parameters. Appends across files 148 | id=2e79870c-6504-4ac6-a2b7-01da7a6532f1 149 | 150 | [Headers] # Headers for the API-call. Appends across files 151 | Authorization: Bearer $(./get-jwt-token.sh) 152 | Content-Type: application/json 153 | 154 | [Method] # HTTP method. Overwrites across files 155 | POST 156 | 157 | [Body] # Body for the API-call. Overwrites across files 158 | { 159 | "title": "Reaping death", 160 | "content": "There is a place beyond the dreamworlds past the womb of night." 161 | } 162 | 163 | [Config] # Ain specific config. Overwrites across files 164 | Timeout=10 165 | 166 | [Backend] # How to make the API-call. Overwrites across files. Mandatory 167 | curl 168 | 169 | [BackendOptions] # Options to the selected backends. Appends across files 170 | -sS # Comments are ignored. 171 | ``` 172 | The template files can be named anything but some unique ending-convention such as .ain is recommended so you can [find](https://man7.org/linux/man-pages/man1/find.1.html) them easily. 173 | 174 | Ain understands eight [Sections] with each of the sections described in details [below](#supported-sections). The data in sections either appends or overwrites across template files passed to ain. 175 | 176 | Anything after a pound sign (#) is a comment and will be ignored. 177 | 178 | # Running ain 179 | `ain [OPTIONS] [--vars VAR=VALUE ...]` 180 | 181 | Ain accepts one or more template-file(s) as a mandatory argument. As sections appends or overwrite you can organize API-calls into hierarchical structures with increasing specificity using files and folders. 182 | 183 | You can find examples of this in the [examples](https://github.com/jonaslu/ain/tree/main/examples) folder. 184 | 185 | Adding an exclamation-mark (!) at the end of a template file name makes ain open the file in your `$VISUAL` or `$EDITOR` editor. If none is set it falls back to vim in that order. Once opened you edit the template file for this run only. 186 | 187 | Example: 188 | ``` 189 | ain templates/get-blog-post.ain! # Lets you edit the get-blog-post.ain for this run 190 | ``` 191 | 192 | Ain waits for the editor command to exit. Any terminal editor such as vim, emacs, nano etc will be fine. If your editor forks (as [vscode](https://code.visualstudio.com/) does by default) check if there's a flag stopping it from forking. To stop vscode from forking use the `--wait` [flag](https://code.visualstudio.com/docs/editor/command-line#_core-cli-options): 193 | 194 | ``` 195 | export EDITOR="code --wait" 196 | ``` 197 | 198 | If ain is connected to a pipe it will read template file names from the pipe. This enables you to use [find](https://man7.org/linux/man-pages/man1/find.1.html) and a selector such as [fzf](https://github.com/junegunn/fzf) to keep track of the template-files: 199 | ``` 200 | $> find . -name *.ain | fzf -m | ain 201 | ``` 202 | 203 | Template file names specified on the command line are read before names from a pipe. This means that `echo create-blog-post.ain | ain base.ain` is the same as `ain base.ain create-blog-post.ain`. 204 | 205 | When making the call ain mimics how data is returned by the backend. After printing any internal errors of it's own, ain echoes back output from the backend: first the standard error (stderr) and then the standard out (stdout). It then returns the exit code from the backend command as it's own unless there are error specific to ain in which it returns status 1. 206 | 207 | # Supported sections 208 | Sections are case-insensitive and whitespace ignored but by convention uses CamelCase and are left indented. A section cannot be defined twice in a file. A section ends where the next begins or the file ends. 209 | 210 | See [escaping](#escaping) If you need a literal section heading on a new line. 211 | 212 | ## [Host] 213 | Contains the URL to the API. This section appends lines from one template file to the next. This feature allows you to specify a base-url in one file (e g `base.ain`) as such: `http://localhost:3000` and in the next template file specify the endpoint path (e g `login.ain`): `/api/auth/login`. 214 | 215 | It's recommended that you use the [[Query]](#Query) section below for query-parameters as it handles joining with delimiters and trimming whitespace. You can however put raw query-parameters in the [Host] section too. 216 | 217 | Any query-parameters added in the [[Query]](#Query) section are appended last to the URL. The whole URL is properly [url-encoded](#url-encoding) before passed to the backend. The [Host] section must combine to one and only one valid URL. Multiple URLs is not supported. 218 | 219 | Ain performs no validation on the url (as backends differ on what a valid url looks like). If your call fails use `ain -p` as mentioned in [troubleshooting](#troubleshooting) to see the resulting command. 220 | 221 | The [Host] section is mandatory and appends across template files. 222 | 223 | ## [Query] 224 | All lines in the [Query] section is appended last to the resulting URL. This means that you can specify query-parameters that apply to many endpoints in one file instead of having to include the same parameter in all endpoints. 225 | 226 | An example is if an `API_KEY=` query-parameter applies to several endpoints. You can define this in a base-file and simply have the specific endpoint URL and possible extra query-parameters in their own file. 227 | 228 | Example - `base.ain`: 229 | ``` 230 | [Host] 231 | http://localhost:8080/api 232 | 233 | [Query] 234 | API_KEY=a922be9f-1aaf-47ef-b70b-b400a3aa386e 235 | ``` 236 | 237 | `get-post.ain` 238 | ``` 239 | [Host] 240 | /blog/post 241 | 242 | [Query] 243 | id=1 244 | ``` 245 | 246 | This will result in the url: 247 | ``` 248 | http://localhost:8080/api/blog/post?API_KEY=a922be9f-1aaf-47ef-b70b-b400a3aa386e&id=1 249 | ``` 250 | 251 | The whitespace in a query key / value is only significant within the string. 252 | 253 | This means that `page=3` and `page = 3` will become the same query parameter and `page = the next one` will become `page=the+next+one` when processed. If you need actual spaces between the equal-sign and the key / value strings you need to encode it yourself: e g `page+=+3` or put 254 | the key-value in the [[Host]](#Host) section where space is significant. 255 | 256 | Each line under the [Query] section is appended with a delimiter. Ain defaults to the query-string delimiter `&`. See the [[Config]](#Config) section for setting a custom delimiter. 257 | 258 | All query-parameters are properly [url-encoded](#url-encoding). 259 | 260 | The [Query] section appends across template files. 261 | 262 | ## [Headers] 263 | Headers to pass to the API. One header per line. 264 | 265 | Example: 266 | ``` 267 | [Headers] 268 | Authorization: Bearer 888e90f2-319f-40a0-b422-d78bb95f229e 269 | Content-Type: application/json 270 | ``` 271 | 272 | The [Headers] section appends across template files. 273 | 274 | ## [Method] 275 | Http method (e g GET, POST, PATCH). If omitted the backend default is used (GET in both curl, wget and httpie). 276 | 277 | Example: 278 | ``` 279 | [Method] 280 | POST 281 | ``` 282 | 283 | The [Method] section is overridden across template files. 284 | 285 | ## [Body] 286 | If the API call needs a body (as in the POST or PATCH http methods) the content of this section is passed as a file to the backend with formatting retained. 287 | 288 | The file is removed after the API call unless you pass the `-l` flag. Ain places the file in the $TMPDIR directory (usually `/tmp` on your box). You can override this in your shell by explicitly setting the `$TMPDIR` environment variable. 289 | 290 | Passing the print command `-p` flag will cause ain to write out the file named ain-body in the directory where ain is invoked and leave the file after completion. Leaving the body file makes the printed command shareable and runnable. 291 | 292 | The [Body] section removes any leading and trailing whitespace lines, but keeps empty newlines between the first and last non-empty line. 293 | 294 | Example: 295 | ``` 296 | [Body] 297 | 298 | { 299 | "some": "json", # ain removes comments 300 | 301 | "more": "jayson" 302 | } 303 | 304 | ``` 305 | 306 | Is passed as this in the temp-file: 307 | ``` 308 | { 309 | 310 | "some": "json", 311 | 312 | "more": "jayson" 313 | } 314 | ``` 315 | 316 | The [Body] section overwrites across template files. 317 | 318 | ## [Config] 319 | This section contains config for ain. All config parameters are case-insensitive and any whitespace is ignored. Parameters for backends themselves are passed via the [[BackendOptions]](#BackendOptions) section. 320 | 321 | Full config example: 322 | ``` 323 | [Config] 324 | Timeout=3 325 | QueryDelim=; 326 | ``` 327 | 328 | The [Config] sections overwrites across template files. 329 | 330 | ### Timeout 331 | Config format: `Timeout=` 332 | 333 | The timeout is enforced during the whole execution of ain (both running executables and the actual API call). If omitted defaults to no timeout. This is the only section where [executables](#executables) cannot be used, since the timeout needs to be known before the executables are invoked. 334 | 335 | ### Query delimiter 336 | Config format: `QueryDelim=` 337 | 338 | This is the delimiter used when concatenating the lines under the [[Query]](#Query) section. It can be any text that does not contain a space (including the empty string). 339 | 340 | Defaults to (`&`). 341 | 342 | ## [Backend] 343 | The [Backend] specifies what command should be used to run the actual API call. 344 | 345 | Valid options are [curl](https://curl.se/), [wget](https://www.gnu.org/software/wget/) or [httpie](https://httpie.io/). 346 | 347 | Example: 348 | ``` 349 | [Backend] 350 | curl 351 | ``` 352 | 353 | The [Backend] section is mandatory and overwrites across template files. 354 | 355 | ## [BackendOptions] 356 | Backend specific options that are passed on to the [backend](#backend). 357 | 358 | Example: 359 | ``` 360 | [Backend] 361 | curl 362 | 363 | [BackendOptions] 364 | -sS # Makes curl disable its progress bar in a pipe 365 | ``` 366 | 367 | The [BackendOptions] section appends across template files. 368 | 369 | # Variables 370 | Variables lets you specify things that vary such as ports, item ids etc. Ain supports variables via environment variables. Anything inside `${}` in a template is replaced with the value found in the environment. Example `${NODE_ENV}`. Environment variables can be set in your shell in various ways, or via the `--vars VAR1=value1 VAR2=value2` syntax passed after all template file names. 371 | 372 | This will set the variable values in ain:s environment (and available via inheritance in any `$(commands)` spawned from the template [executables](#executables)). Variables set via `--vars` overrides any existing values in the environment, meaning `VAR=1 ain template.ain --vars VAR=2` will result in VAR having the value `2`. 373 | 374 | Ain looks for any .env file in the folder where it's run for any default variable values. You can pass the path to a custom .env file via the `-e` flag. 375 | 376 | Environment variables are replaced before executables and can be used as input to the executable. Example `$(cat ${ENV}/token.json)`. 377 | 378 | Ain uses [envparse](https://github.com/hashicorp/go-envparse) for parsing .env files. 379 | 380 | # Executables 381 | An executable expression (example `$(command arg1 arg2)`) will be replaced by running the command with arguments and replacing the expression with the commands output (STDOUT). For example `$(echo 1)` will be replaced by `1`. 382 | 383 | A real world example is getting JWT tokens from a separate script and share that across templates: 384 | ``` 385 | [Headers] 386 | Authorization: Bearer $(bash -c "./get-login.sh | jq -r '.token'") 387 | ``` 388 | 389 | If shell features such as pipes are needed this can be done via a command string (e g [bash -c](https://man7.org/linux/man-pages/man1/bash.1.html#OPTIONS)) in bash. Note that quoting is needed if the argument contains whitespace as in the example above. See [quoting](#quoting). 390 | 391 | The first word is an command on your $PATH and the rest are arguments to that command. 392 | 393 | See [escaping](#escaping) for arguments containing closing-parentheses `)`. 394 | 395 | Executables are replaced after environment-variables and only once (an executable returned from an executable will not be processed again). 396 | 397 | # Fatals 398 | Ain has two types of errors: fatals and errors. Errors are things internal to ain (it's not your fault) such as not finding the backend-binary. 399 | 400 | Fatals are errors in the template (it's your fault). Fatals include the template file name where the fatal occurred, the line-number and a small context of the template: 401 | ``` 402 | $ ain templates/example.ain 403 | Fatal error in file: templates/example.ain 404 | Cannot find value for variable PORT on line 2: 405 | 1 [Host] 406 | 2 > http://localhost:${PORT} 407 | 3 408 | ``` 409 | 410 | Fatals can be hard to understand if [environment variables](#environment-variables) or [executables](#executables) are replaced in the template. If the line with the fatal contains any replaced value a separate expanded context is printed. It contains up to three lines with the resulting replacement and the row number into the original template: 411 | ``` 412 | $ TIMEOUT=-1 ain templates/example.ain 413 | Fatal error in file: templates/example.ain 414 | Timeout interval must be greater than 0 on line 10: 415 | 9 [Config] 416 | 10 > Timeout=${TIMEOUT} 417 | 11 418 | Expanded context: 419 | 10 > Timeout=-1 420 | ``` 421 | 422 | # Quoting 423 | There are four places where quoting might be necessary: arguments to executables, backend options, invoking the $VISUAL or $EDITOR command and when passing template-names via a pipe. All for the same reasons as bash: a word is an argument to something and a whitespace is the delimiter to the next argument. If whitespace should be retained it must be quoted. 424 | 425 | The canonical example of when quoting is needed is doing more complex things involving pipes. E g `$(sh -c 'find . | fzf -m | xargs echo')`. 426 | 427 | Escaping is kept simple, you can use `\'` or `\"` respectively to insert a literal quote inside a quoted string of the same type. You can avoid this by selecting the other quote character (e g 'I need a " inside this string') when possible. 428 | 429 | # Escaping 430 | TL;DR: To escape a comment `#` precede it with a backtick: `` `#``. 431 | 432 | These symbols have special meaning to ain: 433 | ``` 434 | Symbol -> meaning 435 | # -> comment 436 | ${ -> environment variable 437 | $( -> executable 438 | ``` 439 | 440 | If you need these symbols literally in your output, escape with a backtick: 441 | ``` 442 | Symbol -> output 443 | `# -> # 444 | `${ -> ${ 445 | `$( -> $( 446 | ``` 447 | 448 | If you need a literal backtick just before a symbol, you escape the escaping with a slash: 449 | ``` 450 | \`# 451 | \`${ 452 | \`$( 453 | ``` 454 | 455 | If you need a literal `}` in an environment variable you escape it with a backtick: 456 | ``` 457 | Template -> Environment variable 458 | ${VA`}RZ} -> VA}RZ 459 | ``` 460 | 461 | If you need a literal `)` in an executable, either escape it with a backtick or enclose it in quotes. 462 | These two examples are equivalent and inserts the string Hi: 463 | ``` 464 | $(node -e console.log('Hi'`)) 465 | $(node -e 'console.log("Hi")') 466 | ``` 467 | 468 | If you need a literal backtick right before closing the envvar or executable you escape the backtick with a slash: 469 | ``` 470 | $(echo \`) 471 | ${VAR\`} 472 | ``` 473 | 474 | Since environment variables are only replaced once, `${` doesn't need escaping when returned from an environment variable. E g `VAR='${GOAT}'`, `${GOAT}` is passed literally to the output. Same for executables, any returned value containing `${` does not need escaping. E g `$(echo $(yo )`, `$(yo ` is passed literally to the output. 475 | 476 | Pound sign (#) needs escaping if a comment was not intended when returned from both environment variables and executables. 477 | 478 | A section header (one of the eight listed under [supported sections](#supported-sections)) needs escaping if it's the only text a separate line. It is escaped with a backtick. Example: 479 | ``` 480 | [Body] 481 | I'm part of the 482 | `[Body] 483 | and included in the output. 484 | ``` 485 | 486 | If you need a literal backtick followed by a valid section heading you escape that backtick with a slash. Example: 487 | ``` 488 | [Body] 489 | This text is outputted as 490 | \`[Body] 491 | backtick [Body]. 492 | ``` 493 | 494 | # URL-encoding 495 | Both the path and the query-section of an url is scanned and any invalid characters are [URL-encoded](https://en.wikipedia.org/wiki/Percent-encoding) while already legal encodings (format `%` and `+` for the query string) are kept as is. 496 | 497 | This means that you can mix url-encoded text, half encoded text or unencoded text and ain will convert everything into a properly url-encoded URL. 498 | 499 | Example: 500 | ``` 501 | [Host] 502 | https://localhost:8080/download/file/dir with%20spaces # %20= 503 | 504 | [Query] 505 | filename=filename with %24$ in it # %24=$ 506 | ``` 507 | 508 | Will result in the URL: 509 | ``` 510 | https://localhost:8080/download/file/dir%20with%20spaces?filename=filename+with+%24%24+in+it 511 | ``` 512 | 513 | The only caveats is that ain cannot know if a plus sign (+) is an encoded space or an literal plus sign. In this case ain assumes a space and leave the plus sign as is. 514 | 515 | Second ain cannot know if you meant the literal percent sign followed by two hex characters % instead of an encoded percent character. In this case ain assumes an escaped sequence and leaves the % as is. 516 | 517 | In both cases you need to manually escape the plus (%2B) and percent sign (%25) in the url. 518 | 519 | # Sharing is caring 520 | Ain can print out the command instead of running it via the `-p` flag. This enables you to inspect how the curl, wget or httpie API call would look like: 521 | ``` 522 | ain -p base.ain create-blog-post.ain > share-me.sh 523 | ``` 524 | 525 | The output can then be shared (or for example run over an ssh connection). 526 | 527 | Piping it into bash is equivalent to running the command without `-p`. 528 | ``` 529 | ain -p base.ain create-blog-post.ain | bash 530 | ``` 531 | 532 | Any content within the [[Body]](#Body) section when passing the flag `-p` will be written to a file in the current working directory where ain is invoked. The file is not removed after ain completes. See [[Body]](#body) for details. 533 | 534 | # Handling line endings 535 | Ain uses line-feed (\n) when printing it's output. If you're on windows and storing ain:s result to a file, this 536 | may cause trouble. Instead of trying to guess what line ending we're on (WSL, docker, cygwin etc makes this a wild goose chase), you'll have to manually convert them if the receiving program complains. 537 | 538 | Instructions here: https://stackoverflow.com/a/19914445/1574968 539 | 540 | # Troubleshooting 541 | If the templates are valid but the actual backend call fails, passing the `-p` flag will show you the command ain tries to run. Invoking this yourself in a terminal might give you more clues to what's wrong. 542 | 543 | # Ain in a bigger context 544 | But wait! There's more! 545 | 546 | With ain being terminal friendly there are few neat tricks in the [wiki](https://github.com/jonaslu/ain/wiki) 547 | 548 | # Contributing 549 | I'd love if you want to get your hands dirty and improve ain! 550 | 551 | Besides [go](https://go.dev/), ain comes with a [Taskfile](https://taskfile.dev/). Install it and run `task` in the root-folder for tasks relating to build, running and testing ain. 552 | 553 | ## Commit messages 554 | Commit messages should describe why the change is needed, how the patch solves it and any other background information. Small focused commits are preferable to big blobs. All commits should include a [test plan](https://www.iamjonas.me/2021/04/the-test-plan.html) last in the message. 555 | 556 | Background here: [atomic literate commits](https://www.iamjonas.me/2021/01/literate-atomic-commits.html) 557 | 558 | ## Testing 559 | Any PR modifying code should include verification of changes using tests. 560 | 561 | ### End to end tests 562 | Ain comes with a battery of end-to-end tests which is the preferable way to verify. The tests reside in the folder or sub-folder of `test/e2e/templates`. The main end-to-end task runner is the `test/e2e/e2e_test.go`file. An end-to-end test case is a plain runnable .ain file. By convention ok-{test-name}.ain is used for successful tests and nok-{test-name}.ain for testing failures. 563 | 564 | Yaml is added as comments last in the file, with at lest one empty row between the last section and the yaml, and used by the test runner to validate the output. 565 | 566 | Currently supported yaml parameters are: 567 | ``` 568 | env: <- (array) environment variables to set before the test 569 | args: <- (array) arguments to pass to the test binary 570 | afterargs: <- (array) arguments passed after the test file name (currently --vars) 571 | stderr: <- (string) compared with the stdout output of the test 572 | stdout: <- (string) compared with the stderr output of the test 573 | exitcode: <- (int) compared with the test binary exit code. Defaults to 0 574 | ``` 575 | 576 | Feel free to add more comments with explanation on the verification. 577 | 578 | When adding a test case check the coverage (`task test:cover`) and verify your patch has been touched by tests. 579 | 580 | ### Unit tests 581 | If the patch involves just one or a few methods and it's far easier to test it in isolation then add a unit-test in the same folder as the method under test. 582 | 583 | ### Test plan 584 | The third option is documenting any manual testing. Last in the commit message add a [test plan](https://www.iamjonas.me/2021/04/the-test-plan.html) with one bullet point for each test-case add: setup, execution and verification. Usage of coverage is encouraged so every part of the patch is properly tested. 585 | 586 | For a TL;DR; do a `git log` and see the commit history. 587 | --------------------------------------------------------------------------------