api: Begin implementing an API

This commit is contained in:
Tom 2023-04-28 10:25:36 +02:00
parent 1a5e83be10
commit 2fac0390b1
11 changed files with 186 additions and 23 deletions

8
API.md Normal file
View file

@ -0,0 +1,8 @@
# API Documentation
The distillery comes with an API served under `/api/`.
It is still a work in progress, and will be polished and properly implemented at a later point.
The API is currently disabled by default, and needs to be enabled in `distillery.yaml`.
- `/api/v1/systems`: Returns a (publically visible) list of systems
- `/api/v1/news`: Returns JSON containing all news items

2
go.mod
View file

@ -16,7 +16,7 @@ require (
github.com/pquerna/otp v1.4.0 github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.29.0 github.com/rs/zerolog v1.29.0
github.com/tkw1536/goprogram v0.3.5 github.com/tkw1536/goprogram v0.3.5
github.com/tkw1536/pkglib v0.0.0-20230427125619-a4a18a5b7d15 github.com/tkw1536/pkglib v0.0.0-20230428081457-cc953b972cee
github.com/yuin/goldmark v1.5.4 github.com/yuin/goldmark v1.5.4
github.com/yuin/goldmark-meta v1.1.0 github.com/yuin/goldmark-meta v1.1.0
golang.org/x/crypto v0.8.0 golang.org/x/crypto v0.8.0

2
go.sum
View file

@ -117,6 +117,8 @@ github.com/tkw1536/goprogram v0.3.5 h1:S0axKo3R/vGa4zhYqYDKAZEPhAfwUSSeMtVwnAu4s
github.com/tkw1536/goprogram v0.3.5/go.mod h1:pYr4dMHOSVurbPQ4KTR0ett8XWNISbsRS6zlh9Nsxa8= github.com/tkw1536/goprogram v0.3.5/go.mod h1:pYr4dMHOSVurbPQ4KTR0ett8XWNISbsRS6zlh9Nsxa8=
github.com/tkw1536/pkglib v0.0.0-20230427125619-a4a18a5b7d15 h1:sVy3pSreMY5obUOGz2jCaPYbXh+5vklqMJrZZsrII+0= github.com/tkw1536/pkglib v0.0.0-20230427125619-a4a18a5b7d15 h1:sVy3pSreMY5obUOGz2jCaPYbXh+5vklqMJrZZsrII+0=
github.com/tkw1536/pkglib v0.0.0-20230427125619-a4a18a5b7d15/go.mod h1:0A1B9Cc5+yJXR3eeB14CqD4dFSbEjjWRo5Pr9M3XYuI= github.com/tkw1536/pkglib v0.0.0-20230427125619-a4a18a5b7d15/go.mod h1:0A1B9Cc5+yJXR3eeB14CqD4dFSbEjjWRo5Pr9M3XYuI=
github.com/tkw1536/pkglib v0.0.0-20230428081457-cc953b972cee h1:UmnHJnYpOon95zBUzzGteSkTAO6VSHacSbjFEKjEqo0=
github.com/tkw1536/pkglib v0.0.0-20230428081457-cc953b972cee/go.mod h1:0A1B9Cc5+yJXR3eeB14CqD4dFSbEjjWRo5Pr9M3XYuI=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=

View file

@ -39,6 +39,10 @@ http:
# This email address can be configured here. # This email address can be configured here.
certbot_email: null certbot_email: null
# Enable or Disable the HTTP API.
# In the future, it will be enabled by default, but at this point it is not.
api: null
# Configuration for the (public) homepage of the distillery. # Configuration for the (public) homepage of the distillery.
home: home:
# the title of the distillery to be set # the title of the distillery to be set

View file

@ -2,9 +2,12 @@ package config
import ( import (
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"strings" "strings"
"github.com/FAU-CDI/wisski-distillery/internal/config/validators"
"github.com/tkw1536/pkglib/httpx"
"golang.org/x/net/idna" "golang.org/x/net/idna"
) )
@ -22,6 +25,30 @@ type HTTPConfig struct {
// It can be enabled by setting an email for certbot certificates. // It can be enabled by setting an email for certbot certificates.
// This email address can be configured here. // This email address can be configured here.
CertbotEmail string `yaml:"certbot_email" validate:"email"` CertbotEmail string `yaml:"certbot_email" validate:"email"`
// API determines if the API is enabled.
// In a future version of the distillery, it will be enabled by default.
API validators.NullableBool `yaml:"api" validate:"bool" default:"false"`
}
var apiNotEnabled = httpx.Response{
StatusCode: http.StatusForbidden,
Body: []byte(`{"message":"API is not enabled"}`),
}
func (hcfg HTTPConfig) APIDecorator(methods ...string) func(http.Handler) http.Handler {
methods = append(methods, "OPTIONS") // always permit the options method!
if !hcfg.API.Value {
return func(http.Handler) http.Handler {
return httpx.PermitMethods(apiNotEnabled, methods...)
}
}
// permit only the specified methods
return func(h http.Handler) http.Handler {
return httpx.PermitMethods(h, methods...)
}
} }
// JoinPath returns the root public url joined with the provided parts. // JoinPath returns the root public url joined with the provided parts.

