Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Variadic route options #176

Merged
merged 20 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ fmt:

lint:
which golangci-lint || go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run
golangci-lint run --fix ./...

lint-markdown:
markdownlint --ignore documentation/node_modules --dot .
Expand Down
33 changes: 26 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,20 +145,39 @@ func (r *MyInput) InTransform(context.Context) error {
```go
package main

import "github.com/go-fuego/fuego"
import (
"github.com/go-fuego/fuego"
"github.com/go-fuego/fuego/option"
"github.com/go-fuego/fuego/param"
)

func main() {
s := fuego.NewServer()

// Custom OpenAPI options that cannot be deduced by the controller signature
fuego.Post(s, "/", myController).
Description("This route does something").
Summary("This is my summary").
Tags("MyTag"). // A tag is set by default according to the return type (can be deactivated)
Deprecated()
// Custom OpenAPI options
fuego.Post(s, "/", myController
option.Description("This route does something..."),
option.Summary("This is my summary"),
option.Tags("MyTag"), // A tag is set by default according to the return type (can be deactivated)
option.Deprecated(), // Marks the route as deprecated in the OpenAPI spec

option.Query("name", "Declares a query parameter with default value", param.Default("Carmack")),
option.Header("Authorization", "Bearer token", param.Required()),
optionPagination,
optionCustomBehavior,
)

s.Run()
}

var optionPagination = option.Group(
option.QueryInt("page", "Page number", param.Default(1), param.Example("1st page", 1), param.Example("42nd page", 42)),
option.QueryInt("perPage", "Number of items per page"),
)

var optionCustomBehavior = func(r *fuego.BaseRoute) {
r.XXX = "YYY"
}
```

### Std lib compatibility
Expand Down
2 changes: 2 additions & 0 deletions check-all-modules.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/bin/bash

set -euo pipefail

mods=$(go list -f '{{.Dir}}' -m)
for mod in $mods; do
cd "$mod"
Expand Down
6 changes: 3 additions & 3 deletions cmd/fuego/commands/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func TestCreateController(t *testing.T) {
require.NoError(t, err)
require.Contains(t, res, "package controller")
require.Contains(t, res, `fuego.Get(booksGroup, "/{id}", rs.getBooks)`)
require.Contains(t, res, `func (rs BooksRessources) postBooks(c *fuego.ContextWithBody[BooksCreate]) (Books, error)`)
require.FileExists(t, "./controllers/books.go")
os.Remove("./controllers/books.go")
require.Contains(t, res, `func (rs BooksResources) postBooks(c *fuego.ContextWithBody[BooksCreate]) (Books, error)`)
require.FileExists(t, "./controller/books.go")
os.Remove("./controller/books.go")
}
10 changes: 5 additions & 5 deletions cmd/fuego/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/go-fuego/fuego v0.14.0
github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.4
golang.org/x/text v0.17.0
golang.org/x/text v0.18.0
)

