diff --git a/README.en.md b/README.en.md index d1a564e0..555de35a 100644 --- a/README.en.md +++ b/README.en.md @@ -108,7 +108,7 @@ Use "hrp [command] --help" for more information about a command. 关注 HttpRunner 的微信公众号,第一时间获得最新资讯。 -HttpRunner +HttpRunner 如果你期望加入 HttpRunner 核心用户群,请填写[用户调研问卷][survey]并留下你的联系方式,作者将拉你进群。 diff --git a/README.md b/README.md index 0581a634..f8949730 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ HttpRunner is in Sentry Sponsored plan. 关注 HttpRunner 的微信公众号,第一时间获得最新资讯。 -HttpRunner +HttpRunner 如果你期望加入 HttpRunner 核心用户群,请填写[用户调研问卷][survey]并留下你的联系方式,作者将拉你进群。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b3dc23dd..118ede44 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,7 @@ - fix: step request elapsed timing should contain ContentTransfer part - fix #1288: unable to go get httprunner v4 +- feat: support converting Postman collection to HttpRunner testcase **python version** diff --git a/examples/data/postman2case/postman_collection.json b/examples/data/postman2case/postman_collection.json new file mode 100644 index 00000000..5cedbcf8 --- /dev/null +++ b/examples/data/postman2case/postman_collection.json @@ -0,0 +1,346 @@ +{ + "info": { + "_postman_id": "0417a445-b206-4ea2-b1d2-5441afd6c6b9", + "name": "postman collection demo", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "folder1", + "item": [ + { + "name": "folder2", + "item": [ + { + "name": "Get with params", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/:path?k1=v1&k2=v2", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "query": [ + { + "key": "k1", + "value": "v1" + }, + { + "key": "k2", + "value": "v2" + }, + { + "key": "k3", + "value": "v3", + "disabled": true + } + ], + "variable": [ + { + "key": "path", + "value": "get" + } + ] + } + }, + "response": [ + { + "name": "Get with params", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/:path?k1=v1&k2=v2", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "query": [ + { + "key": "k1", + "value": "v1" + }, + { + "key": "k2", + "value": "v2" + }, + { + "key": "k3", + "value": "v3", + "disabled": true + } + ], + "variable": [ + { + "key": "path", + "value": "get" + } + ] + } + }, + "_postman_previewlanguage": "json", + "header": null, + "cookie": [], + "body": "{\n \"args\": {\n \"k1\": \"v1\",\n \"k2\": \"v2\"\n },\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"PostmanRuntime/7.29.0\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\"\n },\n \"url\": \"https://postman-echo.com/get?k1=v1&k2=v2\"\n}" + } + ] + } + ] + } + ] + }, + { + "name": "folder3", + "item": [ + { + "name": "Post form-data", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "k1", + "value": "v1", + "type": "text" + }, + { + "key": "k2", + "value": "v2", + "type": "text" + }, + { + "key": "k3", + "value": "v3", + "type": "text", + "disabled": true + } + ] + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post x-www-form-urlencoded", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "k1", + "value": "v1", + "type": "text" + }, + { + "key": "k2", + "value": "v2", + "type": "text" + }, + { + "key": "k3", + "value": "v3", + "type": "text", + "disabled": true + } + ] + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post raw json", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"k1\": \"v1\",\n \"k2\": \"v2\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post raw text", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "have a nice day", + "options": { + "raw": { + "language": "text" + } + } + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get request headers", + "request": { + "method": "GET", + "header": [ + { + "key": "User-Agent", + "value": "HttpRunner", + "type": "text" + }, + { + "key": "User-Name", + "value": "bbx", + "type": "text", + "disabled": true + } + ], + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "headers" + } + ] + } + }, + "response": [ + { + "name": "Get request headers", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "User-Agent", + "value": "HttpRunner", + "type": "text" + }, + { + "key": "User-Name", + "value": "bbx", + "type": "text", + "disabled": true + } + ], + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "headers" + } + ] + } + }, + "_postman_previewlanguage": "json", + "header": null, + "cookie": [], + "body": "{\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"HttpRunner\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\"\n }\n}" + } + ] + } + ] +} \ No newline at end of file diff --git a/hrp/cmd/postman2case.go b/hrp/cmd/postman2case.go new file mode 100644 index 00000000..5ccedacb --- /dev/null +++ b/hrp/cmd/postman2case.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "errors" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/v4/hrp/internal/postman2case" +) + +// postman2caseCmd represents the postman2case command +var postman2caseCmd = &cobra.Command{ + Use: "postman2case $postman_path...", + Short: "convert postman collection to json/yaml testcase files", + Long: `convert postman collection to json/yaml testcase files`, + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + setLogLevel(logLevel) + }, + RunE: func(cmd *cobra.Command, args []string) error { + var outputFiles []string + for _, arg := range args { + // must choose one + if !postman2JSONFlag && !postman2YAMLFlag { + return errors.New("please select convert format type") + } + var outputPath string + var err error + + postman := postman2case.NewCollection(arg) + + // specify output dir + if postman2Dir != "" { + postman.SetOutputDir(postman2Dir) + } + + // generate json/yaml files + if genYAMLFlag { + outputPath, err = postman.GenYAML() + } else { + outputPath, err = postman.GenJSON() // default + } + if err != nil { + return err + } + outputFiles = append(outputFiles, outputPath) + } + log.Info().Strs("output", outputFiles).Msg("convert testcase success") + return nil + }, +} + +var ( + postman2JSONFlag bool + postman2YAMLFlag bool + postman2Dir string +) + +func init() { + rootCmd.AddCommand(postman2caseCmd) + postman2caseCmd.Flags().BoolVarP(&postman2JSONFlag, "to-json", "j", true, "convert to JSON format") + postman2caseCmd.Flags().BoolVarP(&postman2YAMLFlag, "to-yaml", "y", false, "convert to YAML format") + postman2caseCmd.Flags().StringVarP(&postman2Dir, "output-dir", "d", "", "specify output directory, default to the same dir with postman collection file") +} diff --git a/hrp/internal/postman2case/collection.go b/hrp/internal/postman2case/collection.go new file mode 100644 index 00000000..ddabee21 --- /dev/null +++ b/hrp/internal/postman2case/collection.go @@ -0,0 +1,74 @@ +package postman2case + +/* +Postman Collection format reference: +https://schema.postman.com/json/collection/v2.0.0/collection.json +https://schema.postman.com/json/collection/v2.1.0/collection.json +*/ + +// TCollection represents the postman exported file +type TCollection struct { + Info TInfo `json:"info"` + Items []TItem `json:"item"` +} + +// TInfo gives information about the collection +type TInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Schema string `json:"schema"` +} + +// TItem contains the detail information of request and expected responses +// item could be defined recursively +type TItem struct { + Items []TItem `json:"item"` + Name string `json:"name"` + Request TRequest `json:"request"` + Responses []TResponse `json:"response"` +} + +type TRequest struct { + Method string `json:"method"` + Headers []TField `json:"header"` + Body TBody `json:"body"` + URL TUrl `json:"url"` + Description string `json:"description"` +} + +type TResponse struct { + Name string `json:"name"` + OriginalRequest TRequest `json:"originalRequest"` + Status string `json:"status"` + Code int `json:"code"` + Headers []TField `json:"headers"` + Body string `json:"body"` +} + +type TUrl struct { + Raw string `json:"raw"` + Protocol string `json:"protocol"` + Path []string `json:"path"` + Description string `json:"description"` + Query []TField `json:"query"` + Variable []TField `json:"variable"` +} + +type TField struct { + Key string `json:"key"` + Value string `json:"value"` + Src string `json:"src"` + Description string `json:"description"` + Type string `json:"type"` + Disabled bool `json:"disabled"` + Enable bool `json:"enable"` +} + +type TBody struct { + Mode string `json:"mode"` + FormData []TField `json:"formdata"` + URLEncoded []TField `json:"urlencoded"` + Raw string `json:"raw"` + Disabled bool `json:"disabled"` + Options interface{} `json:"options"` +} diff --git a/hrp/internal/postman2case/core.go b/hrp/internal/postman2case/core.go new file mode 100644 index 00000000..f4b8b4e3 --- /dev/null +++ b/hrp/internal/postman2case/core.go @@ -0,0 +1,364 @@ +package postman2case + +import ( + "bytes" + "fmt" + "github.com/httprunner/httprunner/v4/hrp/internal/json" + "io" + "mime/multipart" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/v4/hrp" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" +) + +const ( + enumBodyRaw = "raw" + enumBodyUrlEncoded = "urlencoded" + enumBodyFormData = "formdata" + enumBodyFile = "file" + enumBodyGraphQL = "graphql" +) + +const ( + enumFieldTypeText = "text" + enumFieldTypeFile = "file" +) + +const ( + suffixName = ".converted" + extensionJSON = ".json" + extensionYAML = ".yaml" +) + +var contentTypeMap = map[string]string{ + "text": "text/plain", + "javascript": "application/javascript", + "json": "application/json", + "html": "text/html", + "xml": "application/xml", +} + +func NewCollection(path string) *collection { + return &collection{ + path: path, + } +} + +type collection struct { + path string + outputDir string +} + +func (c *collection) SetOutputDir(dir string) { + log.Info().Str("dir", dir).Msg("set output directory") + c.outputDir = dir +} + +func (c *collection) GenJSON() (jsonPath string, err error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath = c.genOutputPath(extensionJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + return +} + +func (c *collection) GenYAML() (yamlPath string, err error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath = c.genOutputPath(extensionYAML) + err = builtin.Dump2YAML(testCase, yamlPath) + return +} + +func (c *collection) genOutputPath(suffix string) string { + file := getFilenameWithoutExtension(c.path) + suffix + if c.outputDir != "" { + return filepath.Join(c.outputDir, file) + } else { + return filepath.Join(filepath.Dir(c.path), file) + } +} + +func getFilenameWithoutExtension(path string) string { + base := filepath.Base(path) + ext := filepath.Ext(base) + return base[0:len(base)-len(ext)] + suffixName +} + +func (c *collection) makeTestCase() (*hrp.TCase, error) { + tCollection, err := c.load() + if err != nil { + return nil, err + } + teststeps, err := c.prepareTestSteps(tCollection) + if err != nil { + return nil, err + } + tCase := &hrp.TCase{ + Config: c.prepareConfig(tCollection), + TestSteps: teststeps, + } + return tCase, nil +} + +func (c *collection) load() (*TCollection, error) { + collection := &TCollection{} + err := builtin.LoadFile(c.path, collection) + if err != nil { + return nil, errors.Wrap(err, "load postman collection failed") + } + return collection, nil +} + +func (c *collection) prepareConfig(tCollection *TCollection) *hrp.TConfig { + return hrp.NewConfig(tCollection.Info.Name). + SetVerifySSL(false) +} + +func (c *collection) prepareTestSteps(tCollection *TCollection) ([]*hrp.TStep, error) { + // recursively convert collection items into a list + var itemList []TItem + for _, item := range tCollection.Items { + extractItemList(item, &itemList) + } + + var steps []*hrp.TStep + for _, item := range itemList { + step, err := c.prepareTestStep(&item) + if err != nil { + return nil, err + } + steps = append(steps, step) + } + return steps, nil +} + +func extractItemList(item TItem, itemList *[]TItem) { + // current item contains no other items and request is not empty + if len(item.Items) == 0 { + if !reflect.DeepEqual(item.Request, TRequest{}) { + *itemList = append(*itemList, item) + } + return + } + + // look up all items inside + for _, i := range item.Items { + // append item name + i.Name = fmt.Sprintf("%s - %s", item.Name, i.Name) + extractItemList(i, itemList) + } +} + +func (c *collection) prepareTestStep(item *TItem) (*hrp.TStep, error) { + log.Info(). + Str("method", item.Request.Method). + Str("url", item.Request.URL.Raw). + Msg("convert teststep") + + step := &tStep{ + hrp.TStep{ + Request: &hrp.Request{}, + Validators: make([]interface{}, 0), + }, + } + if err := step.makeRequestName(item); err != nil { + return nil, err + } + if err := step.makeRequestMethod(item); err != nil { + return nil, err + } + if err := step.makeRequestURL(item); err != nil { + return nil, err + } + if err := step.makeRequestParams(item); err != nil { + return nil, err + } + if err := step.makeRequestHeadersAndCookies(item); err != nil { + return nil, err + } + if err := step.makeRequestBody(item); err != nil { + return nil, err + } + if err := step.makeValidate(item); err != nil { + return nil, err + } + return &step.TStep, nil +} + +type tStep struct { + hrp.TStep +} + +// makeRequestName indicates the step name the same as item name +func (s *tStep) makeRequestName(item *TItem) error { + s.Name = item.Name + return nil +} + +func (s *tStep) makeRequestMethod(item *TItem) error { + s.Request.Method = hrp.HTTPMethod(item.Request.Method) + return nil +} + +func (s *tStep) makeRequestURL(item *TItem) error { + rawUrl := item.Request.URL.Raw + // parse path variables like ":path" in https://postman-echo.com/:path?k1=v1&k2=v2 + for _, field := range item.Request.URL.Variable { + pathVar := ":" + field.Key + rawUrl = strings.Replace(rawUrl, pathVar, field.Value, -1) + } + u, err := url.Parse(rawUrl) + if err != nil { + return errors.Wrap(err, "parse URL error") + } + s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path) + return nil +} + +func (s *tStep) makeRequestParams(item *TItem) error { + s.Request.Params = make(map[string]interface{}) + for _, field := range item.Request.URL.Query { + if field.Disabled { + continue + } + s.Request.Params[field.Key] = field.Value + } + return nil +} + +func (s *tStep) makeRequestHeadersAndCookies(item *TItem) error { + s.Request.Headers = make(map[string]string) + for _, field := range item.Request.Headers { + if field.Disabled { + continue + } + if strings.EqualFold(field.Key, "cookie") { + s.Request.Cookies[field.Key] = field.Value + continue + } + s.Request.Headers[field.Key] = field.Value + } + return nil +} + +func (s *tStep) makeRequestBody(item *TItem) error { + mode := item.Request.Body.Mode + if mode == "" { + return nil + } + switch mode { + case enumBodyRaw: + return s.makeRequestBodyRaw(item) + case enumBodyFormData: + return s.makeRequestBodyFormData(item) + case enumBodyUrlEncoded: + return s.makeRequestBodyUrlEncoded(item) + case enumBodyFile, enumBodyGraphQL: + return errors.New("not supported body type") + } + return nil +} + +func (s *tStep) makeRequestBodyRaw(item *TItem) (err error) { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("make request body raw failed: %v", p) + } + }() + + // extract language type + iOptions := item.Request.Body.Options + iLanguage := iOptions.(map[string]interface{})["raw"] + languageType := iLanguage.(map[string]interface{})["language"].(string) + + // make request body and indicate Content-Type + rawBody := item.Request.Body.Raw + if languageType == "json" { + var iBody interface{} + err = json.Unmarshal([]byte(rawBody), &iBody) + if err != nil { + return errors.Wrap(err, "make request body raw failed") + } + s.Request.Body = iBody + } else { + s.Request.Body = rawBody + } + s.Request.Headers["Content-Type"] = contentTypeMap[languageType] + return +} + +func (s *tStep) makeRequestBodyFormData(item *TItem) (err error) { + defer func() { + if err != nil { + err = errors.Wrap(err, "make request body form-data failed") + } + }() + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + for _, field := range item.Request.Body.FormData { + if field.Disabled { + continue + } + // form data could be text or file + if field.Type == enumFieldTypeText { + err = writer.WriteField(field.Key, field.Value) + if err != nil { + return + } + } else if field.Type == enumFieldTypeFile { + err = writeFormDataFile(writer, &field) + if err != nil { + return + } + } + } + err = writer.Close() + s.Request.Body = payload.String() + s.Request.Headers["Content-Type"] = writer.FormDataContentType() + return +} + +func writeFormDataFile(writer *multipart.Writer, field *TField) error { + file, err := os.Open(field.Src) + if err != nil { + return err + } + defer file.Close() + formFile, err := writer.CreateFormFile(field.Key, filepath.Base(field.Src)) + if err != nil { + return err + } + _, err = io.Copy(formFile, file) + return err +} + +func (s *tStep) makeRequestBodyUrlEncoded(item *TItem) error { + payloadMap := make(map[string]string) + for _, field := range item.Request.Body.URLEncoded { + if field.Disabled { + continue + } + payloadMap[field.Key] = field.Value + } + s.Request.Body = payloadMap + s.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded" + return nil +} + +// TODO makeValidate from example response +func (s *tStep) makeValidate(item *TItem) error { + return nil +} diff --git a/hrp/internal/postman2case/core_test.go b/hrp/internal/postman2case/core_test.go new file mode 100644 index 00000000..47b9eabc --- /dev/null +++ b/hrp/internal/postman2case/core_test.go @@ -0,0 +1,39 @@ +package postman2case + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var collectionPath = "../../../examples/data/postman2case/postman_collection.json" + +func TestLoadPostmanCollection(t *testing.T) { + c, err := NewCollection(collectionPath).load() + if !assert.NoError(t, err) { + t.Fatal(err) + } + if !assert.Equal(t, "postman collection demo", c.Info.Name) { + t.Fatal() + } +} + +func TestGenJSON(t *testing.T) { + jsonPath, err := NewCollection(collectionPath).GenJSON() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, jsonPath) { + t.Fatal() + } +} + +func TestGenYAML(t *testing.T) { + yamlPath, err := NewCollection(collectionPath).GenYAML() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, yamlPath) { + t.Fatal() + } +}