refactor: move har2case to internal

This commit is contained in:
debugtalk
2021-12-27 17:16:15 +08:00
parent e6fadb6f26
commit 4fcab5cad5
5 changed files with 1 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
# har2case
Convert HAR(HTTP Archive) to YAML/JSON testcases for HttpRunner and HttpRunner+.
## Install
## Quick Start
## Examples

344
internal/har2case/core.go Normal file
View File

@@ -0,0 +1,344 @@
package har2case
import (
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/hrp"
"github.com/httprunner/hrp/internal/ga"
)
const (
suffixJSON = ".json"
suffixYAML = ".yaml"
)
func NewHAR(path string) *har {
return &har{
path: path,
}
}
type har struct {
path string
filterStr string
excludeStr string
outputDir string
}
func (h *har) SetOutputDir(dir string) {
h.outputDir = dir
}
func (h *har) GenJSON() (jsonPath string, err error) {
event := ga.EventTracking{
Category: "har2case",
Action: "hrp har2case --to-json",
}
// report start event
go ga.SendEvent(event)
// report running timing event
defer ga.SendEvent(event.StartTiming("execution"))
tCase, err := h.makeTestCase()
if err != nil {
return "", err
}
jsonPath = h.genOutputPath(suffixJSON)
err = tCase.Dump2JSON(jsonPath)
return
}
func (h *har) GenYAML() (yamlPath string, err error) {
event := ga.EventTracking{
Category: "har2case",
Action: "hrp har2case --to-yaml",
}
// report start event
go ga.SendEvent(event)
// report running timing event
defer ga.SendEvent(event.StartTiming("execution"))
tCase, err := h.makeTestCase()
if err != nil {
return "", err
}
yamlPath = h.genOutputPath(suffixYAML)
err = tCase.Dump2YAML(yamlPath)
return
}
func (h *har) makeTestCase() (*hrp.TCase, error) {
teststeps, err := h.prepareTestSteps()
if err != nil {
return nil, err
}
tCase := &hrp.TCase{
Config: h.prepareConfig(),
TestSteps: teststeps,
}
return tCase, nil
}
func (h *har) load() (*Har, error) {
fp, err := os.Open(h.path)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
data, err := ioutil.ReadAll(fp)
fp.Close()
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
har := &Har{}
err = json.Unmarshal(data, har)
if err != nil {
return nil, fmt.Errorf("json.Unmarshal error: %w", err)
}
return har, nil
}
func (h *har) prepareConfig() *hrp.TConfig {
return hrp.NewConfig("testcase description").
SetVerifySSL(false).ToStruct()
}
func (h *har) prepareTestSteps() ([]*hrp.TStep, error) {
har, err := h.load()
if err != nil {
return nil, err
}
var steps []*hrp.TStep
for _, entry := range har.Log.Entries {
step, err := h.prepareTestStep(&entry)
if err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, nil
}
func (h *har) prepareTestStep(entry *Entry) (*hrp.TStep, error) {
log.Info().
Str("method", entry.Request.Method).
Str("url", entry.Request.URL).
Msg("convert teststep")
step := &tStep{
TStep: hrp.TStep{
Request: &hrp.Request{},
Validators: make([]hrp.Validator, 0),
},
}
if err := step.makeRequestMethod(entry); err != nil {
return nil, err
}
if err := step.makeRequestURL(entry); err != nil {
return nil, err
}
if err := step.makeRequestParams(entry); err != nil {
return nil, err
}
if err := step.makeRequestCookies(entry); err != nil {
return nil, err
}
if err := step.makeRequestHeaders(entry); err != nil {
return nil, err
}
if err := step.makeRequestBody(entry); err != nil {
return nil, err
}
if err := step.makeValidate(entry); err != nil {
return nil, err
}
return &step.TStep, nil
}
type tStep struct {
hrp.TStep
}
func (s *tStep) makeRequestMethod(entry *Entry) error {
s.Request.Method = entry.Request.Method
return nil
}
func (s *tStep) makeRequestURL(entry *Entry) error {
u, err := url.Parse(entry.Request.URL)
if err != nil {
log.Error().Err(err).Msg("make request url failed")
return err
}
s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Hostname()+u.Path)
return nil
}
func (s *tStep) makeRequestParams(entry *Entry) error {
s.Request.Params = make(map[string]interface{})
for _, param := range entry.Request.QueryString {
s.Request.Params[param.Name] = param.Value
}
return nil
}
func (s *tStep) makeRequestCookies(entry *Entry) error {
s.Request.Cookies = make(map[string]string)
for _, cookie := range entry.Request.Cookies {
s.Request.Cookies[cookie.Name] = cookie.Value
}
return nil
}
func (s *tStep) makeRequestHeaders(entry *Entry) error {
s.Request.Headers = make(map[string]string)
for _, header := range entry.Request.Headers {
if strings.EqualFold(header.Name, "cookie") {
continue
}
s.Request.Headers[header.Name] = header.Value
}
return nil
}
func (s *tStep) makeRequestBody(entry *Entry) error {
mimeType := entry.Request.PostData.MimeType
if mimeType == "" {
// GET/HEAD/DELETE without body
return nil
}
// POST/PUT with body
if strings.HasPrefix(mimeType, "application/json") {
// post json
var body interface{}
err := json.Unmarshal([]byte(entry.Request.PostData.Text), &body)
if err != nil {
log.Error().Err(err).Msg("make request body failed")
return err
}
s.Request.Body = body
} else if strings.HasPrefix(mimeType, "application/x-www-form-urlencoded") {
// post form
var paramsList []string
for _, param := range entry.Request.PostData.Params {
paramsList = append(paramsList, fmt.Sprintf("%s=%s", param.Name, param.Value))
}
s.Request.Body = strings.Join(paramsList, "&")
} else if strings.HasPrefix(mimeType, "text/plain") {
// post raw data
s.Request.Body = entry.Request.PostData.Text
} else {
// TODO
log.Error().Msgf("makeRequestBody: Not implemented for mimeType %s", mimeType)
}
return nil
}
func (s *tStep) makeValidate(entry *Entry) error {
// make validator for response status code
s.Validators = append(s.Validators, hrp.Validator{
Check: "status_code",
Assert: "equals",
Expect: entry.Response.Status,
Message: "assert response status code",
})
// make validators for response headers
for _, header := range entry.Response.Headers {
// assert Content-Type
if strings.EqualFold(header.Name, "Content-Type") {
s.Validators = append(s.Validators, hrp.Validator{
Check: "headers.Content-Type",
Assert: "equals",
Expect: header.Value,
Message: "assert response header Content-Type",
})
}
}
// make validators for response body
respBody := entry.Response.Content
if respBody.Text == "" {
// response body is empty
return nil
}
if strings.HasPrefix(respBody.MimeType, "application/json") {
// response body is json
if respBody.Encoding == "base64" {
// decode base64 text
data, err := base64.StdEncoding.DecodeString(respBody.Text)
if err != nil {
return errors.Wrap(err, "decode base64 error")
}
// convert to json
var body interface{}
if err = json.Unmarshal(data, &body); err != nil {
return errors.Wrap(err, "json.Unmarshal body error")
}
jsonBody, ok := body.(map[string]interface{})
if !ok {
return fmt.Errorf("response body is not json, not matched with MimeType")
}
// response body is json
keys := make([]string, 0, len(jsonBody))
for k := range jsonBody {
keys = append(keys, k)
}
// sort map keys to keep validators in stable order
sort.Strings(keys)
for _, key := range keys {
value := jsonBody[key]
switch v := value.(type) {
case map[string]interface{}:
continue
case []interface{}:
continue
default:
s.Validators = append(s.Validators, hrp.Validator{
Check: fmt.Sprintf("body.%s", key),
Assert: "equals",
Expect: v,
Message: fmt.Sprintf("assert response body %s", key),
})
}
}
}
}
return nil
}
func (h *har) genOutputPath(suffix string) string {
file := getFilenameWithoutExtension(h.path) + suffix
if h.outputDir != "" {
return filepath.Join(h.outputDir, file)
} else {
return filepath.Join(filepath.Dir(h.path), file)
}
}
func getFilenameWithoutExtension(path string) string {
base := filepath.Base(path)
ext := filepath.Ext(base)
return base[0 : len(base)-len(ext)]
}

