Skip to content

Commit

Permalink
Add multitype and runtime validations (#16)
Browse files Browse the repository at this point in the history
* add multitype and runtime validations

* fix bugs and improve test infra
  • Loading branch information
ori-shalom committed May 21, 2023
1 parent d33305a commit e8e010b
Show file tree
Hide file tree
Showing 71 changed files with 2,174 additions and 804 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/lint-test-cover.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ jobs:
- name: Install Golang
uses: actions/setup-go@v3
with:
go-version: 1.18
go-version: 1.20.4
check-latest: true

- name: Print Go Version
run: go version

- name: Cache go modules
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: |
~/.cache/go-build
Expand All @@ -46,7 +46,7 @@ jobs:
- name: Test and produce coverage profile
id: coverage
run: echo "::set-output name=total::$(make test-coverage)"
run: echo "total=$(make test-coverage)" >> $GITHUB_OUTPUT

- name: Update README.md with coverage
run: |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.DS_Store

# Jet Brains
.idea

Expand Down
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ COVERAGE_PROFILE_FILE := profile.cov
COVERAGE_HTML_FILE := coverage.html
SHELL := bash -eo pipefail -c


$(COVERAGE_PROFILE_FILE): $(shell find router examples)
@go test -v -race -failfast \
-coverprofile=$@ \
Expand All @@ -17,13 +16,18 @@ test-coverage: $(COVERAGE_PROFILE_FILE)
$(COVERAGE_HTML_FILE): $(COVERAGE_PROFILE_FILE)
@go tool cover -html=$(COVERAGE_PROFILE_FILE) -o $(COVERAGE_HTML_FILE)

FILE ?= file0

.PHONY: show-coverage
show-coverage: $(COVERAGE_HTML_FILE)
$(eval ANCHOR:=$(shell cat $(COVERAGE_HTML_FILE) | grep -E '<option value=".*">.*</option>' | grep $(FILE) | sed -n 's/^.*value="\(.*\)".*$$/\1/p'))
@sed -E -i '' 's/select\("file[0-9]+"\);/select("$(ANCHOR)");/g' $(COVERAGE_HTML_FILE)
@open $(COVERAGE_HTML_FILE)


.PHONY: test
test:
@go test -race -failfast ./...
@go test -race ./...

.PHONY: clean-coverage
clean-coverage:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Cellotape - Beta - OpenAPI Router for Go

![](https://badgen.net/badge/coverage/25/green?icon=github)
![99.2%](https://badgen.net/badge/coverage/99.2%25/green?icon=github)

Cellotape requires Go 1.18 or above.

Expand Down
1 change: 0 additions & 1 deletion examples/hello_world_example/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ func TestHelloWorldExample(t *testing.T) {
handler, err := router.NewOpenAPIRouter(spec).
WithOperation("greet", api.GreetOperationHandler).
AsHandler()
fmt.Println(err)
require.NoError(t, err)

ts := httptest.NewServer(handler)
Expand Down
2 changes: 1 addition & 1 deletion examples/todo_list_app_example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func mainHandler() error {
return err
}
port := 8080
fmt.Printf("Starting HTTP server on port %d\n", port)
log.Printf("Starting HTTP server on port %d\n", port)
if err = http.ListenAndServe(fmt.Sprintf(":%d", port), handler); err != nil {
return err
}
Expand Down
63 changes: 61 additions & 2 deletions examples/todo_list_app_example/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestGetAllTasks(t *testing.T) {
assert.JSONEq(t, `{
"results": [],
"page": 0,
"pageSize": 0,
"pageSize": 10,
"isLast": true
}`, string(response))
}
Expand All @@ -52,7 +52,7 @@ func TestCreateNewTaskAndGetIt(t *testing.T) {
req, err := http.NewRequest("POST", fmt.Sprintf("%s/tasks", ts.URL), request)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer secret")
//req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", "application/json")
client := http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
Expand All @@ -77,6 +77,65 @@ func TestCreateNewTaskAndGetIt(t *testing.T) {
assert.JSONEq(t, taskJson, string(data))
}

func TestRequestQueryParamViolateSchemaValidations(t *testing.T) {
ts := initAPI(t)
defer ts.Close()
req, err := http.NewRequest("GET", fmt.Sprintf("%s/tasks?pageSize=30", ts.URL), nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer secret")
client := http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
response, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t,
`invalid request query param. parameter "pageSize" in query has an error: number must be at most 20`,
string(response))
}

func TestRequestPathParamViolateSchemaValidations(t *testing.T) {
ts := initAPI(t)
defer ts.Close()
req, err := http.NewRequest("GET", fmt.Sprintf("%s/tasks/123", ts.URL), nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer secret")
client := http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
response, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t,
`invalid request path param. parameter "id" in path has an error: minimum string length is 36`,
string(response))
}

func TestRequestBodyViolateSchemaValidations(t *testing.T) {
ts := initAPI(t)
defer ts.Close()
taskJson := `{
"summary": "code first approach",
"description": "add support for code first approach",
"status": "archived"
}`
request := bytes.NewBufferString(taskJson)

req, err := http.NewRequest("POST", fmt.Sprintf("%s/tasks", ts.URL), request)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", "application/json")
client := http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
response, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t,
`invalid request body. request body has an error: doesn't match schema #/components/schemas/Task: value "archived" is not one of the allowed values`,
string(response))
}

func initAPI(t *testing.T) *httptest.Server {
spec, err := router.NewSpecFromData(specData)
require.NoError(t, err)
Expand Down
3 changes: 2 additions & 1 deletion examples/todo_list_app_example/middlewares/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import (

"github.com/piiano/cellotape/examples/todo_list_app_example/models"
r "github.com/piiano/cellotape/router"
"github.com/piiano/cellotape/router/utils"
)

const token = "secret"

var authHeader = fmt.Sprintf("Bearer %s", token)

var AuthMiddleware = r.NewHandler(func(c *r.Context, req r.Request[r.Nil, r.Nil, r.Nil]) (r.Response[authResponses], error) {
var AuthMiddleware = r.NewHandler(func(c *r.Context, req r.Request[utils.Nil, utils.Nil, utils.Nil]) (r.Response[authResponses], error) {
if req.Headers.Get("Authorization") != authHeader {
return r.SendJSON(authResponses{Unauthorized: models.HttpError{
Error: "Unauthorized",
Expand Down
8 changes: 4 additions & 4 deletions examples/todo_list_app_example/middlewares/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import (
r "github.com/piiano/cellotape/router"
)

var LoggerMiddleware = r.NewHandler(loggerHandler)
var LoggerMiddleware = r.RawHandler(loggerHandler)

func loggerHandler(c *r.Context, request r.Request[r.Nil, r.Nil, r.Nil]) (r.Response[any], error) {
func loggerHandler(c *r.Context) error {
start := time.Now()
response, err := c.Next()
duration := time.Since(start)
if err != nil {
log.Printf("[ERROR] error occurred: %s. - %s - [%s] %s\n", err.Error(), duration, c.Request.Method, c.Request.URL.Path)
return r.Response[any]{}, nil
return err
}
log.Printf("[INFO] (status %d | %d bytes | %s) - [%s] %s\n", response.Status, len(response.Body), duration, c.Request.Method, c.Request.URL.Path)
return r.Response[any]{}, nil
return nil
}
3 changes: 2 additions & 1 deletion examples/todo_list_app_example/middlewares/powered_by.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package middlewares

import (
r "github.com/piiano/cellotape/router"
"github.com/piiano/cellotape/router/utils"
)

var PoweredByMiddleware = r.NewHandler(poweredByHandler)

func poweredByHandler(c *r.Context, _ r.Request[r.Nil, r.Nil, r.Nil]) (r.Response[any], error) {
func poweredByHandler(c *r.Context, _ r.Request[utils.Nil, utils.Nil, utils.Nil]) (r.Response[any], error) {
c.Writer.Header().Add("X-Powered-By", "Piiano OpenAPI Router")
_, err := c.Next()
return r.Response[any]{}, err
Expand Down
2 changes: 2 additions & 0 deletions examples/todo_list_app_example/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ components:
Id:
type: string
format: uuid
minLength: 36
maxLength: 36
nullable: false
Identifiable:
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import (
m "github.com/piiano/cellotape/examples/todo_list_app_example/models"
"github.com/piiano/cellotape/examples/todo_list_app_example/services"
r "github.com/piiano/cellotape/router"
"github.com/piiano/cellotape/router/utils"
)

func createNewTaskOperation(tasks services.TasksService) r.Handler {
return r.NewHandler(func(c *r.Context, request r.Request[m.Task, r.Nil, r.Nil]) (r.Response[createNewTaskResponses], error) {
return r.NewHandler(func(c *r.Context, request r.Request[m.Task, utils.Nil, utils.Nil]) (r.Response[createNewTaskResponses], error) {
id := tasks.CreateTask(request.Body)
return r.SendOKJSON(createNewTaskResponses{OK: m.Identifiable{ID: id}}), nil
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import (
m "github.com/piiano/cellotape/examples/todo_list_app_example/models"
"github.com/piiano/cellotape/examples/todo_list_app_example/services"
r "github.com/piiano/cellotape/router"
"github.com/piiano/cellotape/router/utils"
)

func deleteTaskByIDOperation(tasks services.TasksService) r.Handler {
return r.NewHandler(func(_ *r.Context, request r.Request[r.Nil, idPathParam, r.Nil]) (r.Response[deleteTaskByIDResponses], error) {
return r.NewHandler(func(_ *r.Context, request r.Request[utils.Nil, idPathParam, utils.Nil]) (r.Response[deleteTaskByIDResponses], error) {
id, err := uuid.Parse(request.PathParams.ID)
if err != nil {
return r.SendJSON(deleteTaskByIDResponses{
Expand All @@ -35,7 +36,7 @@ func deleteTaskByIDOperation(tasks services.TasksService) r.Handler {
}

type deleteTaskByIDResponses struct {
NoContent r.Nil `status:"204"`
NoContent utils.Nil `status:"204"`
BadRequest m.HttpError `status:"400"`
Gone m.HttpError `status:"410"`
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import (
m "github.com/piiano/cellotape/examples/todo_list_app_example/models"
"github.com/piiano/cellotape/examples/todo_list_app_example/services"
r "github.com/piiano/cellotape/router"
"github.com/piiano/cellotape/router/utils"
)

func getTaskByIDOperation(tasks services.TasksService) r.Handler {
return r.NewHandler(func(_ *r.Context, request r.Request[r.Nil, idPathParam, r.Nil]) (r.Response[getTaskByIDResponses], error) {
return r.NewHandler(func(_ *r.Context, request r.Request[utils.Nil, idPathParam, utils.Nil]) (r.Response[getTaskByIDResponses], error) {
id, err := uuid.Parse(request.PathParams.ID)
if err != nil {
return r.SendJSON(getTaskByIDResponses{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import (
m "github.com/piiano/cellotape/examples/todo_list_app_example/models"
"github.com/piiano/cellotape/examples/todo_list_app_example/services"
r "github.com/piiano/cellotape/router"
"github.com/piiano/cellotape/router/utils"
)

func getTasksPageOperation(tasks services.TasksService) r.Handler {
return r.NewHandler(func(_ *r.Context, request r.Request[r.Nil, r.Nil, paginationQueryParams]) (r.Response[getTasksPageResponses], error) {
return r.NewHandler(func(_ *r.Context, request r.Request[utils.Nil, utils.Nil, paginationQueryParams]) (r.Response[getTasksPageResponses], error) {
tasksPage := tasks.GetTasksPage(request.QueryParams.Page, request.QueryParams.PageSize)
return r.SendOKJSON(getTasksPageResponses{OK: tasksPage}, http.Header{"Cache-Control": {"max-age=10"}}), nil
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import (
m "github.com/piiano/cellotape/examples/todo_list_app_example/models"
"github.com/piiano/cellotape/examples/todo_list_app_example/services"
r "github.com/piiano/cellotape/router"
"github.com/piiano/cellotape/router/utils"
)

func updateTaskByIDOperation(tasks services.TasksService) r.Handler {
return r.NewHandler(func(_ *r.Context, request r.Request[m.Task, idPathParam, r.Nil]) (r.Response[updateTaskByIDResponses], error) {
return r.NewHandler(func(_ *r.Context, request r.Request[m.Task, idPathParam, utils.Nil]) (r.Response[updateTaskByIDResponses], error) {
id, err := uuid.Parse(request.PathParams.ID)
if err != nil {
return r.SendJSON(updateTaskByIDResponses{
Expand All @@ -35,7 +36,7 @@ func updateTaskByIDOperation(tasks services.TasksService) r.Handler {
}

type updateTaskByIDResponses struct {
NoContent r.Nil `status:"204"`
NoContent utils.Nil `status:"204"`
BadRequest m.HttpError `status:"400"`
NotFound m.HttpError `status:"404"`
}
14 changes: 6 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
module github.com/piiano/cellotape

go 1.18
go 1.20

retract (
v1.0.0 // Published accidentally.
v2.0.0 // Published accidentally.
)
retract v1.0.0 // Published accidentally.

require (
github.com/getkin/kin-openapi v0.94.0
github.com/getkin/kin-openapi v0.112.0
github.com/gin-gonic/gin v1.7.7
github.com/google/uuid v1.3.0
github.com/invopop/jsonschema v0.4.0
github.com/julienschmidt/httprouter v1.3.0
github.com/stretchr/testify v1.7.1
github.com/stretchr/testify v1.8.1
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/swag v0.19.5 // indirect
github.com/go-playground/locales v0.14.0 // indirect
Expand All @@ -27,12 +23,14 @@ require (
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.7 // indirect
github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect
github.com/invopop/yaml v0.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd // indirect
Expand Down
Loading

0 comments on commit e8e010b

Please sign in to comment.