Skip to content
Snippets Groups Projects
Commit 95aec197 authored by Jens Heise's avatar Jens Heise
Browse files

Add web server functionality with templating.

parent 8f0bd077
No related branches found
No related tags found
No related merge requests found
Showing
with 966 additions and 218 deletions
...@@ -2,12 +2,12 @@ package main ...@@ -2,12 +2,12 @@ package main
import ( import (
"context" "context"
"fmt"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/org-harmony/harmony/core/auth" "github.com/org-harmony/harmony/core/auth"
"github.com/org-harmony/harmony/core/config" "github.com/org-harmony/harmony/core/config"
"github.com/org-harmony/harmony/core/event" "github.com/org-harmony/harmony/core/event"
"github.com/org-harmony/harmony/core/trace" "github.com/org-harmony/harmony/core/trace"
"github.com/org-harmony/harmony/core/trans"
"github.com/org-harmony/harmony/core/web" "github.com/org-harmony/harmony/core/web"
) )
...@@ -18,6 +18,7 @@ func main() { ...@@ -18,6 +18,7 @@ func main() {
l := trace.NewLogger() l := trace.NewLogger()
em := event.NewEventManager(l) em := event.NewEventManager(l)
v := validator.New(validator.WithRequiredStructEnabled()) v := validator.New(validator.WithRequiredStructEnabled())
translator := trans.NewTranslator()
webCfg := &web.Cfg{} webCfg := &web.Cfg{}
err := config.C(webCfg, config.From("web"), config.Validate(v)) err := config.C(webCfg, config.From("web"), config.Validate(v))
...@@ -25,14 +26,39 @@ func main() { ...@@ -25,14 +26,39 @@ func main() {
l.Error(WebMod, "failed to load config", err) l.Error(WebMod, "failed to load config", err)
return return
} }
baseT, err := web.NewTemplater(webCfg.UI, translator, web.FromBaseTemplate())
if err != nil {
l.Error(WebMod, "failed to create base templater", err)
return
}
lpT, err := web.NewTemplater(webCfg.UI, translator, web.FromLandingPageTemplate())
if err != nil {
l.Error(WebMod, "failed to create landing page templater", err)
return
}
s := web.NewServer( s := web.NewServer(
web.WithFileServer(webCfg.Server.AssetFsCfg), webCfg,
web.WithAddr(fmt.Sprintf("%s:%s", webCfg.Server.Addr, webCfg.Server.Port)), web.WithTemplater(baseT, web.BaseTemplate),
web.WithTemplater(lpT, web.LandingPageTemplate),
web.WithLogger(l), web.WithLogger(l),
web.WithEventManger(em), web.WithEventManger(em),
) )
s.RegisterController(nil) s.RegisterControllers(
web.NewController(
"sys.home",
"/",
web.WithTemplaters(s.Templaters()),
web.Get(func(io web.HandlerIO, ctx context.Context) {
if err := io.Render("auth/login.go.html", web.LandingPageTemplate, nil); err != nil {
l.Error(WebMod, "failed to render home template", err)
io.IssueError(web.IntErr())
}
}),
),
)
auth.LoadConfig(v) auth.LoadConfig(v)
......
[server] [server]
base_url = "http://localhost:8080"
address = "" address = ""
port = "8080" port = "8080"
[server.asset_fs] [server.asset_fs]
root = "public/assets" root = "public/assets"
route = "/assets" route = "/assets"
[ui]
assets_uri = "/assets"
[ui.templates]
dir = "templates"
base_dir = "templates/base"
landing_page_filepath = "templates/landing-page.go.html"
landing_page_name = "landing-page"
\ No newline at end of file
...@@ -8,15 +8,15 @@ import ( ...@@ -8,15 +8,15 @@ import (
"github.com/org-harmony/harmony/core/config" "github.com/org-harmony/harmony/core/config"
) )
// Config is the config for the auth package. // Cfg is the config for the auth package.
type Config struct { type Cfg struct {
// Provider contains a list of OAuth2 providers. // Provider contains a list of OAuth2 providers.
Provider map[string]ProviderConfig `toml:"provider"` Provider map[string]ProviderCfg `toml:"provider"`
EnableOAuth2 bool `toml:"enable_oauth2"` EnableOAuth2 bool `toml:"enable_oauth2"`
} }
// ProviderConfig is the config for an OAuth2 provider. // ProviderCfg is the config for an OAuth2 provider.
type ProviderConfig struct { type ProviderCfg struct {
Name string `toml:"name"` Name string `toml:"name"`
AuthorizeURI string `toml:"authorize_uri"` AuthorizeURI string `toml:"authorize_uri"`
AccessTokenURI string `toml:"access_token_uri"` AccessTokenURI string `toml:"access_token_uri"`
...@@ -27,7 +27,7 @@ type ProviderConfig struct { ...@@ -27,7 +27,7 @@ type ProviderConfig struct {
func LoadConfig(v *validator.Validate) { func LoadConfig(v *validator.Validate) {
// TODO remove and implement real auth logic // TODO remove and implement real auth logic
cfg := &Config{} cfg := &Cfg{}
err := config.C(cfg, config.From("auth"), config.Validate(v)) err := config.C(cfg, config.From("auth"), config.Validate(v))
if err != nil { if err != nil {
fmt.Printf("failed to load auth config: %v", err) fmt.Printf("failed to load auth config: %v", err)
......
...@@ -150,6 +150,40 @@ func C(c any, opts ...Option) error { ...@@ -150,6 +150,40 @@ func C(c any, opts ...Option) error {
return nil return nil
} }
// ToEnv reads a TOML config file and loads it into the environment.
// As with C, the options are passed through Option functions.
//
// The config file will be loaded recursively, meaning that nested maps will be flattened and joined with underscores.
// The values will be converted to strings and may be accessed through os.Getenv(<CONFIG_NAME>_<KEY>).
//
// The Validate() has no effect on this function.
// As of right now there is no validation implemented for env variables loaded from config files.
func ToEnv(opts ...Option) error {
o := defaultOptions()
for _, opt := range opts {
opt(o)
}
fPath := path.Join(o.dir, fmt.Sprintf("%s.%s", o.filename, o.fileExt))
b, err := os.ReadFile(fPath)
if err != nil {
return herr.NewReadFile(fPath, err)
}
m := make(map[string]any)
err = toml.Unmarshal(b, &m)
if err != nil {
return herr.NewParse("config to env", err)
}
fm := makeEnvMap(m)
if err := mapToEnv(fm); err != nil {
return herr.ErrSetEnv
}
return nil
}
// parseConfig unmarshalls byte slices into the given config struct. // parseConfig unmarshalls byte slices into the given config struct.
func parseConfig(config any, b ...[]byte) error { func parseConfig(config any, b ...[]byte) error {
for _, v := range b { for _, v := range b {
...@@ -247,40 +281,6 @@ func overwriteWithEnv(c any) (err error) { ...@@ -247,40 +281,6 @@ func overwriteWithEnv(c any) (err error) {
return nil return nil
} }
// ToEnv reads a TOML config file and loads it into the environment.
// As with C, the options are passed through Option functions.
//
// The config file will be loaded recursively, meaning that nested maps will be flattened and joined with underscores.
// The values will be converted to strings and may be accessed through os.Getenv(<CONFIG_NAME>_<KEY>).
//
// The Validate() has no effect on this function.
// As of right now there is no validation implemented for env variables loaded from config files.
func ToEnv(opts ...Option) error {
o := defaultOptions()
for _, opt := range opts {
opt(o)
}
fPath := path.Join(o.dir, fmt.Sprintf("%s.%s", o.filename, o.fileExt))
b, err := os.ReadFile(fPath)
if err != nil {
return herr.NewReadFile(fPath, err)
}
m := make(map[string]any)
err = toml.Unmarshal(b, &m)
if err != nil {
return herr.NewParse("config to env", err)
}
fm := makeEnvMap(m)
if err := mapToEnv(fm); err != nil {
return herr.ErrSetEnv
}
return nil
}
// mapToEnv loads the given map into the environment. // mapToEnv loads the given map into the environment.
func mapToEnv(m map[string]string) error { func mapToEnv(m map[string]string) error {
for k, v := range m { for k, v := range m {
......
...@@ -17,6 +17,21 @@ type InvalidOptions struct { ...@@ -17,6 +17,21 @@ type InvalidOptions struct {
Prev error Prev error
} }
type InvalidConfig struct {
Config any
Prev error
}
type Parse struct {
Parsable any
Prev error
}
type ReadFile struct {
Path string
Prev error
}
func NewInvalidOptions(options any, prev error) *InvalidOptions { func NewInvalidOptions(options any, prev error) *InvalidOptions {
return &InvalidOptions{ return &InvalidOptions{
Options: options, Options: options,
...@@ -28,11 +43,6 @@ func (e *InvalidOptions) Error() string { ...@@ -28,11 +43,6 @@ func (e *InvalidOptions) Error() string {
return fmt.Sprintf("invalid options %s: %s", e.Options, e.Prev.Error()) return fmt.Sprintf("invalid options %s: %s", e.Options, e.Prev.Error())
} }
type InvalidConfig struct {
Config any
Prev error
}
func NewInvalidConfig(config any, prev error) *InvalidConfig { func NewInvalidConfig(config any, prev error) *InvalidConfig {
return &InvalidConfig{ return &InvalidConfig{
Config: config, Config: config,
...@@ -44,11 +54,6 @@ func (e *InvalidConfig) Error() string { ...@@ -44,11 +54,6 @@ func (e *InvalidConfig) Error() string {
return fmt.Sprintf("invalid config %s: %s", e.Config, e.Prev.Error()) return fmt.Sprintf("invalid config %s: %s", e.Config, e.Prev.Error())
} }
type Parse struct {
Parsable any
Prev error
}
func NewParse(parsable any, prev error) *Parse { func NewParse(parsable any, prev error) *Parse {
return &Parse{ return &Parse{
Parsable: parsable, Parsable: parsable,
...@@ -60,11 +65,6 @@ func (e *Parse) Error() string { ...@@ -60,11 +65,6 @@ func (e *Parse) Error() string {
return fmt.Sprintf("failed to parse %s, with: %s", e.Parsable, e.Prev.Error()) return fmt.Sprintf("failed to parse %s, with: %s", e.Parsable, e.Prev.Error())
} }
type ReadFile struct {
Path string
Prev error
}
func NewReadFile(path string, prev error) *ReadFile { func NewReadFile(path string, prev error) *ReadFile {
return &ReadFile{ return &ReadFile{
Path: path, Path: path,
......
...@@ -9,6 +9,17 @@ import ( ...@@ -9,6 +9,17 @@ import (
const LogPkgKey = "module" const LogPkgKey = "module"
// StdLogger is the system's default logger.
type StdLogger struct {
// slog is the underlying structured logger used by the StdLogger.
slog *slog.Logger
}
// TestLogger is a logger that writes to the test's log.
type TestLogger struct {
test *testing.T
}
type Logger interface { type Logger interface {
Debug(mod, msg string, args ...any) Debug(mod, msg string, args ...any)
Info(mod, msg string, args ...any) Info(mod, msg string, args ...any)
...@@ -16,12 +27,6 @@ type Logger interface { ...@@ -16,12 +27,6 @@ type Logger interface {
Error(mod, msg string, err error, args ...any) Error(mod, msg string, err error, args ...any)
} }
// StdLogger is the system's default logger.
type StdLogger struct {
// slog is the underlying structured logger used by the StdLogger.
slog *slog.Logger
}
// NewLogger creates a new standard logger that writes to stdout. // NewLogger creates a new standard logger that writes to stdout.
func NewLogger() Logger { func NewLogger() Logger {
return &StdLogger{ return &StdLogger{
...@@ -51,11 +56,6 @@ func (l *StdLogger) Error(mod, msg string, err error, args ...any) { ...@@ -51,11 +56,6 @@ func (l *StdLogger) Error(mod, msg string, err error, args ...any) {
l.Log(slog.LevelError, mod, msg, args...) l.Log(slog.LevelError, mod, msg, args...)
} }
// TestLogger is a logger that writes to the test's log.
type TestLogger struct {
test *testing.T
}
func NewTestLogger(t *testing.T) Logger { func NewTestLogger(t *testing.T) Logger {
return &TestLogger{ return &TestLogger{
test: t, test: t,
......
...@@ -7,15 +7,15 @@ import ( ...@@ -7,15 +7,15 @@ import (
"fmt" "fmt"
) )
type StdTranslator struct {
translations map[string]string
}
type Translator interface { type Translator interface {
T(s string, ctx context.Context) string T(s string, ctx context.Context) string
Tf(s string, ctx context.Context, args ...any) string Tf(s string, ctx context.Context, args ...any) string
} }
type StdTranslator struct {
translations map[string]string
}
func NewTranslator() *StdTranslator { func NewTranslator() *StdTranslator {
return &StdTranslator{ return &StdTranslator{
translations: make(map[string]string), translations: make(map[string]string),
......
package web
import "net/http"
// HandlerError is issued by a controller's handler to the client.
// It contains the error, status code, and message to be issued.
//
// It is safe to display the message to the client.
//
// If Internal is true, the error is an internal server error and should not be displayed to the client.
// It should be assumed that the error has been logged if it is internal.
type HandlerError struct {
Err error
Internal bool
Status int
Message string
}
// ExtErr returns a new HandlerError with the provided error, status code, and message.
func ExtErr(err error, status int, message string) HandlerError {
return HandlerError{
Err: err,
Status: status,
Message: message,
Internal: false,
}
}
// IntErr returns a new internal HandlerError with a valid status code.
func IntErr() HandlerError {
return HandlerError{
Internal: true,
Status: http.StatusInternalServerError,
}
}
// Error returns the error message.
func (e *HandlerError) Error() string {
if e.Internal {
return "internal server error - please review the logs"
}
if e.Message != "" {
return e.Message
}
return e.Err.Error()
}
This diff is collapsed.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="{{ asset "css/bulma.min.css" }}">
<script defer src="{{ asset "js/htmx.min.js" }}"></script>
<title>Document</title>
</head>
<body>
<section class="section">
<div class="container">
<h1 class="title">
Hello World
</h1>
<p class="subtitle">
My first website with <strong>Bulma</strong>!
</p>
</div>
</section>
</body>
</html>
\ No newline at end of file
package web package web
import (
"context"
"fmt"
"github.com/org-harmony/harmony/core/trans"
"html/template"
"path/filepath"
)
const (
// BaseTemplate is the base template name.
BaseTemplate = "index"
// LandingPageTemplate is the landing page template name.
LandingPageTemplate = "landing-page"
)
// UICfg is the web packages UI configuration.
type UICfg struct {
AssetsUri string `toml:"assets_uri" validate:"required"`
Templates *TemplatesCfg `toml:"templates" validate:"required"`
}
// TemplatesCfg is the web packages UI templates configuration.
type TemplatesCfg struct {
Dir string `toml:"dir" validate:"required"`
BaseDir string `toml:"base_dir" validate:"required"`
LandingPageFilepath string `toml:"landing_page_filepath" validate:"required"`
LandingPageName string `toml:"landing_page_name" validate:"required"`
}
// DeriveTemplater is a base templater that reads the templates from the directory specified in the UICfg struct.
// All templates requested from the DeriveTemplater will derive from the base template.
// The DeriveTemplater will also load the landing page template at the same time the base template is loaded and saved.
type DeriveTemplater struct {
ui *UICfg
trans trans.Translator
from *template.Template
}
type DeriveTemplaterOption func(*DeriveTemplater) error
// Templater allows to load a template for a given template path.
type Templater interface {
Template(templatePath string) (*template.Template, error)
}
// FromBaseTemplate option specifies the base template to derive from.
func FromBaseTemplate() DeriveTemplaterOption {
return func(t *DeriveTemplater) error {
base, err := ctrlBaseTmpl(t.trans, t.ui)
if err != nil {
return fmt.Errorf("failed to load base template: %w", err)
}
t.from = base
return nil
}
}
// FromLandingPageTemplate option specifies the landing page template to derive from.
func FromLandingPageTemplate() DeriveTemplaterOption {
return func(t *DeriveTemplater) error {
lp, err := ctrlLpTmpl(t.trans, t.ui)
if err != nil {
return fmt.Errorf("failed to load landing page template: %w", err)
}
t.from = lp
return nil
}
}
// FromTemplate allows to provide a template to derive from.
func FromTemplate(tmpl *template.Template) DeriveTemplaterOption {
return func(t *DeriveTemplater) error {
t.from = tmpl
return nil
}
}
// NewTemplater returns a new DeriveTemplater.
func NewTemplater(ui *UICfg, trans trans.Translator, opts ...DeriveTemplaterOption) (*DeriveTemplater, error) {
t := &DeriveTemplater{
ui: ui,
trans: trans,
}
for _, opt := range opts {
err := opt(t)
if err != nil {
return nil, err
}
}
if t.from == nil {
return nil, fmt.Errorf("no template to derive from provided")
}
return t, nil
}
// Template returns the template for the given template path.
// The DeriveTemplater will return the templates based on the configuration provided in the UICfg struct.
// If the templatePath is the LandingPageTemplate or BaseTemplate, the corresponding template will be returned.
// All other templates will be loaded from the templates directory in the UICfg struct.
func (t *DeriveTemplater) Template(path string) (*template.Template, error) {
f, err := t.from.Clone()
if err != nil {
return nil, fmt.Errorf("failed to clone derived from template: %w", err)
}
if f.Name() == path {
return f, nil
}
return f.New(BaseTemplate).ParseFiles(filepath.Join(t.ui.Templates.Dir, path))
}
// ctrlLpTmpl returns the landing page template.
func ctrlLpTmpl(t trans.Translator, ui *UICfg) (*template.Template, error) {
base, err := ctrlBaseTmpl(t, ui)
if err != nil {
return nil, err
}
return base.New(LandingPageTemplate).ParseFiles(ui.Templates.LandingPageFilepath)
}
// ctrlBaseTmpl returns the base template for controllers.
func ctrlBaseTmpl(t trans.Translator, ui *UICfg) (*template.Template, error) {
return template.
New("index").
Funcs(ctrlTmplUtilFunc(t, ui)).
ParseGlob(filepath.Join(ui.Templates.BaseDir, "*.go.html"))
}
// ctrlTmplUtilFunc returns a template.FuncMap for use in templates.
// It contains the functions that are expected to be used in Controller templates.
func ctrlTmplUtilFunc(t trans.Translator, ui *UICfg) template.FuncMap {
return template.FuncMap{
"t": func(s string, ctx context.Context) string {
return t.T(s, ctx)
},
"tf": func(s string, ctx context.Context, args ...any) string {
return t.Tf(s, ctx, args...)
},
"html": func(s string) template.HTML {
return template.HTML(s)
},
"asset": func(filename string) string {
return filepath.Join(ui.AssetsUri, filename)
},
}
}
...@@ -3,21 +3,10 @@ ...@@ -3,21 +3,10 @@
// to easily extend upon this package and allow web communication. // to easily extend upon this package and allow web communication.
package web package web
import (
"context"
"net/http"
)
const Pkg = "sys.web" const Pkg = "sys.web"
// Cfg is the web packages configuration.
type Cfg struct { type Cfg struct {
Server *ServerCfg `toml:"server" validate:"required"` Server *ServerCfg `toml:"server" validate:"required"`
UI *UICfg `toml:"ui" validate:"required"`
} }
type Server interface {
Serve(ctx context.Context) error
RegisterController(c ...Controller)
RegisterMiddleware(m ...func(http.Handler) http.Handler)
}
type Controller interface{}
@import "bulma.min.css";
.is-logo {
border: 2px solid #e0e0e0;
height: 128px;
margin: 0 auto;
width: 128px;
}
\ No newline at end of file
public/assets/img/harmony.png

25.1 KiB

{{ define "content-body" }}
<h1>This is the login.</h1>
{{ end }}
\ No newline at end of file
{{ define "content-body" }}
<h1 class="title">
Welcome back
</h1>
<p class="subtitle">
This is the dashboard.
</p>
{{ end }}
\ No newline at end of file
{{ define "content-sidebar-menu" }}
<p class="menu-label">
Administration
</p>
<ul class="menu-list">
<li><a>Team Settings</a></li>
<li>
<a class="is-active">Manage Your Team</a>
<ul>
<li><a>Members</a></li>
<li><a>Plugins</a></li>
<li><a>Add a member</a></li>
</ul>
</li>
<li><a>Invitations</a></li>
<li><a>Cloud Storage Environment Settings</a></li>
<li><a>Authentication</a></li>
</ul>
{{ end }}
\ No newline at end of file
{{ define "header-navigation-menu" }}
<li class="is-active">
<a href="/">Overview</a>
</li>
<li>
<a>Modifiers</a>
</li>
<li>
<a>Grid</a>
</li>
<li>
<a>Elements</a>
</li>
<li>
<a>Components</a>
</li>
<li>
<a>Layout</a>
</li>
{{ end }}
\ No newline at end of file
{{ define "index" }}
<!DOCTYPE html>
<html lang="de">
<head>
{{ block "head" . }}
{{ block "meta" . }}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
{{ end }}
{{ block "title-container" . }}
<title>{{ block "title" . }}Home{{ end }} - HARMONY</title>
{{ end }}
{{ block "styles" . }}
<link rel="stylesheet" href="{{ asset "css/styles.css" }}">
{{ end }}
{{ block "scripts" . }}
<script defer src="{{ asset "js/htmx.min.js" }}"></script>
{{ end }}
{{ end }}
</head>
<body>
{{ block "body" . }}
{{ template "layout" . }}
{{ end }}
</body>
</html>
{{ end }}
\ No newline at end of file
{{ define "layout" }}
{{ block "header" . }}
{{ block "header-branding" . }}
<section class="hero header">
<div class="hero-body header-body">
<div class="tile is-ancestor">
<div class="tile is-2 is-parent">
<div class="tile">
<img class="is-logo" src="{{ asset "img/harmony.png" }}" alt="HARMONY Logo" />
</div>
</div>
<div class="tile is-vertical is-parent">
<div class="tile is-child">
<h2 class="title">
{{ block "header-title" . }}HARMONY{{ end }}
</h2>
</div>
<div class="tile is-child">
<h3 class="subtitle">
{{ block "header-subtitle" . }}Highly Adaptable Requirements Management and OrgaNization sYstem{{ end }}
</h3>
</div>
</div>
</div>
</div>
{{ end }}
{{ block "header-navigation" . }}
<div class="hero-foot header-navigation">
<nav class="tabs is-boxed is-fullwidth">
<div class="container">
<ul>
{{ template "header-navigation-menu" . }}
</ul>
</div>
</nav>
</div>
{{ end }}
</section>
{{ end }}
{{ block "content" . }}
<section class="section content">
<div class="content-inner">
<div class="tile is-ancestor">
<div class="tile is-3 is-parent content-sidebar">
<div class="tile is-child box">
{{ block "content-sidebar" . }}
<aside class="content-sidebar">
{{ template "content-sidebar-menu" . }}
</aside>
{{ end }}
</div>
</div>
<div class="tile is-parent content-body">
<div class="tile is-child">
{{ template "content-body" . }}
</div>
</div>
</div>
</div>
</section>
{{ end }}
{{ block "footer" . }}
<footer class="footer">
<div class="content has-text-centered">
<p>
Visit <strong>HARMONY</strong> on <a href="https://github.com/org-harmony/harmony" target="_blank">GitHub</a>.
</p>
</div>
</footer>
{{ end }}
{{ end }}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment