diff --git a/action_core.go b/action_core.go index c92e47d..2bd08eb 100644 --- a/action_core.go +++ b/action_core.go @@ -11,7 +11,7 @@ import ( "github.com/Luzifer/twitch-bot/internal/actors/timeout" "github.com/Luzifer/twitch-bot/internal/actors/whisper" "github.com/Luzifer/twitch-bot/plugins" - "github.com/gorilla/mux" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) @@ -34,12 +34,33 @@ func init() { } } +func registerRoute(route plugins.HTTPRouteRegistrationArgs) error { + r := router. + PathPrefix(fmt.Sprintf("/%s/", route.Module)). + Subrouter() + + if route.IsPrefix { + r.PathPrefix(route.Path). + HandlerFunc(route.HandlerFunc). + Methods(route.Method) + } else { + r.HandleFunc(route.Path, route.HandlerFunc). + Methods(route.Method) + } + + if !route.SkipDocumentation { + return errors.Wrap(registerSwaggerRoute(route), "registering documentation") + } + + return nil +} + func getRegistrationArguments() plugins.RegistrationArguments { return plugins.RegistrationArguments{ FormatMessage: formatMessage, - GetHTTPRouter: func(name string) *mux.Router { return router.PathPrefix(fmt.Sprintf("/%s/", name)).Subrouter() }, GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) }, RegisterActor: registerAction, + RegisterAPIRoute: registerRoute, RegisterCron: cronService.AddFunc, RegisterTemplateFunction: tplFuncs.Register, SendMessage: sendMessage, diff --git a/action_counter.go b/action_counter.go index 6d05513..ab6de6d 100644 --- a/action_counter.go +++ b/action_counter.go @@ -1,15 +1,71 @@ package main import ( + "fmt" + "net/http" "strconv" "github.com/Luzifer/twitch-bot/plugins" "github.com/go-irc/irc" + "github.com/gorilla/mux" "github.com/pkg/errors" ) func init() { registerAction(func() plugins.Actor { return &ActorCounter{} }) + + registerRoute(plugins.HTTPRouteRegistrationArgs{ + Description: "Returns the (formatted) value as a plain string", + HandlerFunc: routeActorCounterGetValue, + Method: http.MethodGet, + Module: "counter", + Name: "Get Counter Value", + Path: "/{name}", + QueryParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "Template to apply to the value: Variations of %d sprintf template are supported once", + Name: "template", + Required: false, + Type: "string", + }, + }, + ResponseType: plugins.HTTPRouteResponseTypeTextPlain, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "Name of the counter to query", + Name: "name", + }, + }, + }) + + registerRoute(plugins.HTTPRouteRegistrationArgs{ + Description: "Updates the value of the counter", + HandlerFunc: routeActorCounterSetValue, + Method: http.MethodPatch, + Module: "counter", + Name: "Set Counter Value", + Path: "/{name}", + QueryParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "If set to `true` the given value is set instead of added", + Name: "absolute", + Required: false, + Type: "boolean", + }, + { + Description: "Value to add / set for the given counter", + Name: "value", + Required: true, + Type: "int64", + }, + }, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "Name of the counter to update", + Name: "name", + }, + }, + }) } type ActorCounter struct { @@ -58,3 +114,33 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule) (p func (a ActorCounter) IsAsync() bool { return false } func (a ActorCounter) Name() string { return "counter" } + +func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) { + template := r.FormValue("template") + if template == "" { + template = "%d" + } + + w.Header().Set("Content-Type", "text-plain") + fmt.Fprintf(w, template, store.GetCounterValue(mux.Vars(r)["name"])) +} + +func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) { + var ( + absolute = r.FormValue("absolute") == "true" + err error + value int64 + ) + + if value, err = strconv.ParseInt(r.FormValue("value"), 10, 64); err != nil { + http.Error(w, errors.Wrap(err, "parsing value").Error(), http.StatusBadRequest) + return + } + + if err = store.UpdateCounter(mux.Vars(r)["name"], value, absolute); err != nil { + http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/action_setvar.go b/action_setvar.go index e58797a..b17abf9 100644 --- a/action_setvar.go +++ b/action_setvar.go @@ -1,13 +1,56 @@ package main import ( + "fmt" + "net/http" + "github.com/Luzifer/twitch-bot/plugins" "github.com/go-irc/irc" + "github.com/gorilla/mux" "github.com/pkg/errors" ) func init() { registerAction(func() plugins.Actor { return &ActorSetVariable{} }) + + registerRoute(plugins.HTTPRouteRegistrationArgs{ + Description: "Returns the value as a plain string", + HandlerFunc: routeActorSetVarGetValue, + Method: http.MethodGet, + Module: "setvariable", + Name: "Get Variable Value", + Path: "/{name}", + ResponseType: plugins.HTTPRouteResponseTypeTextPlain, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "Name of the variable to query", + Name: "name", + }, + }, + }) + + registerRoute(plugins.HTTPRouteRegistrationArgs{ + Description: "Updates the value of the variable", + HandlerFunc: routeActorSetVarSetValue, + Method: http.MethodPatch, + Module: "setvariable", + Name: "Set Variable Value", + Path: "/{name}", + QueryParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "Value to set for the given variable", + Name: "value", + Required: true, + Type: "string", + }, + }, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "Name of the variable to update", + Name: "name", + }, + }, + }) } type ActorSetVariable struct { @@ -46,3 +89,17 @@ func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule func (a ActorSetVariable) IsAsync() bool { return false } func (a ActorSetVariable) Name() string { return "setvariable" } + +func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text-plain") + fmt.Fprint(w, store.GetVariable(mux.Vars(r)["name"])) +} + +func routeActorSetVarSetValue(w http.ResponseWriter, r *http.Request) { + if err := store.SetVariable(mux.Vars(r)["name"], r.FormValue("value")); err != nil { + http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/go.mod b/go.mod index c13894f..be98cc9 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb // indirect golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect diff --git a/go.sum b/go.sum index c0ebcba..83bf57d 100644 --- a/go.sum +++ b/go.sum @@ -289,6 +289,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb h1:G0Rrif8QdbAz7Xy53H4Xumy6TuyKHom8pu8z/jdLwwM= +github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb/go.mod h1:398xiAftMV/w8frjipnUzjr/WQ+E2fnGRv9yXobxyyk= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= diff --git a/main.go b/main.go index 5b490e6..6636d42 100644 --- a/main.go +++ b/main.go @@ -39,7 +39,7 @@ var ( configLock = new(sync.RWMutex) cronService *cron.Cron - router *mux.Router + router = mux.NewRouter() sendMessage func(m *irc.Message) error @@ -80,9 +80,11 @@ func main() { var err error cronService = cron.New() - router = mux.NewRouter() twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchToken) + router.HandleFunc("/", handleSwaggerHTML) + router.HandleFunc("/openapi.json", handleSwaggerRequest) + if err = loadPlugins(cfg.PluginDir); err != nil { log.WithError(err).Fatal("Unable to load plugins") } diff --git a/plugins/http_api.go b/plugins/http_api.go new file mode 100644 index 0000000..5836a8c --- /dev/null +++ b/plugins/http_api.go @@ -0,0 +1,36 @@ +package plugins + +import "net/http" + +type ( + HTTPRouteParamDocumentation struct { + Description string + Name string + Required bool + Type string + } + + HTTPRouteRegistrationArgs struct { + Description string + HandlerFunc http.HandlerFunc + IsPrefix bool + Method string + Module string + Name string + Path string + QueryParams []HTTPRouteParamDocumentation + ResponseType HTTPRouteResponseType + RouteParams []HTTPRouteParamDocumentation + SkipDocumentation bool + } + + HTTPRouteResponseType uint64 + + HTTPRouteRegistrationFunc func(HTTPRouteRegistrationArgs) error +) + +const ( + HTTPRouteResponseTypeNo200 HTTPRouteResponseType = iota + HTTPRouteResponseTypeTextPlain + HTTPRouteResponseTypeJSON +) diff --git a/plugins/interface.go b/plugins/interface.go index 2765d88..b69b09c 100644 --- a/plugins/interface.go +++ b/plugins/interface.go @@ -2,7 +2,6 @@ package plugins import ( "github.com/go-irc/irc" - "github.com/gorilla/mux" "github.com/robfig/cron/v3" log "github.com/sirupsen/logrus" ) @@ -26,8 +25,6 @@ type ( CronRegistrationFunc func(spec string, cmd func()) (cron.EntryID, error) - HTTPRouterCreationFunc func(name string) *mux.Router - LoggerCreationFunc func(moduleName string) *log.Entry MsgFormatter func(tplString string, m *irc.Message, r *Rule, fields map[string]interface{}) (string, error) @@ -38,12 +35,12 @@ type ( RegistrationArguments struct { // FormatMessage is a method to convert templates into strings using internally known variables / configs FormatMessage MsgFormatter - // GetHTTPRouter returns a new mux.Router with `/{name}/` prefix - GetHTTPRouter HTTPRouterCreationFunc // GetLogger returns a sirupsen log.Entry pre-configured with the module name GetLogger LoggerCreationFunc // RegisterActor is used to register a new IRC rule-actor implementing the Actor interface RegisterActor ActorRegistrationFunc + // RegisterAPIRoute registers a new HTTP handler function including documentation + RegisterAPIRoute HTTPRouteRegistrationFunc // RegisterCron is a method to register cron functions in the global cron instance RegisterCron CronRegistrationFunc // RegisterTemplateFunction can be used to register a new template functions diff --git a/swagger.go b/swagger.go new file mode 100644 index 0000000..66b573f --- /dev/null +++ b/swagger.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + _ "embed" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/Luzifer/twitch-bot/plugins" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/wzshiming/openapi/spec" +) + +var ( + swaggerDoc = spec.OpenAPI{ + OpenAPI: "3.0.3", // This generator uses v3 of OpenAPI standard + Info: &spec.Info{ + Title: "Twitch-Bot public API", + Version: "v1", + }, + Servers: []*spec.Server{ + {URL: "/", Description: "Current bot instance"}, + }, + Paths: make(spec.Paths), + Components: &spec.Components{ + Responses: map[string]*spec.Response{ + "genericErrorResponse": spec.TextPlainResponse(nil).WithDescription("An error occurred: See error message"), + "inputErrorResponse": spec.TextPlainResponse(nil).WithDescription("Data sent to API is invalid: See error message"), + "notFoundResponse": spec.TextPlainResponse(nil).WithDescription("Document was not found or insufficient permissions"), + }, + }, + } + + //go:embed swagger.html + swaggerHTML []byte +) + +func handleSwaggerHTML(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + + if _, err := io.Copy(w, bytes.NewReader(swaggerHTML)); err != nil { + http.Error(w, errors.Wrap(err, "writing frontend").Error(), http.StatusInternalServerError) + } +} + +func handleSwaggerRequest(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(swaggerDoc); err != nil { + http.Error(w, errors.Wrap(err, "rendering documentation").Error(), http.StatusInternalServerError) + } +} + +func registerSwaggerRoute(route plugins.HTTPRouteRegistrationArgs) error { + fullPath := strings.Join([]string{ + "", + route.Module, + strings.TrimLeft(route.Path, "/"), + }, "/") + + pi, ok := swaggerDoc.Paths[fullPath] + if !ok { + pi = &spec.PathItem{} + + for _, param := range route.RouteParams { + pi.Parameters = append( + pi.Parameters, + spec.PathParam(param.Name, spec.StringProperty()).WithDescription(param.Description), + ) + } + + swaggerDoc.Paths[fullPath] = pi + } + + op := &spec.Operation{ + Summary: route.Name, + Description: route.Description, + Tags: []string{route.Module}, + Responses: map[string]*spec.Response{ + "204": spec.TextPlainResponse(nil).WithDescription("Successful execution without response object"), + "404": spec.RefResponse("notFoundResponse"), + "500": spec.RefResponse("genericErrorResponse"), + }, + } + + switch route.ResponseType { + case plugins.HTTPRouteResponseTypeJSON: + op.Responses["200"] = spec.JSONResponse(nil).WithDescription("Successful execution with JSON object response") + + case plugins.HTTPRouteResponseTypeNo200: + // We don't add a 200 then + + case plugins.HTTPRouteResponseTypeTextPlain: + op.Responses["200"] = spec.TextPlainResponse(nil).WithDescription("Successful execution with plain text response") + } + + for _, param := range route.QueryParams { + var ps *spec.Schema + + switch param.Type { + case "bool", "boolean": + ps = spec.BooleanProperty() + + case "int", "int64": + ps = spec.Int64Property() + + case "string": + ps = spec.StringProperty() + + default: + log.WithFields(log.Fields{"module": route.Module, "type": param.Type}).Warn("Module registered unhandled query-param type") + ps = spec.StringProperty() + } + + specParam := spec.QueryParam(param.Name, ps). + WithDescription(param.Description) + + if !param.Required { + specParam = specParam.AsOptional() + } + + op.Parameters = append( + op.Parameters, + specParam, + ) + } + + switch route.Method { + case http.MethodDelete: + pi.Delete = op + case http.MethodGet: + pi.Get = op + case http.MethodPatch: + op.Responses["400"] = spec.RefResponse("inputErrorResponse") + pi.Patch = op + case http.MethodPost: + op.Responses["400"] = spec.RefResponse("inputErrorResponse") + pi.Post = op + case http.MethodPut: + op.Responses["400"] = spec.RefResponse("inputErrorResponse") + pi.Put = op + default: + return errors.Errorf("assignment for %q is not implemented", route.Method) + } + + return nil +} diff --git a/swagger.html b/swagger.html new file mode 100644 index 0000000..af43b52 --- /dev/null +++ b/swagger.html @@ -0,0 +1,34 @@ + +
+ +