Skip to content

Commit

Permalink
tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mildwonkey committed Oct 28, 2024
1 parent 383c78c commit 0e4a1d6
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 27 deletions.
5 changes: 4 additions & 1 deletion src/pkg/domains/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ import (
func (a ApiDomain) makeRequests(_ context.Context) (types.DomainResources, error) {
collection := make(map[string]interface{}, 0)

// defaultOpts apply to all requests, but may be overridden by adding an
// options block to an individual request.
var defaultOpts *ApiOpts
if a.Spec.Options == nil {
defaultOpts = new(ApiOpts)
} else {
defaultOpts = a.Spec.Options
}

// configure the default HTTP client using any top-level Options. Individual requests with overrides will get bespoke clients.
// configure the default HTTP client using any top-level Options. Individual
// requests with overrides will get bespoke clients.
transport := &http.Transport{}
if defaultOpts.Proxy != "" {
proxy, err := url.Parse(a.Spec.Options.Proxy)
Expand Down
80 changes: 61 additions & 19 deletions src/pkg/domains/api/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,92 @@ package api
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
)

var defaultTimeout = 30 * time.Second

// validateAndMutateSpec validates the spec values and applies any defaults or
// other mutations necessary. The original values are not modified.
// other mutations or normalizations necessary. The original values are not modified.
// validateAndMutateSpec will validate the entire object and may return multiple
// errors.
func validateAndMutateSpec(spec *ApiSpec) (errs error) {
if spec == nil {
return errors.New("spec is required")
}
if len(spec.Requests) == 0 {
errs = errors.Join(errors.New("some requests must be specified"))
errs = errors.Join(errs, errors.New("some requests must be specified"))
}

if spec.Options != nil {
if spec.Options.Timeout != "" {
duration, err := time.ParseDuration(spec.Options.Timeout)
if err != nil {
errs = errors.Join(fmt.Errorf("invalid wait timeout string: %s", spec.Options.Timeout))
}
spec.Options.timeout = &duration
}
} else {
// add an Options struct with a sane default timeout
if spec.Options == nil {
spec.Options = &ApiOpts{}
}
if spec.Options.timeout == nil {
d := 30 * time.Second
spec.Options.timeout = &d
err := validateAndMutateOptions(spec.Options)
if err != nil {
errs = errors.Join(errs, err)
}

for _, request := range spec.Requests {
if request.Name == "" {
errs = errors.Join(errors.New("request name cannot be empty"))
errs = errors.Join(errs, errors.New("request name cannot be empty"))
}
if request.URL == "" {
errs = errors.Join(errors.New("request url cannot be empty"))
errs = errors.Join(errs, errors.New("request url cannot be empty"))
}
url, err := url.Parse(request.URL)
if err != nil {
errs = errors.Join(errs, errors.New("invalid request url"))
} else {
request.reqURL = url
}
if request.Method != "" {
if request.Method != "Get" && request.Method != "Head" && request.Method != "Post" && request.Method != "PostForm" {
errs = errors.Join(fmt.Errorf("unsupported method: %s", request.Method))
switch m := request.Method; m {
case "Get", "get", "GET":
request.method = http.MethodGet
case "Head", "head", "HEAD":
request.method = http.MethodHead
case "Post", "post", "POST":
request.method = http.MethodPost
case "Postform", "postform", "POSTFORM": // PostForm is not a separate HTTP method, but it uses a different function (http.PostForm)
request.method = "POSTFORM"
default:
errs = errors.Join(errs, fmt.Errorf("unsupported method: %s", request.Method))
}
}
if request.Options != nil {
validateAndMutateOptions(request.Options)
}
}

return errs
}

func validateAndMutateOptions(opts *ApiOpts) (errs error) {
if opts == nil {
return errors.New("opts cannot be nil")
}

if opts.Timeout != "" {
duration, err := time.ParseDuration(opts.Timeout)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("invalid wait timeout string: %s", opts.Timeout))
}
opts.timeout = &duration
}

if opts.timeout == nil {
opts.timeout = &defaultTimeout
}

if opts.Proxy != "" {
proxyURL, err := url.Parse(opts.Proxy)
if err != nil {
// not logging the input URL in case it has embedded credentials
errs = errors.Join(errs, errors.New("invalid proxy string"))
}
opts.proxyURL = proxyURL
}

return errs
Expand Down
131 changes: 131 additions & 0 deletions src/pkg/domains/api/spec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package api

import (
"net/url"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
)

func TestValidateAndMutateOptions(t *testing.T) {
var testTimeout = 10 * time.Second
var zeroTimeout = 0 * time.Second
tests := map[string]struct {
input, want *ApiOpts
expectErrs int
}{
"error: nil input": {
nil,
nil,
1,
},
"empty input, defaults are populated": {
&ApiOpts{},
&ApiOpts{
timeout: &defaultTimeout,
},
0,
},
"valid input, internal fields populated": {
&ApiOpts{
Timeout: "10s",
Proxy: "https://my.proxy",
},
&ApiOpts{
Timeout: "10s",
Proxy: "https://my.proxy",
timeout: &testTimeout,
proxyURL: &url.URL{
Scheme: "https",
Host: "my.proxy",
},
},
0,
},
"several errors": {
&ApiOpts{
Proxy: "close//butinvalid\n\r",
Timeout: "more nonsense",
},
&ApiOpts{
Proxy: "close//butinvalid\n\r",
Timeout: "more nonsense",
timeout: &zeroTimeout, // there was an error, so this is set to zero value
},
2,
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
err := validateAndMutateOptions(test.input)
if err != nil {
if test.expectErrs == 0 {
t.Fatalf("expected success, got error(s) %s", err)
} else if uw, ok := err.(interface{ Unwrap() []error }); ok {
errs := uw.Unwrap()
require.Equal(t, test.expectErrs, len(errs))
} else {
if test.expectErrs != 1 {
t.Fatalf("expected multiple errors, got one: %s", err)
}
}
} else {
if test.expectErrs != 0 {
t.Fatal("expected error(s), got success")
}
}

if diff := cmp.Diff(test.want, test.input, cmp.AllowUnexported(ApiOpts{})); diff != "" {
t.Fatalf("wrong result(-got +want):\n%s\n", diff)
}
})
}
}

func TestValidateAndMutateSpec(t *testing.T) {
tests := map[string]struct {
input, want *ApiSpec
expectErrs int
}{
"error: nil input": {
nil, nil, 1,
},
"error: empty input, nil options": {
&ApiSpec{},
&ApiSpec{
Options: &ApiOpts{timeout: &defaultTimeout},
},
1,
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
err := validateAndMutateSpec(test.input)
if err != nil {
if test.expectErrs == 0 {
t.Fatalf("expected success, got error(s) %s", err)
} else if uw, ok := err.(interface{ Unwrap() []error }); ok {
errs := uw.Unwrap()
require.Equal(t, test.expectErrs, len(errs))
} else {
if test.expectErrs != 1 {
t.Fatalf("expected multiple errors, got one: %s", err)
}
}
} else {
if test.expectErrs != 0 {
t.Fatal("expected error(s), got success")
}
}

if diff := cmp.Diff(test.want, test.input, cmp.AllowUnexported(ApiSpec{}, ApiOpts{})); diff != "" {
t.Fatalf("wrong result(-got +want):\n%s\n", diff)
}
})
}

}
19 changes: 12 additions & 7 deletions src/pkg/domains/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"net/url"
"time"

"github.com/defenseunicorns/lula/src/types"
Expand Down Expand Up @@ -43,14 +44,17 @@ type ApiSpec struct {

// Request is a single API request
type Request struct {
Name string `json:"name" yaml:"name"`
URL string `json:"url" yaml:"url"`
Method string `json:"method,omitempty" yaml:"method,omitempty"`
IsExecutable bool `json:"executable,omitempty" yaml:"executable,omitempty"`
Body string `json:"body,omitempty" yaml:"body,omitempty"`
Params map[string]string `json:"parameters,omitempty" yaml:"parameters,omitempty"`
Name string `json:"name" yaml:"name"`
URL string `json:"url" yaml:"url"`
Method string `json:"method,omitempty" yaml:"method,omitempty"`
Body string `json:"body,omitempty" yaml:"body,omitempty"`
Params map[string]string `json:"parameters,omitempty" yaml:"parameters,omitempty"`
// ApiOpts specific to this request
Options *ApiOpts `json:"options,omitempty" yaml:"options,omitempty"`

// internally-managed options
reqURL *url.URL
method string
}

type ApiOpts struct {
Expand All @@ -60,5 +64,6 @@ type ApiOpts struct {
Headers []string `json:"headers,omitempty" yaml:"headers,omitempty"`

// internally-managed options
timeout *time.Duration
timeout *time.Duration
proxyURL *url.URL
}

0 comments on commit 0e4a1d6

Please sign in to comment.