diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..8132458 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,42 @@ +# Configuration for Release Drafter: https://github.com/toolmantim/release-drafter +name-template: 'v$NEXT_PATCH_VERSION 🌈' +tag-template: 'v$NEXT_PATCH_VERSION' +version-template: $MAJOR.$MINOR.$PATCH +# Emoji reference: https://gitmoji.carloscuesta.me/ +categories: + - title: 'πŸš€ Features' + labels: + - 'feature' + - 'enhancement' + - 'kind/feature' + - title: 'πŸ› Bug Fixes' + labels: + - 'fix' + - 'bugfix' + - 'bug' + - 'regression' + - 'kind/bug' + - title: πŸ“ Documentation updates + labels: + - documentation + - 'kind/doc' + - title: πŸ‘» Maintenance + labels: + - chore + - dependencies + - 'kind/chore' + - 'kind/dep' + - title: 🚦 Tests + labels: + - test + - tests +exclude-labels: + - reverted + - no-changelog + - skip-changelog + - invalid +change-template: '* $TITLE (#$NUMBER) @$AUTHOR' +template: | + ## What’s Changed + + $CHANGES diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..73fcc4f --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,14 @@ +name: Release Drafter + +on: + push: + branches: + - master + +jobs: + UpdateReleaseDraft: + runs-on: ubuntu-20.04 + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GH_PUBLISH_SECRETS }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d5459a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM golang:1.17 as builder + +WORKDIR /workspace +COPY . . +RUN go mod download +RUN CGO_ENABLE=0 go build -ldflags "-w -s" -o atest cmd/*.go + +FROM ghcr.io/linuxsuren/hd:v0.0.67 as hd + +FROM alpine:3.10 + +LABEL "com.github.actions.name"="API testing" +LABEL "com.github.actions.description"="API testing" +LABEL "com.github.actions.icon"="home" +LABEL "com.github.actions.color"="red" + +LABEL "repository"="https://github.com/linuxsuren/api-testing" +LABEL "homepage"="https://github.com/linuxsuren/api-testing" +LABEL "maintainer"="Rick " + +LABEL "Name"="API testing" + +ENV LC_ALL C.UTF-8 +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US.UTF-8 + +RUN apk add --no-cache \ + git \ + openssh-client \ + libc6-compat \ + libstdc++ + +COPY entrypoint.sh /entrypoint.sh +COPY --from=builder /workspace/atest /usr/bin/atest +COPY --from=hd /usr/local/bin/hd /usr/local/bin/hd + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5edb3b --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +This is a API testing tool. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..21f31e8 --- /dev/null +++ b/action.yml @@ -0,0 +1,12 @@ +name: 'API testing' +description: 'API testing' +inputs: + pattern: + description: 'The pattern of the items' + required: true + default: 'testcase-*.yaml' +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.pattern }} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..a1fc7d5 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,51 @@ +package main + +import ( + "github.com/linuxsuren/api-testing/pkg/runner" + "github.com/linuxsuren/api-testing/pkg/testing" + "github.com/spf13/cobra" + "os" + "path/filepath" +) + +type option struct { + pattern string +} + +func main() { + opt := &option{} + + cmd := &cobra.Command{ + Use: "atest", + RunE: opt.runE, + } + + // set flags + flags := cmd.Flags() + flags.StringVarP(&opt.pattern, "pattern", "p", "testcase-*.yaml", + "The file pattern which try to execute the test cases") + + // run command + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} + +func (o *option) runE(cmd *cobra.Command, args []string) (err error) { + var files []string + if files, err = filepath.Glob(o.pattern); err == nil { + for i := range files { + item := files[i] + + var testcase *testing.TestCase + if testcase, err = testing.Parse(item); err != nil { + return + } + + if err = runner.RunTestCase(testcase); err != nil { + return + } + } + } + return +} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..5e9156a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +atest -p "$1" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2e4beec --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/linuxsuren/api-testing + +go 1.17 + +require ( + github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 + github.com/spf13/cobra v1.4.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/sergi/go-diff v1.2.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ffb8471 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= +github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/pkg/exec/command.go b/pkg/exec/command.go new file mode 100644 index 0000000..02ed261 --- /dev/null +++ b/pkg/exec/command.go @@ -0,0 +1,110 @@ +package exec + +import ( + "bytes" + "io" + "os" + "os/exec" + "sync" +) + +// LookPath is the wrapper of os/exec.LookPath +func LookPath(file string) (string, error) { + return exec.LookPath(file) +} + +// RunCommandAndReturn runs a command, then returns the output +func RunCommandAndReturn(name, dir string, args ...string) (result string, err error) { + stdout := &bytes.Buffer{} + if err = RunCommandWithBuffer(name, dir, stdout, nil, args...); err == nil { + result = stdout.String() + } + return +} + +// RunCommandWithBuffer runs a command with buffer +// stdout and stderr could be nil +func RunCommandWithBuffer(name, dir string, stdout, stderr *bytes.Buffer, args ...string) error { + if stdout == nil { + stdout = &bytes.Buffer{} + } + if stderr != nil { + stderr = &bytes.Buffer{} + } + return RunCommandWithIO(name, dir, stdout, stderr, args...) +} + +// RunCommandWithIO runs a command with given IO +func RunCommandWithIO(name, dir string, stdout, stderr io.Writer, args ...string) (err error) { + command := exec.Command(name, args...) + if dir != "" { + command.Dir = dir + } + + //var stdout []byte + //var errStdout error + stdoutIn, _ := command.StdoutPipe() + stderrIn, _ := command.StderrPipe() + err = command.Start() + if err != nil { + return + } + + // cmd.Wait() should be called only after we finish reading + // from stdoutIn and stderrIn. + // wg ensures that we finish + var wg sync.WaitGroup + wg.Add(1) + go func() { + _, _ = copyAndCapture(stdout, stdoutIn) + wg.Done() + }() + + _, _ = copyAndCapture(stderr, stderrIn) + + wg.Wait() + + err = command.Wait() + return +} + +// RunCommandInDir runs a command +func RunCommandInDir(name, dir string, args ...string) error { + return RunCommandWithIO(name, dir, os.Stdout, os.Stderr, args...) +} + +// RunCommand runs a command +func RunCommand(name string, arg ...string) (err error) { + return RunCommandInDir(name, "", arg...) +} + +// RunCommandWithSudo runs a command with sudo +func RunCommandWithSudo(name string, args ...string) (err error) { + newArgs := make([]string, 0) + newArgs = append(newArgs, name) + newArgs = append(newArgs, args...) + return RunCommand("sudo", newArgs...) +} + +func copyAndCapture(w io.Writer, r io.Reader) ([]byte, error) { + var out []byte + buf := make([]byte, 1024, 1024) + for { + n, err := r.Read(buf[:]) + if n > 0 { + d := buf[:n] + out = append(out, d...) + _, err := w.Write(d) + if err != nil { + return out, err + } + } + if err != nil { + // Read returns io.EOF at the end of file, which is not an error for us + if err == io.EOF { + err = nil + } + return out, err + } + } +} diff --git a/pkg/runner/simple.go b/pkg/runner/simple.go new file mode 100644 index 0000000..06b7905 --- /dev/null +++ b/pkg/runner/simple.go @@ -0,0 +1,114 @@ +package runner + +import ( + "bytes" + "fmt" + "github.com/andreyvit/diff" + "github.com/linuxsuren/api-testing/pkg/exec" + "github.com/linuxsuren/api-testing/pkg/testing" + "io" + "io/ioutil" + "net/http" + "strings" +) + +func RunTestCase(testcase *testing.TestCase) (err error) { + fmt.Printf("start to run: '%s'\n", testcase.Name) + if err = doPrepare(testcase); err != nil { + err = fmt.Errorf("failed to prepare, error: %v", err) + return + } + + defer func() { + if testcase.Clean.CleanPrepare { + if err = doCleanPrepare(testcase); err != nil { + return + } + } + }() + + client := http.Client{} + var requestBody io.Reader + if testcase.Request.Body != "" { + requestBody = bytes.NewBufferString(testcase.Request.Body) + } + + var request *http.Request + if request, err = http.NewRequest(testcase.Request.Method, testcase.Request.API, requestBody); err != nil { + return + } + + // set headers + for key, val := range testcase.Request.Header { + request.Header.Add(key, val) + } + + // send the HTTP request + var resp *http.Response + if resp, err = client.Do(request); err != nil { + return + } + + if testcase.Expect.StatusCode != 0 { + if err = expectInt(testcase.Name, testcase.Expect.StatusCode, resp.StatusCode); err != nil { + return + } + } + + for key, val := range testcase.Expect.Header { + actualVal := resp.Header.Get(key) + if err = expectString(testcase.Name, val, actualVal); err != nil { + return + } + } + + if testcase.Expect.Body != "" { + var data []byte + if data, err = ioutil.ReadAll(resp.Body); err != nil { + return + } + + if string(data) != strings.TrimSpace(testcase.Expect.Body) { + err = fmt.Errorf("case: %s, got different response body, diff: \n%s", testcase.Name, + diff.LineDiff(testcase.Expect.Body, string(data))) + return + } + } + return +} + +func doPrepare(testcase *testing.TestCase) (err error) { + for i := range testcase.Prepare.Kubernetes { + item := testcase.Prepare.Kubernetes[i] + + if err = exec.RunCommand("kubectl", "apply", "-f", item); err != nil { + return + } + } + return +} + +func doCleanPrepare(testcase *testing.TestCase) (err error) { + for i := range testcase.Prepare.Kubernetes { + item := testcase.Prepare.Kubernetes[i] + + if err = exec.RunCommand("kubectl", "delete", "-f", item); err != nil { + return + } + } + return +} + +func expectInt(name string, expect, actual int) (err error) { + if expect != actual { + err = fmt.Errorf("case: %s, expect %d, actual %d", name, expect, actual) + } + return +} + +func expectString(name, expect, actual string) (err error) { + if expect != actual { + err = fmt.Errorf("case: %s, expect %s, actual %s", name, expect, actual) + } + return +} diff --git a/pkg/testing/case.go b/pkg/testing/case.go new file mode 100644 index 0000000..e0145ba --- /dev/null +++ b/pkg/testing/case.go @@ -0,0 +1,32 @@ +package testing + +type TestCase struct { + Name string + Group string + Prepare Prepare `yaml:"prepare"` + Request Request `yaml:"request"` + Expect Response `yaml:"expect"` + Clean Clean `yaml:"clean"` +} + +type Prepare struct { + Kubernetes []string `yaml:"kubernetes"` +} + +type Request struct { + API string `yaml:"api"` + Method string `yaml:"method"` + Query map[string]string `yaml:"query"` + Header map[string]string `yaml:"header"` + Body string `yaml:"body"` +} + +type Response struct { + StatusCode int `yaml:"statusCode"` + Body string `yaml:"body"` + Header map[string]string `yaml:"header"` +} + +type Clean struct { + CleanPrepare bool `yaml:"cleanPrepare"` +} diff --git a/pkg/testing/parser.go b/pkg/testing/parser.go new file mode 100644 index 0000000..5a4cfff --- /dev/null +++ b/pkg/testing/parser.go @@ -0,0 +1,19 @@ +package testing + +import ( + "gopkg.in/yaml.v2" + "io/ioutil" +) + +func Parse(configFile string) (testCase *TestCase, err error) { + var data []byte + if data, err = ioutil.ReadFile(configFile); err != nil { + return + } + + testCase = &TestCase{} + if err = yaml.Unmarshal(data, testCase); err != nil { + return + } + return +}