View File

@@ -0,0 +1,117 @@
package har2case
import (
"testing"
"github.com/stretchr/testify/assert"
)
var (
harPath = "../examples/har/demo.har"
harPath2 = "../examples/har/postman-echo.har"
)
func TestGenJSON(t *testing.T) {
jsonPath, err := NewHAR(harPath).GenJSON()
if !assert.NoError(t, err) {
t.Fail()
}
if !assert.NotEmpty(t, jsonPath) {
t.Fail()
}
}
func TestGenYAML(t *testing.T) {
yamlPath, err := NewHAR(harPath2).GenYAML()
if !assert.NoError(t, err) {
t.Fail()
}
if !assert.NotEmpty(t, yamlPath) {
t.Fail()
}
}
func TestLoadHAR(t *testing.T) {
har := NewHAR(harPath)
h, err := har.load()
if !assert.NoError(t, err) {
t.Fail()
}
if !assert.Equal(t, "GET", h.Log.Entries[0].Request.Method) {
t.Fail()
}
if !assert.Equal(t, "POST", h.Log.Entries[1].Request.Method) {
t.Fail()
}
}
func TestMakeTestCase(t *testing.T) {
har := NewHAR(harPath)
tCase, err := har.makeTestCase()
if !assert.NoError(t, err) {
t.Fail()
}
// make request method
if !assert.EqualValues(t, "GET", tCase.TestSteps[0].Request.Method) {
t.Fail()
}
if !assert.EqualValues(t, "POST", tCase.TestSteps[1].Request.Method) {
t.Fail()
}
// make request url
if !assert.Equal(t, "https://postman-echo.com/get", tCase.TestSteps[0].Request.URL) {
t.Fail()
}
if !assert.Equal(t, "https://postman-echo.com/post", tCase.TestSteps[1].Request.URL) {
t.Fail()
}
// make request params
if !assert.Equal(t, "HDnY8", tCase.TestSteps[0].Request.Params["foo1"]) {
t.Fail()
}
// make request cookies
if !assert.NotEmpty(t, tCase.TestSteps[1].Request.Cookies["sails.sid"]) {
t.Fail()
}
// make request headers
if !assert.Equal(t, "HttpRunnerPlus", tCase.TestSteps[0].Request.Headers["User-Agent"]) {
t.Fail()
}
if !assert.Equal(t, "postman-echo.com", tCase.TestSteps[0].Request.Headers["Host"]) {
t.Fail()
}
// make request data
if !assert.Equal(t, nil, tCase.TestSteps[0].Request.Body) {
t.Fail()
}
if !assert.Equal(t, map[string]interface{}{"foo1": "HDnY8", "foo2": 12.3}, tCase.TestSteps[1].Request.Body) {
t.Fail()
}
if !assert.Equal(t, "foo1=HDnY8&foo2=12.3", tCase.TestSteps[2].Request.Body) {
t.Fail()
}
// make validators
if !assert.Equal(t, "status_code", tCase.TestSteps[0].Validators[0].Check) {
t.Fail()
}
if !assert.Equal(t, "headers.Content-Type", tCase.TestSteps[0].Validators[1].Check) {
t.Fail()
}
if !assert.Equal(t, "body.url", tCase.TestSteps[0].Validators[2].Check) {
t.Fail()
}
}
func TestGetFilenameWithoutExtension(t *testing.T) {
filename := getFilenameWithoutExtension("examples/har/postman-echo.har")
if !assert.Equal(t, "postman-echo", filename) {
t.Fail()
}
}

