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

[FEATURE REQUEST] MVC - More Elegent OnErrorCode registration? #1595

Closed
AlbinoGeek opened this issue Aug 16, 2020 · 12 comments
Closed

[FEATURE REQUEST] MVC - More Elegent OnErrorCode registration? #1595

AlbinoGeek opened this issue Aug 16, 2020 · 12 comments

Comments

@AlbinoGeek
Copy link

Summary

Chances are I've just missed something in the documentation, but my current way of registering error handlers in an MVC Controller is rather... inelegant. I basically took my existing non-MVC code and dropped it directly into a Controller file.

Code Currently

package main

import (
	"fmt"
	"time"

	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/mvc"
	"github.com/spf13/viper"
)

// PublicController represents all the static pages of our core website
type PublicController struct{}

// BeforeActivation is fired by `iris` once before the `PublicController` is registered
func (c *PublicController) BeforeActivation(b mvc.BeforeActivation) {
	// is there a batter place to register views in an MVC Controller?
	b.Router().RegisterView(iris.HTML(viper.GetString("TemplatesDir"), ".html").
		Layout("layouts/base.html").
		Reload(viper.GetBool("DevMode")))

	// this particular portion does not look very elegant
	b.Router().OnErrorCode(iris.StatusNotFound, func(ctx iris.Context) {
		ctx.ViewData("title", fmt.Sprintf("%s - Not Found", viper.GetString("SiteTitle")))
		ctx.StatusCode(iris.StatusNotFound)
		if err := ctx.View("404.html"); err != nil {
			ctx.StatusCode(iris.StatusInternalServerError)
			ctx.Writef(err.Error())
		}
	})
}

// Get represents our index
func (c *PublicController) Get() mvc.Result {
	return mvc.View{Name: "index.html"}
}

Suggestions

Rely on Golang 1.13+ errors.As

https://blog.golang.org/go1.13-errors

type mvcDotError struct { // off in mvc as Error ( mvc.Error )
	Code int // compiler didn't like iris.StatusCode
	Err  error
}

func (e mvcDotError) Error() string { // required to satisfy `errors.As`
	return fmt.Sprintf("mvc error %d: %s", e.Code, e.Err.Error())
}

// since you already have an HandleError, why not have it do both?
// it would be really nice (consistent) if we could return an mvc.Result here
func (c *PublicController) HandleError(ctx iris.Context, err error) {
	var mvcErr mvcDotError // ugly but unavoidable

	// check if we have an mvc.Error
	if ok := errors.As(err, &mvcErr); ok {
		switch mvcErr.Code {
		case iris.StatusNotFound:
			ctx.View("404.html")
		default:
			ctx.View("500.html")
		}
		return
	}

	// we don't have an mvc.Error
	ctx.HTML(fmt.Sprintf("<i>%s</i>", err.Error()))
}

Support Golang <1.13 without errors.As

// StatusNotFound represents our 404 handler
func (c *PublicController) StatusNotFound() mvc.Result {
	return mvc.View{Name: "404.html"}
}

// or perhaps less specific?
// but "Error" already means an MVC Error.. HandleError + OnError seem redundant
// either way, it would be really nice (consistent) if we could return an mvc.Result here
func (c *PublicController) OnError(code iris.StatusCode) mvc.Result {
	select code {
		case iris.StatusNotFound:
			return mvc.View{Name: "404.html"}
		default:
			return mvc.View{Name: "500.html"}
	}
}
@kataras
Copy link
Owner

kataras commented Aug 18, 2020

Hello @AlbinoGeek,

