diff --git a/.dockerignore b/.dockerignore index 3bb60f99..f9653b66 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,6 @@ bin dist site -docs node_modules .git .idea diff --git a/.gitignore b/.gitignore index e012930d..0d27db6d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,6 @@ /*.pem bin/ dist/ -docs/swagger.json -docs/swagger.yaml docs/docs.go site/ config.yml diff --git a/Makefile b/Makefile index 90fc9106..f053f00c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean generate build swagger test e2e-test lint run fmt docker-build help +.PHONY: all clean generate build test e2e-test lint run fmt docker-build help .DEFAULT_GOAL:=help VERSION?=$(shell git describe --always --tags) @@ -31,12 +31,6 @@ all: build test lint ## Build binary (with tests) clean: ## cleans output directory rm -rf $(BIN_OUT_DIR)/* -swagger: ## creates swagger documentation as html file - npm install bootprint bootprint-openapi html-inline - go run github.com/swaggo/swag/cmd/swag init -g api/api.go - $(shell) node_modules/bootprint/bin/bootprint.js openapi docs/swagger.json /tmp/swagger/ - $(shell) node_modules/html-inline/bin/cmd.js /tmp/swagger/index.html > docs/swagger.html - serve_docs: ## serves online docs pip install mkdocs-material mkdocs serve diff --git a/api/api.go b/api/api.go deleted file mode 100644 index afc1fcff..00000000 --- a/api/api.go +++ /dev/null @@ -1,63 +0,0 @@ -// @title blocky API -// @description blocky API - -// @contact.name blocky@github -// @contact.url https://github.com/0xERR0R/blocky - -// @license.name Apache 2.0 -// @license.url http://www.apache.org/licenses/LICENSE-2.0.html - -// @BasePath /api/ - -// Package api provides basic API structs for the REST services -package api - -const ( - // PathBlockingStatusPath defines the REST endpoint for blocking status - PathBlockingStatusPath = "/api/blocking/status" - - // PathBlockingEnablePath defines the REST endpoint for blocking enable - PathBlockingEnablePath = "/api/blocking/enable" - - // PathBlockingDisablePath defines the REST endpoint for blocking disable - PathBlockingDisablePath = "/api/blocking/disable" - - // PathListsRefresh defines the REST endpoint for blocking refresh - PathListsRefresh = "/api/lists/refresh" - - // PathQueryPath defines the REST endpoint for query - PathQueryPath = "/api/query" - - // PathDohQuery DoH Url - PathDohQuery = "/dns-query" -) - -// QueryRequest is a data structure for a DNS request -type QueryRequest struct { - // query for DNS request - Query string `json:"query"` - // request type (A, AAAA, ...) - Type string `json:"type"` -} - -// QueryResult is a data structure for the DNS result -type QueryResult struct { - // blocky reason for resolution - Reason string `json:"reason"` - // response type (CACHED, BLOCKED, ...) - ResponseType string `json:"responseType"` - // actual DNS response - Response string `json:"response"` - // DNS return code (NOERROR, NXDOMAIN, ...) - ReturnCode string `json:"returnCode"` -} - -// BlockingStatus represents the current blocking status -type BlockingStatus struct { - // True if blocking is enabled - Enabled bool `json:"enabled"` - // Disabled group names - DisabledGroups []string `json:"disabledGroups"` - // If blocking is temporary disabled: amount of seconds until blocking will be enabled - AutoEnableInSec uint `json:"autoEnableInSec"` -} diff --git a/api/api_client.gen.go b/api/api_client.gen.go new file mode 100644 index 00000000..e8124e7d --- /dev/null +++ b/api/api_client.gen.go @@ -0,0 +1,687 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.14.0 DO NOT EDIT. +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/runtime" +) + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // DisableBlocking request + DisableBlocking(ctx context.Context, params *DisableBlockingParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // EnableBlocking request + EnableBlocking(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // BlockingStatus request + BlockingStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListRefresh request + ListRefresh(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // QueryWithBody request with any body + QueryWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + Query(ctx context.Context, body QueryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) DisableBlocking(ctx context.Context, params *DisableBlockingParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDisableBlockingRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) EnableBlocking(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewEnableBlockingRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) BlockingStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewBlockingStatusRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListRefresh(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListRefreshRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) QueryWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewQueryRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) Query(ctx context.Context, body QueryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewQueryRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewDisableBlockingRequest generates requests for DisableBlocking +func NewDisableBlockingRequest(server string, params *DisableBlockingParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/blocking/disable") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Duration != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "duration", runtime.ParamLocationQuery, *params.Duration); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Groups != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "groups", runtime.ParamLocationQuery, *params.Groups); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewEnableBlockingRequest generates requests for EnableBlocking +func NewEnableBlockingRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/blocking/enable") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewBlockingStatusRequest generates requests for BlockingStatus +func NewBlockingStatusRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/blocking/status") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListRefreshRequest generates requests for ListRefresh +func NewListRefreshRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/lists/refresh") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewQueryRequest calls the generic Query builder with application/json body +func NewQueryRequest(server string, body QueryJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewQueryRequestWithBody(server, "application/json", bodyReader) +} + +// NewQueryRequestWithBody generates requests for Query with any type of body +func NewQueryRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/query") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // DisableBlockingWithResponse request + DisableBlockingWithResponse(ctx context.Context, params *DisableBlockingParams, reqEditors ...RequestEditorFn) (*DisableBlockingResponse, error) + + // EnableBlockingWithResponse request + EnableBlockingWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableBlockingResponse, error) + + // BlockingStatusWithResponse request + BlockingStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*BlockingStatusResponse, error) + + // ListRefreshWithResponse request + ListRefreshWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRefreshResponse, error) + + // QueryWithBodyWithResponse request with any body + QueryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*QueryResponse, error) + + QueryWithResponse(ctx context.Context, body QueryJSONRequestBody, reqEditors ...RequestEditorFn) (*QueryResponse, error) +} + +type DisableBlockingResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// Status returns HTTPResponse.Status +func (r DisableBlockingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DisableBlockingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type EnableBlockingResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// Status returns HTTPResponse.Status +func (r EnableBlockingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r EnableBlockingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type BlockingStatusResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ApiBlockingStatus +} + +// Status returns HTTPResponse.Status +func (r BlockingStatusResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r BlockingStatusResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ListRefreshResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// Status returns HTTPResponse.Status +func (r ListRefreshResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListRefreshResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type QueryResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ApiQueryResult +} + +// Status returns HTTPResponse.Status +func (r QueryResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r QueryResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// DisableBlockingWithResponse request returning *DisableBlockingResponse +func (c *ClientWithResponses) DisableBlockingWithResponse(ctx context.Context, params *DisableBlockingParams, reqEditors ...RequestEditorFn) (*DisableBlockingResponse, error) { + rsp, err := c.DisableBlocking(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDisableBlockingResponse(rsp) +} + +// EnableBlockingWithResponse request returning *EnableBlockingResponse +func (c *ClientWithResponses) EnableBlockingWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableBlockingResponse, error) { + rsp, err := c.EnableBlocking(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseEnableBlockingResponse(rsp) +} + +// BlockingStatusWithResponse request returning *BlockingStatusResponse +func (c *ClientWithResponses) BlockingStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*BlockingStatusResponse, error) { + rsp, err := c.BlockingStatus(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseBlockingStatusResponse(rsp) +} + +// ListRefreshWithResponse request returning *ListRefreshResponse +func (c *ClientWithResponses) ListRefreshWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRefreshResponse, error) { + rsp, err := c.ListRefresh(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListRefreshResponse(rsp) +} + +// QueryWithBodyWithResponse request with arbitrary body returning *QueryResponse +func (c *ClientWithResponses) QueryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*QueryResponse, error) { + rsp, err := c.QueryWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseQueryResponse(rsp) +} + +func (c *ClientWithResponses) QueryWithResponse(ctx context.Context, body QueryJSONRequestBody, reqEditors ...RequestEditorFn) (*QueryResponse, error) { + rsp, err := c.Query(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseQueryResponse(rsp) +} + +// ParseDisableBlockingResponse parses an HTTP response from a DisableBlockingWithResponse call +func ParseDisableBlockingResponse(rsp *http.Response) (*DisableBlockingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DisableBlockingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + +// ParseEnableBlockingResponse parses an HTTP response from a EnableBlockingWithResponse call +func ParseEnableBlockingResponse(rsp *http.Response) (*EnableBlockingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &EnableBlockingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + +// ParseBlockingStatusResponse parses an HTTP response from a BlockingStatusWithResponse call +func ParseBlockingStatusResponse(rsp *http.Response) (*BlockingStatusResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &BlockingStatusResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ApiBlockingStatus + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + +// ParseListRefreshResponse parses an HTTP response from a ListRefreshWithResponse call +func ParseListRefreshResponse(rsp *http.Response) (*ListRefreshResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListRefreshResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + +// ParseQueryResponse parses an HTTP response from a QueryWithResponse call +func ParseQueryResponse(rsp *http.Response) (*QueryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &QueryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ApiQueryResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} diff --git a/api/api_endpoints.go b/api/api_endpoints.go deleted file mode 100644 index 9ad78c30..00000000 --- a/api/api_endpoints.go +++ /dev/null @@ -1,152 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "strings" - "time" - - "github.com/0xERR0R/blocky/log" - "github.com/0xERR0R/blocky/util" - - "github.com/go-chi/chi/v5" -) - -const ( - contentTypeHeader = "content-type" - jsonContentType = "application/json" -) - -// BlockingControl interface to control the blocking status -type BlockingControl interface { - EnableBlocking() - DisableBlocking(duration time.Duration, disableGroups []string) error - BlockingStatus() BlockingStatus -} - -// ListRefresher interface to control the list refresh -type ListRefresher interface { - RefreshLists() -} - -// BlockingEndpoint endpoint for the blocking status control -type BlockingEndpoint struct { - control BlockingControl -} - -// ListRefreshEndpoint endpoint for list refresh -type ListRefreshEndpoint struct { - refresher ListRefresher -} - -// RegisterEndpoint registers an implementation as HTTP endpoint -func RegisterEndpoint(router chi.Router, t interface{}) { - if a, ok := t.(BlockingControl); ok { - registerBlockingEndpoints(router, a) - } - - if a, ok := t.(ListRefresher); ok { - registerListRefreshEndpoints(router, a) - } -} - -func registerListRefreshEndpoints(router chi.Router, refresher ListRefresher) { - l := &ListRefreshEndpoint{refresher} - - router.Post(PathListsRefresh, l.apiListRefresh) -} - -// apiListRefresh is the http endpoint to trigger the refresh of all lists -// @Summary List refresh -// @Description Refresh all lists -// @Tags lists -// @Success 200 "Lists were reloaded" -// @Router /lists/refresh [post] -func (l *ListRefreshEndpoint) apiListRefresh(rw http.ResponseWriter, _ *http.Request) { - rw.Header().Set(contentTypeHeader, jsonContentType) - l.refresher.RefreshLists() -} - -func registerBlockingEndpoints(router chi.Router, control BlockingControl) { - s := &BlockingEndpoint{control} - // register API endpoints - router.Get(PathBlockingEnablePath, s.apiBlockingEnable) - router.Get(PathBlockingDisablePath, s.apiBlockingDisable) - router.Get(PathBlockingStatusPath, s.apiBlockingStatus) -} - -// apiBlockingEnable is the http endpoint to enable the blocking status -// @Summary Enable blocking -// @Description enable the blocking status -// @Tags blocking -// @Success 200 "Blocking is enabled" -// @Router /blocking/enable [get] -func (s *BlockingEndpoint) apiBlockingEnable(rw http.ResponseWriter, _ *http.Request) { - log.Log().Info("enabling blocking...") - - s.control.EnableBlocking() - - rw.Header().Set(contentTypeHeader, jsonContentType) -} - -// apiBlockingDisable is the http endpoint to disable the blocking status -// @Summary Disable blocking -// @Description disable the blocking status -// @Tags blocking -// @Param duration query string false "duration of blocking (Example: 300s, 5m, 1h, 5m30s)" Format(duration) -// @Param groups query string false "groups to disable (comma separated). If empty, disable all groups" Format(string) -// @Success 200 "Blocking is disabled" -// @Failure 400 "Wrong duration format" -// @Failure 400 "Unknown group" -// @Router /blocking/disable [get] -func (s *BlockingEndpoint) apiBlockingDisable(rw http.ResponseWriter, req *http.Request) { - var ( - duration time.Duration - groups []string - err error - ) - - rw.Header().Set(contentTypeHeader, jsonContentType) - - // parse duration from query parameter - durationParam := req.URL.Query().Get("duration") - if len(durationParam) > 0 { - duration, err = time.ParseDuration(durationParam) - if err != nil { - log.Log().Errorf("wrong duration format '%s'", log.EscapeInput(durationParam)) - rw.WriteHeader(http.StatusBadRequest) - - return - } - } - - groupsParam := req.URL.Query().Get("groups") - if len(groupsParam) > 0 { - groups = strings.Split(groupsParam, ",") - } - - err = s.control.DisableBlocking(duration, groups) - if err != nil { - log.Log().Error("can't disable the blocking: ", log.EscapeInput(err.Error())) - rw.WriteHeader(http.StatusBadRequest) - } -} - -// apiBlockingStatus is the http endpoint to get current blocking status -// @Summary Blocking status -// @Description get current blocking status -// @Tags blocking -// @Produce json -// @Success 200 {object} api.BlockingStatus "Returns current blocking status" -// @Router /blocking/status [get] -func (s *BlockingEndpoint) apiBlockingStatus(rw http.ResponseWriter, _ *http.Request) { - status := s.control.BlockingStatus() - - rw.Header().Set(contentTypeHeader, jsonContentType) - - response, err := json.Marshal(status) - util.LogOnError("unable to marshal response ", err) - - _, err = rw.Write(response) - util.LogOnError("unable to write response ", err) -} diff --git a/api/api_endpoints_test.go b/api/api_endpoints_test.go deleted file mode 100644 index a6445b95..00000000 --- a/api/api_endpoints_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "time" - - . "github.com/0xERR0R/blocky/helpertest" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/go-chi/chi/v5" -) - -type BlockingControlMock struct { - enabled bool -} - -type ListRefreshMock struct { - refreshTriggered bool -} - -func (l *ListRefreshMock) RefreshLists() { - l.refreshTriggered = true -} - -func (b *BlockingControlMock) EnableBlocking() { - b.enabled = true -} - -func (b *BlockingControlMock) DisableBlocking(time.Duration, []string) error { - b.enabled = false - - return nil -} - -func (b *BlockingControlMock) BlockingStatus() BlockingStatus { - return BlockingStatus{Enabled: b.enabled} -} - -var _ = Describe("API tests", func() { - Describe("Register router", func() { - RegisterEndpoint(chi.NewRouter(), &BlockingControlMock{}) - RegisterEndpoint(chi.NewRouter(), &ListRefreshMock{}) - }) - - Describe("Lists API", func() { - When("List refresh is called", func() { - r := &ListRefreshMock{} - sut := &ListRefreshEndpoint{refresher: r} - It("should trigger the list refresh", func() { - resp, _ := DoGetRequest("/api/lists/refresh", sut.apiListRefresh) - Expect(resp).Should(HaveHTTPStatus(http.StatusOK)) - Expect(resp).Should(HaveHTTPHeaderWithValue("Content-type", "application/json")) - }) - }) - }) - - Describe("Control blocking status via API", func() { - var ( - bc *BlockingControlMock - sut *BlockingEndpoint - ) - - BeforeEach(func() { - bc = &BlockingControlMock{enabled: true} - sut = &BlockingEndpoint{control: bc} - }) - - When("Disable blocking is called", func() { - It("should disable blocking resolver", func() { - By("Calling Rest API to deactivate", func() { - resp, _ := DoGetRequest("/api/blocking/disable", sut.apiBlockingDisable) - Expect(resp).Should(HaveHTTPStatus(http.StatusOK)) - Expect(resp).Should(HaveHTTPHeaderWithValue("Content-type", "application/json")) - }) - }) - }) - - When("Disable blocking is called with a wrong parameter", func() { - It("Should return http bad request as return code", func() { - resp, _ := DoGetRequest("/api/blocking/disable?duration=xyz", sut.apiBlockingDisable) - Expect(resp).Should(HaveHTTPStatus(http.StatusBadRequest)) - Expect(resp).Should(HaveHTTPHeaderWithValue("Content-type", "application/json")) - }) - }) - - When("Disable blocking is called with a duration parameter", func() { - It("Should disable blocking only for the passed amount of time", func() { - By("ensure that the blocking status is active", func() { - Expect(bc.enabled).Should(BeTrue()) - }) - - By("Calling Rest API to deactivate blocking for 0.5 sec", func() { - resp, _ := DoGetRequest("/api/blocking/disable?duration=500ms", sut.apiBlockingDisable) - Expect(resp).Should(HaveHTTPStatus(http.StatusOK)) - Expect(resp).Should(HaveHTTPHeaderWithValue("Content-type", "application/json")) - }) - - By("ensure that the blocking is disabled", func() { - // now is blocking disabled - Expect(bc.enabled).Should(BeFalse()) - }) - }) - }) - - When("Blocking status is called", func() { - It("should return correct status", func() { - By("enable blocking via API", func() { - resp, _ := DoGetRequest("/api/blocking/enable", sut.apiBlockingEnable) - Expect(resp).Should(HaveHTTPStatus(http.StatusOK)) - Expect(resp).Should(HaveHTTPHeaderWithValue("Content-type", "application/json")) - }) - - By("Query blocking status via API should return 'enabled'", func() { - resp, body := DoGetRequest("/api/blocking/status", sut.apiBlockingStatus) - Expect(resp).Should(HaveHTTPStatus(http.StatusOK)) - Expect(resp).Should(HaveHTTPHeaderWithValue("Content-type", "application/json")) - var result BlockingStatus - err := json.NewDecoder(body).Decode(&result) - Expect(err).Should(Succeed()) - - Expect(result.Enabled).Should(BeTrue()) - }) - - By("disable blocking via API", func() { - resp, _ := DoGetRequest("/api/blocking/disable?duration=500ms", sut.apiBlockingDisable) - Expect(resp).Should(HaveHTTPStatus(http.StatusOK)) - Expect(resp).Should(HaveHTTPHeaderWithValue("Content-type", "application/json")) - }) - - By("Query blocking status via API again should return 'disabled'", func() { - resp, body := DoGetRequest("/api/blocking/status", sut.apiBlockingStatus) - Expect(resp).Should(HaveHTTPStatus(http.StatusOK)) - Expect(resp).Should(HaveHTTPHeaderWithValue("Content-type", "application/json")) - - var result BlockingStatus - err := json.NewDecoder(body).Decode(&result) - Expect(err).Should(Succeed()) - - Expect(result.Enabled).Should(BeFalse()) - }) - }) - }) - }) -}) diff --git a/api/api_interface_impl.go b/api/api_interface_impl.go new file mode 100644 index 00000000..c16ab87c --- /dev/null +++ b/api/api_interface_impl.go @@ -0,0 +1,147 @@ +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --config=types.cfg.yaml ../docs/api/openapi.yaml +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --config=server.cfg.yaml ../docs/api/openapi.yaml +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --config=client.cfg.yaml ../docs/api/openapi.yaml + +package api + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/0xERR0R/blocky/log" + "github.com/0xERR0R/blocky/model" + "github.com/0xERR0R/blocky/util" + "github.com/go-chi/chi/v5" + "github.com/miekg/dns" +) + +// BlockingStatus represents the current blocking status +type BlockingStatus struct { + // True if blocking is enabled + Enabled bool + // Disabled group names + DisabledGroups []string + // If blocking is temporary disabled: amount of seconds until blocking will be enabled + AutoEnableInSec int +} + +// BlockingControl interface to control the blocking status +type BlockingControl interface { + EnableBlocking() + DisableBlocking(duration time.Duration, disableGroups []string) error + BlockingStatus() BlockingStatus +} + +// ListRefresher interface to control the list refresh +type ListRefresher interface { + RefreshLists() error +} + +type Querier interface { + Query(question string, qType dns.Type) (*model.Response, error) +} + +func RegisterOpenAPIEndpoints(router chi.Router, impl StrictServerInterface) { + HandlerFromMuxWithBaseURL(NewStrictHandler(impl, nil), router, "/api") +} + +type OpenAPIInterfaceImpl struct { + control BlockingControl + querier Querier + refresher ListRefresher +} + +func NewOpenAPIInterfaceImpl(control BlockingControl, querier Querier, refresher ListRefresher) *OpenAPIInterfaceImpl { + return &OpenAPIInterfaceImpl{ + control: control, + querier: querier, + refresher: refresher, + } +} + +func (i *OpenAPIInterfaceImpl) DisableBlocking(_ context.Context, + request DisableBlockingRequestObject, +) (DisableBlockingResponseObject, error) { + var ( + duration time.Duration + groups []string + err error + ) + + if request.Params.Duration != nil { + duration, err = time.ParseDuration(*request.Params.Duration) + if err != nil { + return DisableBlocking400TextResponse(log.EscapeInput(err.Error())), nil + } + } + + if request.Params.Groups != nil { + groups = strings.Split(*request.Params.Groups, ",") + } + + err = i.control.DisableBlocking(duration, groups) + + if err != nil { + return DisableBlocking400TextResponse(log.EscapeInput(err.Error())), nil + } + + return DisableBlocking200Response{}, nil +} + +func (i *OpenAPIInterfaceImpl) EnableBlocking(_ context.Context, _ EnableBlockingRequestObject, +) (EnableBlockingResponseObject, error) { + i.control.EnableBlocking() + + return EnableBlocking200Response{}, nil +} + +func (i *OpenAPIInterfaceImpl) BlockingStatus(_ context.Context, _ BlockingStatusRequestObject, +) (BlockingStatusResponseObject, error) { + blStatus := i.control.BlockingStatus() + + result := ApiBlockingStatus{ + Enabled: blStatus.Enabled, + } + + if blStatus.AutoEnableInSec > 0 { + result.AutoEnableInSec = &blStatus.AutoEnableInSec + } + + if len(blStatus.DisabledGroups) > 0 { + result.DisabledGroups = &blStatus.DisabledGroups + } + + return BlockingStatus200JSONResponse(result), nil +} + +func (i *OpenAPIInterfaceImpl) ListRefresh(_ context.Context, + _ ListRefreshRequestObject, +) (ListRefreshResponseObject, error) { + err := i.refresher.RefreshLists() + if err != nil { + return ListRefresh500TextResponse(log.EscapeInput(err.Error())), nil + } + + return ListRefresh200Response{}, nil +} + +func (i *OpenAPIInterfaceImpl) Query(_ context.Context, request QueryRequestObject) (QueryResponseObject, error) { + qType := dns.Type(dns.StringToType[request.Body.Type]) + if qType == dns.Type(dns.TypeNone) { + return Query400TextResponse(fmt.Sprintf("unknown query type '%s'", request.Body.Type)), nil + } + + resp, err := i.querier.Query(dns.Fqdn(request.Body.Query), qType) + if err != nil { + return nil, err + } + + return Query200JSONResponse(ApiQueryResult{ + Reason: resp.Reason, + ResponseType: resp.RType.String(), + Response: util.AnswerToString(resp.Res.Answer), + ReturnCode: dns.RcodeToString[resp.Res.Rcode], + }), nil +} diff --git a/api/api_interface_impl_test.go b/api/api_interface_impl_test.go new file mode 100644 index 00000000..02afdb6a --- /dev/null +++ b/api/api_interface_impl_test.go @@ -0,0 +1,216 @@ +package api + +import ( + "context" + "errors" + "time" + + // . "github.com/0xERR0R/blocky/helpertest" + "github.com/0xERR0R/blocky/model" + "github.com/0xERR0R/blocky/util" + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + + . "github.com/0xERR0R/blocky/helpertest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type BlockingControlMock struct { + mock.Mock +} + +type ListRefreshMock struct { + mock.Mock +} + +type QuerierMock struct { + mock.Mock +} + +func (m *ListRefreshMock) RefreshLists() error { + args := m.Called() + + return args.Error(0) +} + +func (m *BlockingControlMock) EnableBlocking() { + _ = m.Called() +} + +func (m *BlockingControlMock) DisableBlocking(t time.Duration, g []string) error { + args := m.Called(t, g) + + return args.Error(0) +} + +func (m *BlockingControlMock) BlockingStatus() BlockingStatus { + args := m.Called() + + return args.Get(0).(BlockingStatus) +} + +func (m *QuerierMock) Query(question string, qType dns.Type) (*model.Response, error) { + args := m.Called(question, qType) + + return args.Get(0).(*model.Response), args.Error(1) +} + +var _ = Describe("API implementation tests", func() { + var ( + blockingControlMock *BlockingControlMock + querierMock *QuerierMock + listRefreshMock *ListRefreshMock + sut *OpenAPIInterfaceImpl + ) + + BeforeEach(func() { + blockingControlMock = &BlockingControlMock{} + querierMock = &QuerierMock{} + listRefreshMock = &ListRefreshMock{} + sut = NewOpenAPIInterfaceImpl(blockingControlMock, querierMock, listRefreshMock) + }) + + AfterEach(func() { + blockingControlMock.AssertExpectations(GinkgoT()) + querierMock.AssertExpectations(GinkgoT()) + listRefreshMock.AssertExpectations(GinkgoT()) + }) + + Describe("Query API", func() { + When("Query is called", func() { + It("should return 200 on success", func() { + queryResponse, err := util.NewMsgWithAnswer( + "domain.", 123, A, "0.0.0.0", + ) + Expect(err).Should(Succeed()) + + querierMock.On("Query", "google.com.", A).Return(&model.Response{ + Res: queryResponse, + Reason: "reason", + }, nil) + + resp, err := sut.Query(context.Background(), QueryRequestObject{ + Body: &ApiQueryRequest{ + Query: "google.com", Type: "A", + }, + }) + Expect(err).Should(Succeed()) + var resp200 Query200JSONResponse + Expect(resp).Should(BeAssignableToTypeOf(resp200)) + resp200 = resp.(Query200JSONResponse) + Expect(resp200.Reason).Should(Equal("reason")) + Expect(resp200.Response).Should(Equal("A (0.0.0.0)")) + Expect(resp200.ResponseType).Should(Equal("RESOLVED")) + Expect(resp200.ReturnCode).Should(Equal("NOERROR")) + }) + + It("should return 400 on wrong parameter", func() { + resp, err := sut.Query(context.Background(), QueryRequestObject{ + Body: &ApiQueryRequest{ + Query: "google.com", + Type: "WRONGTYPE", + }, + }) + Expect(err).Should(Succeed()) + var resp400 Query400TextResponse + Expect(resp).Should(BeAssignableToTypeOf(resp400)) + Expect(resp).Should(Equal(Query400TextResponse("unknown query type 'WRONGTYPE'"))) + }) + }) + }) + + Describe("Lists API", func() { + When("List refresh is called", func() { + It("should return 200 on success", func() { + listRefreshMock.On("RefreshLists").Return(nil) + + resp, err := sut.ListRefresh(context.Background(), ListRefreshRequestObject{}) + Expect(err).Should(Succeed()) + var resp200 ListRefresh200Response + Expect(resp).Should(BeAssignableToTypeOf(resp200)) + }) + + It("should return 500 on failure", func() { + listRefreshMock.On("RefreshLists").Return(errors.New("failed")) + + resp, err := sut.ListRefresh(context.Background(), ListRefreshRequestObject{}) + Expect(err).Should(Succeed()) + var resp500 ListRefresh500TextResponse + Expect(resp).Should(BeAssignableToTypeOf(resp500)) + Expect(resp).Should(Equal(ListRefresh500TextResponse("failed"))) + }) + }) + }) + + Describe("Control blocking status via API", func() { + When("Disable blocking is called", func() { + It("should return 200 on success", func() { + blockingControlMock.On("DisableBlocking", 3*time.Second, []string{"gr1", "gr2"}).Return(nil) + duration := "3s" + grroups := "gr1,gr2" + + resp, err := sut.DisableBlocking(context.Background(), DisableBlockingRequestObject{ + Params: DisableBlockingParams{ + Duration: &duration, + Groups: &grroups, + }, + }) + Expect(err).Should(Succeed()) + var resp200 DisableBlocking200Response + Expect(resp).Should(BeAssignableToTypeOf(resp200)) + }) + + It("should return 400 on failure", func() { + blockingControlMock.On("DisableBlocking", mock.Anything, mock.Anything).Return(errors.New("failed")) + resp, err := sut.DisableBlocking(context.Background(), DisableBlockingRequestObject{}) + Expect(err).Should(Succeed()) + var resp400 DisableBlocking400TextResponse + Expect(resp).Should(BeAssignableToTypeOf(resp400)) + Expect(resp).Should(Equal(DisableBlocking400TextResponse("failed"))) + }) + + It("should return 400 on wrong duration parameter", func() { + wrongDuration := "4sds" + resp, err := sut.DisableBlocking(context.Background(), DisableBlockingRequestObject{ + Params: DisableBlockingParams{ + Duration: &wrongDuration, + }, + }) + Expect(err).Should(Succeed()) + var resp400 DisableBlocking400TextResponse + Expect(resp).Should(BeAssignableToTypeOf(resp400)) + Expect(resp).Should(Equal(DisableBlocking400TextResponse("time: unknown unit \"sds\" in duration \"4sds\""))) + }) + }) + When("Enable blocking is called", func() { + It("should return 200 on success", func() { + blockingControlMock.On("EnableBlocking").Return() + + resp, err := sut.EnableBlocking(context.Background(), EnableBlockingRequestObject{}) + Expect(err).Should(Succeed()) + var resp200 EnableBlocking200Response + Expect(resp).Should(BeAssignableToTypeOf(resp200)) + }) + }) + + When("Blocking status is called", func() { + It("should return 200 and correct status", func() { + blockingControlMock.On("BlockingStatus").Return(BlockingStatus{ + Enabled: false, + DisabledGroups: []string{"gr1", "gr2"}, + AutoEnableInSec: 47, + }) + + resp, err := sut.BlockingStatus(context.Background(), BlockingStatusRequestObject{}) + Expect(err).Should(Succeed()) + var resp200 BlockingStatus200JSONResponse + Expect(resp).Should(BeAssignableToTypeOf(resp200)) + resp200 = resp.(BlockingStatus200JSONResponse) + Expect(resp200.Enabled).Should(Equal(false)) + Expect(resp200.DisabledGroups).Should(HaveValue(Equal([]string{"gr1", "gr2"}))) + Expect(resp200.AutoEnableInSec).Should(HaveValue(BeNumerically("==", 47))) + }) + }) + }) +}) diff --git a/api/api_server.gen.go b/api/api_server.gen.go new file mode 100644 index 00000000..f79db3d9 --- /dev/null +++ b/api/api_server.gen.go @@ -0,0 +1,591 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.14.0 DO NOT EDIT. +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" + strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Disable blocking + // (GET /blocking/disable) + DisableBlocking(w http.ResponseWriter, r *http.Request, params DisableBlockingParams) + // Enable blocking + // (GET /blocking/enable) + EnableBlocking(w http.ResponseWriter, r *http.Request) + // Blocking status + // (GET /blocking/status) + BlockingStatus(w http.ResponseWriter, r *http.Request) + // List refresh + // (POST /lists/refresh) + ListRefresh(w http.ResponseWriter, r *http.Request) + // Performs DNS query + // (POST /query) + Query(w http.ResponseWriter, r *http.Request) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// Disable blocking +// (GET /blocking/disable) +func (_ Unimplemented) DisableBlocking(w http.ResponseWriter, r *http.Request, params DisableBlockingParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Enable blocking +// (GET /blocking/enable) +func (_ Unimplemented) EnableBlocking(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Blocking status +// (GET /blocking/status) +func (_ Unimplemented) BlockingStatus(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// List refresh +// (POST /lists/refresh) +func (_ Unimplemented) ListRefresh(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Performs DNS query +// (POST /query) +func (_ Unimplemented) Query(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// DisableBlocking operation middleware +func (siw *ServerInterfaceWrapper) DisableBlocking(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params DisableBlockingParams + + // ------------- Optional query parameter "duration" ------------- + + err = runtime.BindQueryParameter("form", true, false, "duration", r.URL.Query(), ¶ms.Duration) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "duration", Err: err}) + return + } + + // ------------- Optional query parameter "groups" ------------- + + err = runtime.BindQueryParameter("form", true, false, "groups", r.URL.Query(), ¶ms.Groups) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "groups", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DisableBlocking(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// EnableBlocking operation middleware +func (siw *ServerInterfaceWrapper) EnableBlocking(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EnableBlocking(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// BlockingStatus operation middleware +func (siw *ServerInterfaceWrapper) BlockingStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.BlockingStatus(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// ListRefresh operation middleware +func (siw *ServerInterfaceWrapper) ListRefresh(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListRefresh(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// Query operation middleware +func (siw *ServerInterfaceWrapper) Query(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Query(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/blocking/disable", wrapper.DisableBlocking) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/blocking/enable", wrapper.EnableBlocking) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/blocking/status", wrapper.BlockingStatus) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/lists/refresh", wrapper.ListRefresh) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/query", wrapper.Query) + }) + + return r +} + +type DisableBlockingRequestObject struct { + Params DisableBlockingParams +} + +type DisableBlockingResponseObject interface { + VisitDisableBlockingResponse(w http.ResponseWriter) error +} + +type DisableBlocking200Response struct { +} + +func (response DisableBlocking200Response) VisitDisableBlockingResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DisableBlocking400TextResponse string + +func (response DisableBlocking400TextResponse) VisitDisableBlockingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(400) + + _, err := w.Write([]byte(response)) + return err +} + +type EnableBlockingRequestObject struct { +} + +type EnableBlockingResponseObject interface { + VisitEnableBlockingResponse(w http.ResponseWriter) error +} + +type EnableBlocking200Response struct { +} + +func (response EnableBlocking200Response) VisitEnableBlockingResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type BlockingStatusRequestObject struct { +} + +type BlockingStatusResponseObject interface { + VisitBlockingStatusResponse(w http.ResponseWriter) error +} + +type BlockingStatus200JSONResponse ApiBlockingStatus + +func (response BlockingStatus200JSONResponse) VisitBlockingStatusResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListRefreshRequestObject struct { +} + +type ListRefreshResponseObject interface { + VisitListRefreshResponse(w http.ResponseWriter) error +} + +type ListRefresh200Response struct { +} + +func (response ListRefresh200Response) VisitListRefreshResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type ListRefresh500TextResponse string + +func (response ListRefresh500TextResponse) VisitListRefreshResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(500) + + _, err := w.Write([]byte(response)) + return err +} + +type QueryRequestObject struct { + Body *QueryJSONRequestBody +} + +type QueryResponseObject interface { + VisitQueryResponse(w http.ResponseWriter) error +} + +type Query200JSONResponse ApiQueryResult + +func (response Query200JSONResponse) VisitQueryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type Query400TextResponse string + +func (response Query400TextResponse) VisitQueryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(400) + + _, err := w.Write([]byte(response)) + return err +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Disable blocking + // (GET /blocking/disable) + DisableBlocking(ctx context.Context, request DisableBlockingRequestObject) (DisableBlockingResponseObject, error) + // Enable blocking + // (GET /blocking/enable) + EnableBlocking(ctx context.Context, request EnableBlockingRequestObject) (EnableBlockingResponseObject, error) + // Blocking status + // (GET /blocking/status) + BlockingStatus(ctx context.Context, request BlockingStatusRequestObject) (BlockingStatusResponseObject, error) + // List refresh + // (POST /lists/refresh) + ListRefresh(ctx context.Context, request ListRefreshRequestObject) (ListRefreshResponseObject, error) + // Performs DNS query + // (POST /query) + Query(ctx context.Context, request QueryRequestObject) (QueryResponseObject, error) +} + +type StrictHandlerFunc = strictnethttp.StrictHttpHandlerFunc +type StrictMiddlewareFunc = strictnethttp.StrictHttpMiddlewareFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// DisableBlocking operation middleware +func (sh *strictHandler) DisableBlocking(w http.ResponseWriter, r *http.Request, params DisableBlockingParams) { + var request DisableBlockingRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DisableBlocking(ctx, request.(DisableBlockingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DisableBlocking") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DisableBlockingResponseObject); ok { + if err := validResponse.VisitDisableBlockingResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// EnableBlocking operation middleware +func (sh *strictHandler) EnableBlocking(w http.ResponseWriter, r *http.Request) { + var request EnableBlockingRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.EnableBlocking(ctx, request.(EnableBlockingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "EnableBlocking") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(EnableBlockingResponseObject); ok { + if err := validResponse.VisitEnableBlockingResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// BlockingStatus operation middleware +func (sh *strictHandler) BlockingStatus(w http.ResponseWriter, r *http.Request) { + var request BlockingStatusRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.BlockingStatus(ctx, request.(BlockingStatusRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "BlockingStatus") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(BlockingStatusResponseObject); ok { + if err := validResponse.VisitBlockingStatusResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ListRefresh operation middleware +func (sh *strictHandler) ListRefresh(w http.ResponseWriter, r *http.Request) { + var request ListRefreshRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListRefresh(ctx, request.(ListRefreshRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListRefresh") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListRefreshResponseObject); ok { + if err := validResponse.VisitListRefreshResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Query operation middleware +func (sh *strictHandler) Query(w http.ResponseWriter, r *http.Request) { + var request QueryRequestObject + + var body QueryJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Query(ctx, request.(QueryRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Query") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(QueryResponseObject); ok { + if err := validResponse.VisitQueryResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/api_types.gen.go b/api/api_types.gen.go new file mode 100644 index 00000000..5a32aeb4 --- /dev/null +++ b/api/api_types.gen.go @@ -0,0 +1,52 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.14.0 DO NOT EDIT. +package api + +// ApiBlockingStatus defines model for api.BlockingStatus. +type ApiBlockingStatus struct { + // AutoEnableInSec If blocking is temporary disabled: amount of seconds until blocking will be enabled + AutoEnableInSec *int `json:"autoEnableInSec,omitempty"` + + // DisabledGroups Disabled group names + DisabledGroups *[]string `json:"disabledGroups,omitempty"` + + // Enabled True if blocking is enabled + Enabled bool `json:"enabled"` +} + +// ApiQueryRequest defines model for api.QueryRequest. +type ApiQueryRequest struct { + // Query query for DNS request + Query string `json:"query"` + + // Type request type (A, AAAA, ...) + Type string `json:"type"` +} + +// ApiQueryResult defines model for api.QueryResult. +type ApiQueryResult struct { + // Reason blocky reason for resolution + Reason string `json:"reason"` + + // Response actual DNS response + Response string `json:"response"` + + // ResponseType response type (CACHED, BLOCKED, ...) + ResponseType string `json:"responseType"` + + // ReturnCode DNS return code (NOERROR, NXDOMAIN, ...) + ReturnCode string `json:"returnCode"` +} + +// DisableBlockingParams defines parameters for DisableBlocking. +type DisableBlockingParams struct { + // Duration duration of blocking (Example: 300s, 5m, 1h, 5m30s) + Duration *string `form:"duration,omitempty" json:"duration,omitempty"` + + // Groups groups to disable (comma separated). If empty, disable all groups + Groups *string `form:"groups,omitempty" json:"groups,omitempty"` +} + +// QueryJSONRequestBody defines body for Query for application/json ContentType. +type QueryJSONRequestBody = ApiQueryRequest diff --git a/api/client.cfg.yaml b/api/client.cfg.yaml new file mode 100644 index 00000000..afb91a48 --- /dev/null +++ b/api/client.cfg.yaml @@ -0,0 +1,4 @@ +package: api +generate: + client: true +output: api_client.gen.go \ No newline at end of file diff --git a/api/server.cfg.yaml b/api/server.cfg.yaml new file mode 100644 index 00000000..2146abb4 --- /dev/null +++ b/api/server.cfg.yaml @@ -0,0 +1,6 @@ +package: api +generate: + chi-server: true + strict-server: true + embedded-spec: false +output: api_server.gen.go \ No newline at end of file diff --git a/api/types.cfg.yaml b/api/types.cfg.yaml new file mode 100644 index 00000000..6cf50ee3 --- /dev/null +++ b/api/types.cfg.yaml @@ -0,0 +1,4 @@ +package: api +generate: + models: true +output: api_types.gen.go \ No newline at end of file diff --git a/cmd/blocking.go b/cmd/blocking.go index b6122ef7..00fbf921 100644 --- a/cmd/blocking.go +++ b/cmd/blocking.go @@ -1,7 +1,7 @@ package cmd import ( - "encoding/json" + "context" "fmt" "net/http" "strings" @@ -47,16 +47,20 @@ func newBlockingCommand() *cobra.Command { } func enableBlocking(_ *cobra.Command, _ []string) error { - resp, err := http.Get(apiURL(api.PathBlockingEnablePath)) + client, err := api.NewClientWithResponses(apiURL()) + if err != nil { + return fmt.Errorf("can't create client: %w", err) + } + + resp, err := client.EnableBlockingWithResponse(context.Background()) if err != nil { return fmt.Errorf("can't execute %w", err) } - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { + if resp.StatusCode() == http.StatusOK { log.Log().Info("OK") } else { - return fmt.Errorf("response NOK, Status: %s", resp.Status) + return fmt.Errorf("response NOK, Status: %s", resp.Status()) } return nil @@ -66,48 +70,62 @@ func disableBlocking(cmd *cobra.Command, _ []string) error { duration, _ := cmd.Flags().GetDuration("duration") groups, _ := cmd.Flags().GetStringArray("groups") - resp, err := http.Get(fmt.Sprintf("%s?duration=%s&groups=%s", - apiURL(api.PathBlockingDisablePath), duration, strings.Join(groups, ","))) + durationString := duration.String() + groupsString := strings.Join(groups, ",") + + client, err := api.NewClientWithResponses(apiURL()) + if err != nil { + return fmt.Errorf("can't create client: %w", err) + } + + resp, err := client.DisableBlockingWithResponse(context.Background(), &api.DisableBlockingParams{ + Duration: &durationString, + Groups: &groupsString, + }) if err != nil { return fmt.Errorf("can't execute %w", err) } - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { + if resp.StatusCode() == http.StatusOK { log.Log().Info("OK") } else { - return fmt.Errorf("response NOK, Status: %s", resp.Status) + return fmt.Errorf("response NOK, Status: %s", resp.Status()) } return nil } func statusBlocking(_ *cobra.Command, _ []string) error { - resp, err := http.Get(apiURL(api.PathBlockingStatusPath)) + client, err := api.NewClientWithResponses(apiURL()) + if err != nil { + return fmt.Errorf("can't create client: %w", err) + } + + resp, err := client.BlockingStatusWithResponse(context.Background()) if err != nil { return fmt.Errorf("can't execute %w", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("response NOK, Status: %s", resp.Status) + if resp.StatusCode() != http.StatusOK { + return fmt.Errorf("response NOK, Status: %s", resp.Status()) } - var result api.BlockingStatus - err = json.NewDecoder(resp.Body).Decode(&result) - if err != nil { return fmt.Errorf("can't parse response %w", err) } - if result.Enabled { + if resp.JSON200.Enabled { log.Log().Info("blocking enabled") } else { - if result.AutoEnableInSec == 0 { - log.Log().Infof("blocking disabled for groups: %s", strings.Join(result.DisabledGroups, "; ")) + var groupNames string + if resp.JSON200.DisabledGroups != nil { + groupNames = strings.Join(*resp.JSON200.DisabledGroups, "; ") + } + if resp.JSON200.AutoEnableInSec == nil || *resp.JSON200.AutoEnableInSec == 0 { + log.Log().Infof("blocking disabled for groups: %s", groupNames) } else { - log.Log().Infof("blocking disabled for groups: %s, for %d seconds", - strings.Join(result.DisabledGroups, "; "), result.AutoEnableInSec) + log.Log().Infof("blocking disabled for groups: '%s', for %d seconds", + groupNames, *resp.JSON200.AutoEnableInSec) } } diff --git a/cmd/blocking_test.go b/cmd/blocking_test.go index 4ffa44ee..18eebc7a 100644 --- a/cmd/blocking_test.go +++ b/cmd/blocking_test.go @@ -96,9 +96,11 @@ var _ = Describe("Blocking command", func() { When("status blocking is called via REST and blocking is enabled", func() { BeforeEach(func() { mockFn = func(w http.ResponseWriter, _ *http.Request) { - response, err := json.Marshal(api.BlockingStatus{ + w.Header().Add("Content-Type", "application/json") + i := 5 + response, err := json.Marshal(api.ApiBlockingStatus{ Enabled: true, - AutoEnableInSec: uint(5), + AutoEnableInSec: &i, }) Expect(err).Should(Succeed()) @@ -112,13 +114,15 @@ var _ = Describe("Blocking command", func() { }) }) When("status blocking is called via REST and blocking is disabled", func() { - var autoEnable uint + var autoEnable int + diabledGroups := []string{"abc"} BeforeEach(func() { mockFn = func(w http.ResponseWriter, _ *http.Request) { - response, err := json.Marshal(api.BlockingStatus{ + w.Header().Add("Content-Type", "application/json") + response, err := json.Marshal(api.ApiBlockingStatus{ Enabled: false, - AutoEnableInSec: autoEnable, - DisabledGroups: []string{"abc"}, + AutoEnableInSec: &autoEnable, + DisabledGroups: &diabledGroups, }) Expect(err).Should(Succeed()) @@ -129,7 +133,7 @@ var _ = Describe("Blocking command", func() { It("should show the blocking status with time", func() { autoEnable = 5 Expect(statusBlocking(newBlockingCommand(), []string{})).Should(Succeed()) - Expect(loggerHook.LastEntry().Message).Should(Equal("blocking disabled for groups: abc, for 5 seconds")) + Expect(loggerHook.LastEntry().Message).Should(Equal("blocking disabled for groups: 'abc', for 5 seconds")) }) It("should show the blocking status", func() { autoEnable = 0 diff --git a/cmd/lists.go b/cmd/lists.go index 004f2083..eca6de75 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -1,8 +1,8 @@ package cmd import ( + "context" "fmt" - "io" "net/http" "github.com/0xERR0R/blocky/api" @@ -32,16 +32,18 @@ func newRefreshCommand() *cobra.Command { } func refreshList(_ *cobra.Command, _ []string) error { - resp, err := http.Post(apiURL(api.PathListsRefresh), "application/json", nil) + client, err := api.NewClientWithResponses(apiURL()) + if err != nil { + return fmt.Errorf("can't create client: %w", err) + } + + resp, err := client.ListRefreshWithResponse(context.Background()) if err != nil { return fmt.Errorf("can't execute %w", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - - return fmt.Errorf("response NOK, %s %s", resp.Status, string(body)) + if resp.StatusCode() != http.StatusOK { + return fmt.Errorf("response NOK, %s %s", resp.Status(), string(resp.Body)) } log.Log().Info("OK") diff --git a/cmd/query.go b/cmd/query.go index 1cdec875..609372cc 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -1,10 +1,8 @@ package cmd import ( - "bytes" - "encoding/json" + "context" "fmt" - "io" "net/http" "github.com/0xERR0R/blocky/api" @@ -35,40 +33,30 @@ func query(cmd *cobra.Command, args []string) error { return fmt.Errorf("unknown query type '%s'", typeFlag) } - apiRequest := api.QueryRequest{ + client, err := api.NewClientWithResponses(apiURL()) + if err != nil { + return fmt.Errorf("can't create client: %w", err) + } + + req := api.ApiQueryRequest{ Query: args[0], Type: typeFlag, } - jsonValue, err := json.Marshal(apiRequest) + resp, err := client.QueryWithResponse(context.Background(), req) if err != nil { - return fmt.Errorf("can't marshal request: %w", err) + return fmt.Errorf("can't execute %w", err) } - resp, err := http.Post(apiURL(api.PathQueryPath), "application/json", bytes.NewBuffer(jsonValue)) - if err != nil { - return fmt.Errorf("can't execute: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - - return fmt.Errorf("response NOK, %s %s", resp.Status, string(body)) + if resp.StatusCode() != http.StatusOK { + return fmt.Errorf("response NOK, %s %s", resp.Status(), string(resp.Body)) } - var result api.QueryResult - err = json.NewDecoder(resp.Body).Decode(&result) - - if err != nil { - return fmt.Errorf("can't read response: %w", err) - } - - log.Log().Infof("Query result for '%s' (%s):", apiRequest.Query, apiRequest.Type) - log.Log().Infof("\treason: %20s", result.Reason) - log.Log().Infof("\tresponse type: %20s", result.ResponseType) - log.Log().Infof("\tresponse: %20s", result.Response) - log.Log().Infof("\treturn code: %20s", result.ReturnCode) + log.Log().Infof("Query result for '%s' (%s):", req.Query, req.Type) + log.Log().Infof("\treason: %20s", resp.JSON200.Reason) + log.Log().Infof("\tresponse type: %20s", resp.JSON200.ResponseType) + log.Log().Infof("\tresponse: %20s", resp.JSON200.Response) + log.Log().Infof("\treturn code: %20s", resp.JSON200.ReturnCode) return nil } diff --git a/cmd/query_test.go b/cmd/query_test.go index dee1edf4..50aa19da 100644 --- a/cmd/query_test.go +++ b/cmd/query_test.go @@ -37,7 +37,7 @@ var _ = Describe("Blocking command", func() { Describe("Call query command", func() { BeforeEach(func() { mockFn = func(w http.ResponseWriter, _ *http.Request) { - response, err := json.Marshal(api.QueryResult{ + response, err := json.Marshal(api.ApiQueryResult{ Reason: "Reason", ResponseType: "Type", Response: "Response", @@ -52,7 +52,8 @@ var _ = Describe("Blocking command", func() { When("query command is called via REST", func() { BeforeEach(func() { mockFn = func(w http.ResponseWriter, _ *http.Request) { - response, err := json.Marshal(api.QueryResult{ + w.Header().Add("Content-Type", "application/json") + response, err := json.Marshal(api.ApiQueryResult{ Reason: "Reason", ResponseType: "Type", Response: "Response", diff --git a/cmd/root.go b/cmd/root.go index 1b52a6c5..4124a80a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,8 +59,8 @@ Complete documentation is available at https://github.com/0xERR0R/blocky`, return c } -func apiURL(path string) string { - return fmt.Sprintf("http://%s%s", net.JoinHostPort(apiHost, strconv.Itoa(int(apiPort))), path) +func apiURL() string { + return fmt.Sprintf("http://%s%s", net.JoinHostPort(apiHost, strconv.Itoa(int(apiPort))), "/api") } //nolint:gochecknoinits diff --git a/codecov.yml b/codecov.yml index 04142e0d..76a09d9b 100644 --- a/codecov.yml +++ b/codecov.yml @@ -14,4 +14,5 @@ coverage: ignore: - "**/mock_*" - "**/*_enum.go" + - "**/*.gen.go" - "e2e/*.go" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 00000000..2d6299a3 --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,235 @@ +openapi: 3.1.1 +info: + title: blocky API + description: >- + # Blocky + + Blocky is a DNS proxy and ad-blocker for the local network written in Go with following features: + + ## Features + + - **Blocking** - Blocking of DNS queries with external lists (Ad-block, malware) and whitelisting + + - Definition of black and white lists per client group (Kids, Smart home devices, etc.) + - Periodical reload of external black and white lists + - Regex support + - Blocking of request domain, response CNAME (deep CNAME inspection) and response IP addresses (against IP lists) + + - **Advanced DNS configuration** - not just an ad-blocker + + - Custom DNS resolution for certain domain names + - Conditional forwarding to external DNS server + - Upstream resolvers can be defined per client group + + - **Performance** - Improves speed and performance in your network + + - Customizable caching of DNS answers for queries -> improves DNS resolution speed and reduces amount of external DNS + queries + - Prefetching and caching of often used queries + - Using multiple external resolver simultaneously + - Low memory footprint + + - **Various Protocols** - Supports modern DNS protocols + + - DNS over UDP and TCP + - DNS over HTTPS (aka DoH) + - DNS over TLS (aka DoT) + + - **Security and Privacy** - Secure communication + + - Supports modern DNS extensions: DNSSEC, eDNS, ... + - Free configurable blocking lists - no hidden filtering etc. + - Provides DoH Endpoint + - Uses random upstream resolvers from the configuration - increases your privacy through the distribution of your DNS + traffic over multiple provider + - Blocky does **NOT** collect any user data, telemetry, statistics etc. + + - **Integration** - various integration + + - [Prometheus](https://prometheus.io/) metrics + - Prepared [Grafana](https://grafana.com/) dashboards (Prometheus and database) + - Logging of DNS queries per day / per client in CSV format or MySQL/MariaDB/PostgreSQL database - easy to analyze + - Various REST API endpoints + - CLI tool + + - **Simple configuration** - single or multiple configuration files in YAML format + + - Simple to maintain + - Simple to backup + + - **Simple installation/configuration** - blocky was designed for simple installation + + - Stateless (no database, no temporary files) + - Docker image with Multi-arch support + - Single binary + - Supports x86-64 and ARM architectures -> runs fine on Raspberry PI + - Community supported Helm chart for k8s deployment + + ## Quick start + + You can jump to [Installation](https://0xerr0r.github.io/blocky/installation/) chapter in the documentation. + + ## Full documentation + + You can find full documentation and configuration examples + at: [https://0xERR0R.github.io/blocky/](https://0xERR0R.github.io/blocky/) + contact: + name: blocky@github + url: https://github.com/0xERR0R/blocky + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: '1.0' +servers: + - url: /api +paths: + /blocking/disable: + get: + operationId: disableBlocking + tags: + - blocking + summary: Disable blocking + description: disable the blocking status + parameters: + - name: duration + in: query + description: 'duration of blocking (Example: 300s, 5m, 1h, 5m30s)' + schema: + type: string + - name: groups + in: query + description: groups to disable (comma separated). If empty, disable all groups + schema: + type: string + responses: + '200': + description: Blocking is disabled + '400': + description: Bad request (e.g. unknown group) + content: + text/plain: + schema: + type: string + example: Bad request + /blocking/enable: + get: + operationId: enableBlocking + tags: + - blocking + summary: Enable blocking + description: enable the blocking status + responses: + '200': + description: Blocking is enabled + /blocking/status: + get: + operationId: blockingStatus + tags: + - blocking + summary: Blocking status + description: get current blocking status + responses: + '200': + description: Returns current blocking status + content: + application/json: + schema: + $ref: '#/components/schemas/api.BlockingStatus' + /lists/refresh: + post: + operationId: listRefresh + tags: + - lists + summary: List refresh + description: Refresh all lists + responses: + '200': + description: Lists were reloaded + '500': + description: List refresh error + content: + text/plain: + schema: + type: string + example: Error text + /query: + post: + operationId: query + tags: + - query + summary: Performs DNS query + description: Performs DNS query + requestBody: + description: query data + content: + application/json: + schema: + $ref: '#/components/schemas/api.QueryRequest' + required: true + responses: + '200': + description: query was executed + content: + application/json: + schema: + $ref: '#/components/schemas/api.QueryResult' + '400': + description: Wrong request format + content: + text/plain: + schema: + type: string + example: Bad request +components: + schemas: + api.BlockingStatus: + type: object + properties: + autoEnableInSec: + type: integer + minimum: 0 + description: >- + If blocking is temporary disabled: amount of seconds until blocking + will be enabled + disabledGroups: + type: array + description: Disabled group names + items: + type: string + enabled: + type: boolean + description: True if blocking is enabled + required: + - enabled + api.QueryRequest: + type: object + properties: + query: + type: string + description: query for DNS request + type: + type: string + description: request type (A, AAAA, ...) + required: + - query + - type + api.QueryResult: + type: object + properties: + reason: + type: string + description: blocky reason for resolution + response: + type: string + description: actual DNS response + responseType: + type: string + description: response type (CACHED, BLOCKED, ...) + returnCode: + type: string + description: DNS return code (NOERROR, NXDOMAIN, ...) + required: + - reason + - response + - responseType + - returnCode diff --git a/docs/embed.go b/docs/embed.go new file mode 100644 index 00000000..9d27601c --- /dev/null +++ b/docs/embed.go @@ -0,0 +1,6 @@ +package docs + +import _ "embed" + +//go:embed api/openapi.yaml +var OpenAPI string diff --git a/docs/interfaces.md b/docs/interfaces.md index 9916819f..813af09d 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -2,8 +2,16 @@ ## REST API -If http listener is enabled, blocky provides REST API. You can browse the API documentation (Swagger) documentation -under [https://0xERR0R.github.io/blocky/swagger.html](https://0xERR0R.github.io/blocky/swagger.html). + +??? abstract "OpenAPI specification" + + ```yaml + --8<-- "docs/api/openapi.yaml" + ``` + +If http listener is enabled, blocky provides REST API. You can download the [OpenAPI YAML](api/openapi.yaml) interface specification. + +You can also browse the interactive API documentation (RapiDoc) documentation [online](rapidoc.html). ## CLI diff --git a/docs/rapidoc.html b/docs/rapidoc.html new file mode 100644 index 00000000..c9739167 --- /dev/null +++ b/docs/rapidoc.html @@ -0,0 +1,23 @@ + + +
+ + + + +- Base URL: /api/, - Version: -
--
blocky API
- - - --
-Operation | -Description | -
---|---|
GET /blocking/disable | -Disable blocking - |
-
GET /blocking/enable | -Enable blocking - |
-
GET /blocking/status | -Blocking status - |
-
-
-Operation | -Description | -
---|---|
POST /lists/refresh | -List refresh - |
-
-
-Operation | -Description | -
---|---|
POST /query | -Performs DNS query - |
-
disable the blocking status
- -- | - | - | - | - |
---|---|---|---|---|
- duration - | -duration of blocking (Example: 300s, 5m, 1h, 5m30s) - |
- query | -- string (duration) - - - | -- | -
- groups - | -groups to disable (comma separated). If empty, disable all groups - |
- query | -- string (string) - - - | -- | -
Blocking is disabled
- -Unknown group
- -enable the blocking status
- -Blocking is enabled
- -get current blocking status
- -application/json -
- -Returns current blocking status
- -Refresh all lists
- -Lists were reloaded
- -Performs DNS query
- -application/json -
--
query data
- -application/json -
- -query was executed
- -Wrong request format
- -If blocking is temporary disabled: amount of seconds until blocking will be enabled
- -Disabled group names
- -True if blocking is enabled
- -query for DNS request
- -request type (A, AAAA, ...)
- -blocky reason for resolution
- -actual DNS response
- -response type (CACHED, BLOCKED, ...)
- -DNS return code (NOERROR, NXDOMAIN, ...)
- -'+(r?e:ye(e,!0))+"
\n":""+(r?e:ye(e,!0))+"
\n"}blockquote(e){return`\n${e}\n`}html(e){return e}heading(e,t,r,n){if(this.options.headerIds){return`
${e}
\n`}table(e,t){return t&&(t=`${t}`),"${e}
`}br(){return this.options.xhtml?"An error occurred:
"+ye(e.message+"",!0)+"";throw e}try{const r=qe.lex(e,t);if(t.walkTokens){if(t.async)return Promise.all(We.walkTokens(r,t.walkTokens)).then((()=>He.parse(r,t))).catch(n);We.walkTokens(r,t.walkTokens)}return He.parse(r,t)}catch(e){n(e)}}We.options=We.setOptions=function(e){var t;return Ce(We.defaults,e),t=We.defaults,ce=t,We},We.getDefaults=le,We.defaults=ce,We.use=function(...e){const t=We.defaults.extensions||{renderers:{},childTokens:{}};e.forEach((e=>{const r=Ce({},e);if(r.async=We.defaults.async||r.async,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if(e.renderer){const r=t.renderers[e.name];t.renderers[e.name]=r?function(...t){let n=e.renderer.apply(this,t);return!1===n&&(n=r.apply(this,t)),n}:e.renderer}if(e.tokenizer){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");t[e.level]?t[e.level].unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),r.extensions=t),e.renderer){const t=We.defaults.renderer||new Ue;for(const r in e.renderer){const n=t[r];t[r]=(...o)=>{let a=e.renderer[r].apply(t,o);return!1===a&&(a=n.apply(t,o)),a}}r.renderer=t}if(e.tokenizer){const t=We.defaults.tokenizer||new Le;for(const r in e.tokenizer){const n=t[r];t[r]=(...o)=>{let a=e.tokenizer[r].apply(t,o);return!1===a&&(a=n.apply(t,o)),a}}r.tokenizer=t}if(e.walkTokens){const t=We.defaults.walkTokens;r.walkTokens=function(r){let n=[];return n.push(e.walkTokens.call(this,r)),t&&(n=n.concat(t.call(this,r))),n}}We.setOptions(r)}))},We.walkTokens=function(e,t){let r=[];for(const n of e)switch(r=r.concat(t.call(We,n)),n.type){case"table":for(const e of n.header)r=r.concat(We.walkTokens(e.tokens,t));for(const e of n.rows)for(const n of e)r=r.concat(We.walkTokens(n.tokens,t));break;case"list":r=r.concat(We.walkTokens(n.items,t));break;default:We.defaults.extensions&&We.defaults.extensions.childTokens&&We.defaults.extensions.childTokens[n.type]?We.defaults.extensions.childTokens[n.type].forEach((function(e){r=r.concat(We.walkTokens(n[e],t))})):n.tokens&&(r=r.concat(We.walkTokens(n.tokens,t)))}return r},We.parseInline=function(e,t){if(null==e)throw new Error("marked.parseInline(): input parameter is undefined or null");if("string"!=typeof e)throw new Error("marked.parseInline(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected");_e(t=Ce({},We.defaults,t||{}));try{const r=qe.lexInline(e,t);return t.walkTokens&&We.walkTokens(r,t.walkTokens),He.parseInline(r,t)}catch(e){if(e.message+="\nPlease report this to https://github.com/markedjs/marked.",t.silent)return"
An error occurred:
"+ye(e.message+"",!0)+"";throw e}},We.Parser=He,We.parser=He.parse,We.Renderer=Ue,We.TextRenderer=ze,We.Lexer=qe,We.lexer=qe.lex,We.Tokenizer=Le,We.Slugger=Me,We.parse=We;We.options,We.setOptions,We.use,We.walkTokens,We.parseInline,He.parse,qe.lex;var Ve=r(660),Ge=r.n(Ve);r(251),r(358),r(46),r(503),r(277),r(874),r(366),r(57),r(16);const Ke=c` + .hover-bg:hover{ + background: var(--bg3); + } + ::selection { + background: var(--selection-bg); + color: var(--selection-fg); + } + .regular-font{ + font-family:var(--font-regular); + } + .mono-font { + font-family:var(--font-mono); + } + .title { + font-size: calc(var(--font-size-small) + 18px); + font-weight: normal + } + .sub-title{ font-size: 20px;} + .req-res-title { + font-family: var(--font-regular); + font-size: calc(var(--font-size-small) + 4px); + font-weight:bold; + margin-bottom:8px; + text-align:left; + } + .tiny-title { + font-size:calc(var(--font-size-small) + 1px); + font-weight:bold; + } + .regular-font-size { font-size: var(--font-size-regular); } + .small-font-size { font-size: var(--font-size-small); } + .upper { text-transform: uppercase; } + .primary-text{ color: var(--primary-color); } + .bold-text { font-weight:bold; } + .gray-text { color: var(--light-fg); } + .red-text {color: var(--red)} + .blue-text {color: var(--blue)} + .multiline { + overflow: scroll; + max-height: var(--resp-area-height, 400px); + color: var(--fg3); + } + .method-fg.put { color: var(--orange); } + .method-fg.post { color: var(--green); } + .method-fg.get { color: var(--blue); } + .method-fg.delete { color: var(--red); } + .method-fg.options, + .method-fg.head, + .method-fg.patch { + color: var(--yellow); + } + + h1{ font-family:var(--font-regular); font-size:28px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h2{ font-family:var(--font-regular); font-size:24px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h3{ font-family:var(--font-regular); font-size:18px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h4{ font-family:var(--font-regular); font-size:16px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h5{ font-family:var(--font-regular); font-size:14px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h6{ font-family:var(--font-regular); font-size:14px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + + h1,h2,h3,h4,h5,h5{ + margin-block-end: 0.2em; + } + p { margin-block-start: 0.5em; } + a { color: var(--blue); cursor:pointer; } + a.inactive-link { + color:var(--fg); + text-decoration: none; + cursor:text; + } + + code, + pre { + margin: 0px; + font-family: var(--font-mono); + font-size: calc(var(--font-size-mono) - 1px); + } + + .m-markdown, + .m-markdown-small { + display:block; + } + + .m-markdown p, + .m-markdown span { + font-size: var(--font-size-regular); + line-height:calc(var(--font-size-regular) + 8px); + } + .m-markdown li { + font-size: var(--font-size-regular); + line-height:calc(var(--font-size-regular) + 10px); + } + + .m-markdown-small p, + .m-markdown-small span, + .m-markdown-small li { + font-size: var(--font-size-small); + line-height: calc(var(--font-size-small) + 6px); + } + .m-markdown-small li { + line-height: calc(var(--font-size-small) + 8px); + } + + .m-markdown p:not(:first-child) { + margin-block-start: 24px; + } + + .m-markdown-small p:not(:first-child) { + margin-block-start: 12px; + } + .m-markdown-small p:first-child { + margin-block-start: 0; + } + + .m-markdown p, + .m-markdown-small p { + margin-block-end: 0 + } + + .m-markdown code span { + font-size:var(--font-size-mono); + } + + .m-markdown-small code, + .m-markdown code { + padding: 1px 6px; + border-radius: 2px; + color: var(--inline-code-fg); + background-color: var(--bg3); + font-size: calc(var(--font-size-mono)); + line-height: 1.2; + } + + .m-markdown-small code { + font-size: calc(var(--font-size-mono) - 1px); + } + + .m-markdown-small pre, + .m-markdown pre { + white-space: pre-wrap; + overflow-x: auto; + line-height: normal; + border-radius: 2px; + border: 1px solid var(--code-border-color); + } + + .m-markdown pre { + padding: 12px; + background-color: var(--code-bg); + color:var(--code-fg); + } + + .m-markdown-small pre { + margin-top: 4px; + padding: 2px 4px; + background-color: var(--bg3); + color: var(--fg2); + } + + .m-markdown-small pre code, + .m-markdown pre code { + border:none; + padding:0; + } + + .m-markdown pre code { + color: var(--code-fg); + background-color: var(--code-bg); + background-color: transparent; + } + + .m-markdown-small pre code { + color: var(--fg2); + background-color: var(--bg3); + } + + .m-markdown ul, + .m-markdown ol { + padding-inline-start: 30px; + } + + .m-markdown-small ul, + .m-markdown-small ol { + padding-inline-start: 20px; + } + + .m-markdown-small a, + .m-markdown a { + color:var(--blue); + } + + .m-markdown-small img, + .m-markdown img { + max-width: 100%; + } + + /* Markdown table */ + + .m-markdown-small table, + .m-markdown table { + border-spacing: 0; + margin: 10px 0; + border-collapse: separate; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: calc(var(--font-size-small) + 1px); + line-height: calc(var(--font-size-small) + 4px); + max-width: 100%; + } + + .m-markdown-small table { + font-size: var(--font-size-small); + line-height: calc(var(--font-size-small) + 2px); + margin: 8px 0; + } + + .m-markdown-small td, + .m-markdown-small th, + .m-markdown td, + .m-markdown th { + vertical-align: top; + border-top: 1px solid var(--border-color); + line-height: calc(var(--font-size-small) + 4px); + } + + .m-markdown-small tr:first-child th, + .m-markdown tr:first-child th { + border-top: 0 none; + } + + .m-markdown th, + .m-markdown td { + padding: 10px 12px; + } + + .m-markdown-small th, + .m-markdown-small td { + padding: 8px 8px; + } + + .m-markdown th, + .m-markdown-small th { + font-weight: 600; + background-color: var(--bg2); + vertical-align: middle; + } + + .m-markdown-small table code { + font-size: calc(var(--font-size-mono) - 2px); + } + + .m-markdown table code { + font-size: calc(var(--font-size-mono) - 1px); + } + + .m-markdown blockquote, + .m-markdown-small blockquote { + margin-inline-start: 0; + margin-inline-end: 0; + border-left: 3px solid var(--border-color); + padding: 6px 0 6px 6px; + } + .m-markdown hr{ + border: 1px solid var(--border-color); + } +`,Je=c` +/* Button */ +.m-btn { + border-radius: var(--border-radius); + font-weight: 600; + display: inline-block; + padding: 6px 16px; + font-size: var(--font-size-small); + outline: 0; + line-height: 1; + text-align: center; + white-space: nowrap; + border: 2px solid var(--primary-color); + background-color:transparent; + transition: background-color 0.2s; + user-select: none; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); +} +.m-btn.primary { + background-color: var(--primary-color); + color: var(--primary-color-invert); +} +.m-btn.thin-border { border-width: 1px; } +.m-btn.large { padding:8px 14px; } +.m-btn.small { padding:5px 12px; } +.m-btn.tiny { padding:5px 6px; } +.m-btn.circle { border-radius: 50%; } +.m-btn:hover { + background-color: var(--primary-color); + color: var(--primary-color-invert); +} +.m-btn.nav { border: 2px solid var(--nav-accent-color); } +.m-btn.nav:hover { + background-color: var(--nav-accent-color); +} +.m-btn:disabled{ + background-color: var(--bg3); + color: var(--fg3); + border-color: var(--fg3); + cursor: not-allowed; + opacity: 0.4; +} +.toolbar-btn{ + cursor: pointer; + padding: 4px; + margin:0 2px; + font-size: var(--font-size-small); + min-width: 50px; + color: var(--primary-color-invert); + border-radius: 2px; + border: none; + background-color: var(--primary-color); +} + +input, textarea, select, button, pre { + color:var(--fg); + outline: none; + background-color: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} +button { + font-family: var(--font-regular); +} + +/* Form Inputs */ +pre, +select, +textarea, +input[type="file"], +input[type="text"], +input[type="password"] { + font-family: var(--font-mono); + font-weight: 400; + font-size: var(--font-size-small); + transition: border .2s; + padding: 6px 5px; +} + +select { + font-family: var(--font-regular); + padding: 5px 30px 5px 5px; + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%3E%3Cpath%20d%3D%22M10.3%203.3L6%207.6%201.7%203.3A1%201%200%2000.3%204.7l5%205a1%201%200%20001.4%200l5-5a1%201%200%2010-1.4-1.4z%22%20fill%3D%22%23777777%22%2F%3E%3C%2Fsvg%3E"); + background-position: calc(100% - 5px) center; + background-repeat: no-repeat; + background-size: 10px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + cursor: pointer; +} + +select:hover { + border-color: var(--primary-color); +} + +textarea::placeholder, +input[type="text"]::placeholder, +input[type="password"]::placeholder { + color: var(--placeholder-color); + opacity:1; +} + + +input[type="file"]{ + font-family: var(--font-regular); + padding:2px; + cursor:pointer; + border: 1px solid var(--primary-color); + min-height: calc(var(--font-size-small) + 18px); +} + +input[type="file"]::-webkit-file-upload-button { + font-family: var(--font-regular); + font-size: var(--font-size-small); + outline: none; + cursor:pointer; + padding: 3px 8px; + border: 1px solid var(--primary-color); + background-color: var(--primary-color); + color: var(--primary-color-invert); + border-radius: var(--border-radius);; + -webkit-appearance: none; +} + +pre, +textarea { + scrollbar-width: thin; + scrollbar-color: var(--border-color) var(--input-bg); +} + +pre::-webkit-scrollbar, +textarea::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +pre::-webkit-scrollbar-track, +textarea::-webkit-scrollbar-track { + background:var(--input-bg); +} + +pre::-webkit-scrollbar-thumb, +textarea::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: var(--border-color); +} + +.link { + font-size:var(--font-size-small); + text-decoration: underline; + color:var(--blue); + font-family:var(--font-mono); + margin-bottom:2px; +} + +/* Toggle Body */ +input[type="checkbox"] { + appearance: none; + display: inline-block; + background-color: var(--light-bg); + border: 1px solid var(--light-bg); + border-radius: 9px; + cursor: pointer; + height: 18px; + position: relative; + transition: border .25s .15s, box-shadow .25s .3s, padding .25s; + min-width: 36px; + width: 36px; + vertical-align: top; +} +/* Toggle Thumb */ +input[type="checkbox"]:after { + position: absolute; + background-color: var(--bg); + border: 1px solid var(--light-bg); + border-radius: 8px; + content: ''; + top: 0px; + left: 0px; + right: 16px; + display: block; + height: 16px; + transition: border .25s .15s, left .25s .1s, right .15s .175s; +} + +/* Toggle Body - Checked */ +input[type="checkbox"]:checked { + background-color: var(--green); + border-color: var(--green); +} +/* Toggle Thumb - Checked*/ +input[type="checkbox"]:checked:after { + border: 1px solid var(--green); + left: 16px; + right: 1px; + transition: border .25s, left .15s .25s, right .25s .175s; +}`,Ye=c` +.row, .col{ + display:flex; +} +.row { + align-items:center; + flex-direction: row; +} +.col { + align-items:stretch; + flex-direction: column; +} +`,Ze=c` +.m-table { + border-spacing: 0; + border-collapse: separate; + border: 1px solid var(--light-border-color); + border-radius: var(--border-radius); + margin: 0; + max-width: 100%; + direction: ltr; +} +.m-table tr:first-child td, +.m-table tr:first-child th { + border-top: 0 none; +} +.m-table td, +.m-table th { + font-size: var(--font-size-small); + line-height: calc(var(--font-size-small) + 4px); + padding: 4px 5px 4px; + vertical-align: top; +} + +.m-table.padded-12 td, +.m-table.padded-12 th { + padding: 12px; +} + +.m-table td:not([align]), +.m-table th:not([align]) { + text-align: left; +} + +.m-table th { + color: var(--fg2); + font-size: var(--font-size-small); + line-height: calc(var(--font-size-small) + 18px); + font-weight: 600; + letter-spacing: normal; + background-color: var(--bg2); + vertical-align: bottom; + border-bottom: 1px solid var(--light-border-color); +} + +.m-table > tbody > tr > td, +.m-table > tr > td { + border-top: 1px solid var(--light-border-color); + text-overflow: ellipsis; + overflow: hidden; +} +.table-title { + font-size:var(--font-size-small); + font-weight:bold; + vertical-align: middle; + margin: 12px 0 4px 0; +} +`,Qe=c` +.only-large-screen { display:none; } +.endpoint-head .path{ + display: flex; + font-family:var(--font-mono); + font-size: var(--font-size-small); + align-items: center; + overflow-wrap: break-word; + word-break: break-all; +} + +.endpoint-head .descr { + font-size: var(--font-size-small); + color:var(--light-fg); + font-weight:400; + align-items: center; + overflow-wrap: break-word; + word-break: break-all; + display:none; +} + +.m-endpoint.expanded{margin-bottom:16px; } +.m-endpoint > .endpoint-head{ + border-width:1px 1px 1px 5px; + border-style:solid; + border-color:transparent; + border-top-color:var(--light-border-color); + display:flex; + padding:6px 16px; + align-items: center; + cursor: pointer; +} +.m-endpoint > .endpoint-head.put:hover, +.m-endpoint > .endpoint-head.put.expanded{ + border-color:var(--orange); + background-color:var(--light-orange); +} +.m-endpoint > .endpoint-head.post:hover, +.m-endpoint > .endpoint-head.post.expanded { + border-color:var(--green); + background-color:var(--light-green); +} +.m-endpoint > .endpoint-head.get:hover, +.m-endpoint > .endpoint-head.get.expanded { + border-color:var(--blue); + background-color:var(--light-blue); +} +.m-endpoint > .endpoint-head.delete:hover, +.m-endpoint > .endpoint-head.delete.expanded { + border-color:var(--red); + background-color:var(--light-red); +} + +.m-endpoint > .endpoint-head.head:hover, +.m-endpoint > .endpoint-head.head.expanded, +.m-endpoint > .endpoint-head.patch:hover, +.m-endpoint > .endpoint-head.patch.expanded, +.m-endpoint > .endpoint-head.options:hover, +.m-endpoint > .endpoint-head.options.expanded { + border-color:var(--yellow); + background-color:var(--light-yellow); +} + +.m-endpoint > .endpoint-head.deprecated:hover, +.m-endpoint > .endpoint-head.deprecated.expanded { + border-color:var(--border-color); + filter:opacity(0.6); +} + +.m-endpoint .endpoint-body { + flex-wrap:wrap; + padding:16px 0px 0 0px; + border-width:0px 1px 1px 5px; + border-style:solid; + box-shadow: 0px 4px 3px -3px rgba(0, 0, 0, 0.15); +} +.m-endpoint .endpoint-body.delete{ border-color:var(--red); } +.m-endpoint .endpoint-body.put{ border-color:var(--orange); } +.m-endpoint .endpoint-body.post{border-color:var(--green);} +.m-endpoint .endpoint-body.get{ border-color:var(--blue); } +.m-endpoint .endpoint-body.head, +.m-endpoint .endpoint-body.patch, +.m-endpoint .endpoint-body.options { + border-color:var(--yellow); +} + +.m-endpoint .endpoint-body.deprecated{ + border-color:var(--border-color); + filter:opacity(0.6); +} + +.endpoint-head .deprecated{ + color: var(--light-fg); + filter:opacity(0.6); +} + +.summary{ + padding:8px 8px; +} +.summary .title{ + font-size:calc(var(--font-size-regular) + 2px); + margin-bottom: 6px; + word-break: break-all; +} + +.endpoint-head .method{ + padding:2px 5px; + vertical-align: middle; + font-size:var(--font-size-small); + height: calc(var(--font-size-small) + 16px); + line-height: calc(var(--font-size-small) + 8px); + width: 60px; + border-radius: 2px; + display:inline-block; + text-align: center; + font-weight: bold; + text-transform:uppercase; + margin-right:5px; +} +.endpoint-head .method.delete{ border: 2px solid var(--red);} +.endpoint-head .method.put{ border: 2px solid var(--orange); } +.endpoint-head .method.post{ border: 2px solid var(--green); } +.endpoint-head .method.get{ border: 2px solid var(--blue); } +.endpoint-head .method.get.deprecated{ border: 2px solid var(--border-color); } +.endpoint-head .method.head, +.endpoint-head .method.patch, +.endpoint-head .method.options { + border: 2px solid var(--yellow); +} + +.req-resp-container { + display: flex; + margin-top:16px; + align-items: stretch; + flex-wrap: wrap; + flex-direction: column; + border-top:1px solid var(--light-border-color); +} + +.view-mode-request, +api-response.view-mode { + flex:1; + min-height:100px; + padding:16px 8px; + overflow:hidden; +} +.view-mode-request { + border-width:0 0 1px 0; + border-style:dashed; +} + +.head .view-mode-request, +.patch .view-mode-request, +.options .view-mode-request { + border-color:var(--yellow); +} +.put .view-mode-request { + border-color:var(--orange); +} +.post .view-mode-request { + border-color:var(--green); +} +.get .view-mode-request { + border-color:var(--blue); +} +.delete .view-mode-request { + border-color:var(--red); +} + +@media only screen and (min-width: 1024px) { + .only-large-screen { display:block; } + .endpoint-head .path{ + font-size: var(--font-size-regular); + } + .endpoint-head .descr{ + display: flex; + } + .endpoint-head .m-markdown-small, + .descr .m-markdown-small{ + display:block; + } + .req-resp-container{ + flex-direction: var(--layout, row); + flex-wrap: nowrap; + } + api-response.view-mode { + padding:16px; + } + .view-mode-request.row-layout { + border-width:0 1px 0 0; + padding:16px; + } + .summary{ + padding:8px 16px; + } +} +`,Xe=c` +code[class*="language-"], +pre[class*="language-"] { + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + tab-size: 2; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + white-space: normal; +} + +.token.comment, +.token.block-comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: var(--light-fg) +} + +.token.punctuation { + color: var(--fg); +} + +.token.tag, +.token.attr-name, +.token.namespace, +.token.deleted { + color:var(--pink); +} + +.token.function-name { + color: var(--blue); +} + +.token.boolean, +.token.number, +.token.function { + color: var(--red); +} + +.token.property, +.token.class-name, +.token.constant, +.token.symbol { + color: var(--code-property-color); +} + +.token.selector, +.token.important, +.token.atrule, +.token.keyword, +.token.builtin { + color: var(--code-keyword-color); +} + +.token.string, +.token.char, +.token.attr-value, +.token.regex, +.token.variable { + color: var(--green); +} + +.token.operator, +.token.entity, +.token.url { + color: var(--code-operator-color); +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + +.token.inserted { + color: green; +} +`,et=c` +.tab-panel { + border: none; +} +.tab-buttons { + height:30px; + padding: 4px 4px 0 4px; + border-bottom: 1px solid var(--light-border-color) ; + align-items: stretch; + overflow-y: hidden; + overflow-x: auto; + scrollbar-width: thin; +} +.tab-buttons::-webkit-scrollbar { + height: 1px; + background-color: var(--border-color); +} +.tab-btn { + border: none; + border-bottom: 3px solid transparent; + color: var(--light-fg); + background-color: transparent; + white-space: nowrap; + cursor:pointer; + outline:none; + font-family:var(--font-regular); + font-size:var(--font-size-small); + margin-right:16px; + padding:1px; +} +.tab-btn.active { + border-bottom: 3px solid var(--primary-color); + font-weight:bold; + color:var(--primary-color); +} + +.tab-btn:hover { + color:var(--primary-color); +} +.tab-content { + margin:-1px 0 0 0; + position:relative; + min-height: 50px; +} +`,tt=c` +.nav-bar-info:focus-visible, +.nav-bar-tag:focus-visible, +.nav-bar-path:focus-visible { + outline: 1px solid; + box-shadow: none; + outline-offset: -4px; +} +.nav-bar-expand-all:focus-visible, +.nav-bar-collapse-all:focus-visible, +.nav-bar-tag-icon:focus-visible { + outline: 1px solid; + box-shadow: none; + outline-offset: 2px; +} +.nav-bar { + width:0; + height:100%; + overflow: hidden; + color:var(--nav-text-color); + background-color: var(--nav-bg-color); + background-blend-mode: multiply; + line-height: calc(var(--font-size-small) + 4px); + display:none; + position:relative; + flex-direction:column; + flex-wrap:nowrap; + word-break:break-word; +} +::slotted([slot=nav-logo]){ + padding:16px 16px 0 16px; +} +.nav-scroll { + overflow-x: hidden; + overflow-y: auto; + overflow-y: overlay; + scrollbar-width: thin; + scrollbar-color: var(--nav-hover-bg-color) transparent; +} + +.nav-bar-tag { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: row; +} +.nav-bar.read .nav-bar-tag-icon { + display:none; +} +.nav-bar-paths-under-tag { + overflow:hidden; + transition: max-height .2s ease-out, visibility .3s; +} +.collapsed .nav-bar-paths-under-tag { + visibility: hidden; +} + +.nav-bar-expand-all { + transform: rotate(90deg); + cursor:pointer; + margin-right:10px; +} +.nav-bar-collapse-all { + transform: rotate(270deg); + cursor:pointer; +} +.nav-bar-expand-all:hover, .nav-bar-collapse-all:hover { + color: var(--primary-color); +} + +.nav-bar-tag-icon { + color: var(--nav-text-color); + font-size: 20px; +} +.nav-bar-tag-icon:hover { + color:var(--nav-hover-text-color); +} +.nav-bar.focused .nav-bar-tag-and-paths.collapsed .nav-bar-tag-icon::after { + content: '⌵'; + width:16px; + height:16px; + text-align: center; + display: inline-block; + transform: rotate(-90deg); + transition: transform 0.2s ease-out 0s; +} +.nav-bar.focused .nav-bar-tag-and-paths.expanded .nav-bar-tag-icon::after { + content: '⌵'; + width:16px; + height:16px; + text-align: center; + display: inline-block; + transition: transform 0.2s ease-out 0s; +} +.nav-scroll::-webkit-scrollbar { + width: var(--scroll-bar-width, 8px); +} +.nav-scroll::-webkit-scrollbar-track { + background:transparent; +} +.nav-scroll::-webkit-scrollbar-thumb { + background-color: var(--nav-hover-bg-color); +} + +.nav-bar-tag { + font-size: var(--font-size-regular); + color: var(--nav-accent-color); + border-left:4px solid transparent; + font-weight:bold; + padding: 15px 15px 15px 10px; + text-transform: capitalize; +} + +.nav-bar-components, +.nav-bar-h1, +.nav-bar-h2, +.nav-bar-info, +.nav-bar-tag, +.nav-bar-path { + display:flex; + cursor: pointer; + width: 100%; + border: none; + border-radius:4px; + color: var(--nav-text-color); + background: transparent; + border-left:4px solid transparent; +} + +.nav-bar-h1, +.nav-bar-h2, +.nav-bar-path { + font-size: calc(var(--font-size-small) + 1px); + padding: var(--nav-item-padding); +} +.nav-bar-path.small-font { + font-size: var(--font-size-small); +} + +.nav-bar-info { + font-size: var(--font-size-regular); + padding: 16px 10px; + font-weight:bold; +} +.nav-bar-section { + display: flex; + flex-direction: row; + justify-content: space-between; + font-size: var(--font-size-small); + color: var(--nav-text-color); + padding: var(--nav-item-padding); + font-weight:bold; +} +.nav-bar-section.operations { + cursor:pointer; +} +.nav-bar-section.operations:hover { + color:var(--nav-hover-text-color); + background-color:var(--nav-hover-bg-color); +} + +.nav-bar-section:first-child { + display: none; +} +.nav-bar-h2 {margin-left:12px;} + +.nav-bar-h1.left-bar.active, +.nav-bar-h2.left-bar.active, +.nav-bar-info.left-bar.active, +.nav-bar-tag.left-bar.active, +.nav-bar-path.left-bar.active, +.nav-bar-section.left-bar.operations.active { + border-left:4px solid var(--nav-accent-color); + color:var(--nav-hover-text-color); +} + +.nav-bar-h1.colored-block.active, +.nav-bar-h2.colored-block.active, +.nav-bar-info.colored-block.active, +.nav-bar-tag.colored-block.active, +.nav-bar-path.colored-block.active, +.nav-bar-section.colored-block.operations.active { + background-color: var(--nav-accent-color); + color: var(--nav-accent-text-color); + border-radius: 0; +} + +.nav-bar-h1:hover, +.nav-bar-h2:hover, +.nav-bar-info:hover, +.nav-bar-tag:hover, +.nav-bar-path:hover { + color:var(--nav-hover-text-color); + background-color:var(--nav-hover-bg-color); +} +`,rt=c` +#api-info { + font-size: calc(var(--font-size-regular) - 1px); + margin-top: 8px; + margin-left: -15px; +} + +#api-info span:before { + content: "|"; + display: inline-block; + opacity: 0.5; + width: 15px; + text-align: center; +} +#api-info span:first-child:before { + content: ""; + width: 0px; +} +`,nt=c` + +`;const ot=/[\s#:?&={}]/g,at="_rapidoc_api_key";function it(e){return new Promise((t=>setTimeout(t,e)))}function st(e,t){const r=t.target,n=document.createElement("textarea");n.value=e,n.style.position="fixed",document.body.appendChild(n),n.focus(),n.select();try{document.execCommand("copy"),r.innerText="Copied",setTimeout((()=>{r.innerText="Copy"}),5e3)}catch(e){console.error("Unable to copy",e)}document.body.removeChild(n)}function lt(e,t,r="includes"){if("includes"===r){return`${t.method} ${t.path} ${t.summary||t.description||""} ${t.operationId||""}`.toLowerCase().includes(e.toLowerCase())}return new RegExp(e,"i").test(`${t.method} ${t.path}`)}function ct(e,t=new Set){return e?(Object.keys(e).forEach((r=>{var n;if(t.add(r),e[r].properties)ct(e[r].properties,t);else if(null!==(n=e[r].items)&&void 0!==n&&n.properties){var o;ct(null===(o=e[r].items)||void 0===o?void 0:o.properties,t)}})),t):t}function pt(e,t){if(e){const r=document.createElement("a");document.body.appendChild(r),r.style="display: none",r.href=e,r.download=t,r.click(),r.remove()}}function dt(e){if(e){const t=document.createElement("a");document.body.appendChild(t),t.style="display: none",t.href=e,t.target="_blank",t.click(),t.remove()}}function ut(e){return e&&e.t&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var ht=function(e){return e&&e.Math==Math&&e},ft=ht("object"==typeof globalThis&&globalThis)||ht("object"==typeof window&&window)||ht("object"==typeof self&&self)||ht("object"==typeof ft&&ft)||function(){return this}()||Function("return this")(),mt=function(e){try{return!!e()}catch(e){return!0}},yt=!mt((function(){var e=function(){}.bind();return"function"!=typeof e||e.hasOwnProperty("prototype")})),gt=yt,vt=Function.prototype,bt=vt.apply,xt=vt.call,wt="object"==typeof Reflect&&Reflect.apply||(gt?xt.bind(bt):function(){return xt.apply(bt,arguments)}),$t=yt,kt=Function.prototype,St=kt.bind,At=kt.call,Et=$t&&St.bind(At,At),Ot=$t?function(e){return e&&Et(e)}:function(e){return e&&function(){return At.apply(e,arguments)}},Tt=function(e){return"function"==typeof e},Ct={},jt=!mt((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]})),It=yt,_t=Function.prototype.call,Pt=It?_t.bind(_t):function(){return _t.apply(_t,arguments)},Rt={},Lt={}.propertyIsEnumerable,Ft=Object.getOwnPropertyDescriptor,Dt=Ft&&!Lt.call({1:2},1);Rt.f=Dt?function(e){var t=Ft(this,e);return!!t&&t.enumerable}:Lt;var Bt,Nt,qt=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}},Ut=Ot,zt=Ut({}.toString),Mt=Ut("".slice),Ht=function(e){return Mt(zt(e),8,-1)},Wt=Ot,Vt=mt,Gt=Ht,Kt=ft.Object,Jt=Wt("".split),Yt=Vt((function(){return!Kt("z").propertyIsEnumerable(0)}))?function(e){return"String"==Gt(e)?Jt(e,""):Kt(e)}:Kt,Zt=ft.TypeError,Qt=function(e){if(null==e)throw Zt("Can't call method on "+e);return e},Xt=Yt,er=Qt,tr=function(e){return Xt(er(e))},rr=Tt,nr=function(e){return"object"==typeof e?null!==e:rr(e)},or={},ar=or,ir=ft,sr=Tt,lr=function(e){return sr(e)?e:void 0},cr=function(e,t){return arguments.length<2?lr(ar[e])||lr(ir[e]):ar[e]&&ar[e][t]||ir[e]&&ir[e][t]},pr=Ot({}.isPrototypeOf),dr=cr("navigator","userAgent")||"",ur=ft,hr=dr,fr=ur.process,mr=ur.Deno,yr=fr&&fr.versions||mr&&mr.version,gr=yr&&yr.v8;gr&&(Nt=(Bt=gr.split("."))[0]>0&&Bt[0]<4?1:+(Bt[0]+Bt[1])),!Nt&&hr&&(!(Bt=hr.match(/Edge\/(\d+)/))||Bt[1]>=74)&&(Bt=hr.match(/Chrome\/(\d+)/))&&(Nt=+Bt[1]);var vr=Nt,br=vr,xr=mt,wr=!!Object.getOwnPropertySymbols&&!xr((function(){var e=Symbol();return!String(e)||!(Object(e)instanceof Symbol)||!Symbol.sham&&br&&br<41})),$r=wr&&!Symbol.sham&&"symbol"==typeof Symbol.iterator,kr=cr,Sr=Tt,Ar=pr,Er=$r,Or=ft.Object,Tr=Er?function(e){return"symbol"==typeof e}:function(e){var t=kr("Symbol");return Sr(t)&&Ar(t.prototype,Or(e))},Cr=ft.String,jr=function(e){try{return Cr(e)}catch(e){return"Object"}},Ir=Tt,_r=jr,Pr=ft.TypeError,Rr=function(e){if(Ir(e))return e;throw Pr(_r(e)+" is not a function")},Lr=Rr,Fr=function(e,t){var r=e[t];return null==r?void 0:Lr(r)},Dr=Pt,Br=Tt,Nr=nr,qr=ft.TypeError,Ur={exports:{}},zr=ft,Mr=Object.defineProperty,Hr=ft.i||function(e,t){try{Mr(zr,e,{value:t,configurable:!0,writable:!0})}catch(r){zr[e]=t}return t}("__core-js_shared__",{}),Wr=Hr;(Ur.exports=function(e,t){return Wr[e]||(Wr[e]=void 0!==t?t:{})})("versions",[]).push({version:"3.21.1",mode:"pure",copyright:"© 2014-2022 Denis Pushkarev (zloirock.ru)",license:"https://github.com/zloirock/core-js/blob/v3.21.1/LICENSE",source:"https://github.com/zloirock/core-js"});var Vr=Qt,Gr=ft.Object,Kr=function(e){return Gr(Vr(e))},Jr=Kr,Yr=Ot({}.hasOwnProperty),Zr=Object.hasOwn||function(e,t){return Yr(Jr(e),t)},Qr=Ot,Xr=0,en=Math.random(),tn=Qr(1..toString),rn=function(e){return"Symbol("+(void 0===e?"":e)+")_"+tn(++Xr+en,36)},nn=ft,on=Ur.exports,an=Zr,sn=rn,ln=wr,cn=$r,pn=on("wks"),dn=nn.Symbol,un=dn&&dn.for,hn=cn?dn:dn&&dn.withoutSetter||sn,fn=function(e){if(!an(pn,e)||!ln&&"string"!=typeof pn[e]){var t="Symbol."+e;ln&&an(dn,e)?pn[e]=dn[e]:pn[e]=cn&&un?un(t):hn(t)}return pn[e]},mn=Pt,yn=nr,gn=Tr,vn=Fr,bn=fn,xn=ft.TypeError,wn=bn("toPrimitive"),$n=function(e,t){if(!yn(e)||gn(e))return e;var r,n=vn(e,wn);if(n){if(void 0===t&&(t="default"),r=mn(n,e,t),!yn(r)||gn(r))return r;throw xn("Can't convert object to primitive value")}return void 0===t&&(t="number"),function(e,t){var r,n;if("string"===t&&Br(r=e.toString)&&!Nr(n=Dr(r,e)))return n;if(Br(r=e.valueOf)&&!Nr(n=Dr(r,e)))return n;if("string"!==t&&Br(r=e.toString)&&!Nr(n=Dr(r,e)))return n;throw qr("Can't convert object to primitive value")}(e,t)},kn=Tr,Sn=function(e){var t=$n(e,"string");return kn(t)?t:t+""},An=nr,En=ft.document,On=An(En)&&An(En.createElement),Tn=function(e){return On?En.createElement(e):{}},Cn=Tn,jn=!jt&&!mt((function(){return 7!=Object.defineProperty(Cn("div"),"a",{get:function(){return 7}}).a})),In=jt,_n=Pt,Pn=Rt,Rn=qt,Ln=tr,Fn=Sn,Dn=Zr,Bn=jn,Nn=Object.getOwnPropertyDescriptor;Ct.f=In?Nn:function(e,t){if(e=Ln(e),t=Fn(t),Bn)try{return Nn(e,t)}catch(e){}if(Dn(e,t))return Rn(!_n(Pn.f,e,t),e[t])};var qn=mt,Un=Tt,zn=/#|\.prototype\./,Mn=function(e,t){var r=Wn[Hn(e)];return r==Gn||r!=Vn&&(Un(t)?qn(t):!!t)},Hn=Mn.normalize=function(e){return String(e).replace(zn,".").toLowerCase()},Wn=Mn.data={},Vn=Mn.NATIVE="N",Gn=Mn.POLYFILL="P",Kn=Mn,Jn=Rr,Yn=yt,Zn=Ot(Ot.bind),Qn=function(e,t){return Jn(e),void 0===t?e:Yn?Zn(e,t):function(){return e.apply(t,arguments)}},Xn={},eo=jt&&mt((function(){return 42!=Object.defineProperty((function(){}),"prototype",{value:42,writable:!1}).prototype})),to=ft,ro=nr,no=to.String,oo=to.TypeError,ao=function(e){if(ro(e))return e;throw oo(no(e)+" is not an object")},io=jt,so=jn,lo=eo,co=ao,po=Sn,uo=ft.TypeError,ho=Object.defineProperty,fo=Object.getOwnPropertyDescriptor;Xn.f=io?lo?function(e,t,r){if(co(e),t=po(t),co(r),"function"==typeof e&&"prototype"===t&&"value"in r&&"writable"in r&&!r.writable){var n=fo(e,t);n&&n.writable&&(e[t]=r.value,r={configurable:"configurable"in r?r.configurable:n.configurable,enumerable:"enumerable"in r?r.enumerable:n.enumerable,writable:!1})}return ho(e,t,r)}:ho:function(e,t,r){if(co(e),t=po(t),co(r),so)try{return ho(e,t,r)}catch(e){}if("get"in r||"set"in r)throw uo("Accessors not supported");return"value"in r&&(e[t]=r.value),e};var mo=Xn,yo=qt,go=jt?function(e,t,r){return mo.f(e,t,yo(1,r))}:function(e,t,r){return e[t]=r,e},vo=ft,bo=wt,xo=Ot,wo=Tt,$o=Ct.f,ko=Kn,So=or,Ao=Qn,Eo=go,Oo=Zr,To=function(e){var t=function(r,n,o){if(this instanceof t){switch(arguments.length){case 0:return new e;case 1:return new e(r);case 2:return new e(r,n)}return new e(r,n,o)}return bo(e,this,arguments)};return t.prototype=e.prototype,t},Co=function(e,t){var r,n,o,a,i,s,l,c,p=e.target,d=e.global,u=e.stat,h=e.proto,f=d?vo:u?vo[p]:(vo[p]||{}).prototype,m=d?So:So[p]||Eo(So,p,{})[p],y=m.prototype;for(o in t)r=!ko(d?o:p+(u?".":"#")+o,e.forced)&&f&&Oo(f,o),i=m[o],r&&(s=e.noTargetGet?(c=$o(f,o))&&c.value:f[o]),a=r&&s?s:t[o],r&&typeof i==typeof a||(l=e.bind&&r?Ao(a,vo):e.wrap&&r?To(a):h&&wo(a)?xo(a):a,(e.sham||a&&a.sham||i&&i.sham)&&Eo(l,"sham",!0),Eo(m,o,l),h&&(Oo(So,n=p+"Prototype")||Eo(So,n,{}),Eo(So[n],o,a),e.real&&y&&!y[o]&&Eo(y,o,a)))},jo=Math.ceil,Io=Math.floor,_o=function(e){var t=+e;return t!=t||0===t?0:(t>0?Io:jo)(t)},Po=_o,Ro=Math.max,Lo=Math.min,Fo=function(e,t){var r=Po(e);return r<0?Ro(r+t,0):Lo(r,t)},Do=_o,Bo=Math.min,No=function(e){return e>0?Bo(Do(e),9007199254740991):0},qo=No,Uo=function(e){return qo(e.length)},zo=tr,Mo=Fo,Ho=Uo,Wo=function(e){return function(t,r,n){var o,a=zo(t),i=Ho(a),s=Mo(n,i);if(e&&r!=r){for(;i>s;)if((o=a[s++])!=o)return!0}else for(;i>s;s++)if((e||s in a)&&a[s]===r)return e||s||0;return!e&&-1}},Vo={includes:Wo(!0),indexOf:Wo(!1)},Go={},Ko=Zr,Jo=tr,Yo=Vo.indexOf,Zo=Go,Qo=Ot([].push),Xo=function(e,t){var r,n=Jo(e),o=0,a=[];for(r in n)!Ko(Zo,r)&&Ko(n,r)&&Qo(a,r);for(;t.length>o;)Ko(n,r=t[o++])&&(~Yo(a,r)||Qo(a,r));return a},ea=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],ta=Xo,ra=ea,na=Object.keys||function(e){return ta(e,ra)},oa=Kr,aa=na;Co({target:"Object",stat:!0,forced:mt((function(){aa(1)}))},{keys:function(e){return aa(oa(e))}});var ia=or.Object.keys;const sa=ut({exports:{}}.exports=ia);var la=Ht,ca=Array.isArray||function(e){return"Array"==la(e)},pa={};pa[fn("toStringTag")]="z";var da="[object z]"===String(pa),ua=ft,ha=da,fa=Tt,ma=Ht,ya=fn("toStringTag"),ga=ua.Object,va="Arguments"==ma(function(){return arguments}()),ba=ha?ma:function(e){var t,r,n;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(r=function(e,t){try{return e[t]}catch(e){}}(t=ga(e),ya))?r:va?ma(t):"Object"==(n=ma(t))&&fa(t.callee)?"Arguments":n},xa=ba,wa=ft.String,$a=function(e){if("Symbol"===xa(e))throw TypeError("Cannot convert a Symbol value to a string");return wa(e)},ka={},Sa=jt,Aa=eo,Ea=Xn,Oa=ao,Ta=tr,Ca=na;ka.f=Sa&&!Aa?Object.defineProperties:function(e,t){Oa(e);for(var r,n=Ta(t),o=Ca(t),a=o.length,i=0;a>i;)Ea.f(e,r=o[i++],n[r]);return e};var ja,Ia=cr("document","documentElement"),_a=Ur.exports,Pa=rn,Ra=_a("keys"),La=function(e){return Ra[e]||(Ra[e]=Pa(e))},Fa=ao,Da=ka,Ba=ea,Na=Go,qa=Ia,Ua=Tn,za=La("IE_PROTO"),Ma=function(){},Ha=function(e){return" + + +