diff --git a/cmd/pathbuilders.go b/cmd/pathbuilders.go new file mode 100644 index 0000000..e5e9509 --- /dev/null +++ b/cmd/pathbuilders.go @@ -0,0 +1,69 @@ +package cmd + +import ( + wisski_distillery "github.com/FAU-CDI/wisski-distillery" + "github.com/FAU-CDI/wisski-distillery/internal/core" + "github.com/tkw1536/goprogram/exit" +) + +// Pathbuilders is the 'pathbuilders' command +var Pathbuilders wisski_distillery.Command = pathbuilders{} + +type pathbuilders struct { + Positionals struct { + Slug string `positional-arg-name:"SLUG" required:"1-1" description:"slug of instance to export pathbuilders of"` + Name string `positional-arg-name:"NAME" description:"Name of pathbuilder to get. If omitted, show a list of all pathbuilders"` + } `positional-args:"true"` +} + +func (pathbuilders) Description() wisski_distillery.Description { + return wisski_distillery.Description{ + Requirements: core.Requirements{ + NeedsDistillery: true, + }, + Command: "pathbuilder", + Description: "Lists of displays pathbuilders of a specific WissKI instance", + } +} + +var errPathbuilders = exit.Error{ + Message: "Unable to export pathbuilder: %s", + ExitCode: exit.ExitGeneric, +} + +var errNoPathbuilder = exit.Error{ + Message: "Pathbuilder %q does not exist", + ExitCode: exit.ExitGeneric, +} + +func (pb pathbuilders) Run(context wisski_distillery.Context) error { + // get the wisski + instance, err := context.Environment.Instances().WissKI(pb.Positionals.Slug) + if err != nil { + return err + } + + // get all of the pathbuilders + if pb.Positionals.Name == "" { + names, err := instance.Pathbuilders() + if err != nil { + return errPathbuilders.WithMessageF(err) + } + for _, name := range names { + context.Println(name) + } + return nil + } + + // get all the pathbuilders + xml, err := instance.Pathbuilder(pb.Positionals.Name) + if xml == "" { + return errNoPathbuilder.WithMessageF(pb.Positionals.Name) + } + if err != nil { + return errPathbuilders.WithMessageF(err) + } + context.Printf("%s", xml) + + return nil +} diff --git a/cmd/wdcli/main.go b/cmd/wdcli/main.go index bc53c8c..3033788 100644 --- a/cmd/wdcli/main.go +++ b/cmd/wdcli/main.go @@ -42,6 +42,7 @@ func init() { wdcli.Register(cmd.Shell) wdcli.Register(cmd.BlindUpdate) wdcli.Register(cmd.UpdatePrefixConfig) // TODO: Move into post-instance configuration + wdcli.Register(cmd.Pathbuilders) // backup & cron wdcli.Register(cmd.Snapshot) diff --git a/internal/component/dis/html/index.html b/internal/component/dis/html/index.html new file mode 100644 index 0000000..a219ee5 --- /dev/null +++ b/internal/component/dis/html/index.html @@ -0,0 +1,55 @@ + + + + +Distillery Status Page +

Distillery Status Page

+ +

Overview

+ +

+ Domain: {{.Config.DefaultDomain}}
+ Legacy Domain(s): {{.Config.SelfExtraDomains}}
+ HTTPS Email: {{.Config.CertbotEmail}}
+


+ Homepage Redirect:{{.Config.SelfRedirect}}
+
+ Backup Age: {{.Config.MaxBackupAge}} Day(s)
+
+ Base Directory: {{.Config.DeployRoot}}
+ Configuration File: {{.Config.ConfigPath}}
+ Authorized_Keys File: {{.Config.GlobalAuthorizedKeysFile}}
+
+ MySQL User Prefix: {{.Config.MysqlUserPrefix}}
+ MySQL Database Prefix: {{.Config.MysqlDatabasePrefix}}
+ GraphDB User Prefix: {{.Config.GraphDBUserPrefix}}
+ GraphDB Database Prefix: {{.Config.GraphDBRepoPrefix}}
+
+ Bookkeeping Database: {{.Config.DistilleryBookkeepingDatabase}}
+ Bookkeeping Table: {{.Config.DistilleryBookkeepingTable}}
+

+ +

Instances

+ +

+ {{ .TotalCount }} instance(s) = {{ .RunningCount }} running + {{ .StoppedCount }} stopped
+