This is relatively to : Question: MVC Custom Error Handler? (#1244).

For users who read this post, you can already do this to handle the errors output of a controller method:

mvcApp := mvc.New(app)
mvcApp.HandleError(func(ctx iris.Context, err error) {
	ctx.HTML(fmt.Sprintf("<b>%s</b>", err.Error()))
})
mvcApp.Handle(new(myController))

OR inside a controller:

type myController struct {
}

// overriddes the mvcApp.HandleError function for this controller.
func (c *myController) HandleError(ctx iris.Context, err error) {
     ctx.HTML(fmt.Sprintf("<i>%s</i>", err.Error()))
}

func (c *myController) Get() error {
   return errors.New("test error")
} 

So the suggestion of yours is to handle HTTP errors inside the controller itself? I mean it can be possible now that we support OnErrorCode per party, but that may conflict if you have more than one controller registered in the same Party handling the same error code... We solve the same method-path on the more than one controller by Overlap routing feature but errors cannot be overlapped like that and it will add so much coblexity into the code.

We need to think and discuss of it

@AlbinoGeek
Copy link
Author

AlbinoGeek commented Aug 18, 2020

Well, does the current code allow multiple OnErrorCode to be registered on the same code, and what happens in this case?

If it does, perhaps that is not much different than this case. Also, in these examples, if you returned nil, it would simply fall back to the next error handler (since this one didn't process the error) -- is how I would expect it to work. ( in the examples where it expected an mvc.Result that is.)

Otherwise, they could be stacked like Middleware if you really wanted multiple error handlers (I could see use for this), such as ctx.Next() as is used elsewhere in the framework.

Example of stacking use case:

  • Router has a GetBy on a similar route, but you 404'd with machine-correctable syntax error

in SEO-inspired URL environments, e.g:
/users/1 -> /users/1-joe
/forums/1 -> /forums/1-announcements
/issues/1595 -> /issues/1595-feature-request-mvc-more-elegent-onerrorcode-registration

Although, yes, technically this could also be solved using a controller pre-handler (Use) that wraps/redirects the URLs to the preferred format... although, it would be technically a 404 already, so we're talking UseError at best?

That makes me wonder, how does UseError then differ from OnErrorCode on multiple registration?

@AlbinoGeek
Copy link
Author

AlbinoGeek commented Aug 18, 2020

On further thought, I am leaning towards the implementation:

func (c *PublicController) OnError(code iris.StatusCode, [ctx iris.Context]) mvc.Result {
	select code {
		case iris.StatusNotFound:
			return mvc.View{Name: "404.html"}
		default:
			return mvc.View{Name: "500.html"}
	}
}

Because then it doubles as a fail-through that I can call from my other routes, such as in the GetBy example

func (c *UserController) GetBy(userid uint64, ctx iris.Context) mvc.Result {
	user, err := c.userRepository.FindByID(userid)
	if _, notFound := err.(db.NoRecordError); notFound {
		// follows DRY on handling errors in one place in the controller
		// v.s. literally in every GetBy, PostBy, etc etc.
		return c.OnError(iris.NotFound, ctx)
	}
	// ... existing code
}

@kataras
Copy link
Owner

kataras commented Aug 18, 2020

Well, does the current code allow multiple OnErrorCode to be registered on the same code, and what happens in this case?

Yes, they run as middlewares, chain of handlers with ctx.Next() on Party.On(Any)ErrorCode(...handlers) (note that, it differs from UseError, the UseError is per-party for all error codes and they run even if not a custom error handler is registered).


@AlbinoGeek you don't need to call a c.OnError.

By using custom mvc.Result

You can just create an mvc.Result which will can be an error type too.

The below creates a custom mvc.Result by impl the Dispatch method. This will not send the errorResponse as JSON, instead, the structure itself decides the final result (e.g ctx.View). This is much prefferable way, it can be shared across multiple controllers, it's straight and simple.

package main

import (
	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/mvc"
)

func main() {
	app := iris.New()
	app.RegisterView(iris.HTML("./views", ".html"))

	m := mvc.New(app)
	m.Handle(new(controller))

	app.Listen(":8080")
}

type errorResponse struct {
	Code    int
	Message string
}

/*
// Note: if a struct implements the standard go error, so it's an error
// and its Error() is not empty, then its text will be rendered instead,
// override any Dispatch method.
func (e errorResponse) Error() string {
	return e.Message
}
*/

// implements mvc.Result.
func (e errorResponse) Dispatch(ctx iris.Context) {
	// If u want to use mvc.Result on any method without an output return value
	// go for it:
	//
	view := mvc.View{Code: e.Code, Data: e} // use Code and Message as the template data.

	// TIP: convert e.Code to string and fire the
	// desired vie automatically for known errors that can be rendered
	// through existing view files.
	// view.Name = fmt.Sprintf("%d", e.Code)
	// and view.Dispath(ctx)

	switch e.Code {
	case iris.StatusNotFound:
		view.Name = "404"
	default:
		view.Name = "500"
	}
	view.Dispatch(ctx)

	// Otherwise use ctx methods:
	//
        // if r.Code > 0 {
        //    ctx.StatusCode(r.Code)
        // }
	// switch e.Code {
	// case iris.StatusNotFound:
	// 	// use Code and Message as the template data.
	// 	ctx.View("404.html", e)
	// default:
	// 	ctx.View("500.html", e)
	// }
}

type controller struct{}

type user struct {
	ID uint64 `json:"id"`
}

func (c *controller) GetBy(userid uint64) mvc.Result {
	if userid != 1 {
		return errorResponse{
			Code:    iris.StatusNotFound,
			Message: "User Not Found",
		}
	}

	return mvc.Response{
		Object: user{ID: userid},
	}
}

By Hijacking the response type and value

package main

import (
	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/mvc"
)

func main() {
	app := iris.New()
	app.RegisterView(iris.HTML("./views", ".html"))

	// Hijack each output value of a method (can be used per-party too).
	app.ConfigureContainer().
		UseResultHandler(func(next iris.ResultHandler) iris.ResultHandler {
			return func(ctx iris.Context, v interface{}) error {
				switch val := v.(type) {
				case errorResponse:
					return next(ctx, errorView(val))
				default:
					return next(ctx, v)
				}
			}
		})

	m := mvc.New(app)
	m.Handle(new(controller))

	app.Listen(":8080")
}

func errorView(e errorResponse) mvc.Result {
	switch e.Code {
	case iris.StatusNotFound:
		return mvc.View{Code: e.Code, Name: "404.html", Data: e}
	default:
		return mvc.View{Code: e.Code, Name: "500.html", Data: e}
	}
}

type errorResponse struct {
	Code    int
	Message string
}

type controller struct{}

type user struct {
	ID uint64 `json:"id"`
}

func (c *controller) GetBy(userid uint64) interface{} {
	if userid != 1 {
		return errorResponse{
			Code:    iris.StatusNotFound,
			Message: "User Not Found",
		}
	}

	return user{ID: userid}
}

By implementing PreflightResult (my favorite method)

And optionally will implement the PreflightResult interface if you want to do more, example: (An mvc.Result can implement PreflightResult too):

package main

import (
	"fmt"
	"time"

	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/mvc"
)

func main() {
	app := iris.New()
	app.RegisterView(iris.HTML("./views", ".html"))

	m := mvc.New(app)
	m.Handle(new(controller))

	app.Listen(":8080")
}

type controller struct{}

// Generic response type for JSON results.
type response struct {
	ID        uint64      `json:"id,omitempty"`
	Data      interface{} `json:"data,omitempty"` // {data: result } on fetch actions.
	Code      int         `json:"code,omitempty"`
	Message   string      `json:"message,omitempty"`
	Timestamp int64       `json:"timestamp,omitempty"`
}

func (r response) Preflight(ctx iris.Context) error {
	if r.ID > 0 {
		r.Timestamp = time.Now().Unix()
	}

	if code := r.Code; code > 0 {
		// You can call ctx.View or mvc.Vew{...}.Dispatch
		// to render HTML on Code != 200
		// but in order to not proceed with the response resulting
		// as JSON you MUST return the iris.ErrStopExecution error.
		// Example:
		if code != 200 {
			mvc.View{
				/* calls the ctx.StatusCode */
				Code: code,
				/* use any r.Data as the template data
				OR the whole "response" as its data. */
				Data: r,
				/* automatically pick the template per error (just for the sake of the example) */
				Name: fmt.Sprintf("%d", code),
			}.Dispatch(ctx)

			return iris.ErrStopExecution
		}

		ctx.StatusCode(r.Code)
	}

	return nil
}

type user struct {
	ID uint64 `json:"id"`
}

func (c *controller) GetBy(userid uint64) response {
	if userid != 1 {
		return response{
			Code:    iris.StatusNotFound,
			Message: "User Not Found",
		}
	}

	return response{
		ID:   userid,
		Data: user{ID: userid},
	}
}

You can use that response on non-mvc applications too, using handlers:

c := app.ConfigureContainer()
c.Get("/{id:uint64}", getUserByID)
func getUserByID(id uint64) response {
	if userid != 1 {
		return response{
			Code:    iris.StatusNotFound,
			Message: "User Not Found",
		}
	}

	return response{
		ID:   userid,
		Data: user{ID: userid},
	}
}

You can still use HandleError for catching http errors manually, e.g. ctx.StatusCode(404) on controller method and if ctx.GetStatusCode() == 404 ... handle the "err" and fire a custom page. The only difference of your proposal vs the existing interface is that both Preflight and HandleError accept the iris.Context and they should handle the error without output values (But as we see above, any mvc.Result can be called through normal Handler (errorResponse.Dispatch)), your proposal has output values on error handling: can I ask what happen if that is failed too? who is responsible to handle that error handler's error? Another error handler? and what it looks like? it will return output values too ? and if so, who is responsible to handle the error handler's error handler's error?... you see how that thing is moving on. I need a good example on why existing features is not suitable for this, but without risking looping between error handlers for-ever.

@AlbinoGeek
Copy link
Author

I do apologize in my delay on updating this issue, as you have given such a wonderfully detailed response. I have been working on implementing each of these suggestions and figuring out which one matches my requirements, to produce what would amount to a documented response, with some examples of usage that could be committed to the MVC _examples directory based on your code samples above.

You put up some very valid questions in the above, and I'd love to give these each the attention they need! I should be able to over this weekend, to give this thread a proper response.

@AlbinoGeek
Copy link
Author

AlbinoGeek commented Aug 22, 2020

Alright, perhaps I've gone about this wrong, but it would appear the above only really works for JSON formatted data.

When I attempted to implement the Preflight example for serving HTML pages for example, I got JSON instead:

Preflight Method

type PublicResponse mvc.View

func (r PublicResponse) Preflight(ctx iris.Context) error {
	if r.Code == 0 { // for some reason, Code is always 0
		r.Code = 200
	}

	ctx.StatusCode(r.Code)
	if r.Code != 200 {
		mvc.View{ // unnecessary copy perhaps? but no dispatch if direct inherit
			Name:   fmt.Sprintf("%d.html", r.Code),
			Code:   r.Code,
			Data:   r.Data,
			Err:    r.Err,
			Layout: r.Layout,
		}.Dispatch(ctx)
		return iris.ErrStopExecution
	}

	return nil
}

func (c *PublicController) Get() PublicResponse {
	return PublicResponse{
		Name: "index.html",
	}
}

Result

{
  "Name": "index.html",
  "Layout": "",
  "Data": null, /* did not respect ViewData */
  "Code": 0,    /* did not even fire the Preflight, as code would have been 200 then */
  "Err": null
}

And it doesn't handle 404s, 500s, etc.

Custom mvc.Result

type PublicResponse mvc.View

func (r PublicResponse) Dispatch(ctx iris.Context) {
	if r.Code == 0 { // correct for writeHeader 0 panic
		r.Code = 200
	}

	if r.Code != 200 { // catch and redirect error codes
		r.Name = fmt.Sprintf("%d.html", r.Code)
	}

	ctx.StatusCode(r.Code)
	mvc.View{
		Name:   r.Name,
		Code:   r.Code,
		Data:   r.Data,
		Err:    r.Err,
		Layout: r.Layout,
	}.Dispatch(ctx)
}

func (c *PublicController) Get() mvc.Result {
	return PublicResponse{
		Name: "index.html",
	}
}

Result

It works!

But, it doesn't handle 404s, 500s, etc.

Hijacking Response Type and Value

Not attempted, because I couldn't see a way to attach it to an MVC Controller only.

HandleError

func (c *PublicController) HandleError() error {
	return fmt.Errorf("Testing, 123!")
}

404s, 500s, etc did not trigger this code, so I'm still not sure what HandleError is actually for.


Basically, my goal was to manage all errors in one place per controller.

Something that would avoid the explicit running of a giant list of OnErrorCode per Controller BeforeActivation, per Controller.

Currently, because of signatures, I can't even use the same function to handle regular errors as method errors, because:

image


Not to mention the requirement to both Use and UseGlobal middleware to make them caught for error pages :/

func (c *PublicController) BeforeActivation(b mvc.BeforeActivation) {
	// request ID supplement
	b.Router().Use(requestid.New())
	b.Router().UseError(requestid.New())

	// require basicauth
	auth := basicauth.New(basicauth.Config{
		Users:   viper.GetStringMapString("AuthorizedUsers"),
		Realm:   "Authorization Required", // defaults to "Authorization Required"
		Expires: time.Duration(30) * time.Minute,
	})
	b.Router().Use(auth)
	b.Router().UseError(auth)

	// cache pages
	b.Router().Use(cache.Handler(viper.Get("StaticPageCacheTime").(time.Duration)))

	// compress pages
	b.Router().Use(iris.Compression)
	b.Router().UseError(iris.Compression)

	// load templates used by this subdomain
	b.Router().RegisterView(iris.HTML(viper.GetString("TemplatesDir"), ".html").
		Layout("layouts/base.html").
		Reload(viper.GetBool("DevMode")))

	// make sure ViewData is populated as expected
	b.Router().Use(populateViewData)
	b.Router().UseError(populateViewData)

	// 404 Handler - this isn't elegant if I want to implement multiple error handlers per Controller
	b.Router().OnErrorCode(iris.StatusNotFound, func(ctx iris.Context) {
		ctx.ViewData("title", fmt.Sprintf("%s - Not Found", viper.GetString("SiteTitle")))
		ctx.StatusCode(iris.StatusNotFound)
		if err := ctx.View("404.html"); err != nil {
			ctx.StatusCode(iris.StatusInternalServerError)
			ctx.Writef(err.Error())
		}
	})
}

@kataras
Copy link
Owner

kataras commented Aug 22, 2020

Hello @AlbinoGeek, no worries about the delayed response, you do well thinking one of those.

Let me explain

  • why your Preflight does not work:

The type PublicResponse mvc.View is the same as type PublicResponse struct { mvc.View } so Iris will try to render it as JSON (unless otherwise specified by client's accept content type or a custom Register dependency of that type is provided by the end-developer). And it does not fire a result on 404 too because you return it with a status code of 0 always on the Get method, and then you set it 200, so it's always 200 so the r.Code != 200 will never be executed. That's why it doesn't work, please take a look a closer look of the Preflight example: it does fire 404.html on errors, and JSON on success because I though by your example that you wanted that exact behavior.

  • the Custom Result:

First of all no need to make another copy of mvc.View. And again, in your Get method:

	return PublicResponse{
		Name: "index.html",
	}

You never gave it a Code, so it will always fired as 200. Also, a note ctx.StatusCode(r.Code) is not required when the mvc view has a Code field != 0. If you see my above examples, the status code is not given when the view is executed.

  • About the last,

Not to mention the requirement to both Use and UseGlobal middleware to make them caught for error pages :/

You do not need to register middlewares in your MVC application when they already registered. The b.Router is just the Party that you used to create the mvc.New/Configure, if you already have registered OnErrorCode, Use, UseRouter and e.t.c. you don't have to do that, indeed this is wrong to re-register them, some of them will fired twice

@kataras
Copy link
Owner

kataras commented Aug 22, 2020

@AlbinoGeek If you really need to handle http errors inside the Controller I can implement a HandleHTTPError(code int) <T | Result (View and e.t.c.)> hope that will not complicate things.

@AlbinoGeek
Copy link
Author

In the interest for security, easy maintainability, etc-- I had registered middleware specifically on the Controller because I do not want Compression on the dev AJAX endpoints for example, because these already emit Compressed data, nor apply the same Cache to public data as I do the protected data to avoid those sort of attacks, among other things. I also have different ViewData accessible within an Admin / protected controller than is available in the PublicController.

That being said, could I be using UseRouter for this, or that wouldn't work the same?

And yes, the issue with Preflight was more or less (after your explanation) that it didn't handle standard HTTP Errors, such as a 404 resulting from a path under the Controller that wasn't registered. E.g: a controller mounted at / where someone then went to /asdf but there was no GetAsdf method, I would like to handle in one place within the Controller.

@kataras
Copy link
Owner

kataras commented Aug 22, 2020

package main

import (
	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/mvc"
)

func main() {
	app := iris.New()
	app.Logger().SetLevel("debug")

	app.RegisterView(iris.HTML("./views", ".html"))

	m := mvc.New(app)
	m.Handle(new(controller))

	app.Listen(":8080")
}

type controller struct {
}

func (c *controller) Get() string {
	return "Hello!"
}

// The input parameter of mvc.Code is optional but a good practise to follow.
// You could  register a Context and get its error code through ctx.GetStatusCode().
//
// This can accept dependencies and output values like any other Controller Method,
// however be careful if your registered dependencies depend only on succesful(200...) requests.
//
// Also, take a note that if you register more than one controller.HandleHTTPError
// in the same Party, you need to use the RouteOverlap feature as shown
// in the "authenticated-controller" example, and a dependency on
// a controller's field (or method's input argument) is required
// to select which, between those two controllers, is responsible
// to handle http errors.
func (c *controller) HandleHTTPError(statusCode mvc.Code) mvc.View {
	code := int(statusCode) // cast it to int.

	view := mvc.View{
		Code: code,
		Name: "unexpected-error.html",
	}

	switch code {
	case 404:
		view.Name = "404.html"
		// [...]
	case 500:
		view.Name = "500.html"
	}

	return view
}

image

@AlbinoGeek does something like that suits your needs? (I've managed to do it without huge coblexity or breaking changes)

Note that in logs, now the Party.OnAnyErrorCode and Controller.HandleHTTPError will not log all error codes, but just the ERR thing and the file:line of the handler.

@AlbinoGeek
Copy link
Author

This is awesome! This code change has removed 206 mostly duplicate lines of code from my models. I especially appreciated that when a specified HTML file was not found, the code was served up with no content (allowing the browser to render its best approximation for the respective error supplied), which means I can fall-through to 403 and friends if I needed to, which is a wonderful plus!

The only thing I would ask, is that to be consistent with other APIs, is there any reason your example returned a mvc.View and not an mvc.Result like the others? I was able to change the function usage as follows, and achieved great success:

func (c *PublicController) HandleHTTPError(ctx iris.Context, statusCode mvc.Code) mvc.Result {
	view := mvc.View{
		Code: int(statusCode),
	}
	switch view.Code {
	case 400, 403, 404, 410, 418, 451, 502, 504, 511:
		view.Name = fmt.Sprintf("error-%d.html", view.Code)
	case 429, 503:
		ctx.Header("Retry-After", c.whenShouldRetry(ctx))
	default:
		view.Name = "error-500.html"
	}
	return view
}

For me, I'd call this resolved and then some.

@kataras
Copy link
Owner

kataras commented Aug 22, 2020

@AlbinoGeek Thanks, I am glad that helped!!

, is there any reason your example returned a mvc.View and not an mvc.Result like the others?

No, it's the same. The mvc.View is one builtin struct which implements the mvc.Result, it's my code-style to specify the types I return even if I could just return an interface whenever that is possible, so on big codebases I can see the types with first. It's absolutely fine to return mvc.Result everywhere, it's appealing to the eye and I would use that too if all my methods would return mvc.Result. Also, take note that you can use MVC for JSON/protobuf responses,e.g. Get() User {..return User{}} or Get() User, error and if an error is a Preflight then you can handle the status code and e.t.c. as we seen above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants