feat: return scan report and summary by header (#13898)

Add X-Accept-Vulnerabilities header to the list/get artifact and get
artifact vulnerability addition APIs, and these APIs will traverse the
mime types in this header and return the first report and summary found
from the mime type.

Signed-off-by: He Weiwei <hweiwei@vmware.com>
This commit is contained in:
He Weiwei 2021-01-06 17:54:36 +08:00 committed by GitHub
parent 511bd86930
commit ed31cf9417
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 321 additions and 88 deletions

View File

@ -367,6 +367,7 @@ paths:
- $ref: '#/parameters/query'
- $ref: '#/parameters/page'
- $ref: '#/parameters/pageSize'
- $ref: '#/parameters/acceptVulnerabilities'
- name: with_tag
in: query
description: Specify whether the tags are included inside the returning artifacts
@ -465,6 +466,7 @@ paths:
- $ref: '#/parameters/reference'
- $ref: '#/parameters/page'
- $ref: '#/parameters/pageSize'
- $ref: '#/parameters/acceptVulnerabilities'
- name: with_tag
in: query
description: Specify whether the tags are inclued inside the returning artifacts
@ -699,6 +701,38 @@ paths:
$ref: '#/responses/404'
'500':
$ref: '#/responses/500'
/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/vulnerabilities:
get:
summary: Get the vulnerabilities addition of the specific artifact
description: Get the vulnerabilities addition of the artifact specified by the reference under the project and repository.
tags:
- artifact
operationId: getVulnerabilitiesAddition
parameters:
- $ref: '#/parameters/requestId'
- $ref: '#/parameters/projectName'
- $ref: '#/parameters/repositoryName'
- $ref: '#/parameters/reference'
- $ref: '#/parameters/acceptVulnerabilities'
responses:
'200':
description: Success
headers:
Content-Type:
description: The content type of the vulnerabilities addition
type: string
schema:
type: string
'400':
$ref: '#/responses/400'
'401':
$ref: '#/responses/401'
'403':
$ref: '#/responses/403'
'404':
$ref: '#/responses/404'
'500':
$ref: '#/responses/500'
/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/{addition}:
get:
summary: Get the addition of the specific artifact
@ -715,7 +749,7 @@ paths:
in: path
description: The type of addition.
type: string
enum: [build_history, values.yaml, readme.md, dependencies, vulnerabilities]
enum: [build_history, values.yaml, readme.md, dependencies]
required: true
responses:
'200':
@ -2337,6 +2371,14 @@ parameters:
type: boolean
required: false
default: false
acceptVulnerabilities:
name: X-Accept-Vulnerabilities
in: header
type: string
default: 'application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0'
description: |-
A comma-separated lists of MIME types for the scan report or scan summary. The first mime type will be used when the report found for it.
Currently the mime type supports 'application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0' and 'application/vnd.security.vulnerability.report; version=1.1'
projectName:
name: project_name
in: path

View File

@ -16,6 +16,7 @@ package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
@ -27,14 +28,15 @@ import (
"github.com/goharbor/harbor/src/common/rbac"
"github.com/goharbor/harbor/src/common/utils"
"github.com/goharbor/harbor/src/controller/artifact"
"github.com/goharbor/harbor/src/controller/artifact/processor"
"github.com/goharbor/harbor/src/controller/event/metadata"
"github.com/goharbor/harbor/src/controller/project"
"github.com/goharbor/harbor/src/controller/repository"
"github.com/goharbor/harbor/src/controller/scan"
"github.com/goharbor/harbor/src/controller/tag"
"github.com/goharbor/harbor/src/lib"
"github.com/goharbor/harbor/src/lib/errors"
"github.com/goharbor/harbor/src/pkg/notification"
"github.com/goharbor/harbor/src/pkg/scan/report"
"github.com/goharbor/harbor/src/server/v2.0/handler/assembler"
"github.com/goharbor/harbor/src/server/v2.0/handler/model"
"github.com/goharbor/harbor/src/server/v2.0/models"
@ -42,10 +44,6 @@ import (
"github.com/opencontainers/go-digest"
)
const (
vulnerabilitiesAddition = "vulnerabilities"
)
func newArtifactAPI() *artifactAPI {
return &artifactAPI{
artCtl: artifact.Ctl,
@ -100,7 +98,7 @@ func (a *artifactAPI) ListArtifacts(ctx context.Context, params operation.ListAr
return a.SendError(ctx, err)
}
assembler := assembler.NewVulAssembler(boolValue(params.WithScanOverview))
assembler := assembler.NewVulAssembler(lib.BoolValue(params.WithScanOverview), parseScanReportMimeTypes(params.XAcceptVulnerabilities))
var artifacts []*models.Artifact
for _, art := range arts {
artifact := &model.Artifact{}
@ -131,7 +129,7 @@ func (a *artifactAPI) GetArtifact(ctx context.Context, params operation.GetArtif
art := &model.Artifact{}
art.Artifact = *artifact
assembler.NewVulAssembler(boolValue(params.WithScanOverview)).WithArtifacts(art).Assemble(ctx)
assembler.NewVulAssembler(lib.BoolValue(params.WithScanOverview), parseScanReportMimeTypes(params.XAcceptVulnerabilities)).WithArtifacts(art).Assemble(ctx)
return operation.NewGetArtifactOK().WithPayload(art.ToSwagger())
}
@ -335,6 +333,59 @@ func (a *artifactAPI) ListTags(ctx context.Context, params operation.ListTagsPar
WithPayload(ts)
}
func (a *artifactAPI) GetVulnerabilitiesAddition(ctx context.Context, params operation.GetVulnerabilitiesAdditionParams) middleware.Responder {
if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionRead, rbac.ResourceArtifactAddition); err != nil {
return a.SendError(ctx, err)
}
artifact, err := a.artCtl.GetByReference(ctx, fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName), params.Reference, nil)
if err != nil {
return a.SendError(ctx, err)
}
vulnerabilities := make(map[string]interface{})
for _, mimeType := range parseScanReportMimeTypes(params.XAcceptVulnerabilities) {
reports, err := a.scanCtl.GetReport(ctx, artifact, []string{mimeType})
if err != nil {
return a.SendError(ctx, err)
}
for _, rp := range reports {
// Resolve scan report data only when it is ready
if len(rp.Report) == 0 {
continue
}
vrp, err := report.ResolveData(rp.MimeType, []byte(rp.Report), report.WithArtifactDigest(rp.Digest))
if err != nil {
return a.SendError(ctx, err)
}
if v, ok := vulnerabilities[rp.MimeType]; ok {
r, err := report.Merge(rp.MimeType, v, vrp)
if err != nil {
return a.SendError(ctx, err)
}
vulnerabilities[rp.MimeType] = r
} else {
vulnerabilities[rp.MimeType] = vrp
}
}
if len(vulnerabilities) != 0 {
break
}
}
content, _ := json.Marshal(vulnerabilities)
return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) {
w.Header().Set("Content-Type", "application/json")
w.Write(content)
})
}
func (a *artifactAPI) GetAddition(ctx context.Context, params operation.GetAdditionParams) middleware.Responder {
if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionRead, rbac.ResourceArtifactAddition); err != nil {
return a.SendError(ctx, err)
@ -345,13 +396,7 @@ func (a *artifactAPI) GetAddition(ctx context.Context, params operation.GetAddit
return a.SendError(ctx, err)
}
var addition *processor.Addition
if params.Addition == vulnerabilitiesAddition {
addition, err = resolveVulnerabilitiesAddition(ctx, artifact)
} else {
addition, err = a.artCtl.GetAddition(ctx, artifact.ID, strings.ToUpper(params.Addition))
}
addition, err := a.artCtl.GetAddition(ctx, artifact.ID, strings.ToUpper(params.Addition))
if err != nil {
return a.SendError(ctx, err)
}
@ -393,7 +438,7 @@ func (a *artifactAPI) RemoveLabel(ctx context.Context, params operation.RemoveLa
func option(withTag, withImmutableStatus, withLabel, withSignature *bool) *artifact.Option {
option := &artifact.Option{
WithTag: true, // return the tag by default
WithLabel: boolValue(withLabel),
WithLabel: lib.BoolValue(withLabel),
}
if withTag != nil {
@ -402,8 +447,8 @@ func option(withTag, withImmutableStatus, withLabel, withSignature *bool) *artif
if option.WithTag {
option.TagOption = &tag.Option{
WithImmutableStatus: boolValue(withImmutableStatus),
WithSignature: boolValue(withSignature),
WithImmutableStatus: lib.BoolValue(withImmutableStatus),
WithSignature: lib.BoolValue(withSignature),
}
}

View File

@ -15,9 +15,20 @@
package handler
import (
"testing"
"github.com/goharbor/harbor/src/controller/artifact"
"github.com/goharbor/harbor/src/controller/project"
"github.com/goharbor/harbor/src/pkg/scan/dao/scan"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
"github.com/goharbor/harbor/src/server/v2.0/restapi"
artifacttesting "github.com/goharbor/harbor/src/testing/controller/artifact"
scantesting "github.com/goharbor/harbor/src/testing/controller/scan"
"github.com/goharbor/harbor/src/testing/mock"
htesting "github.com/goharbor/harbor/src/testing/server/v2.0/handler"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/suite"
)
func TestParse(t *testing.T) {
@ -50,3 +61,134 @@ func TestParse(t *testing.T) {
repository, reference, err = parse(input)
require.NotNil(t, err)
}
type ArtifactTestSuite struct {
htesting.Suite
artCtl *artifacttesting.Controller
scanCtl *scantesting.Controller
report1 *scan.Report
report2 *scan.Report
}
func (suite *ArtifactTestSuite) SetupSuite() {
suite.artCtl = &artifacttesting.Controller{}
suite.scanCtl = &scantesting.Controller{}
suite.Config = &restapi.Config{
ArtifactAPI: &artifactAPI{
artCtl: suite.artCtl,
scanCtl: suite.scanCtl,
},
}
suite.Suite.SetupSuite()
mock.OnAnything(projectCtlMock, "GetByName").Return(&project.Project{ProjectID: 1}, nil)
suite.report1 = &scan.Report{
MimeType: v1.MimeTypeNativeReport,
Report: "{}",
}
suite.report2 = &scan.Report{
MimeType: v1.MimeTypeGenericVulnerabilityReport,
Report: "{}",
}
}
func (suite *ArtifactTestSuite) onGetReport(mimeType string, reports ...*scan.Report) {
suite.scanCtl.On("GetReport", mock.Anything, mock.Anything, []string{mimeType}).Return(reports, nil).Once()
}
func (suite *ArtifactTestSuite) TestGetVulnerabilitiesAddition() {
times := 6
suite.Security.On("IsAuthenticated").Return(true).Times(times)
suite.Security.On("IsSysAdmin").Return(true).Times(times)
mock.OnAnything(suite.Security, "Can").Return(true).Times(times)
mock.OnAnything(suite.artCtl, "GetByReference").Return(&artifact.Artifact{}, nil).Times(times)
url := "/projects/library/repositories/photon/artifacts/2.0/additions/vulnerabilities"
{
// report not found for the default X-Accept-Vulnerabilities
suite.onGetReport(v1.MimeTypeNativeReport)
var body map[string]interface{}
res, err := suite.GetJSON(url, &body)
suite.NoError(err)
suite.Equal(200, res.StatusCode)
suite.Empty(body)
}
{
// report found for the default X-Accept-Vulnerabilities
suite.onGetReport(v1.MimeTypeNativeReport, suite.report1)
var body map[string]interface{}
res, err := suite.GetJSON(url, &body)
suite.NoError(err)
suite.Equal(200, res.StatusCode)
suite.NotEmpty(body)
suite.Contains(body, v1.MimeTypeNativeReport)
}
{
// report found for the X-Accept-Vulnerabilities of "application/vnd.security.vulnerability.report; version=1.1"
suite.onGetReport(v1.MimeTypeGenericVulnerabilityReport, suite.report2)
var body map[string]interface{}
res, err := suite.GetJSON(url, &body, map[string]string{"X-Accept-Vulnerabilities": v1.MimeTypeGenericVulnerabilityReport})
suite.NoError(err)
suite.Equal(200, res.StatusCode)
suite.NotEmpty(body)
suite.Contains(body, v1.MimeTypeGenericVulnerabilityReport)
}
{
// report found for "application/vnd.security.vulnerability.report; version=1.1"
// and the X-Accept-Vulnerabilities is "application/vnd.security.vulnerability.report; version=1.1, application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0"
suite.onGetReport(v1.MimeTypeGenericVulnerabilityReport, suite.report2)
var body map[string]interface{}
res, err := suite.GetJSON(url, &body, map[string]string{"X-Accept-Vulnerabilities": v1.MimeTypeGenericVulnerabilityReport + "," + v1.MimeTypeNativeReport})
suite.NoError(err)
suite.Equal(200, res.StatusCode)
suite.NotEmpty(body)
suite.Contains(body, v1.MimeTypeGenericVulnerabilityReport)
}
{
// report not found for "application/vnd.security.vulnerability.report; version=1.1"
// report found for "application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0"
// and the X-Accept-Vulnerabilities is "application/vnd.security.vulnerability.report; version=1.1, application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0"
suite.onGetReport(v1.MimeTypeGenericVulnerabilityReport)
suite.onGetReport(v1.MimeTypeNativeReport, suite.report1)
var body map[string]interface{}
res, err := suite.GetJSON(url, &body, map[string]string{"X-Accept-Vulnerabilities": v1.MimeTypeGenericVulnerabilityReport + "," + v1.MimeTypeNativeReport})
suite.NoError(err)
suite.Equal(200, res.StatusCode)
suite.NotEmpty(body)
suite.Contains(body, v1.MimeTypeNativeReport)
}
{
// report not found for "application/vnd.security.vulnerability.report; version=1.1"
// report not found for "application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0"
// and the X-Accept-Vulnerabilities is "application/vnd.security.vulnerability.report; version=1.1, application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0"
suite.onGetReport(v1.MimeTypeGenericVulnerabilityReport)
suite.onGetReport(v1.MimeTypeNativeReport)
var body map[string]interface{}
res, err := suite.GetJSON(url, &body, map[string]string{"X-Accept-Vulnerabilities": v1.MimeTypeGenericVulnerabilityReport + "," + v1.MimeTypeNativeReport})
suite.NoError(err)
suite.Equal(200, res.StatusCode)
suite.Empty(body)
}
}
func TestArtifactTestSuite(t *testing.T) {
suite.Run(t, &ArtifactTestSuite{})
}

View File

@ -20,7 +20,6 @@ import (
"github.com/goharbor/harbor/src/controller/scan"
"github.com/goharbor/harbor/src/lib"
"github.com/goharbor/harbor/src/lib/log"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
"github.com/goharbor/harbor/src/server/v2.0/handler/model"
)
@ -29,12 +28,13 @@ const (
)
// NewVulAssembler returns vul assembler
func NewVulAssembler(withScanOverview bool) *VulAssembler {
func NewVulAssembler(withScanOverview bool, mimeTypes []string) *VulAssembler {
return &VulAssembler{
scanChecker: scan.NewChecker(),
scanCtl: scan.DefaultController,
withScanOverview: withScanOverview,
mimeTypes: mimeTypes,
}
}
@ -45,6 +45,7 @@ type VulAssembler struct {
artifacts []*model.Artifact
withScanOverview bool
mimeTypes []string
}
// WithArtifacts set artifacts for the assembler
@ -72,11 +73,14 @@ func (assembler *VulAssembler) Assemble(ctx context.Context) error {
artifact.SetAdditionLink(vulnerabilitiesAddition, version)
if assembler.withScanOverview {
overview, err := assembler.scanCtl.GetSummary(ctx, &artifact.Artifact, []string{v1.MimeTypeNativeReport, v1.MimeTypeGenericVulnerabilityReport})
if err != nil {
log.Warningf("get scan summary of artifact %s@%s failed, error:%v", artifact.RepositoryName, artifact.Digest, err)
} else if len(overview) > 0 {
artifact.ScanOverview = overview
for _, mimeType := range assembler.mimeTypes {
overview, err := assembler.scanCtl.GetSummary(ctx, &artifact.Artifact, []string{mimeType})
if err != nil {
log.Warningf("get scan summary of artifact %s@%s for %s failed, error:%v", artifact.RepositoryName, artifact.Digest, mimeType, err)
} else if len(overview) > 0 {
artifact.ScanOverview = overview
break
}
}
}
}

View File

@ -36,6 +36,7 @@ func (suite *VulAssemblerTestSuite) TestScannable() {
scanChecker: checker,
scanCtl: scanCtl,
withScanOverview: true,
mimeTypes: []string{"mimeType"},
}
mock.OnAnything(checker, "IsScannable").Return(true, nil)

View File

@ -36,6 +36,10 @@ import (
"github.com/goharbor/harbor/src/lib/log"
)
var (
baseProjectCtl = project.Ctl
)
// BaseAPI base API handler
type BaseAPI struct{}
@ -68,7 +72,7 @@ func (b *BaseAPI) HasProjectPermission(ctx context.Context, projectIDOrName inte
}
if projectName != "" {
p, err := project.Ctl.GetByName(ctx, projectName)
p, err := baseProjectCtl.GetByName(ctx, projectName)
if err != nil {
log.Errorf("failed to get project %s: %v", projectName, err)
return false

View File

@ -15,9 +15,17 @@
package handler
import (
"github.com/stretchr/testify/suite"
"net/url"
"os"
"testing"
"github.com/goharbor/harbor/src/controller/project"
projecttesting "github.com/goharbor/harbor/src/testing/controller/project"
"github.com/stretchr/testify/suite"
)
var (
projectCtlMock *projecttesting.Controller
)
type baseHandlerTestSuite struct {
@ -108,3 +116,15 @@ func (b *baseHandlerTestSuite) TestLinks() {
func TestBaseHandler(t *testing.T) {
suite.Run(t, &baseHandlerTestSuite{})
}
func TestMain(m *testing.M) {
projectCtlMock = &projecttesting.Controller{}
baseProjectCtl = projectCtlMock
exitVal := m.Run()
baseProjectCtl = project.Ctl
os.Exit(exitVal)
}

View File

@ -15,19 +15,14 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/url"
"reflect"
"strconv"
"strings"
"github.com/goharbor/harbor/src/controller/artifact"
"github.com/goharbor/harbor/src/controller/artifact/processor"
"github.com/goharbor/harbor/src/controller/scan"
"github.com/goharbor/harbor/src/lib"
"github.com/goharbor/harbor/src/lib/log"
"github.com/goharbor/harbor/src/pkg/scan/report"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
)
@ -46,49 +41,24 @@ const (
ScheduleNone = "None"
)
func boolValue(v *bool) bool {
if v != nil {
return *v
}
func parseScanReportMimeTypes(header *string) []string {
var mimeTypes []string
return false
}
func resolveVulnerabilitiesAddition(ctx context.Context, artifact *artifact.Artifact) (*processor.Addition, error) {
reports, err := scan.DefaultController.GetReport(ctx, artifact, []string{v1.MimeTypeNativeReport, v1.MimeTypeGenericVulnerabilityReport})
if err != nil {
return nil, err
}
vulnerabilities := make(map[string]interface{})
for _, rp := range reports {
// Resolve scan report data only when it is ready
if len(rp.Report) == 0 {
continue
}
vrp, err := report.ResolveData(rp.MimeType, []byte(rp.Report), report.WithArtifactDigest(rp.Digest))
if err != nil {
return nil, err
}
if v, ok := vulnerabilities[rp.MimeType]; ok {
r, err := report.Merge(rp.MimeType, v, vrp)
if err != nil {
return nil, err
if header != nil {
for _, mimeType := range strings.Split(*header, ",") {
mimeType = strings.TrimSpace(mimeType)
switch mimeType {
case v1.MimeTypeNativeReport, v1.MimeTypeGenericVulnerabilityReport:
mimeTypes = append(mimeTypes, mimeType)
}
vulnerabilities[rp.MimeType] = r
} else {
vulnerabilities[rp.MimeType] = vrp
}
}
content, _ := json.Marshal(vulnerabilities)
if len(mimeTypes) == 0 {
mimeTypes = append(mimeTypes, v1.MimeTypeNativeReport)
}
return &processor.Addition{
Content: content,
ContentType: "application/json",
}, nil
return mimeTypes
}
func unescapePathParams(params interface{}, fieldNames ...string) error {

View File

@ -63,34 +63,39 @@ func (suite *Suite) TearDownSuite() {
}
// DoReq ...
func (suite *Suite) DoReq(method string, url string, body io.Reader, contentTypes ...string) (*http.Response, error) {
func (suite *Suite) DoReq(method string, url string, body io.Reader, headers ...map[string]string) (*http.Response, error) {
req, err := http.NewRequest(method, suite.ts.URL+"/api/v2.0"+url, body)
if err != nil {
return nil, err
}
contentType := "application/json"
if len(contentTypes) > 0 {
contentType = contentTypes[0]
if len(headers) > 0 {
for key, value := range headers[0] {
req.Header.Set(key, value)
}
}
contentType := req.Header.Get("Content-Type")
if contentType == "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Content-Type", contentType)
return suite.tc.Do(req)
}
// Delete ...
func (suite *Suite) Delete(url string, contentTypes ...string) (*http.Response, error) {
return suite.DoReq(http.MethodDelete, url, nil, contentTypes...)
func (suite *Suite) Delete(url string, headers ...map[string]string) (*http.Response, error) {
return suite.DoReq(http.MethodDelete, url, nil, headers...)
}
// Get ...
func (suite *Suite) Get(url string, contentTypes ...string) (*http.Response, error) {
return suite.DoReq(http.MethodGet, url, nil, contentTypes...)
func (suite *Suite) Get(url string, headers ...map[string]string) (*http.Response, error) {
return suite.DoReq(http.MethodGet, url, nil, headers...)
}
// GetJSON ...
func (suite *Suite) GetJSON(url string, js interface{}) (*http.Response, error) {
res, err := suite.Get(url)
func (suite *Suite) GetJSON(url string, js interface{}, headers ...map[string]string) (*http.Response, error) {
res, err := suite.Get(url, headers...)
if err != nil {
return nil, err
}
@ -113,8 +118,8 @@ func (suite *Suite) GetJSON(url string, js interface{}) (*http.Response, error)
}
// Patch ...
func (suite *Suite) Patch(url string, body io.Reader, contentTypes ...string) (*http.Response, error) {
return suite.DoReq(http.MethodPatch, url, body, contentTypes...)
func (suite *Suite) Patch(url string, body io.Reader, headers ...map[string]string) (*http.Response, error) {
return suite.DoReq(http.MethodPatch, url, body, headers...)
}
// PatchJSON ...
@ -128,8 +133,8 @@ func (suite *Suite) PatchJSON(url string, js interface{}) (*http.Response, error
}
// Post ...
func (suite *Suite) Post(url string, body io.Reader, contentTypes ...string) (*http.Response, error) {
return suite.DoReq(http.MethodPost, url, body, contentTypes...)
func (suite *Suite) Post(url string, body io.Reader, headers ...map[string]string) (*http.Response, error) {
return suite.DoReq(http.MethodPost, url, body, headers...)
}
// PostJSON ...
@ -143,8 +148,8 @@ func (suite *Suite) PostJSON(url string, js interface{}) (*http.Response, error)
}
// Put ...
func (suite *Suite) Put(url string, body io.Reader, contentTypes ...string) (*http.Response, error) {
return suite.DoReq(http.MethodPut, url, body, contentTypes...)
func (suite *Suite) Put(url string, body io.Reader, headers ...map[string]string) (*http.Response, error) {
return suite.DoReq(http.MethodPut, url, body, headers...)
}
// PutJSON ...