Skip to content

Commit

Permalink
Merge pull request #10 from CrowdStrike/chore/add_schema_helpers
Browse files Browse the repository at this point in the history
chore: add fdktest with schema helper fns
  • Loading branch information
jsteenb2 committed Nov 7, 2023
2 parents 1b63936 + c9d6dd5 commit 1847746
Show file tree
Hide file tree
Showing 7 changed files with 349 additions and 10 deletions.
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,74 @@ func newHandler(_ context.Context, cfg config) fdk.Handler {

---

## Working with Request and Response Schemas

Within the fdktest pkg, we maintain test funcs for validating a schema and its integration
with a handler. Example:

```go
package somefn_test

import (
"context"
"net/http"
"testing"

fdk "github.com/CrowdStrike/foundry-fn-go"
"github.com/CrowdStrike/foundry-fn-go/fdktest"
)

func TestHandlerIntegration(t *testing.T) {
reqSchema := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"postalCode": {
"type": "string",
"description": "The person's first name.",
"pattern": "\\d{5}"
},
"optional": {
"type": "string",
"description": "The person's last name."
}
},
"required": [
"postalCode"
]
}`

respSchema := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"foo": {
"type": "string",
"description": "The person's first name.",
"enum": ["bar"]
}
},
"required": [
"foo"
]
}`
handler := fdk.HandlerFn(func(ctx context.Context, r fdk.Request) fdk.Response {
return fdk.Response{Body: fdk.JSON(map[string]string{"foo": "bar"})}
})

req := fdk.Request{
URL: "/",
Method: http.MethodPost,
Body: json.RawMessage(`{"postalCode": "55755"}`),
}

err := fdktest.HandlerSchemaOK(handler, req, reqSchema, respSchema)
if err != nil {
t.Fatal("unexpected err: ", err)
}
}

```

<p align="center"><img src="https://raw.githubusercontent.com/CrowdStrike/falconpy/main/docs/asset/cs-logo-footer.png"><BR/><img width="250px" src="https://raw.githubusercontent.com/CrowdStrike/falconpy/main/docs/asset/adversary-red-eyes.png"></P>
<h3><P align="center">WE STOP BREACHES</P></h3>
79 changes: 79 additions & 0 deletions fdktest/sdk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package fdktest

import (
"context"
"errors"
"fmt"
"time"

"github.com/xeipuuv/gojsonschema"

fdk "github.com/CrowdStrike/foundry-fn-go"
)

// SchemaOK validates that the provided schema conforms to JSON Schema.
func SchemaOK(schema string) error {
schemaLoader := gojsonschema.NewSchemaLoader()
_, err := schemaLoader.Compile(gojsonschema.NewStringLoader(schema))
return err
}

// HandlerSchemaOK validates the handler and schema integrations.
func HandlerSchemaOK(h fdk.Handler, r fdk.Request, reqSchema, respSchema string) error {
if reqSchema != "" {
if err := validateSchema(reqSchema, r.Body); err != nil {
return fmt.Errorf("failed request schema validation: %w", err)
}
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

resp := h.Handle(ctx, r)

if respSchema != "" {
b, err := resp.Body.MarshalJSON()
if err != nil {
return fmt.Errorf("failed to marshal response payload: %w", err)
}

err = validateSchema(respSchema, b)
if err != nil {
return fmt.Errorf("failed response schema validation: %w", err)
}
}

return nil
}

func validateSchema(schema string, payload []byte) error {
result, err := gojsonschema.Validate(
gojsonschema.NewStringLoader(schema),
gojsonschema.NewBytesLoader(payload),
)
if err != nil {
return fmt.Errorf("failed to validate document against schema: %w", err)
}

var errs []error
for _, resErr := range result.Errors() {
errMsg := resErr.String()

// sometimes the library prefixes the string message with (root): which is confusing and is best to defer
// to the description message in this case
id := resErr.Field()
if len(resErr.Details()) > 0 && resErr.Details()["property"] != nil {
id = fmt.Sprintf("%s.%s", id, resErr.Details()["property"])
}
if resErr.Field() == "(root)" {
errMsg = resErr.Description()
if prop, ok := resErr.Details()["property"]; ok {
id = prop.(string)
}
}
errs = append(errs, errors.New(errMsg))
}

return errors.Join(errs...)

}
190 changes: 190 additions & 0 deletions fdktest/sdk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package fdktest_test

import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"

fdk "github.com/CrowdStrike/foundry-fn-go"
"github.com/CrowdStrike/foundry-fn-go/fdktest"
)