View file

@ -8,13 +8,13 @@ import (
// NullableBool represents a bool that can be null // NullableBool represents a bool that can be null
type NullableBool struct { type NullableBool struct {
Null, Value bool Set, Value bool
} }
func (nb *NullableBool) UnmarshalYAML(value *yaml.Node) error { func (nb *NullableBool) UnmarshalYAML(value *yaml.Node) error {
nb.Null = false nb.Set = true
if err := value.Decode(&nb.Value); err != nil { if err := value.Decode(&nb.Value); err != nil {
nb.Null = true nb.Set = false
nb.Value = false nb.Value = false
} }
@ -22,19 +22,19 @@ func (nb *NullableBool) UnmarshalYAML(value *yaml.Node) error {
} }
func (nb NullableBool) MarshalYAML() (interface{}, error) { func (nb NullableBool) MarshalYAML() (interface{}, error) {
if nb.Null { if !nb.Set {
return nil, nil return nil, nil
} }
return nb.Value, nil return nb.Value, nil
} }
func ValidateBool(value *NullableBool, dflt string) (err error) { func ValidateBool(value *NullableBool, dflt string) (err error) {
if value.Null { if !value.Set {
res, err := strconv.ParseBool(dflt) res, err := strconv.ParseBool(dflt)
if err != nil { if err != nil {
return err return err
} }
value.Null = false value.Set = true
value.Value = res value.Value = res
} }
return err return err

View file

@ -0,0 +1,64 @@
package home
import (
"context"
"net/http"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/status"
"github.com/tkw1536/pkglib/httpx"
)
type API struct {
component.Base
Dependencies struct {
Home *Home
}
}
var (
_ component.Routeable = (*API)(nil)
)
func (api *API) Routes() component.Routes {
return component.Routes{
Prefix: "/api/v1/systems",
Exact: true,
Decorator: api.Config.HTTP.APIDecorator("GET"),
}
}
type APISystemInfo struct {
Slug string
URL string
Tagline string
EntityCount int
BundleCount int
LastEdit time.Time
}
func (api *API) HandleRoute(ctx context.Context, path string) (http.Handler, error) {
return httpx.JSON(func(r *http.Request) ([]APISystemInfo, error) {
var statuses []status.WissKI
if api.Dependencies.Home.ShouldShowList(r) {
statuses = api.Dependencies.Home.homeInstances.Get(nil)
}
if len(statuses) == 0 {
return []APISystemInfo{}, nil
}
infos := make([]APISystemInfo, len(statuses))
for i, status := range statuses {
infos[i].Slug = status.Slug
infos[i].URL = status.URL
infos[i].EntityCount = status.Statistics.Bundles.TotalCount()
infos[i].BundleCount = status.Statistics.Bundles.TotalBundles
infos[i].LastEdit = status.Statistics.Bundles.LastEdit().Time
}
return infos, nil
}), nil
}

View file

@ -29,7 +29,6 @@ var aboutTemplate = template.Must(template.New("about.html").Parse(aboutHTML))
// aboutContext is passed to about.html // aboutContext is passed to about.html
type aboutContext struct { type aboutContext struct {
Instances []status.WissKI // list of WissKI Instancaes Instances []status.WissKI // list of WissKI Instancaes
SignedIn bool // is there a signed in user?
Logo template.HTML Logo template.HTML
SelfRedirect string SelfRedirect string
} }
@ -48,6 +47,23 @@ type publicContext struct {
const logoHTML = template.HTML(`<img src="/logo.svg" alt="WissKI Distillery Logo" class="biglogo">`) const logoHTML = template.HTML(`<img src="/logo.svg" alt="WissKI Distillery Logo" class="biglogo">`)
// ShouldShowList determines if the given request should show a WissKI list
func (home *Home) ShouldShowList(r *http.Request) bool {
allowPrivate := home.Config.Home.List.Private.Value
allowPublic := home.Config.Home.List.Public.Value
if allowPrivate == allowPublic {
return allowPrivate
}
user, _ := home.Dependencies.Auth.UserOf(r)
if user == nil {
return allowPublic
} else {
return allowPrivate
}
}
func (home *Home) publicHandler(ctx context.Context) http.Handler { func (home *Home) publicHandler(ctx context.Context) http.Handler {
title := home.Config.Home.Title title := home.Config.Home.Title
@ -75,10 +91,6 @@ func (home *Home) publicHandler(ctx context.Context) http.Handler {
pc.aboutContext.Logo = logoHTML pc.aboutContext.Logo = logoHTML
pc.aboutContext.Instances = home.homeInstances.Get(nil) pc.aboutContext.Instances = home.homeInstances.Get(nil)
pc.aboutContext.SelfRedirect = home.Config.Home.SelfRedirect.String() pc.aboutContext.SelfRedirect = home.Config.Home.SelfRedirect.String()
{
user, _ := home.Dependencies.Auth.UserOf(r)
pc.aboutContext.SignedIn = user != nil
}
// render the about template // render the about template
@ -89,13 +101,8 @@ func (home *Home) publicHandler(ctx context.Context) http.Handler {
// and return about! // and return about!
pc.About = template.HTML(builder.String()) pc.About = template.HTML(builder.String())
// user is not signed in! // check if we should show the list of WissKIs
pc.ListEnabled = home.ShouldShowList(r)
if pc.aboutContext.SignedIn {
pc.ListEnabled = home.Config.Home.List.Private.Value
} else {
pc.ListEnabled = home.Config.Home.List.Public.Value
}
// title of the list // title of the list
pc.ListTitle = home.Config.Home.List.Title pc.ListTitle = home.Config.Home.List.Title

View file

@ -0,0 +1,43 @@
package news
import (
"context"
"encoding/json"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/tkw1536/pkglib/httpx"
)
type API struct {
component.Base
}
var (
_ component.Routeable = (*API)(nil)
)
func (api *API) Routes() component.Routes {
return component.Routes{
Prefix: "/api/v1/news/",
Exact: true,
Decorator: api.Config.HTTP.APIDecorator("GET"),
}
}
func (api *API) HandleRoute(ctx context.Context, path string) (http.Handler, error) {
items, err := Items()
if err != nil {
return nil, err
}
data, err := json.Marshal(items)
if err != nil {
return nil, err
}
return httpx.Response{
ContentType: "application/json",
Body: data,
}, nil
}

View file

@ -187,5 +187,9 @@ func (dis *Distillery) allComponents() []initFunc {
auto[*cron.Cron], auto[*cron.Cron],
auto[*home.UpdateHome], auto[*home.UpdateHome],
auto[*home.UpdateInstanceList], auto[*home.UpdateInstanceList],
// API
auto[*home.API],
auto[*news.API],
} }
} }

View file

@ -79,6 +79,13 @@ type BundleStatistics struct {
TotalMainBundles int `json:"totalMainBundles"` TotalMainBundles int `json:"totalMainBundles"`
} }
func (bs BundleStatistics) TotalCount() (total int) {
for _, bundle := range bs.Bundles {
total += bundle.Count
}
return
}
type LastEdit struct { type LastEdit struct {
Time time.Time Time time.Time
Valid bool Valid bool
@ -101,10 +108,7 @@ func (bs BundleStatistics) LastEdit() (le LastEdit) {
} }
func (bs BundleStatistics) Summary() string { func (bs BundleStatistics) Summary() string {
var totalCount int totalCount := bs.TotalCount()
for _, bundle := range bs.Bundles {
totalCount += bundle.Count
}
if totalCount == 0 { if totalCount == 0 {
return "" return ""
} }