api-testing/pkg/runner/http.go

507 lines
16 KiB
Go

/*
Copyright 2023-2025 API Testing Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package runner
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-openapi/spec"
"github.com/andreyvit/diff"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
"github.com/linuxsuren/api-testing/pkg/apispec"
"github.com/linuxsuren/api-testing/pkg/render"
"github.com/linuxsuren/api-testing/pkg/testing"
"github.com/linuxsuren/api-testing/pkg/util"
"github.com/xeipuuv/gojsonschema"
)
// ReportResult represents the report result of a set of the same API requests
type ReportResult struct {
Name string
API string
Count int
Average time.Duration
Max time.Duration
Min time.Duration
QPS int
Error int
LastErrorMessage string
}
// ReportResultSlice is the alias type of ReportResult slice
type ReportResultSlice []ReportResult
// Len returns the count of slice items
func (r ReportResultSlice) Len() int {
return len(r)
}
// Less returns if i bigger than j
func (r ReportResultSlice) Less(i, j int) bool {
return r[i].Average > r[j].Average
}
// Swap swaps the items
func (r ReportResultSlice) Swap(i, j int) {
tmp := r[i]
r[i] = r[j]
r[j] = tmp
}
type simpleTestCaseRunner struct {
UnimplementedRunner
simpleResponse SimpleResponse
cookies []*http.Cookie
apiSuggestLimit int
}
// NewSimpleTestCaseRunner creates the instance of the simple test case runner
func NewSimpleTestCaseRunner() TestCaseRunner {
runner := &simpleTestCaseRunner{
UnimplementedRunner: NewDefaultUnimplementedRunner(),
simpleResponse: SimpleResponse{},
cookies: []*http.Cookie{},
apiSuggestLimit: 10,
}
return runner
}
func init() {
RegisterRunner("http", func(*testing.TestSuite) TestCaseRunner {
return NewSimpleTestCaseRunner()
})
}
// ContextKey is the alias type of string for context key
type ContextKey string
// NewContextKeyBuilder returns an emtpy context key
func NewContextKeyBuilder() ContextKey {
return ContextKey("")
}
// ParentDir returns the key of the parent directory
func (c ContextKey) ParentDir() ContextKey {
return ContextKey("parentDir")
}
// GetContextValueOrEmpty returns the value of the context key, if not exist, return empty string
func (c ContextKey) GetContextValueOrEmpty(ctx context.Context) string {
if ctx.Value(c) != nil {
return ctx.Value(c).(string)
}
return ""
}
// RunTestCase is the main entry point of a test case
func (r *simpleTestCaseRunner) RunTestCase(testcase *testing.TestCase, dataContext interface{}, ctx context.Context) (output interface{}, err error) {
r.log.Info("start to run: '%s'\n", testcase.Name)
record := NewReportRecord()
defer func(rr *ReportRecord) {
rr.Group = testcase.Group
rr.Name = testcase.Name
rr.EndTime = time.Now()
rr.Error = err
rr.API = testcase.Request.API
rr.Method = testcase.Request.Method
r.testReporter.PutRecord(rr)
}(record)
defer func() {
if err == nil {
err = runJob(testcase.After, dataContext, output)
}
}()
insecure := false
if r.Secure != nil {
insecure = r.Secure.Insecure
}
client := util.TlsAwareHTTPClient(insecure) // TODO should have a way to change it
contextDir := NewContextKeyBuilder().ParentDir().GetContextValueOrEmpty(ctx)
if err = testcase.Request.Render(dataContext, contextDir); err != nil {
return
}
// add proxy setting
if r.proxy != nil && r.proxy.HTTP != "" {
var proxyURL *url.URL
if proxyURL, err = url.Parse(r.proxy.HTTP); err == nil {
client.Transport = &http.Transport{
Proxy: func(req *http.Request) (*url.URL, error) {
for _, noProxy := range strings.Split(r.proxy.No, ",") {
noProxy = strings.TrimSpace(noProxy)
if noProxy != "" && strings.Contains(req.URL.Host, noProxy) {
return nil, nil
}
}
r.log.Info("using proxy: %v\n", proxyURL)
return proxyURL, nil
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
}
} else {
err = fmt.Errorf("failed to parse proxy URL: %v", err)
return
}
}
var requestBody io.Reader
if requestBody, err = testcase.Request.GetBody(); err != nil {
return
}
var request *http.Request
if request, err = http.NewRequestWithContext(ctx, testcase.Request.Method, testcase.Request.API, requestBody); err != nil {
return
}
q := request.URL.Query()
for k := range testcase.Request.Query {
q.Add(k, testcase.Request.Query.GetValue(k))
}
request.URL.RawQuery = q.Encode()
// set headers
for key, val := range testcase.Request.Header {
request.Header.Add(key, val)
}
if err = runJob(testcase.Before, dataContext, nil); err != nil {
return
}
for _, cookie := range r.cookies {
request.AddCookie(cookie)
}
for k, v := range testcase.Request.Cookie {
request.AddCookie(&http.Cookie{
Name: k,
Value: v,
})
}
r.log.Info("start to send request to %v with method %s\n", request.URL, request.Method)
r.log.Info("request header %v\n", request.Header)
// TODO only do this for unit testing, should remove it once we have a better way
if strings.HasPrefix(testcase.Request.API, "http://") && r.proxy == nil {
client = http.DefaultClient
}
// send the HTTP request
var resp *http.Response
if resp, err = client.Do(request); err != nil {
return
}
r.log.Debug("test case %q, status code: %d\n", testcase.Name, resp.StatusCode)
if err = testcase.Expect.Render(dataContext); err != nil {
return
}
if err = expectInt(testcase.Name, testcase.Expect.StatusCode, resp.StatusCode); err != nil {
err = fmt.Errorf("error is: %v", err)
}
for key, val := range testcase.Expect.Header {
actualVal := resp.Header.Get(key)
err = errors.Join(err, expectString(testcase.Name, val, actualVal))
}
respType := util.GetFirstHeaderValue(resp.Header, util.ContentType)
r.withSimpleResponseRecord(resp)
if isNonBinaryContent(respType) {
var responseBodyData []byte
var rErr error
if responseBodyData, rErr = r.withResponseBodyRecord(resp); rErr != nil {
err = errors.Join(err, rErr)
return
}
record.Body = string(responseBodyData)
r.log.Trace("response body: %s\n", record.Body)
if output, rErr = verifyResponseBodyData(testcase.Name, testcase.Expect, respType, responseBodyData); rErr != nil {
err = errors.Join(err, rErr)
return
}
err = errors.Join(err, jsonSchemaValidation(testcase.Expect.Schema, responseBodyData))
} else {
switch respType {
case util.OctetStream, util.Image:
var data []byte
if data, err = io.ReadAll(resp.Body); err == nil {
r.simpleResponse.RawBody = data
r.simpleResponse, err = HandleLargeResponseBody(r.simpleResponse, testcase.Group, testcase.Name)
}
}
r.log.Debug("skip to read the body due to it is not struct content: %q\n", respType)
}
r.cookies = append(r.cookies, resp.Cookies()...)
return
}
func HandleLargeResponseBody(resp SimpleResponse, suite string, caseName string) (SimpleResponse, error) {
const maxSize = 5120
prefix := "isFilePath-" + strings.Join([]string{suite, caseName}, "-")
if len(resp.RawBody) == 0 && len(resp.Body) > 0 {
resp.RawBody = []byte(resp.Body)
}
if len(resp.RawBody) > maxSize {
tmpFile, err := os.CreateTemp("", prefix+"-")
defer tmpFile.Close()
if err != nil {
return resp, fmt.Errorf("failed to create file: %w", err)
}
fmt.Println("response body is too large, will be saved to file", "size", len(resp.RawBody), "path", tmpFile.Name())
if _, err = tmpFile.Write(resp.RawBody); err != nil {
return resp, fmt.Errorf("failed to write response body to file: %w", err)
}
absFilePath, err := filepath.Abs(tmpFile.Name())
if err != nil {
return resp, fmt.Errorf("failed to get absolute file path: %w", err)
}
resp.Body = filepath.Base(absFilePath)
// save the original filename into a new file
_ = os.WriteFile(absFilePath+"name", []byte(resp.getFileName()), 0644)
return resp, nil
}
return resp, nil
}
func ammendHeaders(headers http.Header, body []byte) {
// add content-length if it's missing
if val := headers.Get(util.ContentLength); val == "" {
headers.Add(util.ContentLength, strconv.Itoa(len(body)))
fmt.Printf("add content-length: %d\n", len(body))
}
}
func (r *simpleTestCaseRunner) GetSuggestedAPIs(suite *testing.TestSuite, api string) (result []*testing.TestCase, err error) {
if suite.Spec.URL == "" || suite.Spec.Kind != "swagger" {
return
}
var swagger *spec.Swagger
if swagger, err = apispec.ParseURLToSwagger(suite.Spec.URL); err == nil && swagger != nil {
swaggerAPI := apispec.NewSwaggerAPI(swagger)
for api, methods := range swaggerAPI.ApiMap {
for _, method := range methods {
testcase := &testing.TestCase{
Name: swagger.ID,
Request: testing.Request{
API: api,
Method: strings.ToUpper(method),
Query: make(testing.SortedKeysStringMap),
},
}
switch testcase.Request.Method {
case http.MethodGet:
for _, param := range swagger.Paths.Paths[api].Get.Parameters {
switch param.In {
case "query":
// TODO should have a better way to provide the initial value
(&(testcase.Request)).Query[param.Name] = generateRandomValue(param)
}
}
testcase.Name = swagger.Paths.Paths[api].Get.ID
case http.MethodPost:
testcase.Name = swagger.Paths.Paths[api].Post.ID
case http.MethodPut:
testcase.Name = swagger.Paths.Paths[api].Put.ID
case http.MethodDelete:
testcase.Name = swagger.Paths.Paths[api].Delete.ID
case http.MethodPatch:
testcase.Name = swagger.Paths.Paths[api].Patch.ID
}
result = append(result, testcase)
if len(result) >= r.apiSuggestLimit {
return
}
}
}
}
return
}
func generateRandomValue(param spec.Parameter) interface{} {
switch param.Format {
case "int32", "int64":
return 101
case "boolean":
return true
case "string":
return "random"
default:
return "random"
}
}
func (r *simpleTestCaseRunner) withSimpleResponseRecord(resp *http.Response) {
r.simpleResponse = SimpleResponse{
StatusCode: resp.StatusCode,
Header: make(map[string]string),
}
for key := range resp.Header {
r.simpleResponse.Header[key] = resp.Header.Get(key)
}
}
func (r *simpleTestCaseRunner) withResponseBodyRecord(resp *http.Response) (responseBodyData []byte, err error) {
responseBodyData, err = io.ReadAll(resp.Body)
r.simpleResponse.Body = string(responseBodyData)
// add some headers for convenience
ammendHeaders(resp.Header, responseBodyData)
return
}
// GetResponseRecord returns the response record
func (r *simpleTestCaseRunner) GetResponseRecord() SimpleResponse {
return r.simpleResponse
}
func (r *simpleTestCaseRunner) WithAPISuggestLimit(limit int) {
r.apiSuggestLimit = limit
}
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
}
func jsonSchemaValidation(schema string, body []byte) (err error) {
if schema == "" {
return
}
schemaLoader := gojsonschema.NewStringLoader(schema)
jsonLoader := gojsonschema.NewBytesLoader(body)
var result *gojsonschema.Result
if result, err = gojsonschema.Validate(schemaLoader, jsonLoader); err == nil && !result.Valid() {
err = fmt.Errorf("JSON schema validation failed: %v", result.Errors())
}
return
}
func verifyResponseBodyData(caseName string, expect testing.Response, responseType string, responseBodyData []byte) (output interface{}, err error) {
if expect.Body != "" {
if string(responseBodyData) != strings.TrimSpace(expect.Body) {
err = fmt.Errorf("case: %s, got different response body, diff: \n%s", caseName,
diff.LineDiff(expect.Body, string(responseBodyData)))
return
}
}
verifier := NewBodyVerify(responseType, expect)
if verifier == nil {
runnerLogger.Info("no body verify support with", "response type", responseType)
return
}
if output, err = verifier.Parse(responseBodyData); err != nil {
return
}
mapOutput := map[string]interface{}{
"data": output,
}
if err = verifier.Verify(responseBodyData); err == nil {
err = Verify(expect, mapOutput)
}
return
}
func runJob(job *testing.Job, ctx interface{}, current interface{}) (err error) {
if job == nil {
return
}
var program *vm.Program
env := map[string]interface{}{
"ctx": ctx,
"current": current,
}
for _, item := range job.Items {
var exprText string
if exprText, err = render.Render("job", item, ctx); err != nil {
err = fmt.Errorf("failed to render: %q, error is: %v", item, err)
break
}
if program, err = expr.Compile(exprText, expr.Env(env)); err != nil {
fmt.Printf("failed to compile: %q, %v\n", exprText, err)
return
}
if _, err = expr.Run(program, env); err != nil {
fmt.Printf("failed to Run: %q, %v\n", exprText, err)
return
}
}
return
}
// isNonBinaryContent detect if the content belong to binary
func isNonBinaryContent(contentType string) bool {
if IsJSONCompatileType(contentType) {
return true
}
switch contentType {
case util.JSON, util.YAML, util.Plain, util.OCIImageIndex:
return true
default:
return false
}
}
func IsJSONCompatileType(contentType string) bool {
return strings.HasSuffix(contentType, "+json")
}