Skip to content

Commit

Permalink
feat(render): add render command for executing templates against data…
Browse files Browse the repository at this point in the history
…sets

Pumped about this one. I've realized we can get a long way by just executing templates against qri datasets, which have a rigid structure that makes templating much easier. So with that, I'd like to introduce a new command: `qri render`. `qri render` accepts a dataset name, executes a HTML template, using the specified dataset to populate template data. It has a few features:

* render uses the `template/html` package from golang. users of the Hugo static site generator will feel at home with this syntax
* users can provide custom templates with the --template argument
* if no template is specified, qri uses a default "stock" template that is resolved over IPFS
* this stock template is updated using IPNS, similar to the way we distribute app updates
* template fetching falls back to a P2P gateway over HTTP if configured correctly
* render automatically loads data using the same logic as the `qri data` command, accepting --limit, --offset, and --all params to control data loading
* writes to stdout by default, to a file with --output flag

To get this to work I had to introduce some other new stuff:
* render section in config: this holds the place to lookup default template IPNS addresses
* config.P2P.HTTPGatewayAddr: url of a gateway to resolve content-addressed hashes with qri isn't connected to the p2p network

This opens up a whole world of possiblities, users can use `qri render` to create custom representations of data that suites their needs, and leverage qri to generate these assets as they go

Limitations:
* currently the template must be loaded as a single file, in the future we should add support for partials & stuff
* currently HTML templates only. I've added a "TemplateFormat" param that currently does nothing, but in the future we can use this to expand to other common template output formats (raw text, PDF?)
  • Loading branch information
