Skip to content

Commit

Permalink
Multi-return
Browse files Browse the repository at this point in the history
  • Loading branch information
EwenQuim committed Jul 16, 2024
1 parent 9a69eea commit ff90e44
Show file tree
Hide file tree
Showing 13 changed files with 601 additions and 157 deletions.
57 changes: 10 additions & 47 deletions ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ type ctx[B any] interface {
// and you want to override one above the other, you can do:
// c.Render("admin.page.html", recipes, "partials/aaa/nav.partial.html")
// By default, [templateToExecute] is added to the list of templates to override.
Render(templateToExecute string, data any, templateGlobsToOverride ...string) (HTML, error)
Render(templateToExecute string, data any, templateGlobsToOverride ...string) (CtxRenderer, error)

Cookie(name string) (*http.Cookie, error) // Get request cookie
SetCookie(cookie http.Cookie) // Sets response cookie
Expand Down Expand Up @@ -222,51 +222,14 @@ func (c ContextNoBody) SetCookie(cookie http.Cookie) {
// that the templates will be parsed only once, removing
// the need to parse the templates on each request but also preventing
// to dynamically use new templates.
func (c ContextNoBody) Render(templateToExecute string, data any, layoutsGlobs ...string) (HTML, error) {
if strings.Contains(templateToExecute, "/") || strings.Contains(templateToExecute, "*") {

layoutsGlobs = append(layoutsGlobs, templateToExecute) // To override all blocks defined in the main template
cloned := template.Must(c.templates.Clone())
tmpl, err := cloned.ParseFS(c.fs, layoutsGlobs...)
if err != nil {
return "", HTTPError{
Err: err,
Status: http.StatusInternalServerError,
Title: "Error parsing template",
Detail: fmt.Errorf("error parsing template '%s': %w", layoutsGlobs, err).Error(),
Errors: []ErrorItem{
{
Name: "templates",
Reason: "Check that the template exists and have the correct extension. Globs: " + strings.Join(layoutsGlobs, ", "),
},
},
}
}
c.templates = template.Must(tmpl.Clone())
}

// Get only last template name (for example, with partials/nav/main/nav.partial.html, get nav.partial.html)
myTemplate := strings.Split(templateToExecute, "/")
templateToExecute = myTemplate[len(myTemplate)-1]

c.Res.Header().Set("Content-Type", "text/html; charset=utf-8")
err := c.templates.ExecuteTemplate(c.Res, templateToExecute, data)
if err != nil {
return "", HTTPError{
Err: err,
Status: http.StatusInternalServerError,
Title: "Error rendering template",
Detail: fmt.Errorf("error executing template '%s': %w", templateToExecute, err).Error(),
Errors: []ErrorItem{
{
Name: "templates",
Reason: "Check that the template exists and have the correct extension. Template: " + templateToExecute,
},
},
}
}

return "", err
func (c ContextNoBody) Render(templateToExecute string, data any, layoutsGlobs ...string) (CtxRenderer, error) {
return &StdRenderer{
templateToExecute: templateToExecute,
templates: c.templates,
layoutsGlobs: layoutsGlobs,
fs: c.fs,
data: data,
}, nil
}

// PathParams returns the path parameters of the request.
Expand Down Expand Up @@ -427,7 +390,7 @@ func body[B any](c ContextNoBody) (B, error) {
body, err = readURLEncoded[B](c.Req, c.readOptions)
case "application/xml":
body, err = readXML[B](c.Req.Context(), c.Req.Body, c.readOptions)
case "application/x-yaml":
case "application/x-yaml", "text/yaml; charset=utf-8", "application/yaml": // https://www.rfc-editor.org/rfc/rfc9512.html
body, err = readYAML[B](c.Req.Context(), c.Req.Body, c.readOptions)
case "application/octet-stream":
// Read c.Req Body to bytes
Expand Down
4 changes: 2 additions & 2 deletions examples/full-app-gourmet/views/partials.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import (
"github.com/go-fuego/fuego/examples/full-app-gourmet/store/types"
)

func (rs Ressource) unitPreselected(c fuego.ContextNoBody) (fuego.HTML, error) {
func (rs Ressource) unitPreselected(c fuego.ContextNoBody) (fuego.CtxRenderer, error) {
id := c.QueryParam("IngredientID")

ingredient, err := rs.IngredientsQueries.GetIngredient(c.Context(), id)
if err != nil {
return "", err
return nil, err
}

return c.Render("preselected-unit.partial.html", fuego.H{
Expand Down
27 changes: 15 additions & 12 deletions examples/full-app-gourmet/views/recipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,18 @@ func (rs Ressource) showIndex(c fuego.ContextNoBody) (fuego.Templ, error) {
}), nil
}

func (rs Ressource) showRecipes(c fuego.ContextNoBody) (fuego.Templ, error) {
func (rs Ressource) showRecipes(c fuego.ContextNoBody) (*fuego.DataOrTemplate[[]store.Recipe], error) {
recipes, err := rs.RecipesQueries.GetRecipes(c.Context())
if err != nil {
return nil, err
}

return templa.SearchPage(templa.SearchProps{
Recipes: recipes,
}), nil
return fuego.DataOrHTML(
recipes,
templa.SearchPage(templa.SearchProps{
Recipes: recipes,
}),
), nil
}

func (rs Ressource) relatedRecipes(c fuego.ContextNoBody) (fuego.Templ, error) {
Expand Down Expand Up @@ -220,7 +223,7 @@ func (rs Ressource) healthyRecipes(c fuego.ContextNoBody) (fuego.Templ, error) {
}), nil
}

func (rs Ressource) showRecipesList(c fuego.ContextNoBody) (fuego.HTML, error) {
func (rs Ressource) showRecipesList(c fuego.ContextNoBody) (fuego.CtxRenderer, error) {
search := c.QueryParam("search")
recipes, err := rs.RecipesQueries.SearchRecipes(c.Context(), store.SearchRecipesParams{
Search: sql.NullString{
Expand All @@ -229,41 +232,41 @@ func (rs Ressource) showRecipesList(c fuego.ContextNoBody) (fuego.HTML, error) {
},
})
if err != nil {
return "", err
return nil, err
}

return c.Render("partials/recipes-list.partial.html", recipes)
}

func (rs Ressource) addRecipe(c *fuego.ContextWithBody[store.CreateRecipeParams]) (fuego.HTML, error) {
func (rs Ressource) addRecipe(c *fuego.ContextWithBody[store.CreateRecipeParams]) (fuego.CtxRenderer, error) {
body, err := c.Body()
if err != nil {
return "", err
return nil, err
}

body.ID = uuid.NewString()

_, err = rs.RecipesQueries.CreateRecipe(c.Context(), body)
if err != nil {
return "", err
return nil, err
}

recipes, err := rs.RecipesQueries.GetRecipes(c.Context())
if err != nil {
return "", err
return nil, err
}

return c.Render("pages/admin.page.html", fuego.H{
"Recipes": recipes,
})
}

func (rs Ressource) RecipePage(c fuego.ContextNoBody) (fuego.HTML, error) {
func (rs Ressource) RecipePage(c fuego.ContextNoBody) (fuego.CtxRenderer, error) {
id := c.PathParam("id")

recipe, err := rs.RecipesQueries.GetRecipe(c.Context(), id)
if err != nil {
return "", fmt.Errorf("error getting recipe %s: %w", id, err)
return nil, fmt.Errorf("error getting recipe %s: %w", id, err)
}

ingredients, err := rs.IngredientsQueries.GetIngredientsOfRecipe(c.Context(), id)
Expand Down
64 changes: 63 additions & 1 deletion html.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import (
"fmt"
"html/template"
"io"
"io/fs"
"net/http"
"strings"
)

// CtxRenderer can be used with [github.com/a-h/templ]
// CtxRenderer is an interface that can be used to render a response.
// It is used with standard library templating engine, by using fuego.ContextXXX.Render
// It is compatible with [github.com/a-h/templ] out of the box.
// Example:
//
// func getRecipes(ctx fuego.ContextNoBody) (fuego.CtxRenderer, error) {
Expand Down Expand Up @@ -50,6 +55,63 @@ type HTML string
// H is a shortcut for map[string]any
type H map[string]any

// StdRenderer renders a template using the standard library templating engine.
type StdRenderer struct {
templateToExecute string
templates *template.Template
layoutsGlobs []string
fs fs.FS
data any
}

var _ CtxRenderer = StdRenderer{}

func (s StdRenderer) Render(ctx context.Context, w io.Writer) error {
if strings.Contains(s.templateToExecute, "/") || strings.Contains(s.templateToExecute, "*") {

s.layoutsGlobs = append(s.layoutsGlobs, s.templateToExecute) // To override all blocks defined in the main template
cloned := template.Must(s.templates.Clone())
tmpl, err := cloned.ParseFS(s.fs, s.layoutsGlobs...)
if err != nil {
return HTTPError{
Err: err,
Status: http.StatusInternalServerError,
Title: "Error parsing template",
Detail: fmt.Errorf("error parsing template '%s': %w", s.layoutsGlobs, err).Error(),
Errors: []ErrorItem{
{
Name: "templates",
Reason: "Check that the template exists and have the correct extension. Globs: " + strings.Join(s.layoutsGlobs, ", "),
},
},
}
}
s.templates = template.Must(tmpl.Clone())
}

// Get only last template name (for example, with partials/nav/main/nav.partial.html, get nav.partial.html)
myTemplate := strings.Split(s.templateToExecute, "/")
s.templateToExecute = myTemplate[len(myTemplate)-1]

err := s.templates.ExecuteTemplate(w, s.templateToExecute, s.data)
if err != nil {
return HTTPError{
Err: err,
Status: http.StatusInternalServerError,
Title: "Error rendering template",
Detail: fmt.Errorf("error executing template '%s': %w", s.templateToExecute, err).Error(),
Errors: []ErrorItem{
{
Name: "templates",
Reason: "Check that the template exists and have the correct extension. Template: " + s.templateToExecute,
},
},
}
}

return err
}

// loadTemplates
func (s *Server) loadTemplates(patterns ...string) error {
tmpl, err := template.ParseFS(s.fs, patterns...)
Expand Down
10 changes: 5 additions & 5 deletions html_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func TestRender(t *testing.T) {
WithTemplateGlobs("testdata/*.html"),
)

Get(s, "/test", func(ctx *ContextNoBody) (HTML, error) {
Get(s, "/test", func(ctx *ContextNoBody) (CtxRenderer, error) {
return ctx.Render("testdata/test.html", H{"Name": "test"})
})

Expand All @@ -43,7 +43,7 @@ func TestRender(t *testing.T) {
})

t.Run("cannot parse unexisting file", func(t *testing.T) {
Get(s, "/file-not-found", func(ctx ContextNoBody) (HTML, error) {
Get(s, "/file-not-found", func(ctx ContextNoBody) (CtxRenderer, error) {
return ctx.Render("testdata/not-found.html", H{"Name": "test"})
})

Expand All @@ -56,7 +56,7 @@ func TestRender(t *testing.T) {
})

t.Run("can execute template with missing variable in map", func(t *testing.T) {
Get(s, "/impossible", func(ctx ContextNoBody) (HTML, error) {
Get(s, "/impossible", func(ctx ContextNoBody) (CtxRenderer, error) {
return ctx.Render("testdata/test.html", H{"NotName": "test"})
})

Expand All @@ -71,7 +71,7 @@ func TestRender(t *testing.T) {
})

t.Run("cannot execute template with missing variable in struct", func(t *testing.T) {
Get(s, "/impossible-struct", func(ctx ContextNoBody) (HTML, error) {
Get(s, "/impossible-struct", func(ctx ContextNoBody) (CtxRenderer, error) {
return ctx.Render("testdata/test.html", struct{}{})
})

Expand All @@ -93,7 +93,7 @@ func BenchmarkRender(b *testing.B) {
WithTemplateGlobs("testdata/*.html"),
)

Get(s, "/test", func(ctx ContextNoBody) (HTML, error) {
Get(s, "/test", func(ctx ContextNoBody) (CtxRenderer, error) {
return ctx.Render("testdata/test.html", H{"Name": "test"})
})

Expand Down
61 changes: 61 additions & 0 deletions multi_return.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package fuego

import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"

"gopkg.in/yaml.v3"
)

// DataOrTemplate is a struct that can return either data or a template
// depending on the asked type.
type DataOrTemplate[T any] struct {
Data T
Template any
}

var (
_ CtxRenderer = DataOrTemplate[any]{} // Can render HTML (template)
_ json.Marshaler = DataOrTemplate[any]{} // Can render JSON (data)
_ xml.Marshaler = DataOrTemplate[any]{} // Can render XML (data)
_ yaml.Marshaler = DataOrTemplate[any]{} // Can render YAML (data)
_ fmt.Stringer = DataOrTemplate[any]{} // Can render string (data)
)

func (m DataOrTemplate[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(m.Data)
}

func (m DataOrTemplate[T]) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
return e.Encode(m.Data)
}

func (m DataOrTemplate[T]) MarshalYAML() (interface{}, error) {
return m.Data, nil
}

func (m DataOrTemplate[T]) String() string {
return fmt.Sprintf("%v", m.Data)
}

func (m DataOrTemplate[T]) Render(c context.Context, w io.Writer) error {
switch m.Template.(type) {
case CtxRenderer:
return m.Template.(CtxRenderer).Render(c, w)
case Renderer:
return m.Template.(Renderer).Render(w)
default:
panic("template must be either CtxRenderer or Renderer")
}
}

// Helper function to create a DataOrTemplate return item without specifying the type.
func DataOrHTML[T any](data T, template any) *DataOrTemplate[T] {
return &DataOrTemplate[T]{
Data: data,
Template: template,
}
}
Loading

0 comments on commit ff90e44

Please sign in to comment.