require (
Expand All @@ -20,7 +20,7 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/go-playground/validator/v10 v10.22.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/invopop/yaml v0.3.1 // indirect
Expand All @@ -32,8 +32,8 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
20 changes: 10 additions & 10 deletions cmd/fuego/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
Expand Down Expand Up @@ -56,14 +56,14 @@ github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
Expand Down
74 changes: 67 additions & 7 deletions ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ type ctx[B any] interface {

QueryParam(name string) string
QueryParamArr(name string) []string
QueryParamInt(name string, defaultValue int) int // If the query parameter does not exist or is not an int, it returns the default given value. Use [Ctx.QueryParamIntErr] if you want to know if the query parameter is erroneous.
QueryParamInt(name string) int // If the query parameter is not provided or is not an int, it returns the default given value. Use [Ctx.QueryParamIntErr] if you want to know if the query parameter is erroneous.
QueryParamIntErr(name string) (int, error)
QueryParamBool(name string, defaultValue bool) bool // If the query parameter does not exist or is not a bool, it returns the default given value. Use [Ctx.QueryParamBoolErr] if you want to know if the query parameter is erroneous.
QueryParamBool(name string) bool // If the query parameter is not provided or is not a bool, it returns the default given value. Use [Ctx.QueryParamBoolErr] if you want to know if the query parameter is erroneous.
QueryParamBoolErr(name string) (bool, error)
QueryParams() url.Values

Expand Down Expand Up @@ -124,6 +124,8 @@ type ContextNoBody struct {
fs fs.FS
templates *template.Template

params map[string]OpenAPIParam // list of expected query parameters (declared in the OpenAPI spec)

readOptions readOptions
}

Expand Down Expand Up @@ -265,17 +267,43 @@ func (c ContextNoBody) QueryParams() url.Values {

// QueryParamsArr returns an slice of string from the given query parameter.
func (c ContextNoBody) QueryParamArr(name string) []string {
_, ok := c.params[name]
if !ok {
slog.Warn("query parameter not expected in OpenAPI spec", "param", name)
}
return c.Req.URL.Query()[name]
}

// QueryParam returns the query parameter with the given name.
// If it does not exist, it returns an empty string, unless there is a default value declared in the OpenAPI spec.
//
// Example:
//
// fuego.Get(s, "/test", myController,
// option.Query("name", "Name", param.Default("hey"))
// )
func (c ContextNoBody) QueryParam(name string) string {
_, ok := c.params[name]
if !ok {
slog.Warn("query parameter not expected in OpenAPI spec", "param", name, "expected_one_of", c.params)
}

_, found := c.Req.URL.Query()[name]
if !found {
defaultValue, _ := c.params[name].Default.(string)
return defaultValue
}
return c.Req.URL.Query().Get(name)
}

func (c ContextNoBody) QueryParamIntErr(name string) (int, error) {
param := c.QueryParam(name)
if param == "" {
defaultValue, ok := c.params[name].Default.(int)
if ok {
return defaultValue, nil
}

return 0, QueryParamNotFoundError{ParamName: name}
}

Expand All @@ -292,21 +320,43 @@ func (c ContextNoBody) QueryParamIntErr(name string) (int, error) {
return i, nil
}

func (c ContextNoBody) QueryParamInt(name string, defaultValue int) int {
// QueryParamInt returns the query parameter with the given name as an int.
// If it does not exist, it returns the default value declared in the OpenAPI spec.
// For example, if the query parameter is declared as:
//
// fuego.Get(s, "/test", myController,
// option.QueryInt("page", "Page number", param.Default(1))
// )
//
// and the query parameter does not exist, it will return 1.
// If the query parameter does not exist and there is no default value, or if it is not an int, it returns 0.
func (c ContextNoBody) QueryParamInt(name string) int {
param, err := c.QueryParamIntErr(name)
if err != nil {
return defaultValue
return 0
}

return param
}

// QueryParamBool returns the query parameter with the given name as a bool.
// If the query parameter does not exist or is not a bool, it returns nil.
// If the query parameter does not exist or is not a bool, it returns the default value declared in the OpenAPI spec.
// For example, if the query parameter is declared as:
//
// fuego.Get(s, "/test", myController,
// option.QueryBool("is_ok", "Is OK?", param.Default(true))
// )
//
// and the query parameter does not exist in the HTTP request, it will return true.
// Accepted values are defined as [strconv.ParseBool]
func (c ContextNoBody) QueryParamBoolErr(name string) (bool, error) {
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
param := c.QueryParam(name)
if param == "" {
defaultValue, ok := c.params[name].Default.(bool)
if ok {
return defaultValue, nil
}

return false, QueryParamNotFoundError{ParamName: name}
}

Expand All @@ -322,10 +372,20 @@ func (c ContextNoBody) QueryParamBoolErr(name string) (bool, error) {
return b, nil
}

func (c ContextNoBody) QueryParamBool(name string, defaultValue bool) bool {
// QueryParamBool returns the query parameter with the given name as a bool.
// If the query parameter does not exist or is not a bool, it returns false.
// Accepted values are defined as [strconv.ParseBool]
// Example:
//
// fuego.Get(s, "/test", myController,
// option.QueryBool("is_ok", "Is OK?", param.Default(true))
// )
//
// and the query parameter does not exist in the HTTP request, it will return true.
func (c ContextNoBody) QueryParamBool(name string) bool {
EwenQuim marked this conversation as resolved.
Show resolved Hide resolved
param, err := c.QueryParamBoolErr(name)
if err != nil {
return defaultValue
return false
}

return param
Expand Down
41 changes: 41 additions & 0 deletions ctx_params_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package fuego_test

import (
"net/http"
"net/http/httptest"
"strconv"
"testing"

"github.com/stretchr/testify/require"

"github.com/go-fuego/fuego"
"github.com/go-fuego/fuego/option"
"github.com/go-fuego/fuego/param"
)

func TestParam(t *testing.T) {
t.Run("Query params default values", func(t *testing.T) {
s := fuego.NewServer()

fuego.Get(s, "/test", func(c fuego.ContextNoBody) (string, error) {
name := c.QueryParam("name")
age := c.QueryParamInt("age")
isok := c.QueryParamBool("is_ok")

return name + strconv.Itoa(age) + strconv.FormatBool(isok), nil
},
option.Query("name", "Name", param.Required(), param.Default("hey"), param.Example("example1", "you")),
option.QueryInt("age", "Age", param.Nullable(), param.Default(18), param.Example("example1", 1)),
option.QueryBool("is_ok", "Is OK?", param.Default(true), param.Example("example1", true)),
)

t.Run("Default should correctly set parameter in controller", func(t *testing.T) {
r := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
s.Mux.ServeHTTP(w, r)

require.Equal(t, http.StatusOK, w.Code)
EwenQuim marked this conversation as resolved.
Show resolved Hide resolved
require.Equal(t, "hey18true", w.Body.String())
})
})
}
19 changes: 8 additions & 11 deletions ctx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,14 @@ func TestContext_QueryParam(t *testing.T) {
require.NotEmpty(t, param)
require.Equal(t, "456", param)

paramInt := c.QueryParamInt("id", 0)
paramInt := c.QueryParamInt("id")
require.Equal(t, 456, paramInt)

paramInt = c.QueryParamInt("notfound", 42)
require.Equal(t, 42, paramInt)
paramInt = c.QueryParamInt("notfound")
require.Equal(t, 0, paramInt)

paramInt = c.QueryParamInt("other", 42)
require.Equal(t, 42, paramInt)
paramInt = c.QueryParamInt("other")
require.Equal(t, 0, paramInt)

paramInt, err := c.QueryParamIntErr("id")
require.NoError(t, err)
Expand All @@ -91,14 +91,11 @@ func TestContext_QueryParam(t *testing.T) {
require.NotEmpty(t, param)
require.Equal(t, "true", param)

paramBool := c.QueryParamBool("boo", false)
require.Equal(t, true, paramBool)

paramBool = c.QueryParamBool("notfound", true)
paramBool := c.QueryParamBool("boo")
require.Equal(t, true, paramBool)

paramBool = c.QueryParamBool("other", true)
require.Equal(t, true, paramBool)
paramBool = c.QueryParamBool("notfound")
require.Equal(t, false, paramBool)

paramBool, err := c.QueryParamBoolErr("boo")
require.NoError(t, err)
Expand Down
13 changes: 9 additions & 4 deletions documentation/docs/guides/openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,20 @@ package main

import (
"github.com/go-fuego/fuego"
"github.com/go-fuego/fuego/option"
"github.com/go-fuego/fuego/param"
)

func main() {
s := fuego.NewServer()

fuego.Get(s, "/", helloWorld).
Summary("A simple hello world").
Description("This is a simple hello world").
Deprecated()
fuego.Get(s, "/", helloWorld,
option.Summary("A simple hello world"),
option.Description("This is a simple hello world example"),
option.Query("name", "Name to greet", param.Required(), param.Default("World")),
option.Tags("Hello"),
option.Deprecated(),
)

s.Run()
}
Expand Down
Loading