refactor: improve error handling and response formatting in API

This commit is contained in:
yusing 2025-05-03 17:41:10 +08:00
parent 82c829de18
commit 98e90d7a0b
31 changed files with 657 additions and 185 deletions

View file

@ -1,6 +1,7 @@
package v1
import (
"fmt"
"io"
"net/http"
"os"
@ -51,12 +52,12 @@ func (t FileType) GetPath(filename string) string {
func getArgs(r *http.Request) (fileType FileType, filename string, err error) {
fileType = FileType(r.PathValue("type"))
if !fileType.IsValid() {
err = gphttp.ErrInvalidKey("type")
err = fmt.Errorf("invalid file type: %s", fileType)
return
}
filename = r.PathValue("filename")
if filename == "" {
err = gphttp.ErrMissingKey("filename")
err = fmt.Errorf("missing filename")
}
return
}

View file

@ -1,10 +1,8 @@
package favicon
import (
"errors"
"net/http"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/route/routes"
@ -21,11 +19,11 @@ import (
func GetFavIcon(w http.ResponseWriter, req *http.Request) {
url, alias := req.FormValue("url"), req.FormValue("alias")
if url == "" && alias == "" {
gphttp.ClientError(w, gphttp.ErrMissingKey("url or alias"), http.StatusBadRequest)
gphttp.MissingKey(w, "url or alias")
return
}
if url != "" && alias != "" {
gphttp.ClientError(w, gperr.New("url and alias are mutually exclusive"), http.StatusBadRequest)
gphttp.BadRequest(w, "url and alias are mutually exclusive")
return
}
@ -33,7 +31,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) {
if url != "" {
var iconURL homepage.IconURL
if err := iconURL.Parse(url); err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
gphttp.ClientError(w, req, err, http.StatusBadRequest)
return
}
fetchResult := homepage.FetchFavIconFromURL(req.Context(), &iconURL)
@ -49,7 +47,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) {
// try with route.Icon
r, ok := routes.HTTP.Get(alias)
if !ok {
gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound)
gphttp.ValueNotFound(w, "route", alias)
return
}

View file

@ -69,7 +69,7 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
}
icons, err := homepage.SearchIcons(r.FormValue("keyword"), limit)
if err != nil {
gphttp.ClientError(w, err)
gphttp.ClientError(w, r, err)
return
}
gphttp.RespondJSON(w, r, icons)

View file

@ -20,27 +20,27 @@ func NewAgent(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
name := q.Get("name")
if name == "" {
gphttp.ClientError(w, gphttp.ErrMissingKey("name"))
gphttp.MissingKey(w, "name")
return
}
host := q.Get("host")
if host == "" {
gphttp.ClientError(w, gphttp.ErrMissingKey("host"))
gphttp.MissingKey(w, "host")
return
}
portStr := q.Get("port")
if portStr == "" {
gphttp.ClientError(w, gphttp.ErrMissingKey("port"))
gphttp.MissingKey(w, "port")
return
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
gphttp.ClientError(w, gphttp.ErrInvalidKey("port"))
gphttp.InvalidKey(w, "port")
return
}
hostport := fmt.Sprintf("%s:%d", host, port)
if _, ok := config.GetInstance().GetAgent(hostport); ok {
gphttp.ClientError(w, gphttp.ErrAlreadyExists("agent", hostport), http.StatusConflict)
gphttp.KeyAlreadyExists(w, "agent", hostport)
return
}
t := q.Get("type")
@ -48,10 +48,10 @@ func NewAgent(w http.ResponseWriter, r *http.Request) {
case "docker", "system":
break
case "":
gphttp.ClientError(w, gphttp.ErrMissingKey("type"))
gphttp.MissingKey(w, "type")
return
default:
gphttp.ClientError(w, gphttp.ErrInvalidKey("type"))
gphttp.InvalidKey(w, "type")
return
}
@ -109,13 +109,13 @@ func VerifyNewAgent(w http.ResponseWriter, r *http.Request) {
}
if err := json.Unmarshal(clientPEMData, &data); err != nil {
gphttp.ClientError(w, err, http.StatusBadRequest)
gphttp.ClientError(w, r, err)
return
}
nRoutesAdded, err := config.GetInstance().VerifyNewAgent(data.Host, data.CA, data.Client)
if err != nil {
gphttp.ClientError(w, err)
gphttp.ClientError(w, r, err)
return
}
@ -127,7 +127,7 @@ func VerifyNewAgent(w http.ResponseWriter, r *http.Request) {
filename, ok := certs.AgentCertsFilepath(data.Host)
if !ok {
gphttp.ClientError(w, gphttp.ErrInvalidKey("host"))
gphttp.InvalidKey(w, "host")
return
}

View file

@ -53,7 +53,7 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
r = r.WithContext(context.WithValue(r.Context(), nextHandlerContextKey, next))
defaultAuth.LoginHandler(w, r)
} else {
gphttp.ClientError(w, err, http.StatusUnauthorized)
gphttp.Unauthorized(w, err.Error())
}
return
}

View file

@ -13,7 +13,6 @@ import (
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type Config struct {
@ -70,7 +69,7 @@ func (cfg *Config) Validate() gperr.Error {
if !ok {
b.Add(ErrUnknownProvider.
Subject(cfg.Provider).
Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, Providers))))
With(gperr.DoYouMean(utils.NearestField(cfg.Provider, Providers))))
} else {
_, err := providerConstructor(cfg.Options)
if err != nil {

View file

@ -37,11 +37,11 @@ func (err *baseError) Subjectf(format string, args ...any) Error {
}
func (err baseError) With(extra error) Error {
return &nestedError{&err, []error{extra}}
return &nestedError{err.Err, []error{extra}}
}
func (err baseError) Withf(format string, args ...any) Error {
return &nestedError{&err, []error{fmt.Errorf(format, args...)}}
return &nestedError{err.Err, []error{fmt.Errorf(format, args...)}}
}
func (err *baseError) Error() string {
@ -62,3 +62,11 @@ func (err *baseError) MarshalJSON() ([]byte, error) {
return json.Marshal(err.Error())
}
}
func (err *baseError) Plain() []byte {
return Plain(err.Err)
}
func (err *baseError) Markdown() []byte {
return Markdown(err.Err)
}

View file

@ -50,6 +50,7 @@ func TestBuilderNested(t *testing.T) {
Inner: 1
Inner: 2
Action 2
Inner: 3`
Inner: 3
`
ExpectEqual(t, got, expected)
}

View file

@ -20,6 +20,16 @@ type Error interface {
Subject(subject string) Error
// Subjectf is a wrapper for Subject(fmt.Sprintf(format, args...)).
Subjectf(format string, args ...any) Error
PlainError
MarkdownError
}
type PlainError interface {
Plain() []byte
}
type MarkdownError interface {
Markdown() []byte
}
// this makes JSON marshaling work,

View file

@ -153,6 +153,7 @@ func TestErrorStringNested(t *testing.T) {
2
action 3 > inner3: generic failure
3
3`
3
`
expect.Equal(t, ansi.StripANSI(ne.Error()), want)
}

43
internal/gperr/hint.go Normal file
View file

@ -0,0 +1,43 @@
package gperr
import "github.com/yusing/go-proxy/internal/utils/strutils/ansi"
type Hint struct {
Prefix string
Message string
Suffix string
}
var _ PlainError = (*Hint)(nil)
var _ MarkdownError = (*Hint)(nil)
func (h *Hint) Error() string {
return h.Prefix + ansi.Info(h.Message) + h.Suffix
}
func (h *Hint) Plain() []byte {
return []byte(h.Prefix + h.Message + h.Suffix)
}
func (h *Hint) Markdown() []byte {
return []byte(h.Prefix + "**" + h.Message + "**" + h.Suffix)
}
func (h *Hint) MarshalText() ([]byte, error) {
return h.Plain(), nil
}
func (h *Hint) String() string {
return h.Error()
}
func DoYouMean(s string) *Hint {
if s == "" {
return nil
}
return &Hint{
Prefix: "Do you mean ",
Message: s,
Suffix: "?",
}
}

View file

@ -15,7 +15,7 @@ func log(msg string, err error, level zerolog.Level, logger ...*zerolog.Logger)
} else {
l = logging.GetLogger()
}
l.WithLevel(level).Msg(New(highlight(msg)).With(err).Error())
l.WithLevel(level).Msg(New(highlightANSI(msg)).With(err).Error())
switch level {
case zerolog.FatalLevel:
os.Exit(1)

View file

@ -3,8 +3,6 @@ package gperr
import (
"errors"
"fmt"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
//nolint:recvcheck
@ -67,48 +65,98 @@ func (err *nestedError) Is(other error) bool {
return false
}
var nilError = newError("<nil>")
var bulletPrefix = []byte("• ")
var markdownBulletPrefix = []byte("- ")
var spaces = []byte(" ")
type appendLineFunc func(buf []byte, err error, level int) []byte
func (err *nestedError) Error() string {
if err == nil {
return makeLine("<nil>", 0)
return nilError.Error()
}
if err.Err != nil {
lines := make([]string, 0, 1+len(err.Extras))
lines = append(lines, makeLine(err.Err.Error(), 0))
lines = append(lines, makeLines(err.Extras, 1)...)
return strutils.JoinLines(lines)
buf := appendLineNormal(nil, err.Err, 0)
if len(err.Extras) > 0 {
buf = append(buf, '\n')
buf = appendLines(buf, err.Extras, 1, appendLineNormal)
}
return strutils.JoinLines(makeLines(err.Extras, 0))
return string(buf)
}
//go:inline
func makeLine(err string, level int) string {
const bulletPrefix = "• "
const spaces = " "
func (err *nestedError) Plain() []byte {
if err == nil {
return appendLinePlain(nil, nilError, 0)
}
buf := appendLinePlain(nil, err.Err, 0)
if len(err.Extras) > 0 {
buf = append(buf, '\n')
buf = appendLines(buf, err.Extras, 1, appendLinePlain)
}
return buf
}
func (err *nestedError) Markdown() []byte {
if err == nil {
return appendLineMd(nil, nilError, 0)
}
buf := appendLineMd(nil, err.Err, 0)
if len(err.Extras) > 0 {
buf = append(buf, '\n')
buf = appendLines(buf, err.Extras, 1, appendLineMd)
}
return buf
}
func appendLineNormal(buf []byte, err error, level int) []byte {
if level == 0 {
return err
return append(buf, err.Error()...)
}
return spaces[:2*level] + bulletPrefix + err
buf = append(buf, spaces[:2*level]...)
buf = append(buf, bulletPrefix...)
buf = append(buf, err.Error()...)
return buf
}
func makeLines(errs []error, level int) []string {
if len(errs) == 0 {
return nil
func appendLinePlain(buf []byte, err error, level int) []byte {
if level == 0 {
return append(buf, Plain(err)...)
}
buf = append(buf, spaces[:2*level]...)
buf = append(buf, bulletPrefix...)
buf = append(buf, Plain(err)...)
return buf
}
func appendLineMd(buf []byte, err error, level int) []byte {
if level == 0 {
return append(buf, Markdown(err)...)
}
buf = append(buf, spaces[:2*level]...)
buf = append(buf, markdownBulletPrefix...)
buf = append(buf, Markdown(err)...)
return buf
}
func appendLines(buf []byte, errs []error, level int, appendLine appendLineFunc) []byte {
if len(errs) == 0 {
return buf
}
lines := make([]string, 0, len(errs))
for _, err := range errs {
switch err := wrap(err).(type) {
case *nestedError:
if err.Err != nil {
lines = append(lines, makeLine(err.Err.Error(), level))
lines = append(lines, makeLines(err.Extras, level+1)...)
buf = appendLine(buf, err.Err, level)
buf = append(buf, '\n')
buf = appendLines(buf, err.Extras, level+1, appendLine)
} else {
lines = append(lines, makeLines(err.Extras, level)...)
buf = appendLines(buf, err.Extras, level, appendLine)
}
default:
lines = append(lines, makeLine(err.Error(), level))
buf = appendLine(buf, err, level)
buf = append(buf, '\n')
}
}
return lines
return buf
}

View file

@ -1,10 +1,10 @@
package gperr
import (
"bytes"
"encoding/json"
"errors"
"slices"
"strings"
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
)
@ -19,10 +19,23 @@ type withSubject struct {
const subjectSep = " > "
func highlight(subject string) string {
type highlightFunc func(subject string) string
var _ PlainError = (*withSubject)(nil)
var _ MarkdownError = (*withSubject)(nil)
func highlightANSI(subject string) string {
return ansi.HighlightRed + subject + ansi.Reset
}
func highlightMarkdown(subject string) string {
return "**" + subject + "**"
}
func noHighlight(subject string) string {
return subject
}
func PrependSubject(subject string, err error) error {
if err == nil {
return nil
@ -69,24 +82,38 @@ func (err *withSubject) Unwrap() error {
}
func (err *withSubject) Error() string {
return string(err.fmtError(highlightANSI))
}
func (err *withSubject) Plain() []byte {
return err.fmtError(noHighlight)
}
func (err *withSubject) Markdown() []byte {
return err.fmtError(highlightMarkdown)
}
func (err *withSubject) fmtError(highlight highlightFunc) []byte {
// subject is in reversed order
n := len(err.Subjects)
size := 0
errStr := err.Err.Error()
var sb strings.Builder
var buf bytes.Buffer
for _, s := range err.Subjects {
size += len(s)
}
sb.Grow(size + 2 + n*len(subjectSep) + len(errStr) + len(highlight("")))
buf.Grow(size + 2 + n*len(subjectSep) + len(errStr) + len(highlight("")))
for i := n - 1; i > 0; i-- {
sb.WriteString(err.Subjects[i])
sb.WriteString(subjectSep)
buf.WriteString(err.Subjects[i])
buf.WriteString(subjectSep)
}
sb.WriteString(highlight(err.Subjects[0]))
sb.WriteString(": ")
sb.WriteString(errStr)
return sb.String()
buf.WriteString(highlight(err.Subjects[0]))
if errStr != "" {
buf.WriteString(": ")
buf.WriteString(errStr)
}
return buf.Bytes()
}
// MarshalJSON implements the json.Marshaler interface.

View file

@ -1,8 +1,6 @@
package gperr
import (
"encoding/json"
"errors"
"fmt"
)
@ -29,16 +27,17 @@ func Wrap(err error, message ...string) Error {
if len(message) == 0 || message[0] == "" {
return wrap(err)
}
wrapped := &wrappedError{err, message[0]}
//nolint:errorlint
switch err := err.(type) {
case *baseError:
err.Err = fmt.Errorf("%s: %w", message[0], err.Err)
err.Err = wrapped
return err
case *nestedError:
err.Err = fmt.Errorf("%s: %w", message[0], err.Err)
err.Err = wrapped
return err
}
return &baseError{fmt.Errorf("%s: %w", message[0], err)}
return &baseError{wrapped}
}
func Unwrap(err error) Error {
@ -65,18 +64,6 @@ func wrap(err error) Error {
return &baseError{err}
}
func IsJSONMarshallable(err error) bool {
switch err := err.(type) {
case *nestedError, *withSubject:
return true
case *baseError:
return IsJSONMarshallable(err.Err)
default:
var v json.Marshaler
return errors.As(err, &v)
}
}
func Join(errors ...error) Error {
n := 0
for _, err := range errors {
@ -103,3 +90,27 @@ func Collect[T any, Err error, Arg any, Func func(Arg) (T, Err)](eb *Builder, fn
eb.Add(err)
return result
}
func Plain(err error) []byte {
if err == nil {
return nil
}
if p, ok := err.(PlainError); ok {
return p.Plain()
}
return []byte(err.Error())
}
func Markdown(err error) []byte {
if err == nil {
return nil
}
switch err := err.(type) {
case MarkdownError:
return err.Markdown()
case interface{ Unwrap() []error }:
return appendLines(nil, err.Unwrap(), 0, appendLineMd)
default:
return []byte(err.Error())
}
}

View file

@ -1,63 +1,55 @@
package gperr
import (
"errors"
"testing"
)
type testErr struct{}
func (e *testErr) Error() string {
func (e testErr) Error() string {
return "test error"
}
func (e *testErr) MarshalJSON() ([]byte, error) {
return nil, nil
func (e testErr) Plain() []byte {
return []byte("test error")
}
func TestIsJSONMarshallable(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "testErr",
err: &testErr{},
want: true,
},
{
name: "baseError",
err: &baseError{},
want: false,
},
{
name: "baseError with json marshallable error",
err: &baseError{&testErr{}},
want: true,
},
{
name: "nestedError",
err: &nestedError{},
want: true,
},
{
name: "withSubject",
err: &withSubject{},
want: true,
},
{
name: "standard error",
err: errors.New("test error"),
want: false,
},
}
func (e testErr) Markdown() []byte {
return []byte("**test error**")
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := IsJSONMarshallable(test.err); got != test.want {
t.Errorf("IsJSONMarshallable(%v) = %v, want %v", test.err, got, test.want)
}
})
type testMultiErr struct {
errors []error
}
func (e testMultiErr) Error() string {
return Join(e.errors...).Error()
}
func (e testMultiErr) Unwrap() []error {
return e.errors
}
func TestFormatting(t *testing.T) {
err := testErr{}
plain := Plain(err)
if string(plain) != "test error" {
t.Errorf("expected test error, got %s", string(plain))
}
md := Markdown(err)
if string(md) != "**test error**" {
t.Errorf("expected test error, got %s", string(md))
}
}
func TestMultiError(t *testing.T) {
err := testMultiErr{[]error{testErr{}, testErr{}}}
plain := Plain(err)
if string(plain) != "test error\ntest error" {
t.Errorf("expected test error, got %s", string(plain))
}
md := Markdown(err)
if string(md) != "**test error**\n**test error**" {
t.Errorf("expected test error, got %s", string(md))
}
}

34
internal/gperr/wrapped.go Normal file
View file

@ -0,0 +1,34 @@
package gperr
import (
"errors"
"fmt"
)
type wrappedError struct {
Err error
Message string
}
var _ PlainError = (*wrappedError)(nil)
var _ MarkdownError = (*wrappedError)(nil)
func (e *wrappedError) Error() string {
return fmt.Sprintf("%s: %s", e.Message, e.Err.Error())
}
func (e *wrappedError) Plain() []byte {
return fmt.Appendf(nil, "%s: %s", e.Message, e.Err.Error())
}
func (e *wrappedError) Markdown() []byte {
return fmt.Appendf(nil, "**%s**: %s", e.Message, e.Err.Error())
}
func (e *wrappedError) Unwrap() error {
return e.Err
}
func (e *wrappedError) Is(target error) bool {
return errors.Is(e.Err, target)
}

View file

@ -2,9 +2,8 @@ package homepage
import (
"encoding/json"
"strings"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/homepage/widgets"
"github.com/yusing/go-proxy/internal/utils"
)
@ -13,20 +12,21 @@ type (
Category []*Item
ItemConfig struct {
Show bool `json:"show"`
Name string `json:"name"` // display name
Icon *IconURL `json:"icon"`
Category string `json:"category"`
Description string `json:"description" aliases:"desc"`
SortOrder int `json:"sort_order"`
WidgetConfig map[string]any `json:"widget_config" aliases:"widget"`
Show bool `json:"show"`
Name string `json:"name"` // display name
Icon *IconURL `json:"icon"`
Category string `json:"category"`
Description string `json:"description" aliases:"desc"`
SortOrder int `json:"sort_order"`
}
Item struct {
*ItemConfig
WidgetConfig *widgets.Config `json:"widget_config,omitempty" aliases:"widget"`
Alias string
Provider string
Alias string
Provider string
OriginURL string
}
)
@ -43,23 +43,10 @@ func (cfg *ItemConfig) GetOverride(alias string) *ItemConfig {
}
func (item *Item) MarshalJSON() ([]byte, error) {
var url *string
if !strings.ContainsRune(item.Alias, '.') {
godoxyCfg := config.GetInstance().Value()
// use first domain as base domain
domains := godoxyCfg.MatchDomains
if len(domains) > 0 {
url = new(string)
*url = item.Alias + domains[0]
}
} else {
url = &item.Alias
}
return json.Marshal(map[string]any{
"show": item.Show,
"alias": item.Alias,
"provider": item.Provider,
"url": url,
"name": item.Name,
"icon": item.Icon,
"category": item.Category,

View file

@ -0,0 +1,68 @@
package qbittorrent
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/homepage/widgets"
)
type Client struct {
URL string
Username string
Password string
}
func (c *Client) Initialize(ctx context.Context, url string, cfg map[string]any) error {
c.URL = url
c.Username = cfg["username"].(string)
c.Password = cfg["password"].(string)
_, err := c.Version(ctx)
if err != nil {
return err
}
return nil
}
func (c *Client) doRequest(ctx context.Context, method, endpoint string, query url.Values, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, c.URL+endpoint+query.Encode(), body)
if err != nil {
return nil, err
}
if c.Username != "" && c.Password != "" {
req.SetBasicAuth(c.Username, c.Password)
}
resp, err := widgets.HTTPClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, gperr.Errorf("%w: %d %s", widgets.ErrHTTPStatus, resp.StatusCode, resp.Status)
}
return resp, nil
}
func jsonRequest[T any](ctx context.Context, client *Client, endpoint string, query url.Values) (result T, err error) {
resp, err := client.doRequest(ctx, http.MethodGet, endpoint, query, nil)
if err != nil {
return result, err
}
defer resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return result, err
}
return result, nil
}

View file

@ -0,0 +1,102 @@
package qbittorrent
import (
"context"
"encoding/json"
"net/url"
"strconv"
"time"
)
const endpointLogs = "/api/v2/log/main"
type LogEntry struct {
ID int `json:"id"`
Timestamp int `json:"timestamp"`
Type int `json:"type"`
Message string `json:"message"`
}
const (
LogSeverityNormal = 1 << iota
LogSeverityInfo
LogSeverityWarning
LogSeverityCritical
)
func (l *LogEntry) Time() time.Time {
return time.Unix(int64(l.Timestamp), 0)
}
func (l *LogEntry) Level() string {
switch l.Type {
case LogSeverityNormal:
return "Normal"
case LogSeverityInfo:
return "Info"
case LogSeverityWarning:
return "Warning"
case LogSeverityCritical:
return "Critical"
default:
return "Unknown"
}
}
func (l *LogEntry) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"id": l.ID,
"timestamp": l.Timestamp,
"level": l.Level(),
"message": l.Message,
})
}
// params:
//
// normal: bool
// info: bool
// warning: bool
// critical: bool
// last_known_id: int
func (c *Client) GetLogs(ctx context.Context, lastKnownID int) ([]*LogEntry, error) {
return jsonRequest[[]*LogEntry](ctx, c, endpointLogs, url.Values{
"last_known_id": {strconv.Itoa(lastKnownID)},
})
}
func (c *Client) WatchLogs(ctx context.Context) (<-chan *LogEntry, <-chan error) {
ch := make(chan *LogEntry)
errCh := make(chan error)
lastKnownID := -1
go func() {
defer close(ch)
defer close(errCh)
for {
select {
case <-ctx.Done():
return
default:
logs, err := c.GetLogs(ctx, lastKnownID)
if err != nil {
errCh <- err
}
if len(logs) == 0 {
time.Sleep(1 * time.Second)
continue
}
for _, log := range logs {
ch <- log
}
lastKnownID = logs[len(logs)-1].ID
}
}
}()
return ch, errCh
}

View file

@ -0,0 +1,32 @@
package qbittorrent
import (
"context"
"github.com/yusing/go-proxy/internal/homepage/widgets"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
const endpointTransferInfo = "/api/v2/transfer/info"
type TransferInfo struct {
ConnectionStatus string `json:"connection_status"`
SessionDownloads uint64 `json:"dl_info_data"`
SessionUploads uint64 `json:"up_info_data"`
DownloadSpeed uint64 `json:"dl_info_speed"`
UploadSpeed uint64 `json:"up_info_speed"`
}
func (c *Client) Data(ctx context.Context) ([]widgets.NameValue, error) {
info, err := jsonRequest[TransferInfo](ctx, c, endpointTransferInfo, nil)
if err != nil {
return nil, err
}
return []widgets.NameValue{
{Name: "Status", Value: info.ConnectionStatus},
{Name: "Download", Value: strutils.FormatByteSize(info.SessionDownloads)},
{Name: "Upload", Value: strutils.FormatByteSize(info.SessionUploads)},
{Name: "Download Speed", Value: strutils.FormatByteSize(info.DownloadSpeed) + "/s"},
{Name: "Upload Speed", Value: strutils.FormatByteSize(info.UploadSpeed) + "/s"},
}, nil
}

View file

@ -0,0 +1,21 @@
package qbittorrent
import (
"context"
"io"
)
func (c *Client) Version(ctx context.Context) (string, error) {
resp, err := c.doRequest(ctx, "GET", "/api/v2/app/version", nil, nil)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}

View file

@ -0,0 +1,14 @@
package widgets
import (
"net/http"
"time"
"github.com/yusing/go-proxy/internal/gperr"
)
var HTTPClient = &http.Client{
Timeout: 10 * time.Second,
}
var ErrHTTPStatus = gperr.New("http status")

View file

@ -0,0 +1,49 @@
package widgets
import (
"context"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/utils"
)
type (
Config struct {
Provider string `json:"provider"`
Config Widget `json:"config"`
}
Widget interface {
Initialize(ctx context.Context, url string, cfg map[string]any) error
Data(ctx context.Context) ([]NameValue, error)
}
NameValue struct {
Name string `json:"name"`
Value string `json:"value"`
}
)
const (
WidgetProviderQbittorrent = "qbittorrent"
)
var widgetProviders = map[string]struct{}{
WidgetProviderQbittorrent: {},
}
var ErrInvalidProvider = gperr.New("invalid provider")
func (cfg *Config) UnmarshalMap(m map[string]any) error {
cfg.Provider = m["provider"].(string)
if _, ok := widgetProviders[cfg.Provider]; !ok {
return ErrInvalidProvider.Subject(cfg.Provider)
}
delete(m, "provider")
m, ok := m["config"].(map[string]any)
if !ok {
return gperr.New("invalid config")
}
if err := utils.MapUnmarshalValidate(m, &cfg.Config); err != nil {
return err
}
return nil
}

View file

@ -10,6 +10,15 @@ type (
AcceptContentType []ContentType
)
const (
ContentTypeJSON = ContentType("application/json")
ContentTypeTextPlain = ContentType("text/plain")
ContentTypeTextHTML = ContentType("text/html")
ContentTypeTextMarkdown = ContentType("text/markdown")
ContentTypeTextXML = ContentType("text/xml")
ContentTypeXHTML = ContentType("application/xhtml+xml")
)
func GetContentType(h http.Header) ContentType {
ct := h.Get("Content-Type")
if ct == "" {
@ -35,15 +44,15 @@ func GetAccept(h http.Header) AcceptContentType {
}
func (ct ContentType) IsHTML() bool {
return ct == "text/html" || ct == "application/xhtml+xml"
return ct == ContentTypeTextHTML || ct == ContentTypeXHTML
}
func (ct ContentType) IsJSON() bool {
return ct == "application/json"
return ct == ContentTypeJSON
}
func (ct ContentType) IsPlainText() bool {
return ct == "text/plain"
return ct == ContentTypeTextPlain
}
func (act AcceptContentType) IsEmpty() bool {
@ -68,6 +77,15 @@ func (act AcceptContentType) AcceptJSON() bool {
return false
}
func (act AcceptContentType) AcceptMarkdown() bool {
for _, v := range act {
if v == ContentTypeTextMarkdown || v == "*/*" {
return true
}
}
return false
}
func (act AcceptContentType) AcceptPlainText() bool {
for _, v := range act {
if v.IsPlainText() || v == "text/*" || v == "*/*" {

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"syscall"
@ -40,15 +41,22 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error, code ...int)
//
// For JSON marshallable errors (e.g. gperr.Error), it returns the error details as JSON.
// Otherwise, it returns the error details as plain text.
func ClientError(w http.ResponseWriter, err error, code ...int) {
func ClientError(w http.ResponseWriter, r *http.Request, err error, code ...int) {
if len(code) == 0 {
code = []int{http.StatusBadRequest}
}
if gperr.IsJSONMarshallable(err) {
w.WriteHeader(code[0])
accept := GetAccept(r.Header)
switch {
case accept.AcceptJSON():
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(err)
} else {
http.Error(w, err.Error(), code[0])
case accept.AcceptMarkdown():
w.Header().Set("Content-Type", "text/markdown")
w.Write(gperr.Markdown(err))
default:
w.Header().Set("Content-Type", "text/plain")
w.Write(gperr.Plain(err))
}
}
@ -83,18 +91,18 @@ func NotFound(w http.ResponseWriter, err string) {
BadRequest(w, err, http.StatusNotFound)
}
func ErrMissingKey(k string) error {
return gperr.New(k + " is required")
func MissingKey(w http.ResponseWriter, k string) {
BadRequest(w, k+" is required", http.StatusBadRequest)
}
func ErrInvalidKey(k string) error {
return gperr.New(k + " is invalid")
func InvalidKey(w http.ResponseWriter, k string) {
BadRequest(w, k+" is invalid", http.StatusBadRequest)
}
func ErrAlreadyExists(k, v string) error {
return gperr.Errorf("%s %q already exists", k, v)
func KeyAlreadyExists(w http.ResponseWriter, k, v string) {
BadRequest(w, fmt.Sprintf("%s %q already exists", k, v), http.StatusBadRequest)
}
func ErrNotFound(k, v string) error {
return gperr.Errorf("%s %q not found", k, v)
func ValueNotFound(w http.ResponseWriter, k, v string) {
BadRequest(w, fmt.Sprintf("%s %q not found", k, v), http.StatusNotFound)
}

View file

@ -44,7 +44,7 @@ func Get(name string) (*Middleware, Error) {
if !ok {
return nil, ErrUnknownMiddleware.
Subject(name).
Withf(strutils.DoYouMean(utils.NearestField(name, allMiddlewares)))
With(gperr.DoYouMean(utils.NearestField(name, allMiddlewares)))
}
return middleware, nil
}

View file

@ -26,6 +26,9 @@ type (
FieldsBody []LogField
ListBody []string
MessageBody string
ErrorBody struct {
Error error
}
)
var (
@ -113,3 +116,15 @@ func (m MessageBody) Format(format *LogFormat) ([]byte, error) {
}
return nil, fmt.Errorf("unknown format: %v", format)
}
func (e ErrorBody) Format(format *LogFormat) ([]byte, error) {
switch format {
case LogFormatRawJSON:
return json.Marshal(e)
case LogFormatPlain:
return gperr.Plain(e.Error), nil
case LogFormatMarkdown:
return gperr.Markdown(e.Error), nil
}
return nil, fmt.Errorf("unknown format: %v", format)
}

View file

@ -259,7 +259,7 @@ func mapUnmarshalValidate(src SerializedObject, dst any, checkValidateTag bool)
errs.Add(err)
}
} else {
errs.Add(ErrUnknownField.Subject(k).Withf(strutils.DoYouMean(NearestField(k, mapping))))
errs.Add(ErrUnknownField.Subject(k).With(gperr.DoYouMean(NearestField(k, mapping))))
}
}
if hasValidateTag && checkValidateTag {

View file

@ -1,6 +1,8 @@
package ansi
import "regexp"
import (
"regexp"
)
var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`)
@ -43,11 +45,3 @@ func WithANSI(s string, ansi string) string {
func StripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
var ToHTMLClass = map[string]string{
"[91": "log-red",
"[92": "log-green",
"[93": "log-yellow",
"[96": "log-cyan",
"[97": "log-white",
}

View file

@ -5,8 +5,6 @@ import (
"math"
"strconv"
"time"
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
)
// AppendDuration appends a duration to a buffer with the following format:
@ -215,13 +213,6 @@ func AppendByteSize[T ~int | ~uint | ~int64 | ~uint64 | ~float64](size T, buf []
return buf
}
func DoYouMean(s string) string {
if s == "" {
return ""
}
return "Did you mean " + ansi.Info(s) + "?"
}
func Pluralize(n int64) string {
if n > 1 {
return "s"