go /

Building view-trees: What's up with Renderables [Part-7]

.md | permalink | Published on January 04, 2024

Previously: intro, the basics, error handling, async data fetching, http.Handler, and more.


hmmm

Ok, so I'm starting here not knowing exactly where I want to go, and then using this to move forward. Going forward can mean staying where we are with our design.

Following up on the last post, there still is something that doesn't feel right with the design of the library.

Mainly, the difference between the following interface functions:

  • Renderable(context.Context) (Renderable, error)
  • RequestRenderable(*http.Request) (AsRenderable, error)
  • ErrorRenderable(context.Context, error) (AsRenderable, error)

For the first one, we covered that it's useful for us to have something that is representalbe this way– fine. For the second and third however, we have two things that seem different but are closely related.

  1. We don't need to pass a context for RequestRenderable becuase a Request has a context, otherwise I'd be passing func(ctx, r).
  2. We might not need to pass an err to ErrorRenderable since a context can have a CancelCause, but either way we come from a place of context and value.

It brings up the question, and one of the reasons I punted on errors after solving errors: Should they be unified?

  • Should the library follow the principle: everything only gets a context.Context, implying the existence of a *veun.Error which has a Context interface similar to that of *http.Request?

  • Should the library follow the principle: only pass a context and you use that to determine your current state to know what kind of renderable you are producing ala ctx.Err() and ctx.Cause()? One can attach arbitrary values to Context and/or create different ones for different use cases.

Exploration

Writing our own context.Context wrapper would mean that the interface implementor would have to dispatch on some kind of switch statement...

func(ctx veun.Context) (AsRenderable, error) {
    switch ctx.Type() {
    case veun.Request:
        // ... extract the request
    }
}

The above feels like code that would be fairly error prone. There's another way to represent a similar thing, and it's really not bad at all.

func(ctx veun.Context) (veun.AsRenderable, error) {
    return ctx.Renderable(veun.R{
        Request: func(r *http.Request) (veun.AsRenderable, http.Handler, error) {
            // ctx, and r are available here
        },
        Error: func(err error) (veun.AsRenderable, error) {
            // ctx and err are available here
        },
    })
}

I'd need to explore the calling code and how rendering and composition would work in practice actually. Another thing to keep in mind is is if veun.R (for renderable) is also an AsRenderable?

A thing that's nice is if we add more factory types then it's easy to extend the struct. A thing that isn't so nice is that it encodes the kinds of things you need to do in the library and doesn't give more flexibility to try other ways of executing it.

veun.Error tho

Let's try out a hypothetical *veun.Error that's similar in structure and interface to *http.Request.

struct Error { Err error /* ... */ }

func (e *Error) Context() context.Context { /* ... */ }

func (e *Error) WithContext(ctx context.Context) *Error { /* ... */ }

func (e *Error) Error() string { return e.Err.Error() }

func (e *Error) Unwrap() error { return errors.Unwrap(e.Err) }

I'm not really sure we'd need the WithContext here, but why not, let's keep it consistent.

Also we are fulfilling the Error and Unwrap interfaces for errors.

🤔 Does any of this actually help?

While looking at errors, in my implmentations and tests, I kept coming back to a couple of things. Renaming RequestRenderable, ErrorRenderable, Renderable, View, etc.

Re-View, a pivot

At first when starting to write this post, I wanted to explore errors and contexts. A couple of different things I tried were interesting but not good enough or not useful enough, or not intuitive enough. And just repeating Renderable really was the thing to fix.

There's an adage in go that is something like: return structs and accept interfaces, and in our prior situation we were just throwing around interfaces, this meant for concrete implementations, there was always wrapping and unwrapping.

In the search for the right ergnomic and naming I've moved around the and renamed the library code a whole bunch.

Template

In our original implementation, we were returning a View struct which was Renderable. And in a lot of the writing, I was referring to View and Renderable as interchangeable concepts.

I've since separated that out for things to be renderable to HTML here, and there's also the Div functions we can construct using veun.Raw.

Concepts:

  • View
  • ViewForError
  • ViewForRequest

These are the interface functions we're building, Template is an implmentation detail of directly using a html/template.

Views

type MyView struct { /* fields elided */ }

func (v MyView) View(ctx context.Context) (*veun.View, error) {
    return veun.V(veun.Template{
        Tpl:   someTpl,
        Data:  nil,
        Slots: veun.Slots{ /* ... */ },
    }).WithErrorHandler(someErrorHandler), nil
}

A few things jump out from the new implementation of the (now called) AsView interface: veun.V, veun.Template, and *veun.View.

*veun.View is an opaque type, and can only be constructed (in a useful way), by veun.V. This constructor combines HTMLRenderable and ErrorHandler.

We're not doing duck-typing by whether or not the error handler interface is attached to MyView, we're doing it based on wether or not an error handler was explicitly attached to the *View constructed.

This allows us to conitnue to return nil (also ergonomic for construction).

Aside: We are doing duck-typing inside of V but afterwards we get a concrete implementation.

View constructors

Other types, like ViewForRequest, and ViewForError return an AsView.

Rendering

I made an explicit decition to change Render to a function that accepts an AsView, and the rendering to be opaque behind and HTMLRenderable encapsulated by a View.

In the prior version it was actually a bit confusing on what you can call render on and what you can't, where you'd get error handling and where you wouldn't. I wanted to remove that kind of ambiguity and make it simpler to do more.

"veun/vhttp"

I moved all of the http related types and functions to the vhttp package. It's called vhttp since you're using it in conjunction with the net/http standard library and otherwise you'd be import/aliasing it.

We've got vhttp.Handler, request.Handler, and a package of middleware that can be useful for use with standard mux.

What it looks like

Given a MyView like we wrote above that renders something, we can have it be created by an HTTP request.

import (
    "context"
    "net/http"

    "github.com/stanistan/veun"
    "github.com/stanistan/veun/vhttp"
    "github.com/stanistan/veun/vhttp/request"
)

func (v MyView) View(_ context.Context) (*veun.View, error) {
    return veun.View(/*...*/), nil
}

func MyViewRequestHandler() request.Handler {
    return request.HandlerFunc(func(r *http.Request) (veun.AsView, http.Handler, error) {
        // - We can extract data from the request.
        // - We can push up an error
        // - or we can do something with the response, like a 404, or anything.
        return MyView{}, nil, nil
    })
}

func main() {
    // ...
    mux.Handle("/some/path", vhttp.Handler(MyViewRequestHandler()))
    // ...
}

Demo

I'm working on a demo webserver where there are examples of different ways of doing composition, routing, redirects, errors, etc, and the kinds of patterns that become possible and useful when you have all of this in one place.

In the future, I'd like to actually build (or rebuild) something using the library as well as better document the different components that are part of the demo server.