340
internal/har2case/har.go Normal file
View File

@@ -0,0 +1,340 @@
package har2case
import "time"
/*
HTTP Archive (HAR) format
https://w3c.github.io/web-performance/specs/HAR/Overview.html
this file is copied from https://github.com/mrichman/hargo/blob/master/types.go
*/
// Har is a container type for deserialization
type Har struct {
Log Log `json:"log"`
}
// Log represents the root of the exported data. This object MUST be present and its name MUST be "log".
type Log struct {
// The object contains the following name/value pairs:
// Required. Version number of the format.
Version string `json:"version"`
// Required. An object of type creator that contains the name and version
// information of the log creator application.
Creator Creator `json:"creator"`
// Optional. An object of type browser that contains the name and version
// information of the user agent.
Browser Browser `json:"browser"`
// Optional. An array of objects of type page, each representing one exported
// (tracked) page. Leave out this field if the application does not support
// grouping by pages.
Pages []Page `json:"pages,omitempty"`
// Required. An array of objects of type entry, each representing one
// exported (tracked) HTTP request.
Entries []Entry `json:"entries"`
// Optional. A comment provided by the user or the application. Sorting
// entries by startedDateTime (starting from the oldest) is preferred way how
// to export data since it can make importing faster. However the reader
// application should always make sure the array is sorted (if required for
// the import).
Comment string `json:"comment"`
}
// Creator contains information about the log creator application
type Creator struct {
// Required. The name of the application that created the log.
Name string `json:"name"`
// Required. The version number of the application that created the log.
Version string `json:"version"`
// Optional. A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Browser that created the log
type Browser struct {
// Required. The name of the browser that created the log.
Name string `json:"name"`
// Required. The version number of the browser that created the log.
Version string `json:"version"`
// Optional. A comment provided by the user or the browser.
Comment string `json:"comment"`
}
// Page object for every exported web page and one <entry> object for every HTTP request.
// In case when an HTTP trace tool isn't able to group requests by a page,
// the <pages> object is empty and individual requests doesn't have a parent page.
type Page struct {
/* There is one <page> object for every exported web page and one <entry>
object for every HTTP request. In case when an HTTP trace tool isn't able to
group requests by a page, the <pages> object is empty and individual
requests doesn't have a parent page.
*/
// Date and time stamp for the beginning of the page load
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.45+01:00).
StartedDateTime string `json:"startedDateTime"`
// Unique identifier of a page within the . Entries use it to refer the parent page.
ID string `json:"id"`
// Page title.
Title string `json:"title"`
// Detailed timing info about page load.
PageTiming PageTiming `json:"pageTiming"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// PageTiming describes timings for various events (states) fired during the page load.
// All times are specified in milliseconds. If a time info is not available appropriate field is set to -1.
type PageTiming struct {
// Content of the page loaded. Number of milliseconds since page load started
// (page.startedDateTime). Use -1 if the timing does not apply to the current
// request.
// Depeding on the browser, onContentLoad property represents DOMContentLoad
// event or document.readyState == interactive.
OnContentLoad int `json:"onContentLoad"`
// Page is loaded (onLoad event fired). Number of milliseconds since page
// load started (page.startedDateTime). Use -1 if the timing does not apply
// to the current request.
OnLoad int `json:"onLoad"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment"`
}
// Entry is a unique, optional Reference to the parent page.
// Leave out this field if the application does not support grouping by pages.
type Entry struct {
Pageref string `json:"pageref,omitempty"`
// Date and time stamp of the request start
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD).
StartedDateTime string `json:"startedDateTime"`
// Total elapsed time of the request in milliseconds. This is the sum of all
// timings available in the timings object (i.e. not including -1 values) .
Time float32 `json:"time"`
// Detailed info about the request.
Request Request `json:"request"`
// Detailed info about the response.
Response Response `json:"response"`
// Info about cache usage.
Cache Cache `json:"cache"`
// Detailed timing info about request/response round trip.
PageTimings PageTimings `json:"pageTimings"`
// optional (new in 1.2) IP address of the server that was connected
// (result of DNS resolution).
ServerIPAddress string `json:"serverIPAddress,omitempty"`
// optional (new in 1.2) Unique ID of the parent TCP/IP connection, can be
// the client port number. Note that a port number doesn't have to be unique
// identifier in cases where the port is shared for more connections. If the
// port isn't available for the application, any other unique connection ID
// can be used instead (e.g. connection index). Leave out this field if the
// application doesn't support this info.
Connection string `json:"connection,omitempty"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Request contains detailed info about performed request.
type Request struct {
// Request method (GET, POST, ...).
Method string `json:"method"`
// Absolute URL of the request (fragments are not included).
URL string `json:"url"`
// Request HTTP Version.
HTTPVersion string `json:"httpVersion"`
// List of cookie objects.
Cookies []Cookie `json:"cookies"`
// List of header objects.
Headers []NVP `json:"headers"`
// List of query parameter objects.
QueryString []NVP `json:"queryString"`
// Posted data.
PostData PostData `json:"postData"`
// Total number of bytes from the start of the HTTP request message until
// (and including) the double CRLF before the body. Set to -1 if the info
// is not available.
HeaderSize int `json:"headerSize"`
// Size of the request body (POST data payload) in bytes. Set to -1 if the
// info is not available.
BodySize int `json:"bodySize"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment"`
}
// Response contains detailed info about the response.
type Response struct {
// Response status.
Status int `json:"status"`
// Response status description.
StatusText string `json:"statusText"`
// Response HTTP Version.
HTTPVersion string `json:"httpVersion"`
// List of cookie objects.
Cookies []Cookie `json:"cookies"`
// List of header objects.
Headers []NVP `json:"headers"`
// Details about the response body.
Content Content `json:"content"`
// Redirection target URL from the Location response header.
RedirectURL string `json:"redirectURL"`
// Total number of bytes from the start of the HTTP response message until
// (and including) the double CRLF before the body. Set to -1 if the info is
// not available.
// The size of received response-headers is computed only from headers that
// are really received from the server. Additional headers appended by the
// browser are not included in this number, but they appear in the list of
// header objects.
HeadersSize int `json:"headersSize"`
// Size of the received response body in bytes. Set to zero in case of
// responses coming from the cache (304). Set to -1 if the info is not
// available.
BodySize int `json:"bodySize"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Cookie contains list of all cookies (used in <request> and <response> objects).
type Cookie struct {
// The name of the cookie.
Name string `json:"name"`
// The cookie value.
Value string `json:"value"`
// optional The path pertaining to the cookie.
Path string `json:"path,omitempty"`
// optional The host of the cookie.
Domain string `json:"domain,omitempty"`
// optional Cookie expiration time.
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.123+02:00).
Expires string `json:"expires,omitempty"`
// optional Set to true if the cookie is HTTP only, false otherwise.
HTTPOnly bool `json:"httpOnly,omitempty"`
// optional (new in 1.2) True if the cookie was transmitted over ssl, false
// otherwise.
Secure bool `json:"secure,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment bool `json:"comment,omitempty"`
}
// NVP is simply a name/value pair with a comment
type NVP struct {
Name string `json:"name"`
Value string `json:"value"`
Comment string `json:"comment,omitempty"`
}
// PostData describes posted data, if any (embedded in <request> object).
type PostData struct {
// Mime type of posted data.
MimeType string `json:"mimeType"`
// List of posted parameters (in case of URL encoded parameters).
Params []PostParam `json:"params"`
// Plain text posted data
Text string `json:"text"`
// optional (new in 1.2) A comment provided by the user or the
// application.
Comment string `json:"comment,omitempty"`
}
// PostParam is a list of posted parameters, if any (embedded in <postData> object).
type PostParam struct {
// name of a posted parameter.
Name string `json:"name"`
// optional value of a posted parameter or content of a posted file.
Value string `json:"value,omitempty"`
// optional name of a posted file.
FileName string `json:"fileName,omitempty"`
// optional content type of a posted file.
ContentType string `json:"contentType,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Content describes details about response content (embedded in <response> object).
type Content struct {
// Length of the returned content in bytes. Should be equal to
// response.bodySize if there is no compression and bigger when the content
// has been compressed.
Size int `json:"size"`
// optional Number of bytes saved. Leave out this field if the information
// is not available.
Compression int `json:"compression,omitempty"`
// MIME type of the response text (value of the Content-Type response
// header). The charset attribute of the MIME type is included (if
// available).
MimeType string `json:"mimeType"`
// optional Response body sent from the server or loaded from the browser
// cache. This field is populated with textual content only. The text field
// is either HTTP decoded text or a encoded (e.g. "base64") representation of
// the response body. Leave out this field if the information is not
// available.
Text string `json:"text,omitempty"`
// optional (new in 1.2) Encoding used for response text field e.g
// "base64". Leave out this field if the text field is HTTP decoded
// (decompressed & unchunked), than trans-coded from its original character
// set into UTF-8.
Encoding string `json:"encoding,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Cache contains info about a request coming from browser cache.
type Cache struct {
// optional State of a cache entry before the request. Leave out this field
// if the information is not available.
BeforeRequest CacheObject `json:"beforeRequest,omitempty"`
// optional State of a cache entry after the request. Leave out this field if
// the information is not available.
AfterRequest CacheObject `json:"afterRequest,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// CacheObject is used by both beforeRequest and afterRequest
type CacheObject struct {
// optional - Expiration time of the cache entry.
Expires string `json:"expires,omitempty"`
// The last time the cache entry was opened.
LastAccess string `json:"lastAccess"`
// Etag
ETag string `json:"eTag"`
// The number of times the cache entry has been opened.
HitCount int `json:"hitCount"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// PageTimings describes various phases within request-response round trip.
// All times are specified in milliseconds.
type PageTimings struct {
Blocked int `json:"blocked,omitempty"`
// optional - Time spent in a queue waiting for a network connection. Use -1
// if the timing does not apply to the current request.
DNS int `json:"dns,omitempty"`
// optional - DNS resolution time. The time required to resolve a host name.
// Use -1 if the timing does not apply to the current request.
Connect int `json:"connect,omitempty"`
// optional - Time required to create TCP connection. Use -1 if the timing
// does not apply to the current request.
Send int `json:"send"`
// Time required to send HTTP request to the server.
Wait int `json:"wait"`
// Waiting for a response from the server.
Receive int `json:"receive"`
// Time required to read entire response from the server (or cache).
Ssl int `json:"ssl,omitempty"`
// optional (new in 1.2) - Time required for SSL/TLS negotiation. If this
// field is defined then the time is also included in the connect field (to
// ensure backward compatibility with HAR 1.1). Use -1 if the timing does not
// apply to the current request.
Comment string `json:"comment,omitempty"`
// optional (new in 1.2) - A comment provided by the user or the application.
}
// TestResult contains results for an individual HTTP request
type TestResult struct {
URL string `json:"url"`
Status int `json:"status"` // 200, 500, etc.
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Latency int `json:"latency"` // milliseconds
Method string `json:"method"`
HarFile string `json:"harfile"`
}