From 9f485a93c50982c97c4d6cfcacb375034a2a5441 Mon Sep 17 00:00:00 2001 From: infogulch Date: Mon, 14 Oct 2024 22:31:47 -0500 Subject: [PATCH] Refactor to simplify dot config --- TODO.md | 2 - cmd/main.go | 3 - config.go | 70 +++++++--- dot.go | 131 ++---------------- providers/db.go => dot_db.go | 2 +- dot_db_config.go | 55 ++++++++ dot_flags.go | 34 +++++ dot_flush.go | 1 + providers/fs.go => dot_fs.go | 9 +- dot_fs_config.go | 69 +++++++++ dot_instance.go | 2 + providers/nats/kv.go => dot_kv.go | 2 +- dot_kv_config.go | 67 +++++++++ providers/nats/nats.go => dot_nats.go | 4 +- .../natsprovider.go => dot_nats_config.go | 81 ++++------- dot_req.go | 1 + dot_resp.go | 1 + instance.go | 31 ++++- make_tool.cue | 2 +- providers/README.md | 34 ----- providers/dbprovider.go | 96 ------------- providers/fsprovider.go | 107 -------------- providers/kv.go | 9 -- providers/kvprovider.go | 35 ----- providers/nats/kvprovider.go | 113 --------------- test/caddy.json | 20 +-- test/config.json | 26 ++-- test/templates/flags/index.html | 2 + test/templates/kv/index.html | 2 - test/tests/{kv.hurl => flags.hurl} | 2 +- 30 files changed, 387 insertions(+), 626 deletions(-) rename providers/db.go => dot_db.go (99%) create mode 100644 dot_db_config.go create mode 100644 dot_flags.go rename providers/fs.go => dot_fs.go (95%) create mode 100644 dot_fs_config.go rename providers/nats/kv.go => dot_kv.go (97%) create mode 100644 dot_kv_config.go rename providers/nats/nats.go => dot_nats.go (94%) rename providers/nats/natsprovider.go => dot_nats_config.go (54%) delete mode 100644 providers/README.md delete mode 100644 providers/dbprovider.go delete mode 100644 providers/fsprovider.go delete mode 100644 providers/kv.go delete mode 100644 providers/kvprovider.go delete mode 100644 providers/nats/kvprovider.go create mode 100644 test/templates/flags/index.html delete mode 100644 test/templates/kv/index.html rename test/tests/{kv.hurl => flags.hurl} (60%) diff --git a/TODO.md b/TODO.md index 0993923..ef1e361 100644 --- a/TODO.md +++ b/TODO.md @@ -66,8 +66,6 @@ - [ ] See if its possible to implement sql queryrows with https://go.dev/wiki/RangefuncExperiment - Not until caddy releases 2.8.0 and upgrades to 1.22. - [ ] Add command that pre-compresses static files -- [ ] Schema migration? https://david.rothlis.net/declarative-schema-migration-for-sqlite/ -- [ ] Schema generator: https://gitlab.com/Screwtapello/sqlite-schema-diagram/-/blob/main/sqlite-schema-diagram.sql?ref_type=heads - [ ] Add a way to register additional routes dynamically during init - [ ] Organize docs according to https://diataxis.fr/ - [ ] Research alternative template loading strategies: diff --git a/cmd/main.go b/cmd/main.go index 4041b31..b3c0475 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,9 +5,6 @@ package main import ( "github.com/infogulch/xtemplate/app" - _ "github.com/infogulch/xtemplate/providers" - _ "github.com/infogulch/xtemplate/providers/nats" - _ "github.com/mattn/go-sqlite3" ) diff --git a/config.go b/config.go index 6871270..6ebee97 100644 --- a/config.go +++ b/config.go @@ -8,6 +8,8 @@ import ( "html/template" "io/fs" "log/slog" + + "github.com/nats-io/nats-server/v2/server" ) func New() (c *Config) { @@ -26,19 +28,26 @@ type Config struct { // File extension to search for to find template files. Default `.html`. TemplateExtension string `json:"template_extension,omitempty" arg:"--template-ext" default:".html"` + // Whether html templates are minified at load time. Default `true`. + Minify bool `json:"minify,omitempty" arg:"-m,--minify" default:"true"` + + Databases []DotDBConfig `json:"databases" arg:"-"` + Flags []DotFlagsConfig `json:"flags" arg:"-"` + Directories []DotDirConfig `json:"directories" arg:"-"` + KVs []DotKVProvider `json:"kvs" arg:"-"` + Nats []DotNatsProvider `json:"nats" arg:"-"` + CustomProviders []DotProvider `json:"-" arg:"-"` + + // Whether to start a bult-in nats server + StartInternalNatsServer bool `json:"start_internal_nats_server" default:"false"` + NatsServerOpts *server.Options `arg:"-"` + // Left template action delimiter. Default `{{`. LDelim string `json:"left,omitempty" arg:"--ldelim" default:"{{"` // Right template action delimiter. Default `}}`. RDelim string `json:"right,omitempty" arg:"--rdelim" default:"}}"` - // Whether html templates are minified at load time. Default `true`. - Minify bool `json:"minify,omitempty" arg:"-m,--minify" default:"true"` - - // A list of additional custom fields to add to the template dot value - // `{{.}}`. - Dot []DotConfig `json:"dot" arg:"-d,--dot,separate"` - // Additional functions to add to the template execution context. FuncMaps []template.FuncMap `json:"-" arg:"-"` @@ -50,6 +59,40 @@ type Config struct { Logger *slog.Logger `json:"-" arg:"-"` } +func (config *Config) CheckName(name string) error { + for _, d := range config.Databases { + if name == d.FieldName() { + return fmt.Errorf("field name '%s' conflicts with database field name '%s'", name, d.FieldName()) + } + } + for _, f := range config.Flags { + if name == f.FieldName() { + return fmt.Errorf("field name '%s' conflicts with flags field name '%s'", name, f.FieldName()) + } + } + for _, d := range config.Directories { + if name == d.FieldName() { + return fmt.Errorf("field name '%s' conflicts with directory field name '%s'", name, d.FieldName()) + } + } + for _, kv := range config.KVs { + if name == kv.FieldName() { + return fmt.Errorf("field name '%s' conflicts with flags field name '%s'", name, kv.FieldName()) + } + } + for _, d := range config.Nats { + if name == d.FieldName() { + return fmt.Errorf("field name '%s' conflicts with directory field name '%s'", name, d.FieldName()) + } + } + for _, d := range config.CustomProviders { + if name == d.FieldName() { + return fmt.Errorf("field name '%s' conflicts with directory field name '%s'", name, d.FieldName()) + } + } + return nil +} + // FillDefaults sets default values for unset fields func (config *Config) Defaults() *Config { if config.TemplatesDir == "" { @@ -117,17 +160,12 @@ func WithFuncMaps(fm ...template.FuncMap) Option { } } -func WithProvider(name string, p DotProvider) Option { +func WithProvider(p DotProvider) Option { return func(c *Config) error { - for _, d := range c.Dot { - if d.Name == name { - if d.DotProvider != p { - return fmt.Errorf("tried to assign different providers the same name. name: %s; old: %v; new: %v", d.Name, d.DotProvider, p) - } - return nil - } + if err := c.CheckName(p.FieldName()); err != nil { + return err } - c.Dot = append(c.Dot, DotConfig{Name: name, DotProvider: p}) + c.CustomProviders = append(c.CustomProviders, p) return nil } } diff --git a/dot.go b/dot.go index 1fdf467..604d8b3 100644 --- a/dot.go +++ b/dot.go @@ -1,36 +1,16 @@ package xtemplate import ( - "bytes" "context" - "encoding" - "encoding/json" "fmt" "net/http" "net/http/httptest" "reflect" - "slices" "sync" ) -var registrations map[string]RegisteredDotProvider = make(map[string]RegisteredDotProvider) - -func RegisterDot(r RegisteredDotProvider) { - name := r.Type() - if old, ok := registrations[name]; ok { - panic(fmt.Sprintf("DotProvider name already registered: %s (%v)", name, old)) - } - registrations[name] = r -} - -type DotConfig struct { - Name string `json:"name"` - Type string `json:"type"` - DotProvider `json:"-"` -} - type Request struct { - DotConfig + DotProvider ServerCtx context.Context W http.ResponseWriter R *http.Request @@ -41,12 +21,8 @@ type DotProvider interface { // also returns an error. Value will be called with mock values at least // once and still must not panic. Value(Request) (any, error) -} -type RegisteredDotProvider interface { - DotProvider - Type() string - New() DotProvider + FieldName() string } type CleanupDotProvider interface { @@ -54,112 +30,33 @@ type CleanupDotProvider interface { Cleanup(any, error) error } -var _ encoding.TextMarshaler = &DotConfig{} - -func (d *DotConfig) MarshalText() ([]byte, error) { - var parts [][]byte - if r, ok := d.DotProvider.(RegisteredDotProvider); ok { - parts = [][]byte{[]byte(d.Name), {':'}, []byte(r.Type())} - } else { - return nil, fmt.Errorf("dot provider cannot be marshalled: %v (%T)", d.DotProvider, d.DotProvider) - } - if m, ok := d.DotProvider.(encoding.TextMarshaler); ok { - b, err := m.MarshalText() - if err != nil { - return nil, err - } - parts = append(parts, []byte{':'}, b) - } - return slices.Concat(parts...), nil -} - -var _ encoding.TextUnmarshaler = &DotConfig{} - -func (d *DotConfig) UnmarshalText(b []byte) error { - parts := bytes.SplitN(b, []byte{':'}, 3) - if len(parts) < 2 { - return fmt.Errorf("failed to parse DotConfig not enough sections. required format: NAME:PROVIDER_NAME[:PROVIDER_CONFIG]") - } - name, providerType := string(parts[0]), string(parts[1]) - reg, ok := registrations[providerType] - if !ok { - return fmt.Errorf("dot provider with name '%s' is not registered", providerType) - } - d.Name = name - d.Type = providerType - d.DotProvider = reg.New() - if unm, ok := d.DotProvider.(encoding.TextUnmarshaler); ok { - var rest []byte - if len(parts) == 3 { - rest = parts[2] - } - err := unm.UnmarshalText(rest) - if err != nil { - return fmt.Errorf("failed to configure provider %s: %w", providerType, err) - } - } - return nil -} - -var _ json.Marshaler = &DotConfig{} - -func (d *DotConfig) MarshalJSON() ([]byte, error) { - type T DotConfig - return json.Marshal((*T)(d)) -} - -var _ json.Unmarshaler = &DotConfig{} - -func (d *DotConfig) UnmarshalJSON(b []byte) error { - type T DotConfig - dc := T{} - if err := json.Unmarshal(b, &dc); err != nil { - return err - } - r, ok := registrations[dc.Type] - if !ok { - return fmt.Errorf("no provider registered with the type '%s': %+v", dc.Type, dc) - } - p := r.New() - if err := json.Unmarshal(b, p); err != nil { - return fmt.Errorf("failed to decode provider %s (%v): %w", dc.Type, p, err) - } - d.Name = dc.Name - d.Type = dc.Type - d.DotProvider = p - return nil -} - -func makeDot(dcs []DotConfig) dot { - fields := make([]reflect.StructField, 0, len(dcs)) +func makeDot(dps []DotProvider) dot { + fields := make([]reflect.StructField, 0, len(dps)) cleanups := []cleanup{} mockHttpRequest := httptest.NewRequest("GET", "/", nil) - for i, dc := range dcs { - mockRequest := Request{dc, context.Background(), mockResponseWriter{}, mockHttpRequest} - a, _ := dc.DotProvider.Value(mockRequest) + for i, dp := range dps { + mockRequest := Request{dp, context.Background(), mockResponseWriter{}, mockHttpRequest} + a, _ := dp.Value(mockRequest) t := reflect.TypeOf(a) if t.Kind() == reflect.Interface && t.NumMethod() == 0 { t = t.Elem() } f := reflect.StructField{ - Name: dc.Name, + Name: dp.FieldName(), Type: t, Anonymous: false, // alas } - if f.Name == "" { - f.Name = f.Type.Name() - } fields = append(fields, f) - if cdp, ok := dc.DotProvider.(CleanupDotProvider); ok { + if cdp, ok := dp.(CleanupDotProvider); ok { cleanups = append(cleanups, cleanup{i, cdp}) } } typ := reflect.StructOf(fields) - return dot{dcs, cleanups, &sync.Pool{New: func() any { v := reflect.New(typ).Elem(); return &v }}} + return dot{dps, cleanups, &sync.Pool{New: func() any { v := reflect.New(typ).Elem(); return &v }}} } type dot struct { - dcs []DotConfig + dps []DotProvider cleanups []cleanup pool *sync.Pool } @@ -172,11 +69,11 @@ type cleanup struct { func (d *dot) value(sctx context.Context, w http.ResponseWriter, r *http.Request) (val *reflect.Value, err error) { val = d.pool.Get().(*reflect.Value) val.SetZero() - for i, dc := range d.dcs { + for i, dp := range d.dps { var a any - a, err = dc.Value(Request{dc, sctx, w, r}) + a, err = dp.Value(Request{dp, sctx, w, r}) if err != nil { - err = fmt.Errorf("failed to construct dot value for %s (%v): %w", dc.Name, dc.DotProvider, err) + err = fmt.Errorf("failed to construct dot value for %s (%v): %w", dp.FieldName(), dp, err) val.SetZero() d.pool.Put(val) val = nil diff --git a/providers/db.go b/dot_db.go similarity index 99% rename from providers/db.go rename to dot_db.go index f904694..b347f90 100644 --- a/providers/db.go +++ b/dot_db.go @@ -1,4 +1,4 @@ -package providers +package xtemplate import ( "context" diff --git a/dot_db_config.go b/dot_db_config.go new file mode 100644 index 0000000..cb7336f --- /dev/null +++ b/dot_db_config.go @@ -0,0 +1,55 @@ +package xtemplate + +import ( + "database/sql" + "errors" + "fmt" +) + +func WithDB(name string, db *sql.DB, opt *sql.TxOptions) Option { + return func(c *Config) error { + if db == nil { + return fmt.Errorf("cannot create database provider with nil sql.DB. name: %s", name) + } + if err := c.CheckName(name); err != nil { + return err + } + c.Databases = append(c.Databases, DotDBConfig{Name: name, DB: db, TxOptions: opt}) + return nil + } +} + +type DotDBConfig struct { + *sql.DB `json:"-"` + *sql.TxOptions `json:"-"` + Name string `json:"name"` + Driver string `json:"driver"` + Connstr string `json:"connstr"` + MaxOpenConns int `json:"max_open_conns"` +} + +var _ CleanupDotProvider = &DotDBConfig{} + +func (d *DotDBConfig) FieldName() string { return d.Name } +func (d *DotDBConfig) Value(r Request) (any, error) { + if d.DB == nil { + db, err := sql.Open(d.Driver, d.Connstr) + if err != nil { + return &DotDB{}, fmt.Errorf("failed to open database with driver name '%s': %w", d.Driver, err) + } + db.SetMaxOpenConns(d.MaxOpenConns) + if err := db.Ping(); err != nil { + return &DotDB{}, fmt.Errorf("failed to ping database on open: %w", err) + } + d.DB = db + } + return &DotDB{d.DB, GetLogger(r.R.Context()), r.R.Context(), d.TxOptions, nil}, nil +} +func (dp *DotDBConfig) Cleanup(v any, err error) error { + d := v.(*DotDB) + if err != nil { + return errors.Join(err, d.rollback()) + } else { + return errors.Join(err, d.commit()) + } +} diff --git a/dot_flags.go b/dot_flags.go new file mode 100644 index 0000000..3bd0072 --- /dev/null +++ b/dot_flags.go @@ -0,0 +1,34 @@ +package xtemplate + +import "fmt" + +type DotFlags struct { + m map[string]string +} + +func (d DotFlags) Value(key string) string { + return d.m[key] +} + +func WithFlags(name string, flags map[string]string) Option { + return func(c *Config) error { + if flags == nil { + return fmt.Errorf("cannot create DotKVProvider with null map with name %s", name) + } + if err := c.CheckName(name); err != nil { + return err + } + c.Flags = append(c.Flags, DotFlagsConfig{name, flags}) + return nil + } +} + +type DotFlagsConfig struct { + Name string `json:"name"` + Values map[string]string `json:"values"` +} + +var _ DotProvider = &DotFlagsConfig{} + +func (d *DotFlagsConfig) FieldName() string { return d.Name } +func (d *DotFlagsConfig) Value(_ Request) (any, error) { return DotFlags{d.Values}, nil } diff --git a/dot_flush.go b/dot_flush.go index 5072ab1..a9f9589 100644 --- a/dot_flush.go +++ b/dot_flush.go @@ -11,6 +11,7 @@ import ( type dotFlushProvider struct{} +func (dotFlushProvider) FieldName() string { return "Flush" } func (dotFlushProvider) Value(r Request) (any, error) { f, ok := r.W.(flusher) if !ok { diff --git a/providers/fs.go b/dot_fs.go similarity index 95% rename from providers/fs.go rename to dot_fs.go index 8a9a997..4f6a6f1 100644 --- a/providers/fs.go +++ b/dot_fs.go @@ -1,4 +1,4 @@ -package providers +package xtemplate import ( "bytes" @@ -7,7 +7,6 @@ import ( "io/fs" "log/slog" "path" - "sync" ) type dotFS struct { @@ -22,12 +21,6 @@ type Dir struct { path string } -var bufPool = sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - // Dir returns a func (d Dir) Dir(name string) (Dir, error) { name = path.Clean(name) diff --git a/dot_fs_config.go b/dot_fs_config.go new file mode 100644 index 0000000..2c14f7e --- /dev/null +++ b/dot_fs_config.go @@ -0,0 +1,69 @@ +package xtemplate + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + "os" +) + +// WithDir creates an [xtemplate.Option] that can be used with +// [xtemplate.Config.Server], [xtemplate.Config.Instance], or [xtemplate.Main] +// to add an fs dot provider to the config. +func WithDir(name string, fs fs.FS) Option { + return func(c *Config) error { + if fs == nil { + return fmt.Errorf("cannot create DotFSProvider with null FS with name %s", name) + } + if err := c.CheckName(name); err != nil { + return err + } + c.Directories = append(c.Directories, DotDirConfig{Name: name, FS: fs}) + return nil + } +} + +// DotDirConfig can configure an xtemplate dot field to provide file system +// access to templates. You can configure xtemplate to use it three ways: +// +// By setting a cli flag: “ +type DotDirConfig struct { + Name string `json:"name"` + fs.FS `json:"-"` + Path string `json:"path"` +} + +var _ CleanupDotProvider = &DotDirConfig{} + +func (c *DotDirConfig) FieldName() string { return c.Name } +func (p *DotDirConfig) Value(r Request) (any, error) { + if p.FS == nil { + newfs := os.DirFS(p.Path) + if _, err := newfs.(interface { + Stat(string) (fs.FileInfo, error) + }).Stat("."); err != nil { + return Dir{}, fmt.Errorf("failed to stat fs current directory '%s': %w", p.Path, err) + } + p.FS = newfs + } + return Dir{dot: &dotFS{p.FS, GetLogger(r.R.Context()), make(map[fs.File]struct{})}, path: "."}, nil +} +func (p *DotDirConfig) Cleanup(a any, err error) error { + v := a.(Dir).dot + errs := []error{} + for file := range v.opened { + if err := file.Close(); err != nil { + p := &fs.PathError{} + if errors.As(err, &p) && p.Op == "close" && p.Err.Error() == "file already closed" { + // ignore + } else { + errs = append(errs, err) + } + } + } + if len(errs) != 0 { + v.log.Warn("failed to close files", slog.Any("errors", errors.Join(errs...))) + } + return err +} diff --git a/dot_instance.go b/dot_instance.go index 179c984..2f0cc4d 100644 --- a/dot_instance.go +++ b/dot_instance.go @@ -12,6 +12,8 @@ type dotXProvider struct { instance *Instance } +func (dotXProvider) FieldName() string { return "X" } + func (p dotXProvider) Value(Request) (any, error) { return DotX(p), nil } diff --git a/providers/nats/kv.go b/dot_kv.go similarity index 97% rename from providers/nats/kv.go rename to dot_kv.go index 9dc874a..d132703 100644 --- a/providers/nats/kv.go +++ b/dot_kv.go @@ -1,4 +1,4 @@ -package nats +package xtemplate import ( "context" diff --git a/dot_kv_config.go b/dot_kv_config.go new file mode 100644 index 0000000..6768d05 --- /dev/null +++ b/dot_kv_config.go @@ -0,0 +1,67 @@ +package xtemplate + +import ( + "context" + "fmt" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +func WithNatsKV(name string, kv jetstream.KeyValue) Option { + return WithProvider(&DotKVProvider{Name: name, KV: kv}) +} + +func WithKVUrl(name, bucket, url string, opts ...nats.Option) Option { + return func(c *Config) error { + return WithProvider(&DotKVProvider{Name: name, get: func(ctx context.Context) (jetstream.KeyValue, error) { + return newKV(nil, bucket, nil, nil, nil, url, opts, ctx) + }})(c) + } +} + +type DotKVProvider struct { + get func(context.Context) (jetstream.KeyValue, error) + Name string + KV jetstream.KeyValue +} + +func (d *DotKVProvider) FieldName() string { return d.Name } +func (d *DotKVProvider) Value(r Request) (any, error) { + if d.KV == nil { + if d.get == nil { + return &DotKV{}, fmt.Errorf("no kv provided") + } + kv, err := d.get(r.ServerCtx) + if err != nil { + return &DotKV{}, err + } + d.KV = kv + } + return &DotKV{d.KV, r.R.Context()}, nil +} + +func newKV(kv jetstream.KeyValue, bucket string, js jetstream.JetStream, conn *nats.Conn, options *nats.Options, url string, opts []nats.Option, ctx context.Context) (jetstream.KeyValue, error) { + var err error + // I must admit this is a bit strange + for { + if kv != nil { + return kv, nil + } else if js != nil { + kv, err = js.KeyValue(ctx, bucket) + if err != nil { + return nil, fmt.Errorf("failed to create kv from jetstream: %w", err) + } + } else if conn != nil { + js, err = jetstream.New(conn) + if err != nil { + return nil, fmt.Errorf("failed to create jetstream from connection: %w", err) + } + } else { + conn, err = newConn(options, url, opts, ctx) + if err != nil { + return nil, fmt.Errorf("failed to create nats connection: %w", err) + } + } + } +} diff --git a/providers/nats/nats.go b/dot_nats.go similarity index 94% rename from providers/nats/nats.go rename to dot_nats.go index 595c2b4..5a2d26c 100644 --- a/providers/nats/nats.go +++ b/dot_nats.go @@ -1,4 +1,4 @@ -package nats +package xtemplate import ( "context" @@ -36,7 +36,7 @@ func (d *DotNats) Request(subject, data string, timeout_ ...time.Duration) (*nat var timeout time.Duration switch len(timeout_) { case 0: - timeout = 1 * time.Second + timeout = 5 * time.Second case 1: timeout = timeout_[0] default: diff --git a/providers/nats/natsprovider.go b/dot_nats_config.go similarity index 54% rename from providers/nats/natsprovider.go rename to dot_nats_config.go index ef0eebe..f45161c 100644 --- a/providers/nats/natsprovider.go +++ b/dot_nats_config.go @@ -1,94 +1,71 @@ -package nats +package xtemplate import ( "context" - "encoding" - "encoding/json" "fmt" - "github.com/infogulch/xtemplate" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" ) -func init() { - xtemplate.RegisterDot(&DotNatsProvider{}) -} - -func WithConn(name string, conn *nats.Conn) xtemplate.Option { - return func(c *xtemplate.Config) error { +func WithConn(name string, conn *nats.Conn) Option { + return func(c *Config) error { if conn == nil { return fmt.Errorf("cannot to create DotNatsProvider with null nats Conn with name %s", name) } - return xtemplate.WithProvider(name, &DotNatsProvider{Conn: conn})(c) + if err := c.CheckName(name); err != nil { + return err + } + c.Nats = append(c.Nats, DotNatsProvider{Name: name, Conn: conn}) + return nil } } -func WithConnUrl(name string, url string, opts ...nats.Option) xtemplate.Option { - return func(c *xtemplate.Config) error { - return xtemplate.WithProvider(name, &DotNatsProvider{get: func(ctx context.Context) (*nats.Conn, error) { +func WithConnUrl(name string, url string, opts ...nats.Option) Option { + return func(c *Config) error { + if err := c.CheckName(name); err != nil { + return err + } + c.Nats = append(c.Nats, DotNatsProvider{Name: name, get: func(ctx context.Context) (*nats.Conn, error) { conn, err := newConn(nil, url, opts, ctx) if err != nil { return nil, fmt.Errorf("failed to start connection with name %s: %w", name, err) } return conn, nil - }})(c) + }}) + return nil } } -func WithConnOptions(name string, options *nats.Options) xtemplate.Option { - return func(c *xtemplate.Config) error { +func WithConnOptions(name string, options *nats.Options) Option { + return func(c *Config) error { if options == nil { return fmt.Errorf("cannot to create DotNatsProvider with null nats Options with name %s", name) } - return xtemplate.WithProvider(name, &DotNatsProvider{get: func(ctx context.Context) (*nats.Conn, error) { + if err := c.CheckName(name); err != nil { + return err + } + c.Nats = append(c.Nats, DotNatsProvider{get: func(ctx context.Context) (*nats.Conn, error) { conn, err := newConn(options, "", nil, ctx) if err != nil { return nil, fmt.Errorf("failed to create connection with name %s: %w", name, err) } return conn, nil - }})(c) + }}) + return nil } } type DotNatsProvider struct { - Conn *nats.Conn get func(context.Context) (*nats.Conn, error) + Name string + Conn *nats.Conn } -var _ encoding.TextUnmarshaler = &DotNatsProvider{} -var _ json.Unmarshaler = &DotNatsProvider{} - -func (d *DotNatsProvider) UnmarshalText(b []byte) error { - url := string(b) - d.get = func(ctx context.Context) (*nats.Conn, error) { - return newConn(nil, url, nil, ctx) - } - return nil -} -func (d *DotNatsProvider) UnmarshalJSON(b []byte) error { - var options struct { - Options *nats.Options `json:"options,omitempty"` - } - err := json.Unmarshal(b, &options) - if err != nil { - return err - } - d.get = func(ctx context.Context) (*nats.Conn, error) { - conn, err := newConn(options.Options, "", nil, ctx) - if err != nil { - return nil, fmt.Errorf("failed to create nats connection: %w", err) - } - return conn, nil - } - return nil -} - -var _ xtemplate.DotProvider = &DotNatsProvider{} +var _ DotProvider = &DotNatsProvider{} -func (DotNatsProvider) New() xtemplate.DotProvider { return &DotNatsProvider{} } -func (DotNatsProvider) Type() string { return "nats" } -func (d *DotNatsProvider) Value(r xtemplate.Request) (any, error) { +func (d *DotNatsProvider) FieldName() string { return d.Name } +func (d *DotNatsProvider) Value(r Request) (any, error) { if d.Conn == nil { if d.get == nil { return &DotNats{}, fmt.Errorf("no nats connection provided") diff --git a/dot_req.go b/dot_req.go index ef0f269..fd50049 100644 --- a/dot_req.go +++ b/dot_req.go @@ -6,6 +6,7 @@ import ( type dotReqProvider struct{} +func (dotReqProvider) FieldName() string { return "Req" } func (dotReqProvider) Value(r Request) (any, error) { return DotReq{r.R}, nil } diff --git a/dot_resp.go b/dot_resp.go index 95ef36a..94df3dd 100644 --- a/dot_resp.go +++ b/dot_resp.go @@ -13,6 +13,7 @@ import ( type dotRespProvider struct{} +func (dotRespProvider) FieldName() string { return "Resp" } func (dotRespProvider) Value(r Request) (any, error) { return DotResp{ Header: make(http.Header), diff --git a/instance.go b/instance.go index 86ce984..9d0d0e2 100644 --- a/instance.go +++ b/instance.go @@ -111,13 +111,32 @@ func (config Config) Instance(cfgs ...Option) (*Instance, *InstanceStats, []Inst return nil, nil, nil, fmt.Errorf("error scanning files: %w", err) } - dcInstance := DotConfig{"X", "instance", dotXProvider{build.Instance}} - dcReq := DotConfig{"Req", "req", dotReqProvider{}} - dcResp := DotConfig{"Resp", "resp", dotRespProvider{}} - dcFlush := DotConfig{"Flush", "flush", dotFlushProvider{}} + dcInstance := dotXProvider{build.Instance} + dcReq := dotReqProvider{} + dcResp := dotRespProvider{} + dcFlush := dotFlushProvider{} - build.bufferDot = makeDot(slices.Concat([]DotConfig{dcInstance, dcReq}, config.Dot, []DotConfig{dcResp})) - build.flusherDot = makeDot(slices.Concat([]DotConfig{dcInstance, dcReq}, config.Dot, []DotConfig{dcFlush})) + var dot []DotProvider + + for _, db := range config.Databases { + dot = append(dot, &db) + } + for _, f := range config.Flags { + dot = append(dot, &f) + } + for _, d := range config.Directories { + dot = append(dot, &d) + } + for _, kv := range config.KVs { + dot = append(dot, &kv) + } + for _, n := range config.Nats { + dot = append(dot, &n) + } + dot = append(dot, config.CustomProviders...) + + build.bufferDot = makeDot(slices.Concat([]DotProvider{dcInstance, dcReq}, dot, []DotProvider{dcResp})) + build.flusherDot = makeDot(slices.Concat([]DotProvider{dcInstance, dcReq}, dot, []DotProvider{dcFlush})) { // Invoke all initilization templates, aka any template whose name starts diff --git a/make_tool.cue b/make_tool.cue index 27df0e8..0765ed9 100644 --- a/make_tool.cue +++ b/make_tool.cue @@ -73,7 +73,7 @@ task: run: { mkdataw: file.Mkdir & {path: "\(vars.testdir)/dataw", $after: rmdataw.$done} start: exec.Run & { - cmd: ["bash", "-c", "./xtemplate --loglevel -4 -d DB:sql:sqlite3:file:./dataw/test.sqlite -d FS:fs:./data --config-file config.json &>xtemplate.log &"] + cmd: ["bash", "-c", "./xtemplate --loglevel -4 --config-file config.json &>xtemplate.log &"] dir: vars.testdir $after: mkdataw.$done } diff --git a/providers/README.md b/providers/README.md deleted file mode 100644 index bbdb499..0000000 --- a/providers/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# xtemplate Dot Providers - -Dot Providers is how xtemplate users can customize the template dot value -`{{.}}` to access dynamic functionality from within your templates. - -This directory contains optional Dot Provider implementations created for and -maintained with xtemplate. - -> [!NOTE] -> -> Users can implement and add their own dot providers by implementing the -> `xtemplate.DotProvider` interface and configuring xtemplate to use it. - -> [!NOTE] -> -> xtemplate also exposes the `Config.FuncMaps` - -## Providers - -### `DotKV` - -Add simple key-value string pairs to your templates. Could be used for runtime -config options for your templates. - -### `DotDB` - -Connect to a database with any available go driver by its name to run queries -and execute procedures against your database from within templates. - -To use a driver, import its Go package while building your application. - -### `DotFS` - -Open a directory to list and read files with templates. diff --git a/providers/dbprovider.go b/providers/dbprovider.go deleted file mode 100644 index 23f83aa..0000000 --- a/providers/dbprovider.go +++ /dev/null @@ -1,96 +0,0 @@ -package providers - -import ( - "database/sql" - "encoding" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/infogulch/xtemplate" -) - -func init() { - xtemplate.RegisterDot(&DotDBProvider{}) -} - -func WithDB(name string, db *sql.DB, opt *sql.TxOptions) xtemplate.Option { - return func(c *xtemplate.Config) error { - if db == nil { - return fmt.Errorf("cannot to create DotDBProvider with null DB with name %s", name) - } - return xtemplate.WithProvider(name, &DotDBProvider{DB: db, TxOptions: opt})(c) - } -} - -type DotDBProvider struct { - *sql.DB `json:"-"` - *sql.TxOptions `json:"-"` - Driver string `json:"driver"` - Connstr string `json:"connstr"` - MaxOpenConns int `json:"max_open_conns"` -} - -var _ encoding.TextMarshaler = &DotDBProvider{} - -func (d *DotDBProvider) MarshalText() ([]byte, error) { - if d.Driver == "" || d.Connstr == "" { - return nil, fmt.Errorf("cannot unmarshal because SqlDot does not have the driver and connstr") - } - return []byte(d.Driver + ":" + d.Connstr), nil -} - -var _ encoding.TextUnmarshaler = &DotDBProvider{} - -func (d *DotDBProvider) UnmarshalText(b []byte) error { - parts := strings.SplitN(string(b), ":", 2) - if len(parts) < 2 { - return fmt.Errorf("not enough parameters to configure sql dot. Requires DRIVER:CONNSTR, got: %s", string(b)) - } - d.Driver = parts[0] - d.Connstr = parts[1] - return nil -} - -var _ json.Marshaler = &DotDBProvider{} - -func (d *DotDBProvider) MarshalJSON() ([]byte, error) { - type T DotDBProvider - return json.Marshal((*T)(d)) -} - -var _ json.Unmarshaler = &DotDBProvider{} - -func (d *DotDBProvider) UnmarshalJSON(b []byte) error { - type T DotDBProvider - return json.Unmarshal(b, (*T)(d)) -} - -var _ xtemplate.CleanupDotProvider = &DotDBProvider{} - -func (DotDBProvider) New() xtemplate.DotProvider { return &DotDBProvider{} } -func (DotDBProvider) Type() string { return "sql" } -func (d *DotDBProvider) Value(r xtemplate.Request) (any, error) { - if d.DB == nil { - db, err := sql.Open(d.Driver, d.Connstr) - if err != nil { - return &DotDB{}, fmt.Errorf("failed to open database with driver name '%s': %w", d.Driver, err) - } - db.SetMaxOpenConns(d.MaxOpenConns) - if err := db.Ping(); err != nil { - return &DotDB{}, fmt.Errorf("failed to ping database on open: %w", err) - } - d.DB = db - } - return &DotDB{d.DB, xtemplate.GetLogger(r.R.Context()), r.R.Context(), d.TxOptions, nil}, nil -} - -func (dp *DotDBProvider) Cleanup(v any, err error) error { - d := v.(*DotDB) - if err != nil { - return errors.Join(err, d.rollback()) - } else { - return errors.Join(err, d.commit()) - } -} diff --git a/providers/fsprovider.go b/providers/fsprovider.go deleted file mode 100644 index db1767f..0000000 --- a/providers/fsprovider.go +++ /dev/null @@ -1,107 +0,0 @@ -package providers - -import ( - "encoding" - "encoding/json" - "errors" - "fmt" - "io/fs" - "log/slog" - "os" - - "github.com/infogulch/xtemplate" -) - -func init() { - xtemplate.RegisterDot(&DotFSProvider{}) -} - -// WithFS creates an [xtemplate.Option] that can be used with -// [xtemplate.Config.Server], [xtemplate.Config.Instance], or [xtemplate.Main] -// to add an fs dot provider to the config. -func WithFS(name string, fs fs.FS) xtemplate.Option { - return func(c *xtemplate.Config) error { - if fs == nil { - return fmt.Errorf("cannot create DotFSProvider with null FS with name %s", name) - } - return xtemplate.WithProvider(name, &DotFSProvider{FS: fs})(c) - } -} - -// DotFSProvider can configure an xtemplate dot field to provide file system -// access to templates. You can configure xtemplate to use it three ways: -// -// By setting a cli flag: “ -type DotFSProvider struct { - fs.FS `json:"-"` - Path string `json:"path"` -} - -var _ encoding.TextMarshaler = &DotFSProvider{} - -func (fs *DotFSProvider) MarshalText() ([]byte, error) { - if fs.Path == "" { - return nil, fmt.Errorf("FSDir cannot be marhsaled") - } - return []byte(fs.Path), nil -} - -var _ encoding.TextUnmarshaler = &DotFSProvider{} - -func (fs *DotFSProvider) UnmarshalText(b []byte) error { - dir := string(b) - if dir == "" { - return fmt.Errorf("fs dir cannot be empty string") - } - fs.FS = os.DirFS(dir) - return nil -} - -var _ json.Marshaler = &DotFSProvider{} - -func (d *DotFSProvider) MarshalJSON() ([]byte, error) { - type T DotFSProvider - return json.Marshal((*T)(d)) -} - -var _ json.Unmarshaler = &DotFSProvider{} - -func (d *DotFSProvider) UnmarshalJSON(b []byte) error { - type T DotFSProvider - return json.Unmarshal(b, (*T)(d)) -} - -var _ xtemplate.CleanupDotProvider = &DotFSProvider{} - -func (DotFSProvider) New() xtemplate.DotProvider { return &DotFSProvider{} } -func (DotFSProvider) Type() string { return "fs" } -func (p *DotFSProvider) Value(r xtemplate.Request) (any, error) { - if p.FS == nil { - newfs := os.DirFS(p.Path) - if _, err := newfs.(interface { - Stat(string) (fs.FileInfo, error) - }).Stat("."); err != nil { - return Dir{}, fmt.Errorf("failed to stat fs current directory '%s': %w", p.Path, err) - } - p.FS = newfs - } - return Dir{dot: &dotFS{p.FS, xtemplate.GetLogger(r.R.Context()), make(map[fs.File]struct{})}, path: "."}, nil -} -func (p *DotFSProvider) Cleanup(a any, err error) error { - v := a.(Dir).dot - errs := []error{} - for file := range v.opened { - if err := file.Close(); err != nil { - p := &fs.PathError{} - if errors.As(err, &p) && p.Op == "close" && p.Err.Error() == "file already closed" { - // ignore - } else { - errs = append(errs, err) - } - } - } - if len(errs) != 0 { - v.log.Warn("failed to close files", slog.Any("errors", errors.Join(errs...))) - } - return err -} diff --git a/providers/kv.go b/providers/kv.go deleted file mode 100644 index 7974721..0000000 --- a/providers/kv.go +++ /dev/null @@ -1,9 +0,0 @@ -package providers - -type DotKV struct { - m map[string]string -} - -func (d DotKV) Value(key string) string { - return d.m[key] -} diff --git a/providers/kvprovider.go b/providers/kvprovider.go deleted file mode 100644 index fcfc42f..0000000 --- a/providers/kvprovider.go +++ /dev/null @@ -1,35 +0,0 @@ -package providers - -import ( - "fmt" - - "github.com/infogulch/xtemplate" -) - -func init() { - xtemplate.RegisterDot(&DotKVProvider{}) -} - -func WithKV(name string, kv map[string]string) xtemplate.Option { - return func(c *xtemplate.Config) error { - if kv == nil { - return fmt.Errorf("cannot create DotKVProvider with null map with name %s", name) - } - return xtemplate.WithProvider(name, &DotKVProvider{kv})(c) - } -} - -type DotKVProvider struct { - Values map[string]string `json:"values"` -} - -var _ xtemplate.DotProvider = &DotKVProvider{} - -func (DotKVProvider) New() xtemplate.DotProvider { return &DotKVProvider{} } -func (DotKVProvider) Type() string { return "kv" } -func (c *DotKVProvider) Value(xtemplate.Request) (any, error) { - if c.Values == nil { - c.Values = make(map[string]string) - } - return DotKV{c.Values}, nil -} diff --git a/providers/nats/kvprovider.go b/providers/nats/kvprovider.go deleted file mode 100644 index 37e3cf4..0000000 --- a/providers/nats/kvprovider.go +++ /dev/null @@ -1,113 +0,0 @@ -package nats - -import ( - "context" - "encoding" - "encoding/json" - "fmt" - "strings" - - "github.com/infogulch/xtemplate" - - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" -) - -func init() { - xtemplate.RegisterDot(&DotKVProvider{}) -} - -func WithKV(name string, kv jetstream.KeyValue) xtemplate.Option { - return xtemplate.WithProvider(name, &DotKVProvider{KV: kv}) -} - -func WithKVUrl(name, bucket, url string, opts ...nats.Option) xtemplate.Option { - return func(c *xtemplate.Config) error { - return xtemplate.WithProvider(name, &DotKVProvider{get: func(ctx context.Context) (jetstream.KeyValue, error) { - return newKV(nil, bucket, nil, nil, nil, url, opts, ctx) - }})(c) - } -} - -type DotKVProvider struct { - get func(context.Context) (jetstream.KeyValue, error) - KV jetstream.KeyValue -} - -var _ encoding.TextUnmarshaler = &DotKVProvider{} -var _ json.Unmarshaler = &DotKVProvider{} - -func (d *DotKVProvider) UnmarshalText(b []byte) error { - parts := strings.Split(string(b), ":") - if len(parts) != 2 { - return fmt.Errorf("bad format. flag config must be in the form: URL:BUCKET") - } - url, bucket := parts[0], parts[1] - if bucket == "" { - return fmt.Errorf("cannot use empty bucket name") - } - d.get = func(ctx context.Context) (jetstream.KeyValue, error) { - return newKV(nil, bucket, nil, nil, nil, url, nil, ctx) - } - return nil -} -func (d *DotKVProvider) UnmarshalJSON(b []byte) error { - var options struct { - Bucket string `json:"bucket"` - Options nats.Options `json:"options"` - } - err := json.Unmarshal(b, &options) - if err != nil { - return fmt.Errorf("failed to unmarshal kv options: %w", err) - } - if options.Bucket == "" { - return fmt.Errorf("bucket name must not be empty") - } - d.get = func(ctx context.Context) (jetstream.KeyValue, error) { - return newKV(nil, options.Bucket, nil, nil, &options.Options, "", nil, ctx) - } - return nil -} - -var _ xtemplate.DotProvider = &DotKVProvider{} - -func (DotKVProvider) New() xtemplate.DotProvider { return &DotKVProvider{} } -func (DotKVProvider) Type() string { return "natskv" } -func (d *DotKVProvider) Value(r xtemplate.Request) (any, error) { - if d.KV == nil { - if d.get == nil { - return &DotKV{}, fmt.Errorf("no kv provided") - } - kv, err := d.get(r.ServerCtx) - if err != nil { - return &DotKV{}, err - } - d.KV = kv - } - return &DotKV{d.KV, r.R.Context()}, nil -} - -func newKV(kv jetstream.KeyValue, bucket string, js jetstream.JetStream, conn *nats.Conn, options *nats.Options, url string, opts []nats.Option, ctx context.Context) (jetstream.KeyValue, error) { - var err error - // I must admit this is a bit strange - for { - if kv != nil { - return kv, nil - } else if js != nil { - kv, err = js.KeyValue(ctx, bucket) - if err != nil { - return nil, fmt.Errorf("failed to create kv from jetstream: %w", err) - } - } else if conn != nil { - js, err = jetstream.New(conn) - if err != nil { - return nil, fmt.Errorf("failed to create jetstream from connection: %w", err) - } - } else { - conn, err = newConn(options, url, opts, ctx) - if err != nil { - return nil, fmt.Errorf("failed to create nats connection: %w", err) - } - } - } -} diff --git a/test/caddy.json b/test/caddy.json index 79363bd..ce646a5 100644 --- a/test/caddy.json +++ b/test/caddy.json @@ -12,39 +12,39 @@ { "handler": "xtemplate", "minify": true, - "dot": [ + "databases": [ { - "type": "sql", "name": "DB", "driver": "sqlite3", "connstr": "file:./dataw/test.sqlite" - }, + } + ], + "directories": [ { - "type": "fs", "name": "FS", "path": "./data" }, { - "type": "fs", "name": "FSW", "path": "./dataw" }, { - "type": "fs", "name": "Migrations", "path": "./migrations" - }, + } + ], + "flags": [ { - "type": "kv", "name": "KV", "values": { "a": "1", "b": "2", "hello": "world" } - }, + } + ], + "nats": [ { - "type": "nats", "name": "Nats" } ] diff --git a/test/config.json b/test/config.json index 5d74007..49534d9 100644 --- a/test/config.json +++ b/test/config.json @@ -1,27 +1,33 @@ { - "dot": [ + "directories": [ + { + "name": "FS", + "path": "./data" + }, { - "type": "fs", "name": "FSW", "path": "./dataw" }, { - "type": "fs", "name": "Migrations", "path": "./migrations" - }, + } + ], + "databases": [ { - "type": "kv", - "name": "KV", + "name": "DB", + "driver": "sqlite3", + "connstr": "file:./dataw/test.sqlite" + } + ], + "flags": [ + { + "name": "Flags", "values": { "a": "1", "b": "2", "hello": "world" } - }, - { - "type": "nats", - "name": "Nats" } ] } \ No newline at end of file diff --git a/test/templates/flags/index.html b/test/templates/flags/index.html new file mode 100644 index 0000000..80d003f --- /dev/null +++ b/test/templates/flags/index.html @@ -0,0 +1,2 @@ + +

a: {{.Flags.Value "a"}} diff --git a/test/templates/kv/index.html b/test/templates/kv/index.html deleted file mode 100644 index 3da4e1f..0000000 --- a/test/templates/kv/index.html +++ /dev/null @@ -1,2 +0,0 @@ - -

a: {{.KV.Value "a"}} diff --git a/test/tests/kv.hurl b/test/tests/flags.hurl similarity index 60% rename from test/tests/kv.hurl rename to test/tests/flags.hurl index 866afe1..1943e25 100644 --- a/test/tests/kv.hurl +++ b/test/tests/flags.hurl @@ -1,5 +1,5 @@ # get kv -GET http://localhost:8080/kv +GET http://localhost:8080/flags HTTP 200 [Asserts]