From 8ea9a3c8172123f48093334cf63f2cf82831480a Mon Sep 17 00:00:00 2001 From: Mark Bates Date: Wed, 14 Feb 2018 16:24:07 -0500 Subject: [PATCH] Handle multiple types for resources fixes #389 (#919) * wip r.Interface * renamed render.Interface to render.Auto * cleaned up tests for auto * Handle multiple types for resources fixes #389 * removed a few unneeded lines in the generated resource code * fixed broken test --- Dockerfile | 15 +- .../cmd/filetests/generate_underscore.json | 13 +- buffalo/cmd/filetests/resource_json-xml.json | 20 -- buffalo/cmd/generate/resource.go | 8 +- default_context.go | 8 + generators/resource/generator.go | 10 +- generators/resource/resource.go | 11 +- .../actions/resource-json-xml.go.tmpl | 171 ----------- .../actions/resource-json-xml_test.go.tmpl | 12 - .../actions/resource-use_model.go.tmpl | 35 +-- handler.go | 21 +- render/auto.go | 189 ++++++++++++ render/auto_test.go | 271 ++++++++++++++++++ 13 files changed, 504 insertions(+), 280 deletions(-) delete mode 100644 buffalo/cmd/filetests/resource_json-xml.json delete mode 100644 generators/resource/templates/actions/resource-json-xml.go.tmpl delete mode 100644 generators/resource/templates/actions/resource-json-xml_test.go.tmpl create mode 100644 render/auto.go create mode 100644 render/auto_test.go diff --git a/Dockerfile b/Dockerfile index dfc5315ec..29b65a45c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN go get -v -u github.com/gobuffalo/makr RUN go get -v -u github.com/markbates/grift RUN go get -v -u github.com/markbates/inflect RUN go get -v -u github.com/markbates/refresh +RUN go get -v -u github.com/markbates/willie RUN go get -v -u github.com/gobuffalo/tags ENV BP=$GOPATH/src/github.com/gobuffalo/buffalo @@ -61,20 +62,6 @@ RUN rm models/user.go RUN rm actions/users_test.go RUN rm -rv templates/users -RUN buffalo g resource --type=json users name:text email:text -RUN filetest -c $GOPATH/src/github.com/gobuffalo/buffalo/buffalo/cmd/filetests/resource_json-xml.json - -RUN rm models/user_test.go -RUN rm models/user.go -RUN rm actions/users_test.go - -RUN buffalo g resource --type=xml users name:text email:text -RUN filetest -c $GOPATH/src/github.com/gobuffalo/buffalo/buffalo/cmd/filetests/resource_json-xml.json - -RUN rm models/user_test.go -RUN rm models/user.go -RUN rm actions/users_test.go - RUN buffalo g resource ouch RUN buffalo d resource -y ouch RUN filetest -c $GOPATH/src/github.com/gobuffalo/buffalo/buffalo/cmd/filetests/destroy_resource_all.json diff --git a/buffalo/cmd/filetests/generate_underscore.json b/buffalo/cmd/filetests/generate_underscore.json index 5bd6a8492..c08c2d227 100644 --- a/buffalo/cmd/filetests/generate_underscore.json +++ b/buffalo/cmd/filetests/generate_underscore.json @@ -1,8 +1,7 @@ [{ - "path": "actions/person_events.go", - "contains": [ - "c.Set(\"personEvents\", personEvents)", - "c.Set(\"personEvent\", personEvent)" - ] - } -] \ No newline at end of file + "path": "actions/person_events.go", + "contains": [ + "c.Render(200, r.Auto(c, personEvents))", + "c.Render(200, r.Auto(c, personEvent))" + ] +}] diff --git a/buffalo/cmd/filetests/resource_json-xml.json b/buffalo/cmd/filetests/resource_json-xml.json deleted file mode 100644 index 46e059e8c..000000000 --- a/buffalo/cmd/filetests/resource_json-xml.json +++ /dev/null @@ -1,20 +0,0 @@ -[{ - "path": "templates/users/_form.html", - "absent": true -}, -{ - "path": "templates/users/edit.html", - "absent": true -}, -{ - "path": "templates/users/index.html", - "absent": true -}, -{ - "path": "templates/users/new.html", - "absent": true -}, -{ - "path": "templates/users/show.html", - "absent": true -}] \ No newline at end of file diff --git a/buffalo/cmd/generate/resource.go b/buffalo/cmd/generate/resource.go index 0929ec0c0..5ba2c372a 100644 --- a/buffalo/cmd/generate/resource.go +++ b/buffalo/cmd/generate/resource.go @@ -1,8 +1,6 @@ package generate import ( - "strings" - "github.com/markbates/inflect" "github.com/pkg/errors" @@ -14,9 +12,9 @@ import ( var resourceOptions = struct { SkipMigration bool SkipModel bool + SkipTemplates bool ModelName string Name string - MimeType string }{} // ResourceCmd generates a new actions/resource file and a stub test. @@ -30,9 +28,9 @@ var ResourceCmd = &cobra.Command{ if err != nil { return errors.WithStack(err) } - o.MimeType = strings.ToUpper(resourceOptions.MimeType) o.SkipModel = resourceOptions.SkipModel o.SkipMigration = resourceOptions.SkipMigration + o.SkipTemplates = resourceOptions.SkipTemplates if resourceOptions.ModelName != "" { o.UseModel = true o.Model = inflect.Name(resourceOptions.ModelName) @@ -51,9 +49,9 @@ var resourceMN string func init() { ResourceCmd.Flags().BoolVarP(&resourceOptions.SkipMigration, "skip-migration", "s", false, "tells resource generator not-to add model migration") ResourceCmd.Flags().BoolVarP(&resourceOptions.SkipModel, "skip-model", "", false, "tells resource generator not to generate model nor migrations") + ResourceCmd.Flags().BoolVarP(&resourceOptions.SkipTemplates, "skip-templates", "", false, "tells resource generator not to generate templates for the resource") ResourceCmd.Flags().StringVarP(&resourceOptions.ModelName, "use-model", "", "", "tells resource generator to reference an existing model in generated code") ResourceCmd.Flags().StringVarP(&resourceOptions.Name, "name", "n", "", "allows to define a different model name for the resource being generated.") - ResourceCmd.Flags().StringVarP(&resourceOptions.MimeType, "type", "", "html", "sets the resource type (html, json, xml)") } const resourceExamples = `$ buffalo g resource users diff --git a/default_context.go b/default_context.go index 7358dc558..1528cbf90 100644 --- a/default_context.go +++ b/default_context.go @@ -15,6 +15,7 @@ import ( "github.com/gobuffalo/buffalo/binding" "github.com/gobuffalo/buffalo/render" "github.com/gorilla/websocket" + "github.com/markbates/pop" "github.com/pkg/errors" ) @@ -114,10 +115,14 @@ func (d *DefaultContext) Render(status int, rr render.Renderer) error { data["flash"] = d.Flash().data data["session"] = d.Session() data["request"] = d.Request() + data["status"] = status bb := &bytes.Buffer{} err := rr.Render(bb, data) if err != nil { + if er, ok := errors.Cause(err).(render.ErrRedirect); ok { + return d.Redirect(er.Status, er.URL) + } return HTTPError{Status: 500, Cause: errors.WithStack(err)} } @@ -127,6 +132,9 @@ func (d *DefaultContext) Render(status int, rr render.Renderer) error { } d.Response().Header().Set("Content-Type", rr.ContentType()) + if p, ok := data["pagination"].(*pop.Paginator); ok { + d.Response().Header().Set("X-Pagination", p.String()) + } d.Response().WriteHeader(status) _, err = io.Copy(d.Response(), bb) if err != nil { diff --git a/generators/resource/generator.go b/generators/resource/generator.go index 7f496e141..b98a5054e 100644 --- a/generators/resource/generator.go +++ b/generators/resource/generator.go @@ -16,8 +16,8 @@ type Generator struct { Model inflect.Name `json:"model"` SkipMigration bool `json:"skip_migration"` SkipModel bool `json:"skip_model"` + SkipTemplates bool `json:"skip_templates"` UseModel bool `json:"use_model"` - MimeType string `json:"mime_type"` FilesPath string `json:"files_path"` ActionsPath string `json:"actions_path"` Props []Prop `json:"props"` @@ -27,8 +27,7 @@ type Generator struct { // New constructs new options for generating a resource func New(modelName string, args ...string) (Generator, error) { o := Generator{ - MimeType: "HTML", - Args: args, + Args: args, } pwd, _ := os.Getwd() o.App = meta.New(pwd) @@ -54,11 +53,6 @@ func New(modelName string, args ...string) (Generator, error) { // Validate that the options have what you need to build a new resource func (o Generator) Validate() error { - mt := o.MimeType - if mt != "HTML" && mt != "JSON" && mt != "XML" { - return errors.New("invalid resource type, you need to choose between \"html\", \"xml\" and \"json\"") - } - if len(o.Args) == 0 && o.Model == "" { return errors.New("you must specify a resource name") } diff --git a/generators/resource/resource.go b/generators/resource/resource.go index 5a4fa3d16..8d7990709 100644 --- a/generators/resource/resource.go +++ b/generators/resource/resource.go @@ -23,10 +23,7 @@ func (res Generator) Run(root string, data makr.Data) error { tmplName := "resource-use_model" - mimeType := res.MimeType - if mimeType == "JSON" || mimeType == "XML" { - tmplName = "resource-json-xml" - } else if res.SkipModel { + if res.SkipModel { tmplName = "resource-name" } @@ -45,7 +42,7 @@ func (res Generator) Run(root string, data makr.Data) error { p := strings.Replace(f.WritePath, tmplName, folder, -1) g.Add(makr.NewFile(p, f.Body)) } - if mimeType == "HTML" { + if !res.SkipTemplates { // Adding the html templates to the generator if strings.Contains(f.WritePath, "model-view-") { targetPath := filepath.Join( @@ -80,9 +77,5 @@ func (res Generator) modelCommand() makr.Command { args = append(args, "--skip-migration") } - if res.MimeType == "JSON" || res.MimeType == "XML" { - args = append(args, "--struct-tag", strings.ToLower(res.MimeType)) - } - return makr.NewCommand(exec.Command("buffalo", args...)) } diff --git a/generators/resource/templates/actions/resource-json-xml.go.tmpl b/generators/resource/templates/actions/resource-json-xml.go.tmpl deleted file mode 100644 index 246bb4b49..000000000 --- a/generators/resource/templates/actions/resource-json-xml.go.tmpl +++ /dev/null @@ -1,171 +0,0 @@ -package actions - -import ( - - "github.com/pkg/errors" - "github.com/gobuffalo/buffalo" - "github.com/markbates/pop" - "{{.opts.App.ModelsPkg}}" -) - -// This file is generated by Buffalo. It offers a basic structure for -// adding, editing and deleting a page. If your model is more -// complex or you need more than the basic implementation you need to -// edit this file. - -// Following naming logic is implemented in Buffalo: -// Model: Singular ({{.opts.Model.Model}}) -// DB Table: Plural ({{.opts.Model.Table}}) -// Resource: Plural ({{.opts.Name.Resource}}) -// Path: Plural (/{{.opts.Name.URL}}) -// View Template Folder: Plural (/templates/{{.opts.FilesPath}}/) - -// {{.opts.Name.Resource}}Resource is the resource for the {{.opts.Model.Model}} model -type {{.opts.Name.Resource}}Resource struct{ - buffalo.Resource -} - -// List gets all {{.opts.Model.ModelPlural}}. This function is mapped to the path -// GET /{{.opts.Name.URL}} -func (v {{.opts.Name.Resource}}Resource) List(c buffalo.Context) error { - // Get the DB connection from the context - tx, ok := c.Value("tx").(*pop.Connection) - if !ok { - return errors.WithStack(errors.New("no transaction found")) - } - - {{.opts.Model.VarCasePlural}} := &models.{{.opts.Model.ModelPlural}}{} - - // Paginate results. Params "page" and "per_page" control pagination. - // Default values are "page=1" and "per_page=20". - q := tx.PaginateFromParams(c.Params()) - - // Retrieve all {{.opts.Model.ModelPlural}} from the DB - if err := q.All({{.opts.Model.VarCasePlural}}); err != nil { - return errors.WithStack(err) - } - - // Add the paginator to the headers so clients know how to paginate. - c.Response().Header().Set("X-Pagination", q.Paginator.String()) - - return c.Render(200, r.{{.opts.MimeType}}({{.opts.Model.VarCasePlural}})) -} - -// Show gets the data for one {{.opts.Model.Model}}. This function is mapped to -// the path GET /{{.opts.Name.URL}}/{{"{"}}{{.opts.Name.ParamID}}} -func (v {{.opts.Name.Resource}}Resource) Show(c buffalo.Context) error { - // Get the DB connection from the context - tx, ok := c.Value("tx").(*pop.Connection) - if !ok { - return errors.WithStack(errors.New("no transaction found")) - } - - // Allocate an empty {{.opts.Model.Model}} - {{.opts.Model.VarCaseSingular}} := &models.{{.opts.Model.Model}}{} - - // To find the {{.opts.Model.Model}} the parameter {{.opts.Name.ParamID}} is used. - if err := tx.Find({{.opts.Model.VarCaseSingular}}, c.Param("{{.opts.Name.ParamID}}")); err != nil { - return c.Error(404, err) - } - - return c.Render(200, r.{{.opts.MimeType}}({{.opts.Model.VarCaseSingular}})) -} - -// New default implementation. Returns a 404 -func (v {{.opts.Name.Resource}}Resource) New(c buffalo.Context) error { - return c.Error(404, errors.New("not available")) -} - -// Create adds a {{.opts.Model.Model}} to the DB. This function is mapped to the -// path POST /{{.opts.Name.URL}} -func (v {{.opts.Name.Resource}}Resource) Create(c buffalo.Context) error { - // Allocate an empty {{.opts.Model.Model}} - {{.opts.Model.VarCaseSingular}} := &models.{{.opts.Model.Model}}{} - - // Bind {{.opts.Model.VarCaseSingular}} to the html form elements - if err := c.Bind({{.opts.Model.VarCaseSingular}}); err != nil { - return errors.WithStack(err) - } - - // Get the DB connection from the context - tx, ok := c.Value("tx").(*pop.Connection) - if !ok { - return errors.WithStack(errors.New("no transaction found")) - } - - // Validate the data from the html form - verrs, err := tx.ValidateAndCreate({{.opts.Model.VarCaseSingular}}) - if err != nil { - return errors.WithStack(err) - } - - if verrs.HasAny() { - // Render errors as {{.opts.MimeType}} - return c.Render(400, r.{{.opts.MimeType}}(verrs)) - } - - return c.Render(201, r.{{.opts.MimeType}}({{.opts.Model.VarCaseSingular}})) -} - -// Edit default implementation. Returns a 404 -func (v {{.opts.Name.Resource}}Resource) Edit(c buffalo.Context) error { - return c.Error(404, errors.New("not available")) -} - -// Update changes a {{.opts.Model.Model}} in the DB. This function is mapped to -// the path PUT /{{.opts.Name.URL}}/{{"{"}}{{.opts.Name.ParamID}}} -func (v {{.opts.Name.Resource}}Resource) Update(c buffalo.Context) error { - // Get the DB connection from the context - tx, ok := c.Value("tx").(*pop.Connection) - if !ok { - return errors.WithStack(errors.New("no transaction found")) - } - - // Allocate an empty {{.opts.Model.Model}} - {{.opts.Model.VarCaseSingular}} := &models.{{.opts.Model.Model}}{} - - if err := tx.Find({{.opts.Model.VarCaseSingular}}, c.Param("{{.opts.Name.ParamID}}")); err != nil { - return c.Error(404, err) - } - - // Bind {{.opts.Model.Model}} to the html form elements - if err := c.Bind({{.opts.Model.VarCaseSingular}}); err != nil { - return errors.WithStack(err) - } - - verrs, err := tx.ValidateAndUpdate({{.opts.Model.VarCaseSingular}}) - if err != nil { - return errors.WithStack(err) - } - - if verrs.HasAny() { - // Render errors as {{.opts.MimeType}} - return c.Render(400, r.{{.opts.MimeType}}(verrs)) - } - - return c.Render(200, r.{{.opts.MimeType}}({{.opts.Model.VarCaseSingular}})) -} - -// Destroy deletes a {{.opts.Model.Model}} from the DB. This function is mapped -// to the path DELETE /{{.opts.Name.URL}}/{{"{"}}{{.opts.Name.ParamID}}} -func (v {{.opts.Name.Resource}}Resource) Destroy(c buffalo.Context) error { - // Get the DB connection from the context - tx, ok := c.Value("tx").(*pop.Connection) - if !ok { - return errors.WithStack(errors.New("no transaction found")) - } - - // Allocate an empty {{.opts.Model.Model}} - {{.opts.Model.VarCaseSingular}} := &models.{{.opts.Model.Model}}{} - - // To find the {{.opts.Model.Model}} the parameter {{.opts.Name.ParamID}} is used. - if err := tx.Find({{.opts.Model.VarCaseSingular}}, c.Param("{{.opts.Name.ParamID}}")); err != nil { - return c.Error(404, err) - } - - if err := tx.Destroy({{.opts.Model.VarCaseSingular}}); err != nil { - return errors.WithStack(err) - } - - return c.Render(200, r.{{.opts.MimeType}}({{.opts.Model.VarCaseSingular}})) -} diff --git a/generators/resource/templates/actions/resource-json-xml_test.go.tmpl b/generators/resource/templates/actions/resource-json-xml_test.go.tmpl deleted file mode 100644 index 2d6526ff0..000000000 --- a/generators/resource/templates/actions/resource-json-xml_test.go.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -package actions - -import ( - "testing" - - "github.com/stretchr/testify/require" -) -{{ range $a := .actions }} -func (as *ActionSuite) Test_{{$.opts.Name.Resource}}Resource_{{ camelize $a }}() { - as.Fail("Not Implemented!") -} -{{ end }} diff --git a/generators/resource/templates/actions/resource-use_model.go.tmpl b/generators/resource/templates/actions/resource-use_model.go.tmpl index 3f06eeda3..b443e8b7a 100644 --- a/generators/resource/templates/actions/resource-use_model.go.tmpl +++ b/generators/resource/templates/actions/resource-use_model.go.tmpl @@ -45,13 +45,10 @@ func (v {{.opts.Name.Resource}}Resource) List(c buffalo.Context) error { return errors.WithStack(err) } - // Make {{.opts.Model.ModelPlural}} available inside the html template - c.Set("{{.opts.Model.VarCasePlural}}", {{.opts.Model.VarCasePlural}}) - // Add the paginator to the context so it can be used in the template. c.Set("pagination", q.Paginator) - return c.Render(200, r.HTML("{{.opts.FilesPath}}/index.html")) + return c.Render(200, r.Auto(c, {{.opts.Model.VarCasePlural}})) } // Show gets the data for one {{.opts.Model.Model}}. This function is mapped to @@ -71,19 +68,13 @@ func (v {{.opts.Name.Resource}}Resource) Show(c buffalo.Context) error { return c.Error(404, err) } - // Make {{.opts.Model.VarCaseSingular}} available inside the html template - c.Set("{{.opts.Model.VarCaseSingular}}", {{.opts.Model.VarCaseSingular}}) - - return c.Render(200, r.HTML("{{.opts.FilesPath}}/show.html")) + return c.Render(200, r.Auto(c, {{.opts.Model.VarCaseSingular}})) } // New renders the form for creating a new {{.opts.Model.Model}}. // This function is mapped to the path GET /{{.opts.Name.URL}}/new func (v {{.opts.Name.Resource}}Resource) New(c buffalo.Context) error { - // Make {{.opts.Model.VarCaseSingular}} available inside the html template - c.Set("{{.opts.Model.VarCaseSingular}}", &models.{{.opts.Model.Model}}{}) - - return c.Render(200, r.HTML("{{.opts.FilesPath}}/new.html")) + return c.Render(200, r.Auto(c, &models.{{.opts.Model.Model}}{})) } // Create adds a {{.opts.Model.Model}} to the DB. This function is mapped to the @@ -110,22 +101,19 @@ func (v {{.opts.Name.Resource}}Resource) Create(c buffalo.Context) error { } if verrs.HasAny() { - // Make {{.opts.Model.VarCaseSingular}} available inside the html template - c.Set("{{.opts.Model.UnderSingular}}", {{.opts.Model.VarCaseSingular}}) - // Make the errors available inside the html template c.Set("errors", verrs) // Render again the new.html template that the user can // correct the input. - return c.Render(422, r.HTML("{{.opts.FilesPath}}/new.html")) + return c.Render(422, r.Auto(c, {{.opts.Model.VarCaseSingular}})) } // If there are no errors set a success message c.Flash().Add("success", "{{.opts.Model.Model}} was created successfully") // and redirect to the {{.opts.Name.URL}} index page - return c.Redirect(302, "/{{.opts.Name.URL}}/%s",{{.opts.Model.VarCaseSingular}}.ID) + return c.Render(201, r.Auto(c, {{.opts.Model.VarCaseSingular}})) } // Edit renders a edit form for a {{.opts.Model.Model}}. This function is @@ -144,9 +132,7 @@ func (v {{.opts.Name.Resource}}Resource) Edit(c buffalo.Context) error { return c.Error(404, err) } - // Make {{.opts.Model.VarCaseSingular}} available inside the html template - c.Set("{{.opts.Model.VarCaseSingular}}", {{.opts.Model.VarCaseSingular}}) - return c.Render(200, r.HTML("{{.opts.FilesPath}}/edit.html")) + return c.Render(200, r.Auto(c, {{.opts.Model.VarCaseSingular}})) } // Update changes a {{.opts.Model.Model}} in the DB. This function is mapped to @@ -176,22 +162,19 @@ func (v {{.opts.Name.Resource}}Resource) Update(c buffalo.Context) error { } if verrs.HasAny() { - // Make {{.opts.Model.VarCaseSingular}} available inside the html template - c.Set("{{.opts.Model.UnderSingular}}", {{.opts.Model.VarCaseSingular}}) - // Make the errors available inside the html template c.Set("errors", verrs) // Render again the edit.html template that the user can // correct the input. - return c.Render(422, r.HTML("{{.opts.FilesPath}}/edit.html")) + return c.Render(422, r.Auto(c, {{.opts.Model.VarCaseSingular}})) } // If there are no errors set a success message c.Flash().Add("success", "{{.opts.Model.Model}} was updated successfully") // and redirect to the {{.opts.Name.URL}} index page - return c.Redirect(302, "/{{.opts.Name.URL}}/%s",{{.opts.Model.VarCaseSingular}}.ID) + return c.Render(200, r.Auto(c, {{.opts.Model.VarCaseSingular}})) } // Destroy deletes a {{.opts.Model.Model}} from the DB. This function is mapped @@ -219,5 +202,5 @@ func (v {{.opts.Name.Resource}}Resource) Destroy(c buffalo.Context) error { c.Flash().Add("success", "{{.opts.Model.Model}} was destroyed successfully") // Redirect to the {{.opts.Name.URL}} index page - return c.Redirect(302, "/{{.opts.Name.URL}}") + return c.Render(200, r.Auto(c, {{.opts.Model.VarCaseSingular}})) } diff --git a/handler.go b/handler.go index effb4184d..4ff9c5099 100644 --- a/handler.go +++ b/handler.go @@ -3,6 +3,7 @@ package buffalo import ( "net/http" + "github.com/gobuffalo/x/httpx" gcontext "github.com/gorilla/context" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -39,12 +40,15 @@ func (a *App) newContext(info RouteInfo, res http.ResponseWriter, req *http.Requ session := a.getSession(req, ws) + ct := httpx.ContentType(req) contextData := map[string]interface{}{ "app": a, "env": a.Env, "routes": a.Routes(), "current_route": info, "current_path": req.URL.Path, + "contentType": ct, + "method": req.Method, } for _, route := range a.Routes() { @@ -53,14 +57,15 @@ func (a *App) newContext(info RouteInfo, res http.ResponseWriter, req *http.Requ } return &DefaultContext{ - Context: req.Context(), - response: ws, - request: req, - params: params, - logger: a.Logger, - session: session, - flash: newFlash(session), - data: contextData, + Context: req.Context(), + contentType: ct, + response: ws, + request: req, + params: params, + logger: a.Logger, + session: session, + flash: newFlash(session), + data: contextData, } } diff --git a/render/auto.go b/render/auto.go new file mode 100644 index 000000000..2c026967f --- /dev/null +++ b/render/auto.go @@ -0,0 +1,189 @@ +package render + +import ( + "context" + "fmt" + "io" + "reflect" + "regexp" + "strings" + + "github.com/markbates/inflect" + "github.com/pkg/errors" +) + +var errNoID = errors.New("no ID on model") + +// ErrRedirect indicates to Context#Render that this is a +// redirect and a template shouldn't be rendered. +type ErrRedirect struct { + Status int + URL string +} + +func (ErrRedirect) Error() string { + return "" +} + +// Auto figures out how to render the model based information +// about the request and the name of the model. Auto supports +// automatic rendering of HTML, JSON, and XML. Any status code +// give to Context#Render between 300 - 400 will be respected +// by Auto. Other status codes are not. +/* +# Rules for HTML template lookup: +GET /users - users/index.html +GET /users/id - users/show.html +GET /users/new - users/new.html +GET /users/id/edit - users/edit.html +POST /users - (redirect to /users/id or render user/new.html) +PUT /users/edit - (redirect to /users/id or render user/edit.html) +DELETE /users/id - redirect to /users +*/ +func Auto(ctx context.Context, i interface{}) Renderer { + e := New(Options{}) + return e.Auto(ctx, i) +} + +// Auto figures out how to render the model based information +// about the request and the name of the model. Auto supports +// automatic rendering of HTML, JSON, and XML. Any status code +// give to Context#Render between 300 - 400 will be respected +// by Auto. Other status codes are not. +/* +# Rules for HTML template lookup: +GET /users - users/index.html +GET /users/id - users/show.html +GET /users/new - users/new.html +GET /users/id/edit - users/edit.html +POST /users - (redirect to /users/id or render user/new.html) +PUT /users/edit - (redirect to /users/id or render user/edit.html) +DELETE /users/id - redirect to /users +*/ +func (e *Engine) Auto(ctx context.Context, i interface{}) Renderer { + ct, ok := ctx.Value("contentType").(string) + if !ok { + ct = "text/html" + } + ct = strings.ToLower(ct) + + if strings.Contains(ct, "json") { + return e.JSON(i) + } + + if strings.Contains(ct, "xml") { + return e.XML(i) + } + + return htmlAutoRenderer{ + Engine: e, + model: i, + } +} + +type htmlAutoRenderer struct { + *Engine + model interface{} +} + +func (htmlAutoRenderer) ContentType() string { + return "text/html" +} + +func (ir htmlAutoRenderer) Render(w io.Writer, data Data) error { + name := inflect.Name(ir.typeName()) + name = inflect.Name(name.Singular()) + pname := inflect.Name(name.Plural()) + + if ir.isPlural() { + data[pname.VarCasePlural()] = ir.model + } else { + data[name.VarCaseSingular()] = ir.model + } + + switch data["method"] { + case "PUT", "POST": + if err := ir.redirect(pname, w, data); err != nil { + if er, ok := err.(ErrRedirect); ok && er.Status >= 300 && er.Status < 400 { + return err + } + if data["method"] == "PUT" { + return ir.HTML(fmt.Sprintf("%s/edit.html", pname.File())).Render(w, data) + } + return ir.HTML(fmt.Sprintf("%s/new.html", pname.File())).Render(w, data) + } + return nil + case "DELETE": + return ErrRedirect{ + Status: 302, + URL: "/" + pname.URL(), + } + } + if cp, ok := data["current_path"].(string); ok { + if strings.HasSuffix(cp, "/edit") { + return ir.HTML(fmt.Sprintf("%s/edit.html", pname.File())).Render(w, data) + } + if strings.HasSuffix(cp, "/new") { + return ir.HTML(fmt.Sprintf("%s/new.html", pname.File())).Render(w, data) + } + + x, err := regexp.Compile(fmt.Sprintf("%s/.+", pname.URL())) + if err != nil { + return errors.WithStack(err) + } + if x.MatchString(cp) { + return ir.HTML(fmt.Sprintf("%s/show.html", pname.File())).Render(w, data) + } + } + + return ir.HTML(fmt.Sprintf("%s/%s.html", pname.File(), "index")).Render(w, data) +} + +func (ir htmlAutoRenderer) redirect(name inflect.Name, w io.Writer, data Data) error { + rv := reflect.Indirect(reflect.ValueOf(ir.model)) + f := rv.FieldByName("ID") + if !f.IsValid() { + return errNoID + } + + fi := f.Interface() + rt := reflect.TypeOf(fi) + zero := reflect.Zero(rt) + if fi != zero.Interface() { + url := fmt.Sprintf("/%s/%v", name.URL(), f.Interface()) + + code := 302 + if i, ok := data["status"].(int); ok { + if i >= 300 { + code = i + } + } + return ErrRedirect{ + Status: code, + URL: url, + } + } + return errNoID +} + +func (ir htmlAutoRenderer) typeName() string { + rv := reflect.Indirect(reflect.ValueOf(ir.model)) + rt := rv.Type() + switch rt.Kind() { + case reflect.Slice, reflect.Array: + el := rt.Elem() + return el.Name() + default: + return rt.Name() + } +} + +func (ir htmlAutoRenderer) isPlural() bool { + rv := reflect.Indirect(reflect.ValueOf(ir.model)) + rt := rv.Type() + switch rt.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + return true + } + return false +} diff --git a/render/auto_test.go b/render/auto_test.go new file mode 100644 index 000000000..f71319702 --- /dev/null +++ b/render/auto_test.go @@ -0,0 +1,271 @@ +package render_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gobuffalo/buffalo" + "github.com/gobuffalo/buffalo/render" + "github.com/gobuffalo/packr" + "github.com/markbates/willie" + "github.com/stretchr/testify/require" +) + +type Car struct { + ID int + Name string +} + +type Cars []Car + +func Test_Auto_JSON(t *testing.T) { + r := require.New(t) + + app := buffalo.New(buffalo.Options{}) + app.GET("/cars", func(c buffalo.Context) error { + return c.Render(200, render.Auto(c, []string{"Honda", "Toyota", "Ford", "Chevy"})) + }) + + w := willie.New(app) + + res := w.JSON("/cars").Get() + r.Equal(`["Honda","Toyota","Ford","Chevy"]`, strings.TrimSpace(res.Body.String())) +} + +func Test_Auto_XML(t *testing.T) { + r := require.New(t) + + app := buffalo.New(buffalo.Options{}) + app.GET("/cars", func(c buffalo.Context) error { + return c.Render(200, render.Auto(c, []string{"Honda", "Toyota", "Ford", "Chevy"})) + }) + + w := willie.New(app) + + res := w.XML("/cars").Get() + r.Equal("Honda\nToyota\nFord\nChevy", strings.TrimSpace(res.Body.String())) +} + +func Test_Auto_HTML_List(t *testing.T) { + r := require.New(t) + + err := withHTMLFile("cars/index.html", "INDEX: <%= len(cars) %>", func(e *render.Engine) { + app := buffalo.New(buffalo.Options{}) + app.GET("/cars", func(c buffalo.Context) error { + return c.Render(200, e.Auto(c, Cars{ + {Name: "Ford"}, + {Name: "Chevy"}, + })) + }) + + w := willie.New(app) + res := w.HTML("/cars").Get() + + r.Contains(res.Body.String(), "INDEX: 2") + }) + r.NoError(err) +} + +func Test_Auto_HTML_Show(t *testing.T) { + r := require.New(t) + + err := withHTMLFile("cars/show.html", "Show: <%= car.Name %>", func(e *render.Engine) { + app := buffalo.New(buffalo.Options{}) + app.GET("/cars/{id}", func(c buffalo.Context) error { + return c.Render(200, e.Auto(c, Car{Name: "Honda"})) + }) + + w := willie.New(app) + res := w.HTML("/cars/1").Get() + r.Contains(res.Body.String(), "Show: Honda") + }) + r.NoError(err) +} + +func Test_Auto_HTML_New(t *testing.T) { + r := require.New(t) + + err := withHTMLFile("cars/new.html", "New: <%= car.Name %>", func(e *render.Engine) { + app := buffalo.New(buffalo.Options{}) + app.GET("/cars/new", func(c buffalo.Context) error { + return c.Render(200, e.Auto(c, Car{Name: "Honda"})) + }) + + w := willie.New(app) + res := w.HTML("/cars/new").Get() + r.Contains(res.Body.String(), "New: Honda") + }) + r.NoError(err) +} + +func Test_Auto_HTML_Create(t *testing.T) { + r := require.New(t) + + err := withHTMLFile("cars/new.html", "New: <%= car.Name %>", func(e *render.Engine) { + app := buffalo.New(buffalo.Options{}) + app.POST("/cars", func(c buffalo.Context) error { + return c.Render(201, e.Auto(c, Car{Name: "Honda"})) + }) + + w := willie.New(app) + res := w.HTML("/cars").Post(nil) + r.Contains(res.Body.String(), "New: Honda") + }) + r.NoError(err) +} + +func Test_Auto_HTML_Create_Redirect(t *testing.T) { + r := require.New(t) + + app := buffalo.New(buffalo.Options{}) + app.POST("/cars", func(c buffalo.Context) error { + return c.Render(201, render.Auto(c, Car{ + ID: 1, + Name: "Honda", + })) + }) + + w := willie.New(app) + res := w.HTML("/cars").Post(nil) + r.Equal("/cars/1", res.Location()) + r.Equal(302, res.Code) +} + +func Test_Auto_HTML_Create_Redirect_Error(t *testing.T) { + r := require.New(t) + + err := withHTMLFile("cars/new.html", "Create: <%= car.Name %>", func(e *render.Engine) { + app := buffalo.New(buffalo.Options{}) + app.POST("/cars", func(c buffalo.Context) error { + b := Car{ + Name: "Honda", + } + return c.Render(422, e.Auto(c, b)) + }) + + w := willie.New(app) + res := w.HTML("/cars").Post(nil) + r.Equal(422, res.Code) + r.Contains(res.Body.String(), "Create: Honda") + }) + r.NoError(err) +} + +func Test_Auto_HTML_Edit(t *testing.T) { + r := require.New(t) + + err := withHTMLFile("cars/edit.html", "Edit: <%= car.Name %>", func(e *render.Engine) { + app := buffalo.New(buffalo.Options{}) + app.GET("/cars/{id}/edit", func(c buffalo.Context) error { + return c.Render(200, e.Auto(c, Car{Name: "Honda"})) + }) + + w := willie.New(app) + res := w.HTML("/cars/1/edit").Get() + r.Contains(res.Body.String(), "Edit: Honda") + }) + r.NoError(err) +} + +func Test_Auto_HTML_Update(t *testing.T) { + r := require.New(t) + + err := withHTMLFile("cars/edit.html", "Update: <%= car.Name %>", func(e *render.Engine) { + app := buffalo.New(buffalo.Options{}) + app.PUT("/cars/{id}", func(c buffalo.Context) error { + return c.Render(200, e.Auto(c, Car{Name: "Honda"})) + }) + + w := willie.New(app) + res := w.HTML("/cars/1").Put(nil) + + r.Contains(res.Body.String(), "Update: Honda") + }) + r.NoError(err) +} + +func Test_Auto_HTML_Update_Redirect(t *testing.T) { + r := require.New(t) + + app := buffalo.New(buffalo.Options{}) + app.PUT("/cars/{id}", func(c buffalo.Context) error { + b := Car{ + ID: 1, + Name: "Honda", + } + return c.Render(200, render.Auto(c, b)) + }) + + w := willie.New(app) + res := w.HTML("/cars/1").Put(nil) + r.Equal("/cars/1", res.Location()) + r.Equal(302, res.Code) +} + +func Test_Auto_HTML_Update_Redirect_Error(t *testing.T) { + r := require.New(t) + + err := withHTMLFile("cars/edit.html", "Update: <%= car.Name %>", func(e *render.Engine) { + app := buffalo.New(buffalo.Options{}) + app.PUT("/cars/{id}", func(c buffalo.Context) error { + b := Car{ + ID: 1, + Name: "Honda", + } + return c.Render(422, e.Auto(c, b)) + }) + + w := willie.New(app) + res := w.HTML("/cars/1").Put(nil) + r.Equal(422, res.Code) + r.Contains(res.Body.String(), "Update: Honda") + }) + r.NoError(err) +} + +func Test_Auto_HTML_Destroy_Redirect(t *testing.T) { + r := require.New(t) + + app := buffalo.New(buffalo.Options{}) + app.DELETE("/cars/{id}", func(c buffalo.Context) error { + b := Car{ + ID: 1, + Name: "Honda", + } + return c.Render(200, render.Auto(c, b)) + }) + + w := willie.New(app) + res := w.HTML("/cars/1").Delete() + r.Equal("/cars", res.Location()) + r.Equal(302, res.Code) +} + +func withHTMLFile(name string, contents string, fn func(*render.Engine)) error { + tmpDir := filepath.Join(os.TempDir(), filepath.Dir(name)) + err := os.MkdirAll(tmpDir, 0766) + if err != nil { + return err + } + defer os.Remove(tmpDir) + + tmpFile, err := os.Create(filepath.Join(tmpDir, filepath.Base(name))) + if err != nil { + return err + } + defer os.Remove(tmpFile.Name()) + + _, err = tmpFile.Write([]byte(contents)) + if err != nil { + return err + } + + e := render.New(render.Options{ + TemplatesBox: packr.NewBox(os.TempDir()), + }) + + fn(e) + return nil +}