+ +{{range .Instances}} +
+

{{.Slug}}{{ if not .Running }} not running{{ end }}

+

+ {{.URL}}
+ + + More Details + +

+
+{{end}} + + + + \ No newline at end of file diff --git a/internal/component/dis/html/instance.html b/internal/component/dis/html/instance.html new file mode 100644 index 0000000..0d7b8b9 --- /dev/null +++ b/internal/component/dis/html/instance.html @@ -0,0 +1,16 @@ + + + +Distillery Status Page - {{ .Info.Slug }} +

Distillery Status Page - {{ .Info.Slug }}

+

+ Back to index +

+ +
{{ . }}
+ + + + \ No newline at end of file diff --git a/internal/component/dis/html/static/autolink.css b/internal/component/dis/html/static/autolink.css new file mode 100644 index 0000000..f44f737 --- /dev/null +++ b/internal/component/dis/html/static/autolink.css @@ -0,0 +1,20 @@ +.header-link { + position: relative; + left: 0.5em; + opacity: 0; + font-size: 0.8em; + + -webkit-transition: opacity 0.2s ease-in-out 0.1s; + -moz-transition: opacity 0.2s ease-in-out 0.1s; + -ms-transition: opacity 0.2s ease-in-out 0.1s; + + text-decoration: none; +} + +h2:hover .header-link, +h3:hover .header-link, +h4:hover .header-link, +h5:hover .header-link, +h6:hover .header-link { + opacity: 1; +} \ No newline at end of file diff --git a/internal/component/dis/html/static/autolink.js b/internal/component/dis/html/static/autolink.js new file mode 100644 index 0000000..970d570 --- /dev/null +++ b/internal/component/dis/html/static/autolink.js @@ -0,0 +1,24 @@ +/** adding links to each item, see http://blog.parkermoore.de/2014/08/01/header-anchor-links-in-vanilla-javascript-for-github-pages-and-jekyll/ */ +var anchorForId = function (id) { + var anchor = document.createElement("a"); + anchor.className = "header-link"; + anchor.href = "#" + id; + anchor.innerHTML = "#"; + return anchor; +}; + +var linkifyAnchors = function (level) { + var headers = document.getElementsByTagName("h" + level); + for (var h = 0; h < headers.length; h++) { + var header = headers[h]; + + if (typeof header.id !== "undefined" && header.id !== "") { + header.appendChild(anchorForId(header.id)); + } + } +}; + + +for (var level = 1; level <= 6; level++) { + linkifyAnchors(level); +} \ No newline at end of file diff --git a/internal/component/dis/html/static/dis.css b/internal/component/dis/html/static/dis.css new file mode 100644 index 0000000..5eabc84 --- /dev/null +++ b/internal/component/dis/html/static/dis.css @@ -0,0 +1,67 @@ +html { + line-height: 1.5; + font-family: Roboto; + font-size: 20px; + color: #1a1a1a; + background-color: #fdfdfd; +} +body { + margin: 0 auto; + max-width: 36em; + padding-left: 50px; + padding-right: 50px; + padding-top: 50px; + padding-bottom: 50px; + hyphens: auto; + overflow-wrap: break-word; + text-rendering: optimizeLegibility; + font-kerning: normal; +} + +@media (max-width: 600px) { + body { + font-size: 0.9em; + padding: 1em; + } +} + +h1 { + margin-top: 1.4em; +} + +h2,h3 { + margin-top: 1em; +} + +code { + font-family: Roboto Mono; + color: blue; +} + +p { + margin: 1em 0; + text-align: justify; +} + +a, a:visited { + color: #1a1a1a; +} + +footer { + border-top: 1px solid #1a1a1a; + font-size: small; + text-align: center; +} + +.wisski { + padding-left: 5px; +} + +.wisski.running { + background-color: green; +} + +.wisski.stopped { + background-color: red; +} + diff --git a/internal/component/dis/info.go b/internal/component/dis/info.go index e9cf567..dbf9706 100644 --- a/internal/component/dis/info.go +++ b/internal/component/dis/info.go @@ -1,70 +1,198 @@ package dis import ( - "encoding/json" + "embed" + "html/template" + "io/fs" "net/http" + "strings" + "time" + "github.com/FAU-CDI/wisski-distillery/internal/bookkeeping" "github.com/FAU-CDI/wisski-distillery/internal/component/instances" + "github.com/FAU-CDI/wisski-distillery/internal/config" + "github.com/FAU-CDI/wisski-distillery/pkg/httpx" "github.com/tkw1536/goprogram/stream" "golang.org/x/sync/errgroup" + + _ "embed" ) func (dis *Dis) info(io stream.IOStream) (http.Handler, error) { - return http.HandlerFunc(dis.handleDis), nil + mux := http.NewServeMux() + + // handle everything under /dis/! + mux.HandleFunc("/dis/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/dis/" { + http.Redirect(w, r, "/dis/index", http.StatusTemporaryRedirect) + return + } + http.NotFound(w, r) + }) + + // static stuff + static, err := dis.disStatic() + if err != nil { + return nil, err + } + mux.Handle("/dis/static/", static) + + // render everything + mux.Handle("/dis/index", httpx.HTMLHandler[disIndex]{ + Handler: dis.disIndex, + Template: indexTemplate, + }) + + mux.Handle("/dis/instance/", httpx.HTMLHandler[disInstance]{ + Handler: dis.disInstance, + Template: instanceTemplate, + }) + + // api -- for future usage + mux.Handle("/dis/api/v1/instance/get/", httpx.JSON(dis.getinstance)) + mux.Handle("/dis/api/v1/instance/all", httpx.JSON(dis.allinstances)) + + // ensure that everyone is logged in! + return httpx.BasicAuth(mux, "WissKI Distillery Admin", func(user, pass string) bool { + return user == dis.Config.DisAdminUser && pass == dis.Config.DisAdminPassword + }), nil } -const disLimit = 2 +// disIndex is the context of the "/dis/index" page +type disIndex struct { + Time time.Time -func (dis *Dis) handleDis(w http.ResponseWriter, r *http.Request) { - // make sure the user is authorized - if !dis.authDis(r) { - w.Header().Add("WWW-Authenticate", `Basic realm="WissKI Distillery Admin"`) - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Unauthorized")) + Config *config.Config + + Instances []instances.Info + TotalCount int + RunningCount int + StoppedCount int +} + +func (dis *Dis) disIndex(r *http.Request) (idx disIndex, err error) { + // load instances + idx.Instances, err = dis.allinstances(r) + if err != nil { return } - // create a new error group + // count how many are running and how many are stopped + for _, i := range idx.Instances { + if i.Running { + idx.RunningCount++ + } else { + idx.StoppedCount++ + } + } + idx.TotalCount = len(idx.Instances) + + // get the static properties + idx.Config = dis.Config + + // current time + idx.Time = time.Now() + + return +} + +// disInstance is the context of the "/dis/instance/*" page +type disInstance struct { + Time time.Time + + Instance bookkeeping.Instance + Info instances.Info +} + +func (dis *Dis) disInstance(r *http.Request) (is disInstance, err error) { + // find the slug as the last component of path! + slug := strings.TrimSuffix(r.URL.Path, "/") + slug = slug[strings.LastIndex(slug, "/")+1:] + + // find the instance itself! + instance, err := dis.Instances.WissKI(slug) + if err == instances.ErrWissKINotFound { + return is, httpx.ErrNotFound + } + if err != nil { + return is, err + } + is.Instance = instance.Instance + + // get some more info about the wisski + is.Info, err = instance.Info(false) + if err != nil { + return is, err + } + + // current time + is.Time = time.Now() + + return +} + +//go:embed html/static +var htmlStaticFS embed.FS + +func (*Dis) disStatic() (http.Handler, error) { + fs, err := fs.Sub(htmlStaticFS, "html/static") + if err != nil { + return nil, err + } + + return http.StripPrefix("/dis/static/", http.FileServer(http.FS(fs))), nil +} + +//go:embed "html/index.html" +var indexTemplateStr string +var indexTemplate = template.Must(template.New("index.html").Parse(indexTemplateStr)) + +//go:embed "html/instance.html" +var instanceTemplateString string +var instanceTemplate = template.Must(template.New("instance.html").Parse(instanceTemplateString)) + +func (dis *Dis) getinstance(r *http.Request) (info instances.Info, err error) { + // find the slug as the last component of path! + slug := strings.TrimSuffix(r.URL.Path, "/") + slug = slug[strings.LastIndex(slug, "/")+1:] + + // load the wisski instance! + wisski, err := dis.Instances.WissKI(strings.TrimSuffix(slug, "/")) + if err == instances.ErrWissKINotFound { + return info, httpx.ErrNotFound + } + if err != nil { + return info, err + } + + // get info about it! + return wisski.Info(false) +} + +func (dis *Dis) allinstances(*http.Request) (infos []instances.Info, err error) { var errgroup errgroup.Group - errgroup.SetLimit(disLimit) // list all the instances all, err := dis.Instances.All() if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal server error")) - return + return nil, err } // get all of their info! - infos := make([]instances.Info, len(all)) + infos = make([]instances.Info, len(all)) for i, instance := range all { { i := i instance := instance errgroup.Go(func() (err error) { - infos[i], err = instance.Info() + infos[i], err = instance.Info(true) return err }) } } - // if some info call failed - if err := errgroup.Wait(); err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal server error")) - w.Write([]byte("\n")) - return - } - - // and return the json - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(infos) -} - -func (dis *Dis) authDis(r *http.Request) bool { - user, pass, ok := r.BasicAuth() - return ok && user == dis.Config.DisAdminUser && pass == dis.Config.DisAdminPassword + // wait for the results, and return + err = errgroup.Wait() + return } diff --git a/internal/component/dis/stack/Dockerfile b/internal/component/dis/stack/Dockerfile index 272586a..b141487 100644 --- a/internal/component/dis/stack/Dockerfile +++ b/internal/component/dis/stack/Dockerfile @@ -2,4 +2,4 @@ FROM docker.io/library/docker:20.10-cli COPY wdcli /wdcli EXPOSE 8888 -CMD ["/wdcli","--internal-in-docker","--config","${CONFIG_PATH}","server","--bind","0.0.0.0:8888"] \ No newline at end of file +CMD ["/wdcli","--internal-in-docker","--config", "${CONFIG_PATH}","server","--bind","0.0.0.0:8888"] \ No newline at end of file diff --git a/internal/component/instances/instances/barrel/wisskiutils/export_pathbuilder.php b/internal/component/instances/php/export_pathbuilder.php similarity index 67% rename from internal/component/instances/instances/barrel/wisskiutils/export_pathbuilder.php rename to internal/component/instances/php/export_pathbuilder.php index 983e655..aeae448 100644 --- a/internal/component/instances/instances/barrel/wisskiutils/export_pathbuilder.php +++ b/internal/component/instances/php/export_pathbuilder.php @@ -1,17 +1,33 @@ getStorage('wisski_pathbuilder')->loadMultiple(); +/** all_xml lists all pathbuilders, and returns the corresponding xml */ +function all_xml(): object { + $all = \Drupal::entityTypeManager()->getStorage('wisski_pathbuilder')->loadMultiple(); + return (object)array_map("entity_to_xml", $all); +} -// map over the pathbuilders -$xmls = array_map(function($pb) { + +/** all_list lists the ids of all pathbuilders */ +function all_list(): Array { + return array_keys(\Drupal::entityQuery('wisski_pathbuilder')->execute()); +} + +/** one_xml serializes a single pathbuilder as xml */ +function one_xml(string $id): string { + $pb = \Drupal::entityTypeManager()->getStorage('wisski_pathbuilder')->load($id); + if ($pb === NULL) { + return ""; + } + return entity_to_xml($pb); +} + +// ================================================================================= +// ================================================================================= + + +function entity_to_xml($pb) { $xml = new \SimpleXMLElement(""); $paths = $pb->getAllPaths(); @@ -58,6 +74,4 @@ $xmls = array_map(function($pb) { $dom = dom_import_simplexml($xml)->ownerDocument; $dom->formatOutput = TRUE; return $dom->saveXML(); -}, $pbs); - -echo json_encode($xmls); \ No newline at end of file +} \ No newline at end of file diff --git a/internal/component/instances/wisski_pathbuilders.go b/internal/component/instances/wisski_pathbuilders.go index 60edcdc..7cf7d8b 100644 --- a/internal/component/instances/wisski_pathbuilders.go +++ b/internal/component/instances/wisski_pathbuilders.go @@ -1,34 +1,45 @@ package instances import ( - "bytes" - "encoding/json" - "errors" "fmt" "io/fs" "os" "path/filepath" + _ "embed" + "github.com/tkw1536/goprogram/stream" "golang.org/x/exp/maps" "golang.org/x/exp/slices" ) -var errPathbuildersExecFailed = errors.New("ExportPathbuilders: Failed to call export_pathbuilder") +//go:embed php/export_pathbuilder.php +var exportPathbuilderPHP string + +// Pathbuilders returns the ids of all pathbuilders in consistent order. +func (wisski *WissKI) Pathbuilders() (ids []string, err error) { + err = wisski.ExecPHPScript(stream.FromNil(), &ids, exportPathbuilderPHP, "all_list") + slices.Sort(ids) + return +} + +// Pathbuilder returns a single pathbuilder as xml. +// If it does not exist, it returns the empty string and nil error. +func (wisski *WissKI) Pathbuilder(id string) (xml string, err error) { + err = wisski.ExecPHPScript(stream.FromNil(), &xml, exportPathbuilderPHP, "one_xml", id) + return +} + +// AllPathbuilders returns all pathbuilders serialized as xml +func (wisski *WissKI) AllPathbuilders() (pathbuilders map[string]string, err error) { + err = wisski.ExecPHPScript(stream.FromNil(), &pathbuilders, exportPathbuilderPHP, "all_xml") + return +} // ExportPathbuilders writes pathbuilders into the directory dest func (wisski *WissKI) ExportPathbuilders(dest string) error { - // export all the pathbuilders into the buffer - var buffer bytes.Buffer - wu := stream.NewIOStream(&buffer, nil, nil, 0) - code, err := wisski.Barrel().Exec(wu, "barrel", "/bin/bash", "/user_shell.sh", "-c", "drush php:script /wisskiutils/export_pathbuilder.php") - if err != nil || code != 0 { - return errPathbuildersExecFailed - } - - // decode them as a json array - var pathbuilders map[string]string - if err := json.NewDecoder(&buffer).Decode(&pathbuilders); err != nil { + pathbuilders, err := wisski.AllPathbuilders() + if err != nil { return err } diff --git a/internal/component/instances/wisski_php.go b/internal/component/instances/wisski_php.go index cc585aa..4ebf071 100644 --- a/internal/component/instances/wisski_php.go +++ b/internal/component/instances/wisski_php.go @@ -13,11 +13,12 @@ var ErrExecInvalidCode = errors.New("invalid code to execute") var ErrExecNonZero = errors.New("script returned non-zero code") // ExecPHPScript executes the PHP code as a script within the wisski instance. -// The script should define a function "main", and may define additional functions. +// The script should define a function called entrypoint, and may define additional functions. // // Code must start with "" + code) if err != nil { - return nil, err + return err + } + + entrypointEscape, err := marshalPHP(entrypoint) + if err != nil { + return err } argsEscape, err := marshalPHP(args) if err != nil { - return nil, err + return err } // assemble the script @@ -55,7 +61,7 @@ func (wisski *WissKI) ExecPHPScript(io stream.IOStream, code string, args ...any call_user_func(function(){ ob_start(null, 0, PHP_OUTPUT_HANDLER_CLEANABLE); - $result = call_user_func_array("main", ` + argsEscape + `); + $result = call_user_func_array(` + entrypointEscape + `, ` + argsEscape + `); ob_end_clean(); echo json_encode($result); }); @@ -65,22 +71,19 @@ func (wisski *WissKI) ExecPHPScript(io stream.IOStream, code string, args ...any var output bytes.Buffer res, err := wisski.Shell(io.Streams(&output, nil, strings.NewReader(script), 0), "-c", "drush php:script -") if res != 0 { - return nil, ErrExecNonZero + return ErrExecNonZero } if err != nil { - return nil, err + return err + } + + // did not request to receive a result + if result == nil { + return nil } // decode the output - var result any - err = json.NewDecoder(&output).Decode(&result) - return result, err -} - -// EvalPHP is similar to ExecPHPScript, except that it evaluates a single line of php. -// A single parameter may be passed, which can be accessed using the name $arg inside the expression. -func (wisski *WissKI) EvalPHP(expr string, arg any) (any, error) { - return wisski.ExecPHPScript(stream.FromEnv(), "function main($arg){return "+expr+";}", arg) + return json.NewDecoder(&output).Decode(result) } const marshalRune = 'F' // press to pay respect diff --git a/internal/component/instances/wisski_status.go b/internal/component/instances/wisski_status.go index df52b16..46eea7e 100644 --- a/internal/component/instances/wisski_status.go +++ b/internal/component/instances/wisski_status.go @@ -8,25 +8,36 @@ import ( // Info represents some info about this WissKI type Info struct { Slug string // The slug of the instance + URL string // The public URL of this instance - Running bool // is the instance running? - - DrupalVersion interface{} // version of drupal being used + Running bool // is the instance running? + Pathbuilders []string // list of pathbuilders } // Info returns information about this WissKI instance. -func (wisski *WissKI) Info() (info Info, err error) { +func (wisski *WissKI) Info(quick bool) (info Info, err error) { // static properties info.Slug = wisski.Slug + info.URL = wisski.URL().String() // dynamic properties, TODO: Add more properties here! var group errgroup.Group + // quick check if this wisski is running group.Go(func() (err error) { info.Running, err = wisski.Alive() return }) + // slower checks for extra properties. + // these execute php code + if !quick { + group.Go(func() (err error) { + info.Pathbuilders, err = wisski.Pathbuilders() + return + }) + } + err = group.Wait() return } diff --git a/pkg/httpx/basic.go b/pkg/httpx/basic.go new file mode 100644 index 0000000..65d5404 --- /dev/null +++ b/pkg/httpx/basic.go @@ -0,0 +1,24 @@ +package httpx + +import "net/http" + +var basicUnauthorized = []byte("Unauthorized") + +// BasicAuth returns a new [http.Handler] that requires any credentials to pass the check function +func BasicAuth(handler http.Handler, realm string, check func(username, password string) bool) http.Handler { + var authenticateHeader = `Basic realm="` + realm + `"` + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // if the basic authentication passes + // we can just use the handler! + user, pass, ok := r.BasicAuth() + if ok && check(user, pass) { + handler.ServeHTTP(w, r) + return + } + + // http authentication did not pass + w.Header().Add("WWW-Authenticate", authenticateHeader) + w.WriteHeader(http.StatusUnauthorized) + w.Write(basicUnauthorized) + }) +} diff --git a/pkg/httpx/html.go b/pkg/httpx/html.go new file mode 100644 index 0000000..af8f580 --- /dev/null +++ b/pkg/httpx/html.go @@ -0,0 +1,41 @@ +package httpx + +import ( + "html/template" + "net/http" +) + +type HTMLHandler[T any] struct { + Handler func(r *http.Request) (T, error) + Template *template.Template // called with T +} + +var htmlInternalServerErr = []byte(`Internal Server ErrorInternal Server Error`) +var htmlNotFound = []byte(`Not FoundNot Found`) + +// ServeHTTP calls j(r) and returns json +func (h HTMLHandler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // call the function + result, err := h.Handler(r) + + // entity not found + if err == ErrNotFound { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusNotFound) + w.Write(htmlNotFound) + return + } + + // handle other errors + if err != nil { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusInternalServerError) + w.Write(htmlInternalServerErr) + return + } + + // write out the response as json + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + h.Template.Execute(w, result) +} diff --git a/pkg/httpx/httpx.go b/pkg/httpx/httpx.go new file mode 100644 index 0000000..d6b64fb --- /dev/null +++ b/pkg/httpx/httpx.go @@ -0,0 +1,6 @@ +package httpx + +import "errors" + +// ErrNotFound should be returned from any httpx error to indicate that the item was not found +var ErrNotFound = errors.New("httpx: Error 404") diff --git a/pkg/httpx/json.go b/pkg/httpx/json.go new file mode 100644 index 0000000..bbacc15 --- /dev/null +++ b/pkg/httpx/json.go @@ -0,0 +1,45 @@ +package httpx + +import ( + "encoding/json" + "net/http" +) + +var jsonInternalServerErr = []byte(`{"status":"internal server error"}`) +var jsonNotFound = []byte(`{"status":"not found"}`) + +// JSON creates a new JSONHandler +func JSON[T any](f func(r *http.Request) (T, error)) JSONHandler[T] { + return JSONHandler[T](f) +} + +// JSONHandler implements [http.Handler] by returning values as json to the caller. +// In case of an error, a generic "internal server error" message is returned. +type JSONHandler[T any] func(r *http.Request) (T, error) + +// ServeHTTP calls j(r) and returns json +func (j JSONHandler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // call the function + result, err := j(r) + + // entity not found + if err == ErrNotFound { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + w.Write(jsonNotFound) + return + } + + // handle other errors + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write(jsonInternalServerErr) + return + } + + // write out the response as json + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(result) +}