func TestHandlerSchemaOK(t *testing.T) {
type (
inputs struct {
handler fdk.Handler
req fdk.Request
reqSchema string
respSchema string
}

wantFn func(t *testing.T, err error)
)

tests := []struct {
name string
input inputs
want wantFn
}{
{
name: "with valid req and resp schema and compliant req and resp bodies should pass",
input: inputs{
handler: fdk.HandlerFn(func(ctx context.Context, r fdk.Request) fdk.Response {
return fdk.Response{Body: fdk.JSON(map[string]string{"foo": "bar"})}
}),
req: fdk.Request{
URL: "/",
Method: http.MethodPost,
Body: json.RawMessage(`{"postalCode": "55755"}`),
},
reqSchema: `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"postalCode": {
"type": "string",
"description": "The person's first name.",
"pattern": "\\d{5}"
},
"optional": {
"type": "string",
"description": "The person's last name."
}
},
"required": [
"postalCode"
]
}`,
respSchema: `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"foo": {
"type": "string",
"description": "The person's first name.",
"enum": ["bar"]
}
},
"required": [
"foo"
]
}`,
},
want: mustNoErr,
},
{
name: "with valid req schema and invalid request body should fail",
input: inputs{
handler: fdk.HandlerFn(func(ctx context.Context, r fdk.Request) fdk.Response {
return fdk.Response{Body: fdk.JSON(map[string]string{"foo": "bar"})}
}),
req: fdk.Request{
URL: "/",
Method: http.MethodPost,
Body: json.RawMessage(`{"postalCode": "5"}`),
},
reqSchema: `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"postalCode": {
"type": "string",
"description": "The person's first name.",
"pattern": "\\d{5}"
}
},
"required": [
"postalCode"
]
}`,
},
want: func(t *testing.T, err error) {
errMsg := "failed request schema validation: postalCode: Does not match pattern '\\d{5}'"
if err == nil || !strings.HasSuffix(err.Error(), errMsg) {
t.Fatal("did not get expected error: ", err)
}
},
},
{
name: "with valid resp schema and invalid response body should fail",
input: inputs{
handler: fdk.HandlerFn(func(ctx context.Context, r fdk.Request) fdk.Response {
return fdk.Response{Body: fdk.JSON(map[string]string{"foo": "NOT BAR"})}
}),
req: fdk.Request{
URL: "/",
Method: http.MethodPost,
},
respSchema: `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"foo": {
"type": "string",
"description": "The person's first name.",
"enum": ["bar"]
}
},
"required": [
"foo"
]
}`,
},
want: func(t *testing.T, err error) {
errMsg := "failed response schema validation: foo: foo must be one of the following: \"bar\""
if err == nil || !strings.HasSuffix(err.Error(), errMsg) {
t.Fatal("did not get expected error: ", err)
}
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := fdktest.HandlerSchemaOK(tt.input.handler, tt.input.req, tt.input.reqSchema, tt.input.respSchema)
tt.want(t, err)
})
}
}

func TestSchemaOK(t *testing.T) {
schema := `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"postalCode": {
"type": "string",
"description": "The person's first name.",
"pattern": "\\d{5}"
},
"optional": {
"type": "string",
"description": "The person's last name."
}
},
"required": [
"postalCode"
]
}`

t.Run("with valid schema should pass", func(t *testing.T) {
err := fdktest.SchemaOK(schema)
mustNoErr(t, err)
})

t.Run("with invalid shcema should fail", func(t *testing.T) {
invalidScheam := schema[15:]
err := fdktest.SchemaOK(invalidScheam)
if err == nil {
t.Fatal("expected validation error")
}
})
}

func mustNoErr(t *testing.T, err error) {
if err != nil {
t.Fatal("unexpected error: " + err.Error())
}
}
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ module github.com/CrowdStrike/foundry-fn-go

go 1.21

require github.com/crowdstrike/gofalcon v0.4.2
require (
github.com/crowdstrike/gofalcon v0.4.2
github.com/xeipuuv/gojsonschema v1.2.0
)

require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
Expand All @@ -26,6 +29,8 @@ require (
github.com/oklog/ulid v1.3.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
go.mongodb.org/mongo-driver v1.11.3 // indirect
go.opentelemetry.io/otel v1.14.0 // indirect
go.opentelemetry.io/otel/trace v1.14.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg=
go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng=
Expand Down
1 change: 0 additions & 1 deletion mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ func (m *Mux) Put(route string, h Handler) {
}

func (m *Mux) registerRoute(method, route string, h Handler) {
// TODO(berg): add additional checks for validity of method/route pairs
if route == "" {
panic("route must be provided")
}
Expand Down

0 comments on commit 1847746

Please sign in to comment.