b5 committed May 14, 2018
1 parent c2b4231 commit 607104d
Show file tree
Hide file tree
Showing 16 changed files with 564 additions and 40 deletions.
44 changes: 34 additions & 10 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/datatogether/api/apiutil"
"github.com/ipfs/go-datastore"
golog "github.com/ipfs/go-log"
"github.com/qri-io/cafs"
"github.com/qri-io/qri/config"
"github.com/qri-io/qri/core"
"github.com/qri-io/qri/p2p"
Expand Down Expand Up @@ -40,9 +41,6 @@ func New(r repo.Repo, options ...func(*config.Config)) (s *Server, err error) {
for _, opt := range options {
opt(cfg)
}
// if err := cfg.Validate(); err != nil {
// return nil, fmt.Errorf("server configuration error: %s", err.Error())
// }

s = &Server{
cfg: cfg,
Expand Down Expand Up @@ -88,13 +86,39 @@ func (s *Server) Serve() (err error) {
}

if node, err := s.qriNode.IPFSNode(); err == nil {
go func() {
if err := core.CheckVersion(context.Background(), node.Namesys); err == core.ErrUpdateRequired {
log.Info("This version of qri is out of date, please refer to https://github.com/qri-io/qri/releases/latest for more info")
} else if err != nil {
log.Infof("error checking for software update: %s", err.Error())
}
}()
if pinner, ok := s.qriNode.Repo.Store().(cafs.Pinner); ok {

go func() {
if _, err := core.CheckVersion(context.Background(), node.Namesys, core.PrevIPNSName, core.LastPubVerHash); err == core.ErrUpdateRequired {
log.Info("This version of qri is out of date, please refer to https://github.com/qri-io/qri/releases/latest for more info")
} else if err != nil {
log.Infof("error checking for software update: %s", err.Error())
}
}()

go func() {
// TODO - this is breaking encapsulation pretty hard. Should probs move this stuff into core
if core.Config != nil && core.Config.Render != nil && core.Config.Render.TemplateUpdateAddress != "" {
if latest, err := core.CheckVersion(context.Background(), node.Namesys, core.Config.Render.TemplateUpdateAddress, core.Config.Render.DefaultTemplateHash); err == core.ErrUpdateRequired {
err := pinner.Pin(datastore.NewKey(latest), true)
if err != nil {
log.Debug("error pinning template hash: %s", err.Error())
return
}
if err := core.Config.Set("Render.DefaultTemplateHash", latest); err != nil {
log.Debug("error setting latest hash: %s", err.Error())
return
}
if err := core.SaveConfig(); err != nil {
log.Debug("error saving config hash: %s", err.Error())
return
}
log.Info("auto-updated template hash: %s", latest)
}
}
}()

}
}

info := s.cfg.SummaryString()
Expand Down
79 changes: 79 additions & 0 deletions cmd/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cmd

import (
"fmt"
"io/ioutil"

"github.com/qri-io/qri/core"
"github.com/spf13/cobra"
)

// renderCmd represents the render command
var renderCmd = &cobra.Command{
Use: "render",
Short: "execute a template against a dataset",
Long: `the most common use for render is to generate html from a qri dataset`,
Example: ` render a dataset called me/schools:
$ qri render -o=schools.html me/schools
render a dataset with a custom template:
$ qri render --template=template.html me/schools`,
Annotations: map[string]string{
"group": "dataset",
},
PreRun: func(cmd *cobra.Command, args []string) {
loadConfig()
},
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var template []byte

fp, err := cmd.Flags().GetString("template")
ExitIfErr(err)
out, err := cmd.Flags().GetString("output")
ExitIfErr(err)
limit, err := cmd.Flags().GetInt("limit")
ExitIfErr(err)
offset, err := cmd.Flags().GetInt("offset")
ExitIfErr(err)
all, err := cmd.Flags().GetBool("all")
ExitIfErr(err)

req, err := renderRequests(false)
ExitIfErr(err)

if fp != "" {
template, err = ioutil.ReadFile(fp)
ExitIfErr(err)
}

p := &core.RenderParams{
Ref: args[0],
Template: template,
TemplateFormat: "html",
All: all,
Limit: limit,
Offset: offset,
}

res := []byte{}
err = req.Render(p, &res)
ExitIfErr(err)

if out == "" {
fmt.Print(string(res))
} else {
ioutil.WriteFile(out, res, 0777)
}
},
}

func init() {
renderCmd.Flags().StringP("template", "t", "", "path to template file")
renderCmd.Flags().StringP("output", "o", "", "path to write output file")
renderCmd.Flags().BoolP("all", "a", false, "read all dataset entries (overrides limit, offest)")
renderCmd.Flags().IntP("limit", "l", 50, "max number of records to read")
renderCmd.Flags().IntP("offset", "s", 0, "number of records to skip")

RootCmd.AddCommand(renderCmd)
}
23 changes: 23 additions & 0 deletions cmd/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,29 @@ func datasetRequests(online bool) (*core.DatasetRequests, error) {
return req, nil
}

func renderRequests(online bool) (*core.RenderRequests, error) {
if cli := rpcConn(); cli != nil {
return core.NewRenderRequests(nil, cli), nil
}

if !online {
// TODO - make this not terrible
r, cli, err := repoOrClient(online)
if err != nil {
return nil, err
}
return core.NewRenderRequests(r, cli), nil
}

n, err := qriNode(online)
if err != nil {
return nil, err
}

req := core.NewRenderRequests(n.Repo, nil)
return req, nil
}

func profileRequests(online bool) (*core.ProfileRequests, error) {
r, cli, err := repoOrClient(online)
if err != nil {
Expand Down
26 changes: 17 additions & 9 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type Config struct {
Webapp *Webapp
RPC *RPC
Logging *Logging

Render *Render
}

// DefaultConfig gives a new default qri configuration
Expand All @@ -43,6 +45,8 @@ func DefaultConfig() *Config {
Webapp: DefaultWebapp(),
RPC: DefaultRPC(),
Logging: DefaultLogging(),

Render: DefaultRender(),
}
}

Expand Down Expand Up @@ -214,16 +218,17 @@ func (cfg Config) Validate() error {
"title": "config",
"description": "qri configuration",
"type": "object",
"required": ["Profile", "Repo", "Store", "P2P", "CLI", "API", "Webapp", "RPC"],
"required": ["Profile", "Repo", "Store", "P2P", "CLI", "API", "Webapp", "RPC", "Render"],
"properties" : {
"Profile" : { "type":"object" },
"Repo" : { "type":"object" },
"Store" : { "type":"object" },
"P2P" : { "type":"object" },
"CLI" : { "type":"object" },
"API" : { "type":"object" },
"Webapp" : { "type":"object" },
"RPC" : { "type":"object" }
"Profile" : { "type":"object" },
"Repo" : { "type":"object" },
"Store" : { "type":"object" },
"P2P" : { "type":"object" },
"CLI" : { "type":"object" },
"API" : { "type":"object" },
"Webapp" : { "type":"object" },
"RPC" : { "type":"object" },
"Render" : { "type":"object" }
}
}`)
if err := validate(schema, &cfg); err != nil {
Expand Down Expand Up @@ -291,6 +296,9 @@ func (cfg *Config) Copy() *Config {
if cfg.Logging != nil {
res.Logging = cfg.Logging.Copy()
}
if cfg.Render != nil {
res.Render = cfg.Render.Copy()
}

return res
}
Expand Down
14 changes: 12 additions & 2 deletions config/p2p.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type P2P struct {
// QriBootstrapAddrs lists addresses to bootstrap qri node from
QriBootstrapAddrs []string `json:"qribootstrapaddrs"`

// HTTPGatewayAddr is an address that qri can use to resolve p2p assets
// over HTTP, represented as a url. eg: https://ipfs.io
HTTPGatewayAddr string `json:"httpgatewayaddr"`

// ProfileReplication determines what to do when this peer sees messages
// broadcast by it's own profile (from another peer instance). setting
// ProfileReplication == "full" will cause this peer to automatically pin
Expand All @@ -50,7 +54,8 @@ type P2P struct {
func DefaultP2P() *P2P {
r := rand.Reader
p2p := &P2P{
Enabled: true,
Enabled: true,
HTTPGatewayAddr: "https://ipfs.io",
// DefaultBootstrapAddresses follows the pattern of IPFS boostrapping off known "gateways".
// This boostrapping is specific to finding qri peers, which are IPFS peers that also
// support the qri protocol.
Expand Down Expand Up @@ -106,7 +111,7 @@ func (cfg P2P) Validate() error {
"title": "P2P",
"description": "Config for the p2p",
"type": "object",
"required": ["enabled", "peerid", "pubkey", "privkey", "port", "addrs", "qribootstrapaddrs", "profilereplication", "bootstrapaddrs"],
"required": ["enabled", "peerid", "pubkey", "privkey", "port", "addrs", "httpgatewayaddr", "qribootstrapaddrs", "profilereplication", "bootstrapaddrs"],
"properties": {
"enabled": {
"description": "When true, peer to peer communication is allowed",
Expand Down Expand Up @@ -138,6 +143,10 @@ func (cfg P2P) Validate() error {
"type": "string"
}
},
"httpgatewayaddr": {
"description" : "address that qri can use to resolve p2p assets over HTTP",
"type" : "string"
},
"qribootstrapaddrs": {
"description": "List of addresses to bootstrap the qri node from",
"type": "array",
Expand Down Expand Up @@ -176,6 +185,7 @@ func (cfg *P2P) Copy() *P2P {
PrivKey: cfg.PrivKey,
Port: cfg.Port,
ProfileReplication: cfg.ProfileReplication,
HTTPGatewayAddr: cfg.HTTPGatewayAddr,
}

if cfg.QriBootstrapAddrs != nil {
Expand Down
54 changes: 54 additions & 0 deletions config/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package config

import "github.com/qri-io/jsonschema"

// Render configures the qri render command
type Render struct {
// TemplateUpdateAddress is currently an IPNS location to check for updates. api.Server starts
// this address is checked, and if the hash there differs from DefaultTemplateHash, it'll use that instead
TemplateUpdateAddress string `json:"templateUpdateAddress"`
// DefaultTemplateHash is a hash of the compiled template
// this is fetched and replaced via dnslink when the render server starts
// the value provided here is just a sensible fallback for when dnslink lookup fails,
// pointing to a known prior version of the the render
DefaultTemplateHash string `json:"defaultTemplateHash"`
}

// DefaultRender creates a new default Render configuration
func DefaultRender() *Render {
return &Render{
TemplateUpdateAddress: "/ipns/defaulttmpl.qri.io",
DefaultTemplateHash: "/ipfs/QmeqeRTf2Cvkqdx4xUdWi1nJB2TgCyxmemsL3H4f1eTBaw",
}
}

// Validate validates all fields of render returning all errors found.
func (cfg Render) Validate() error {
schema := jsonschema.Must(`{
"$schema": "http://json-schema.org/draft-06/schema#",
"title": "Render",
"description": "Render for the render",
"type": "object",
"required": ["templateUpdateAddress", "defaultTemplateHash"],
"properties": {
"templateUpdateAddress": {
"description": "address to check for app updates",
"type": "string"
},
"defaultTemplateHash": {
"description": "A hash of the compiled render. This is fetched and replaced via dsnlink when the render server starts. The value provided here is just a sensible fallback for when dnslink lookup fails.",
"type": "string"
}
}
}`)
return validate(schema, &cfg)
}

// Copy returns a deep copy of the Render struct
func (cfg *Render) Copy() *Render {
res := &Render{
TemplateUpdateAddress: cfg.TemplateUpdateAddress,
DefaultTemplateHash: cfg.DefaultTemplateHash,
}
return res
}
33 changes: 33 additions & 0 deletions config/render_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package config

import (
"reflect"
"testing"
)

func TestRenderValidate(t *testing.T) {
err := DefaultRender().Validate()
if err != nil {
t.Errorf("error validating default render: %s", err)
}
}

func TestRenderCopy(t *testing.T) {
cases := []struct {
render *Render
}{
{DefaultRender()},
}
for i, c := range cases {
cpy := c.render.Copy()
if !reflect.DeepEqual(cpy, c.render) {
t.Errorf("Render Copy test case %v, render structs are not equal: \ncopy: %v, \noriginal: %v", i, cpy, c.render)
continue
}
cpy.DefaultTemplateHash = "foo"
if reflect.DeepEqual(cpy, c.render) {
t.Errorf("Render Copy test case %v, editing one render struct should not affect the other: \ncopy: %v, \noriginal: %v", i, cpy, c.render)
continue
}
}
}
4 changes: 4 additions & 0 deletions config/testdata/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ p2p:
online: true
selfreplication: full
boostrapaddrs: []
httpgatewayaddr: https://ipfs.io
webapp:
enabled: true
port: 2505
Expand All @@ -39,3 +40,6 @@ rpc:
port: 2504
logging:
levels: {}
render:
templateupdateaddress: /ipns/defaulttmpl.qri.io
defaulttemplatehash: /ipfs/QmeqeRTf2Cvkqdx4xUdWi1nJB2TgCyxmemsL3H4f1eTBaw
1 change: 1 addition & 0 deletions config/testdata/simple.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ api: null
webapp: null
rpc: null
logging: null
render: null
1 change: 1 addition & 0 deletions core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ func Receivers(node *p2p.QriNode) []Requests {
NewPeerRequests(node, nil),
NewProfileRequests(r, nil),
NewSearchRequests(r, nil),
NewRenderRequests(r, nil),
}
}
Loading

0 comments on commit 607104d

Please sign in to comment.