Skip to content

Commit

Permalink
cleanup to limit scope to Get options
Browse files Browse the repository at this point in the history
  • Loading branch information
mildwonkey committed Oct 28, 2024
1 parent 736856a commit ecafc76
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 126 deletions.
8 changes: 0 additions & 8 deletions src/pkg/common/schemas/validation.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,6 @@
"type": "string",
"format": "uri"
},
"method": {
"type": "string",
"enum": ["Get", "Head", "Post", "PostForm"],
"default": "Get"
},
"body": {
"type": "string"
},
"parameters": {
"type": "object",
"additionalProperties": { "type": "string"}
Expand Down
89 changes: 29 additions & 60 deletions src/pkg/domains/api/api.go
Original file line number Diff line number Diff line change
@@ -1,81 +1,50 @@
package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"

"github.com/defenseunicorns/lula/src/types"
)

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.
transport := &http.Transport{}
if defaultOpts.Proxy != "" {
proxy, err := url.Parse(a.Spec.Options.Proxy)
if err != nil {
return nil, fmt.Errorf("error parsing proxy url: %s", err)
}
transport.Proxy = http.ProxyURL(proxy)
}

defaultClient := &http.Client{Transport: transport}
if defaultOpts.timeout != nil {
defaultClient.Timeout = *defaultOpts.timeout
}

for _, request := range a.Spec.Requests {
resp, err := defaultClient.Get(request.URL)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil,
fmt.Errorf("expected status code 200 but got %d", resp.StatusCode)
func (a ApiDomain) makeRequests(ctx context.Context) (types.DomainResources, error) {
select {
case <-ctx.Done():
return nil, fmt.Errorf("canceled: %s", ctx.Err())
default:
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 {
// This isn't likely to be nil in real usage, since CreateApiDomain
// parses and mutates specs.
defaultOpts = new(ApiOpts)
} else {
defaultOpts = a.Spec.Options
}

defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// configure the default HTTP client using any top-level Options. Individual
// requests with overrides will get bespoke clients.
defaultClient := clientFromOpts(defaultOpts)

contentType := resp.Header.Get("Content-Type")
if contentType == "application/json" {
for _, request := range a.Spec.Requests {
var responseType map[string]interface{}
var err error

var prettyBuff bytes.Buffer
err := json.Indent(&prettyBuff, body, "", " ")
if err != nil {
return nil, err
if request.Options == nil {
responseType, err = doHTTPReq(defaultClient, *request.reqURL, defaultOpts.Headers, request.reqParameters, responseType)
} else {
client := clientFromOpts(request.Options)
responseType, err = doHTTPReq(client, *request.reqURL, request.Options.Headers, request.reqParameters, responseType)
}
prettyJson := prettyBuff.String()

var tempData interface{}
err = json.Unmarshal([]byte(prettyJson), &tempData)
if err != nil {
return nil, err
}
collection[request.Name] = tempData

} else {
return nil, fmt.Errorf("content type %s is not supported", contentType)
collection[request.Name] = responseType
}
return collection, nil
}
return collection, nil
}
73 changes: 73 additions & 0 deletions src/pkg/domains/api/http_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package api

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

func doHTTPReq[T any](client http.Client, url url.URL, headers map[string]string, queryParameters url.Values, respTy T) (T, error) {
// append any query parameters.
q := url.Query()

for k, v := range queryParameters {
// using Add instead of set incase the input URL already had a query encoded
q.Add(k, strings.Join(v, ","))
}
// set the query to the encoded parameters
url.RawQuery = q.Encode()

req, err := http.NewRequest(http.MethodGet, url.String(), nil)
if err != nil {
return respTy, err
}

// add each header to the request
for k, v := range headers {
req.Header.Set(k, v)
}

// do the thing
res, err := client.Do(req)
if err != nil {
return respTy, err
}

if res == nil {
return respTy, fmt.Errorf("error: calling %s returned empty response", url.Redacted())
}
defer res.Body.Close()

responseData, err := io.ReadAll(res.Body)
if err != nil {
return respTy, err
}

if res.StatusCode != http.StatusOK {
return respTy, fmt.Errorf("expected status code 200 but got %d", res.StatusCode)
}

var responseObject T
err = json.Unmarshal(responseData, &responseObject)

if err != nil {
return respTy, fmt.Errorf("error unmarshaling response: %w", err)
}

return responseObject, nil
}

func clientFromOpts(opts *ApiOpts) http.Client {
transport := &http.Transport{}
if opts.proxyURL != nil {
transport.Proxy = http.ProxyURL(opts.proxyURL)
}
c := http.Client{Transport: transport}
if opts.timeout != nil {
c.Timeout = *opts.timeout
}
return c
}
35 changes: 15 additions & 20 deletions src/pkg/domains/api/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package api
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
)
Expand All @@ -30,35 +29,31 @@ func validateAndMutateSpec(spec *ApiSpec) (errs error) {
errs = errors.Join(errs, err)
}

for _, request := range spec.Requests {
if request.Name == "" {
for i := range spec.Requests {
if spec.Requests[i].Name == "" {
errs = errors.Join(errs, errors.New("request name cannot be empty"))
}
if request.URL == "" {
if spec.Requests[i].URL == "" {
errs = errors.Join(errs, errors.New("request url cannot be empty"))
}
url, err := url.Parse(request.URL)
reqUrl, err := url.Parse(spec.Requests[i].URL)
if err != nil {
errs = errors.Join(errs, errors.New("invalid request url"))
} else {
request.reqURL = url
spec.Requests[i].reqURL = reqUrl
}
if 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 spec.Requests[i].Params != nil {
queryParameters := url.Values{}
for k, v := range spec.Requests[i].Params {
queryParameters.Add(k, v)
}
spec.Requests[i].reqParameters = queryParameters
}
if request.Options != nil {
validateAndMutateOptions(request.Options)
if spec.Requests[i].Options != nil {
err = validateAndMutateOptions(spec.Requests[i].Options)
if err != nil {
errs = errors.Join(errs, err)
}
}
}

Expand Down
1 change: 0 additions & 1 deletion src/pkg/domains/api/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,4 @@ func TestValidateAndMutateSpec(t *testing.T) {
}
})
}

}
18 changes: 9 additions & 9 deletions src/pkg/domains/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,30 +38,30 @@ func (a ApiDomain) IsExecutable() bool {
// ApiSpec contains a list of API requests
type ApiSpec struct {
Requests []Request `mapstructure:"requests" json:"requests" yaml:"requests"`
// Opts will be applied to all requests, except those which have their own specified ApiOpts
// Opts will be applied to all requests, except those which have their own
// specified ApiOpts
Options *ApiOpts `mapstructure:"options" json:"options,omitempty" yaml:"options,omitempty"`
}

// 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"`
Body string `json:"body,omitempty" yaml:"body,omitempty"`
Params map[string]string `json:"parameters,omitempty" yaml:"parameters,omitempty"`
// ApiOpts specific to this request
// ApiOpts specific to this request. If ApiOpts is present, values in the
// ApiSpec-level Options are ignored for this request.
Options *ApiOpts `json:"options,omitempty" yaml:"options,omitempty"`

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

type ApiOpts struct {
// Timeout in seconds
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"`
Proxy string `json:"proxy,omitempty" yaml:"proxy,omitempty"`
Headers []string `json:"headers,omitempty" yaml:"headers,omitempty"`
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"`
Proxy string `json:"proxy,omitempty" yaml:"proxy,omitempty"`
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"`

// internally-managed options
timeout *time.Duration
Expand Down
Loading

0 comments on commit ecafc76

Please sign in to comment.