diff --git a/api.go b/api.go index b1d5fde..41f39cc 100644 --- a/api.go +++ b/api.go @@ -6,13 +6,14 @@ package api import ( "context" - "errors" "iter" "net/http" "reflect" "slices" "time" + core "dappco.re/go/core" + "github.com/gin-contrib/expvar" "github.com/gin-contrib/pprof" "github.com/gin-gonic/gin" @@ -193,7 +194,7 @@ func (e *Engine) Serve(ctx context.Context) error { errCh := make(chan error, 1) go func() { - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := srv.ListenAndServe(); err != nil && !core.Is(err, http.ErrServerClosed) { errCh <- err } close(errCh) diff --git a/authentik.go b/authentik.go index 49ae1c7..930bc2e 100644 --- a/authentik.go +++ b/authentik.go @@ -9,6 +9,8 @@ import ( "strings" "sync" + core "dappco.re/go/core" + "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-gonic/gin" ) @@ -207,10 +209,10 @@ func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.H } if groups := c.GetHeader("X-authentik-groups"); groups != "" { - user.Groups = strings.Split(groups, "|") + user.Groups = core.Split(groups, "|") } if ent := c.GetHeader("X-authentik-entitlements"); ent != "" { - user.Entitlements = strings.Split(ent, "|") + user.Entitlements = core.Split(ent, "|") } c.Set(authentikUserKey, user) @@ -220,8 +222,8 @@ func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.H // Block 2: Attempt JWT validation for direct API clients. // Only when OIDC is configured and no user was extracted from headers. if cfg.Issuer != "" && cfg.ClientID != "" && GetUser(c) == nil { - if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { - rawToken := strings.TrimPrefix(auth, "Bearer ") + if auth := c.GetHeader("Authorization"); core.HasPrefix(auth, "Bearer ") { + rawToken := core.TrimPrefix(auth, "Bearer ") if user, err := validateJWT(c.Request.Context(), cfg, rawToken); err == nil { c.Set(authentikUserKey, user) } @@ -235,8 +237,8 @@ func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.H func cloneAuthentikConfig(cfg AuthentikConfig) AuthentikConfig { out := cfg - out.Issuer = strings.TrimSpace(out.Issuer) - out.ClientID = strings.TrimSpace(out.ClientID) + out.Issuer = core.Trim(out.Issuer) + out.ClientID = core.Trim(out.ClientID) out.PublicPaths = normalisePublicPaths(cfg.PublicPaths) return out } @@ -252,11 +254,11 @@ func normalisePublicPaths(paths []string) []string { seen := make(map[string]struct{}, len(paths)) for _, path := range paths { - path = strings.TrimSpace(path) + path = core.Trim(path) if path == "" { continue } - if !strings.HasPrefix(path, "/") { + if !core.HasPrefix(path, "/") { path = "/" + path } path = strings.TrimRight(path, "/") diff --git a/bridge.go b/bridge.go index 101d199..c5f6117 100644 --- a/bridge.go +++ b/bridge.go @@ -6,7 +6,6 @@ import ( "bufio" "bytes" "encoding/json" - "fmt" "io" "iter" "net" @@ -17,9 +16,9 @@ import ( "strconv" "unicode/utf8" - "github.com/gin-gonic/gin" + core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" + "github.com/gin-gonic/gin" ) // ToolDescriptor describes a tool that can be exposed as a REST endpoint. @@ -267,7 +266,7 @@ func newToolInputValidator(schema map[string]any) *toolInputValidator { func (v *toolInputValidator) Validate(body []byte) error { if len(bytes.TrimSpace(body)) == 0 { - return coreerr.E("ToolBridge.Validate", "request body is required", nil) + return core.E("ToolBridge.Validate", "request body is required", nil) } dec := json.NewDecoder(bytes.NewReader(body)) @@ -275,11 +274,11 @@ func (v *toolInputValidator) Validate(body []byte) error { var payload any if err := dec.Decode(&payload); err != nil { - return coreerr.E("ToolBridge.Validate", "invalid JSON", err) + return core.E("ToolBridge.Validate", "invalid JSON", err) } var extra any if err := dec.Decode(&extra); err != io.EOF { - return coreerr.E("ToolBridge.Validate", "request body must contain a single JSON value", nil) + return core.E("ToolBridge.Validate", "request body must contain a single JSON value", nil) } return validateSchemaNode(payload, v.schema, "") @@ -298,12 +297,12 @@ func (v *toolInputValidator) ValidateResponse(body []byte) error { envDec := json.NewDecoder(bytes.NewReader(body)) envDec.UseNumber() if err := envDec.Decode(&envelope); err != nil { - return coreerr.E("ToolBridge.ValidateResponse", "invalid JSON response", err) + return core.E("ToolBridge.ValidateResponse", "invalid JSON response", err) } success, _ := envelope["success"].(bool) if !success { - return coreerr.E("ToolBridge.ValidateResponse", "response is missing a successful envelope", nil) + return core.E("ToolBridge.ValidateResponse", "response is missing a successful envelope", nil) } // data is serialised with omitempty, so a nil/zero-value payload from @@ -316,14 +315,14 @@ func (v *toolInputValidator) ValidateResponse(body []byte) error { encoded, err := json.Marshal(data) if err != nil { - return coreerr.E("ToolBridge.ValidateResponse", "encode response data", err) + return core.E("ToolBridge.ValidateResponse", "encode response data", err) } var payload any dec := json.NewDecoder(bytes.NewReader(encoded)) dec.UseNumber() if err := dec.Decode(&payload); err != nil { - return coreerr.E("ToolBridge.ValidateResponse", "decode response data", err) + return core.E("ToolBridge.ValidateResponse", "decode response data", err) } return validateSchemaNode(payload, v.schema, "") @@ -345,7 +344,7 @@ func validateSchemaNode(value any, schema map[string]any, path string) error { for _, name := range stringList(schema["required"]) { if _, ok := obj[name]; !ok { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s is missing required field %q", displayPath(path), name), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s is missing required field %q", displayPath(path), name), nil) } } @@ -371,7 +370,7 @@ func validateSchemaNode(value any, schema map[string]any, path string) error { continue } } - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s contains unknown field %q", displayPath(path), name), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s contains unknown field %q", displayPath(path), name), nil) } } if err := validateObjectConstraints(obj, schema, path); err != nil { @@ -433,7 +432,7 @@ func validateSchemaNode(value any, schema map[string]any, path string) error { if rawEnum, ok := schema["enum"]; ok { if !enumContains(value, rawEnum) { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be one of the declared enum values", displayPath(path)), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be one of the declared enum values", displayPath(path)), nil) } } @@ -459,7 +458,7 @@ func validateSchemaCombinators(value any, schema map[string]any, path string) er goto anyOfMatched } } - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must match at least one schema in anyOf", displayPath(path)), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must match at least one schema in anyOf", displayPath(path)), nil) } anyOfMatched: @@ -472,15 +471,15 @@ anyOfMatched: } if matches != 1 { if matches == 0 { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must match exactly one schema in oneOf", displayPath(path)), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must match exactly one schema in oneOf", displayPath(path)), nil) } - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s matches multiple schemas in oneOf", displayPath(path)), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s matches multiple schemas in oneOf", displayPath(path)), nil) } } if subschema, ok := schema["not"].(map[string]any); ok && subschema != nil { if err := validateSchemaNode(value, subschema, path); err == nil { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must not match the forbidden schema", displayPath(path)), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must not match the forbidden schema", displayPath(path)), nil) } } @@ -490,18 +489,18 @@ anyOfMatched: func validateStringConstraints(value string, schema map[string]any, path string) error { length := utf8.RuneCountInString(value) if minLength, ok := schemaInt(schema["minLength"]); ok && length < minLength { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be at least %d characters long", displayPath(path), minLength), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be at least %d characters long", displayPath(path), minLength), nil) } if maxLength, ok := schemaInt(schema["maxLength"]); ok && length > maxLength { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be at most %d characters long", displayPath(path), maxLength), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be at most %d characters long", displayPath(path), maxLength), nil) } if pattern, ok := schema["pattern"].(string); ok && pattern != "" { re, err := regexp.Compile(pattern) if err != nil { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s has an invalid pattern %q", displayPath(path), pattern), err) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s has an invalid pattern %q", displayPath(path), pattern), err) } if !re.MatchString(value) { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s does not match pattern %q", displayPath(path), pattern), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s does not match pattern %q", displayPath(path), pattern), nil) } } return nil @@ -509,30 +508,30 @@ func validateStringConstraints(value string, schema map[string]any, path string) func validateNumericConstraints(value any, schema map[string]any, path string) error { if minimum, ok := schemaFloat(schema["minimum"]); ok && numericLessThan(value, minimum) { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be greater than or equal to %v", displayPath(path), minimum), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be greater than or equal to %v", displayPath(path), minimum), nil) } if maximum, ok := schemaFloat(schema["maximum"]); ok && numericGreaterThan(value, maximum) { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be less than or equal to %v", displayPath(path), maximum), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be less than or equal to %v", displayPath(path), maximum), nil) } return nil } func validateArrayConstraints(value []any, schema map[string]any, path string) error { if minItems, ok := schemaInt(schema["minItems"]); ok && len(value) < minItems { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at least %d items", displayPath(path), minItems), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must contain at least %d items", displayPath(path), minItems), nil) } if maxItems, ok := schemaInt(schema["maxItems"]); ok && len(value) > maxItems { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at most %d items", displayPath(path), maxItems), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must contain at most %d items", displayPath(path), maxItems), nil) } return nil } func validateObjectConstraints(value map[string]any, schema map[string]any, path string) error { if minProps, ok := schemaInt(schema["minProperties"]); ok && len(value) < minProps { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at least %d properties", displayPath(path), minProps), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must contain at least %d properties", displayPath(path), minProps), nil) } if maxProps, ok := schemaInt(schema["maxProperties"]); ok && len(value) > maxProps { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at most %d properties", displayPath(path), maxProps), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must contain at most %d properties", displayPath(path), maxProps), nil) } return nil } @@ -685,7 +684,7 @@ func (w *toolResponseRecorder) Written() bool { } func (w *toolResponseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { - return nil, nil, coreerr.E("ToolBridge.ResponseRecorder", "response hijacking is not supported by ToolBridge output validation", nil) + return nil, nil, core.E("ToolBridge.ResponseRecorder", "response hijacking is not supported by ToolBridge output validation", nil) } func (w *toolResponseRecorder) commit() { @@ -733,7 +732,7 @@ func (w *toolResponseRecorder) writeErrorResponse(status int, resp Response[any] } func typeError(path, want string, value any) error { - return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be %s, got %s", displayPath(path), want, describeJSONValue(value)), nil) + return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be %s, got %s", displayPath(path), want, describeJSONValue(value)), nil) } func displayPath(path string) string { @@ -912,6 +911,6 @@ func describeJSONValue(value any) string { case []any: return "array" default: - return fmt.Sprintf("%T", value) + return core.Sprintf("%T", value) } } diff --git a/brotli.go b/brotli.go index b203cf2..ca22f58 100644 --- a/brotli.go +++ b/brotli.go @@ -6,9 +6,10 @@ import ( "io" "net/http" "strconv" - "strings" "sync" + core "dappco.re/go/core" + "github.com/andybalholm/brotli" "github.com/gin-gonic/gin" ) @@ -47,7 +48,7 @@ func newBrotliHandler(level int) *brotliHandler { // Handle is the Gin middleware function that compresses responses with Brotli. func (h *brotliHandler) Handle(c *gin.Context) { - if !strings.Contains(c.Request.Header.Get("Accept-Encoding"), "br") { + if !core.Contains(c.Request.Header.Get("Accept-Encoding"), "br") { c.Next() return } diff --git a/client.go b/client.go index 949e860..0a061ab 100644 --- a/client.go +++ b/client.go @@ -5,22 +5,18 @@ package api import ( "bytes" "encoding/json" - "fmt" "io" "iter" "net/http" "net/url" - "os" "reflect" - "sort" + "slices" "strings" "sync" - "slices" + core "dappco.re/go/core" "gopkg.in/yaml.v3" - - coreerr "dappco.re/go/core/log" ) // OpenAPIClient is a small runtime client that can call operations by their @@ -191,14 +187,26 @@ func (c *OpenAPIClient) Operations() ([]OpenAPIOperation, error) { for operationID, op := range c.operations { operations = append(operations, snapshotOpenAPIOperation(operationID, op)) } - sort.SliceStable(operations, func(i, j int) bool { - if operations[i].OperationID == operations[j].OperationID { - if operations[i].Method == operations[j].Method { - return operations[i].PathTemplate < operations[j].PathTemplate + slices.SortStableFunc(operations, func(a, b OpenAPIOperation) int { + if a.OperationID != b.OperationID { + if a.OperationID < b.OperationID { + return -1 } - return operations[i].Method < operations[j].Method + return 1 } - return operations[i].OperationID < operations[j].OperationID + if a.Method != b.Method { + if a.Method < b.Method { + return -1 + } + return 1 + } + if a.PathTemplate < b.PathTemplate { + return -1 + } + if a.PathTemplate > b.PathTemplate { + return 1 + } + return 0 }) return operations, nil } @@ -290,7 +298,7 @@ func (c *OpenAPIClient) Call(operationID string, params any) (any, error) { op, ok := c.operations[operationID] if !ok { - return nil, coreerr.E("OpenAPIClient.Call", fmt.Sprintf("operation %q not found in OpenAPI spec", operationID), nil) + return nil, core.E("OpenAPIClient.Call", core.Sprintf("operation %q not found in OpenAPI spec", operationID), nil) } merged, err := normaliseParams(params) @@ -343,7 +351,7 @@ func (c *OpenAPIClient) Call(operationID string, params any) (any, error) { } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, coreerr.E("OpenAPIClient.Call", fmt.Sprintf("openapi call %s returned %s: %s", operationID, resp.Status, strings.TrimSpace(string(payload))), nil) + return nil, core.E("OpenAPIClient.Call", core.Sprintf("openapi call %s returned %s: %s", operationID, resp.Status, core.Trim(string(payload))), nil) } if op.responseSchema != nil && len(bytes.TrimSpace(payload)) > 0 { @@ -367,9 +375,9 @@ func (c *OpenAPIClient) Call(operationID string, params any) (any, error) { if success, ok := envelope["success"].(bool); ok { if !success { if errObj, ok := envelope["error"].(map[string]any); ok { - return nil, coreerr.E("OpenAPIClient.Call", fmt.Sprintf("openapi call %s failed: %v", operationID, errObj), nil) + return nil, core.E("OpenAPIClient.Call", core.Sprintf("openapi call %s failed: %v", operationID, errObj), nil) } - return nil, coreerr.E("OpenAPIClient.Call", fmt.Sprintf("openapi call %s failed", operationID), nil) + return nil, core.E("OpenAPIClient.Call", core.Sprintf("openapi call %s failed", operationID), nil) } if data, ok := envelope["data"]; ok { return data, nil @@ -397,23 +405,23 @@ func (c *OpenAPIClient) loadSpec() error { case c.specReader != nil: data, err = io.ReadAll(c.specReader) case c.specPath != "": - f, openErr := os.Open(c.specPath) - if openErr != nil { - return coreerr.E("OpenAPIClient.loadSpec", "read spec", openErr) + cfs := (&core.Fs{}).NewUnrestricted() + r := cfs.Read(c.specPath) + if !r.OK { + return core.E("OpenAPIClient.loadSpec", "read spec", r.Value.(error)) } - defer f.Close() - data, err = io.ReadAll(f) + data = []byte(r.Value.(string)) default: - return coreerr.E("OpenAPIClient.loadSpec", "spec path or reader is required", nil) + return core.E("OpenAPIClient.loadSpec", "spec path or reader is required", nil) } if err != nil { - return coreerr.E("OpenAPIClient.loadSpec", "read spec", err) + return core.E("OpenAPIClient.loadSpec", "read spec", err) } var spec map[string]any if err := yaml.Unmarshal(data, &spec); err != nil { - return coreerr.E("OpenAPIClient.loadSpec", "parse spec", err) + return core.E("OpenAPIClient.loadSpec", "parse spec", err) } operations := make(map[string]openAPIOperation) @@ -434,7 +442,7 @@ func (c *OpenAPIClient) loadSpec() error { } params := parseOperationParameters(operation) operations[operationID] = openAPIOperation{ - method: strings.ToUpper(method), + method: core.Upper(method), pathTemplate: pathTemplate, hasRequestBody: operation["requestBody"] != nil, parameters: params, @@ -484,7 +492,7 @@ func snapshotOpenAPIOperation(operationID string, op openAPIOperation) OpenAPIOp return OpenAPIOperation{ OperationID: operationID, - Method: strings.ToUpper(op.method), + Method: core.Upper(op.method), PathTemplate: op.pathTemplate, HasRequestBody: op.hasRequestBody, Parameters: parameters, @@ -494,7 +502,7 @@ func snapshotOpenAPIOperation(operationID string, op openAPIOperation) OpenAPIOp func (c *OpenAPIClient) buildURL(op openAPIOperation, params map[string]any) (string, error) { base := strings.TrimRight(c.baseURL, "/") if base == "" { - return "", coreerr.E("OpenAPIClient.buildURL", "base URL is required", nil) + return "", core.E("OpenAPIClient.buildURL", "base URL is required", nil) } path := op.pathTemplate @@ -516,12 +524,12 @@ func (c *OpenAPIClient) buildURL(op openAPIOperation, params map[string]any) (st for _, key := range pathKeys { if value, ok := pathValues[key]; ok { placeholder := "{" + key + "}" - path = strings.ReplaceAll(path, placeholder, url.PathEscape(fmt.Sprint(value))) + path = core.Replace(path, placeholder, url.PathEscape(core.Sprint(value))) } } - if strings.Contains(path, "{") { - return "", coreerr.E("OpenAPIClient.buildURL", fmt.Sprintf("missing path parameters for %q", op.pathTemplate), nil) + if core.Contains(path, "{") { + return "", core.E("OpenAPIClient.buildURL", core.Sprintf("missing path parameters for %q", op.pathTemplate), nil) } fullURL, err := url.JoinPath(base, path) @@ -670,7 +678,7 @@ func applyHeaderValue(headers http.Header, key string, value any) { return case []any: for _, item := range v { - headers.Add(key, fmt.Sprint(item)) + headers.Add(key, core.Sprint(item)) } return } @@ -678,12 +686,12 @@ func applyHeaderValue(headers http.Header, key string, value any) { rv := reflect.ValueOf(value) if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) && !(rv.Type().Elem().Kind() == reflect.Uint8) { for i := 0; i < rv.Len(); i++ { - headers.Add(key, fmt.Sprint(rv.Index(i).Interface())) + headers.Add(key, core.Sprint(rv.Index(i).Interface())) } return } - headers.Set(key, fmt.Sprint(value)) + headers.Set(key, core.Sprint(value)) } func applyCookieValues(req *http.Request, values map[string]any) { @@ -703,7 +711,7 @@ func applyCookieValue(req *http.Request, key string, value any) { return case []any: for _, item := range v { - req.AddCookie(&http.Cookie{Name: key, Value: fmt.Sprint(item)}) + req.AddCookie(&http.Cookie{Name: key, Value: core.Sprint(item)}) } return } @@ -711,12 +719,12 @@ func applyCookieValue(req *http.Request, key string, value any) { rv := reflect.ValueOf(value) if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) && !(rv.Type().Elem().Kind() == reflect.Uint8) { for i := 0; i < rv.Len(); i++ { - req.AddCookie(&http.Cookie{Name: key, Value: fmt.Sprint(rv.Index(i).Interface())}) + req.AddCookie(&http.Cookie{Name: key, Value: core.Sprint(rv.Index(i).Interface())}) } return } - req.AddCookie(&http.Cookie{Name: key, Value: fmt.Sprint(value)}) + req.AddCookie(&http.Cookie{Name: key, Value: core.Sprint(value)}) } func parseOperationParameters(operation map[string]any) []openAPIParameter { @@ -784,9 +792,9 @@ func validateParameterValue(param openAPIParameter, value any) error { data, err := json.Marshal(value) if err != nil { - return coreerr.E("OpenAPIClient.validateParameterValue", fmt.Sprintf("marshal %s parameter %q", param.in, param.name), err) + return core.E("OpenAPIClient.validateParameterValue", core.Sprintf("marshal %s parameter %q", param.in, param.name), err) } - if err := validateOpenAPISchema(data, param.schema, fmt.Sprintf("%s parameter %q", param.in, param.name)); err != nil { + if err := validateOpenAPISchema(data, param.schema, core.Sprintf("%s parameter %q", param.in, param.name)); err != nil { return err } return nil @@ -800,7 +808,7 @@ func validateRequiredParameters(op openAPIOperation, params map[string]any, path if parameterProvided(params, param.name, param.in) { continue } - return coreerr.E("OpenAPIClient.buildURL", fmt.Sprintf("missing required %s parameter %q", param.in, param.name), nil) + return core.E("OpenAPIClient.buildURL", core.Sprintf("missing required %s parameter %q", param.in, param.name), nil) } return nil } @@ -840,12 +848,12 @@ func normaliseParams(params any) (map[string]any, error) { data, err := json.Marshal(params) if err != nil { - return nil, coreerr.E("OpenAPIClient.normaliseParams", "marshal params", err) + return nil, core.E("OpenAPIClient.normaliseParams", "marshal params", err) } var out map[string]any if err := json.Unmarshal(data, &out); err != nil { - return nil, coreerr.E("OpenAPIClient.normaliseParams", "decode params", err) + return nil, core.E("OpenAPIClient.normaliseParams", "decode params", err) } return out, nil @@ -936,7 +944,7 @@ func appendQueryValue(query url.Values, key string, value any) { return } - query.Add(key, fmt.Sprint(value)) + query.Add(key, core.Sprint(value)) } func isAbsoluteBaseURL(raw string) bool { @@ -1004,15 +1012,15 @@ func validateOpenAPISchema(body []byte, schema map[string]any, label string) err dec := json.NewDecoder(bytes.NewReader(body)) dec.UseNumber() if err := dec.Decode(&payload); err != nil { - return coreerr.E("OpenAPIClient.validateOpenAPISchema", fmt.Sprintf("validate %s: invalid JSON", label), err) + return core.E("OpenAPIClient.validateOpenAPISchema", core.Sprintf("validate %s: invalid JSON", label), err) } var extra any if err := dec.Decode(&extra); err != io.EOF { - return coreerr.E("OpenAPIClient.validateOpenAPISchema", fmt.Sprintf("validate %s: expected a single JSON value", label), nil) + return core.E("OpenAPIClient.validateOpenAPISchema", core.Sprintf("validate %s: expected a single JSON value", label), nil) } if err := validateSchemaNode(payload, schema, ""); err != nil { - return coreerr.E("OpenAPIClient.validateOpenAPISchema", fmt.Sprintf("validate %s", label), err) + return core.E("OpenAPIClient.validateOpenAPISchema", core.Sprintf("validate %s", label), err) } return nil @@ -1023,15 +1031,15 @@ func validateOpenAPIResponse(payload []byte, schema map[string]any, operationID dec := json.NewDecoder(bytes.NewReader(payload)) dec.UseNumber() if err := dec.Decode(&decoded); err != nil { - return coreerr.E("OpenAPIClient.validateOpenAPIResponse", fmt.Sprintf("openapi call %s returned invalid JSON", operationID), err) + return core.E("OpenAPIClient.validateOpenAPIResponse", core.Sprintf("openapi call %s returned invalid JSON", operationID), err) } var extra any if err := dec.Decode(&extra); err != io.EOF { - return coreerr.E("OpenAPIClient.validateOpenAPIResponse", fmt.Sprintf("openapi call %s returned multiple JSON values", operationID), nil) + return core.E("OpenAPIClient.validateOpenAPIResponse", core.Sprintf("openapi call %s returned multiple JSON values", operationID), nil) } if err := validateSchemaNode(decoded, schema, ""); err != nil { - return coreerr.E("OpenAPIClient.validateOpenAPIResponse", fmt.Sprintf("openapi call %s response does not match spec", operationID), err) + return core.E("OpenAPIClient.validateOpenAPIResponse", core.Sprintf("openapi call %s response does not match spec", operationID), err) } return nil diff --git a/cmd/api/cmd_args.go b/cmd/api/cmd_args.go index 042529a..b2e048d 100644 --- a/cmd/api/cmd_args.go +++ b/cmd/api/cmd_args.go @@ -2,7 +2,11 @@ package api -import "strings" +import ( + "strings" + + core "dappco.re/go/core" +) // splitUniqueCSV trims and deduplicates a comma-separated list while // preserving the first occurrence of each value. @@ -11,12 +15,12 @@ func splitUniqueCSV(raw string) []string { return nil } - parts := strings.Split(raw, ",") + parts := core.Split(raw, ",") values := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, part := range parts { - value := strings.TrimSpace(part) + value := core.Trim(part) if value == "" { continue } @@ -41,11 +45,11 @@ func normalisePublicPaths(paths []string) []string { seen := make(map[string]struct{}, len(paths)) for _, path := range paths { - path = strings.TrimSpace(path) + path = core.Trim(path) if path == "" { continue } - if !strings.HasPrefix(path, "/") { + if !core.HasPrefix(path, "/") { path = "/" + path } path = strings.TrimRight(path, "/") diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go index 57d6af6..0abb259 100644 --- a/cmd/api/cmd_spec.go +++ b/cmd/api/cmd_spec.go @@ -4,11 +4,11 @@ package api import ( "encoding/json" - "fmt" "os" - "strings" - "forge.lthn.ai/core/cli/pkg/cli" + core "dappco.re/go/core" + + "dappco.re/go/core/cli/pkg/cli" goapi "dappco.re/go/core/api" ) @@ -38,7 +38,7 @@ func addSpecCommand(parent *cli.Command) { if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil { return err } - fmt.Fprintf(os.Stderr, "Spec written to %s\n", output) + _, _ = os.Stderr.Write([]byte(core.Sprintf("Spec written to %s\n", output))) return nil } @@ -57,7 +57,7 @@ func parseServers(raw string) []string { } func parseSecuritySchemes(raw string) (map[string]any, error) { - raw = strings.TrimSpace(raw) + raw = core.Trim(raw) if raw == "" { return nil, nil } diff --git a/cmd/api/spec_builder.go b/cmd/api/spec_builder.go index bdd32b3..497feca 100644 --- a/cmd/api/spec_builder.go +++ b/cmd/api/spec_builder.go @@ -3,9 +3,10 @@ package api import ( - "strings" "time" + core "dappco.re/go/core" + goapi "dappco.re/go/core/api" ) @@ -45,24 +46,24 @@ type specBuilderConfig struct { } func newSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) { - swaggerPath := strings.TrimSpace(cfg.swaggerPath) - graphqlPath := strings.TrimSpace(cfg.graphqlPath) - ssePath := strings.TrimSpace(cfg.ssePath) - wsPath := strings.TrimSpace(cfg.wsPath) - cacheTTL := strings.TrimSpace(cfg.cacheTTL) + swaggerPath := core.Trim(cfg.swaggerPath) + graphqlPath := core.Trim(cfg.graphqlPath) + ssePath := core.Trim(cfg.ssePath) + wsPath := core.Trim(cfg.wsPath) + cacheTTL := core.Trim(cfg.cacheTTL) cacheTTLValid := parsePositiveDuration(cacheTTL) builder := &goapi.SpecBuilder{ - Title: strings.TrimSpace(cfg.title), - Summary: strings.TrimSpace(cfg.summary), - Description: strings.TrimSpace(cfg.description), - Version: strings.TrimSpace(cfg.version), + Title: core.Trim(cfg.title), + Summary: core.Trim(cfg.summary), + Description: core.Trim(cfg.description), + Version: core.Trim(cfg.version), SwaggerEnabled: swaggerPath != "", SwaggerPath: swaggerPath, GraphQLEnabled: graphqlPath != "" || cfg.graphqlPlayground, GraphQLPath: graphqlPath, GraphQLPlayground: cfg.graphqlPlayground, - GraphQLPlaygroundPath: strings.TrimSpace(cfg.graphqlPlaygroundPath), + GraphQLPlaygroundPath: core.Trim(cfg.graphqlPlaygroundPath), SSEEnabled: ssePath != "", SSEPath: ssePath, WSEnabled: wsPath != "", @@ -73,18 +74,18 @@ func newSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) { CacheTTL: cacheTTL, CacheMaxEntries: cfg.cacheMaxEntries, CacheMaxBytes: cfg.cacheMaxBytes, - I18nDefaultLocale: strings.TrimSpace(cfg.i18nDefaultLocale), - TermsOfService: strings.TrimSpace(cfg.termsURL), - ContactName: strings.TrimSpace(cfg.contactName), - ContactURL: strings.TrimSpace(cfg.contactURL), - ContactEmail: strings.TrimSpace(cfg.contactEmail), + I18nDefaultLocale: core.Trim(cfg.i18nDefaultLocale), + TermsOfService: core.Trim(cfg.termsURL), + ContactName: core.Trim(cfg.contactName), + ContactURL: core.Trim(cfg.contactURL), + ContactEmail: core.Trim(cfg.contactEmail), Servers: parseServers(cfg.servers), - LicenseName: strings.TrimSpace(cfg.licenseName), - LicenseURL: strings.TrimSpace(cfg.licenseURL), - ExternalDocsDescription: strings.TrimSpace(cfg.externalDocsDescription), - ExternalDocsURL: strings.TrimSpace(cfg.externalDocsURL), - AuthentikIssuer: strings.TrimSpace(cfg.authentikIssuer), - AuthentikClientID: strings.TrimSpace(cfg.authentikClientID), + LicenseName: core.Trim(cfg.licenseName), + LicenseURL: core.Trim(cfg.licenseURL), + ExternalDocsDescription: core.Trim(cfg.externalDocsDescription), + ExternalDocsURL: core.Trim(cfg.externalDocsURL), + AuthentikIssuer: core.Trim(cfg.authentikIssuer), + AuthentikClientID: core.Trim(cfg.authentikClientID), AuthentikTrustedProxy: cfg.authentikTrustedProxy, AuthentikPublicPaths: normalisePublicPaths(splitUniqueCSV(cfg.authentikPublicPaths)), } @@ -110,7 +111,7 @@ func parseLocales(raw string) []string { } func parsePositiveDuration(raw string) bool { - raw = strings.TrimSpace(raw) + raw = core.Trim(raw) if raw == "" { return false } diff --git a/graphql.go b/graphql.go index 0a3298c..e5e615e 100644 --- a/graphql.go +++ b/graphql.go @@ -6,6 +6,8 @@ import ( "net/http" "strings" + core "dappco.re/go/core" + "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/playground" @@ -114,7 +116,7 @@ func mountGraphQL(r *gin.Engine, cfg *graphqlConfig) { // normaliseGraphQLPath coerces custom GraphQL paths into a stable form. // The path always begins with a single slash and never ends with one. func normaliseGraphQLPath(path string) string { - path = strings.TrimSpace(path) + path = core.Trim(path) if path == "" { return defaultGraphQLPath } diff --git a/i18n.go b/i18n.go index 03ddb3b..5c3af01 100644 --- a/i18n.go +++ b/i18n.go @@ -4,7 +4,8 @@ package api import ( "slices" - "strings" + + core "dappco.re/go/core" "github.com/gin-gonic/gin" "golang.org/x/text/language" @@ -200,19 +201,19 @@ func GetMessage(c *gin.Context, key string) (string, bool) { // most specific to least specific. For example, "fr-CA" yields // ["fr-CA", "fr"] and "zh-Hant-TW" yields ["zh-Hant-TW", "zh-Hant", "zh"]. func localeFallbacks(locale string) []string { - locale = strings.TrimSpace(strings.ReplaceAll(locale, "_", "-")) + locale = core.Trim(core.Replace(locale, "_", "-")) if locale == "" { return nil } - parts := strings.Split(locale, "-") + parts := core.Split(locale, "-") if len(parts) == 0 { return []string{locale} } fallbacks := make([]string, 0, len(parts)) for i := len(parts); i >= 1; i-- { - fallbacks = append(fallbacks, strings.Join(parts[:i], "-")) + fallbacks = append(fallbacks, core.Join("-", parts[:i]...)) } return fallbacks diff --git a/middleware.go b/middleware.go index 35e9120..b708268 100644 --- a/middleware.go +++ b/middleware.go @@ -5,12 +5,13 @@ package api import ( "crypto/rand" "encoding/hex" - "fmt" "net/http" "runtime/debug" "strings" "time" + core "dappco.re/go/core" + "github.com/gin-gonic/gin" ) @@ -26,7 +27,7 @@ const requestStartContextKey = "request_start" // and avoids Gin's default plain-text 500 response. func recoveryMiddleware() gin.HandlerFunc { return gin.CustomRecovery(func(c *gin.Context, recovered any) { - fmt.Fprintf(gin.DefaultErrorWriter, "[Recovery] panic recovered: %v\n", recovered) + _, _ = gin.DefaultErrorWriter.Write([]byte(core.Sprintf("[Recovery] panic recovered: %v\n", recovered))) debug.PrintStack() c.AbortWithStatusJSON(http.StatusInternalServerError, Fail( "internal_server_error", @@ -54,8 +55,8 @@ func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc { return } - parts := strings.SplitN(header, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != token { + parts := core.SplitN(header, " ", 2) + if len(parts) != 2 || core.Lower(parts[0]) != "bearer" || parts[1] != token { c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token")) return } @@ -85,7 +86,7 @@ func isPublicPath(requestPath, publicPath string) bool { return true } - return strings.HasPrefix(requestPath, normalized+"/") + return core.HasPrefix(requestPath, normalized+"/") } // requestIDMiddleware ensures every response carries an X-Request-ID header. diff --git a/openapi.go b/openapi.go index ab2b27b..2035bb2 100644 --- a/openapi.go +++ b/openapi.go @@ -6,13 +6,13 @@ import ( "encoding/json" "iter" "net/http" - "sort" + "slices" "strconv" "strings" "time" "unicode" - "slices" + core "dappco.re/go/core" ) // SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups. @@ -162,16 +162,16 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { if sb.CacheMaxBytes > 0 { spec["x-cache-max-bytes"] = sb.CacheMaxBytes } - if locale := strings.TrimSpace(sb.I18nDefaultLocale); locale != "" { + if locale := core.Trim(sb.I18nDefaultLocale); locale != "" { spec["x-i18n-default-locale"] = locale } if len(sb.I18nSupportedLocales) > 0 { spec["x-i18n-supported-locales"] = slices.Clone(sb.I18nSupportedLocales) } - if issuer := strings.TrimSpace(sb.AuthentikIssuer); issuer != "" { + if issuer := core.Trim(sb.AuthentikIssuer); issuer != "" { spec["x-authentik-issuer"] = issuer } - if clientID := strings.TrimSpace(sb.AuthentikClientID); clientID != "" { + if clientID := core.Trim(sb.AuthentikClientID); clientID != "" { spec["x-authentik-client-id"] = clientID } if sb.AuthentikTrustedProxy { @@ -353,8 +353,8 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { for _, g := range groups { for _, rd := range g.descs { fullPath := joinOpenAPIPath(g.basePath, rd.Path) - method := strings.ToLower(rd.Method) - deprecated := rd.Deprecated || strings.TrimSpace(rd.SunsetDate) != "" || strings.TrimSpace(rd.Replacement) != "" + method := core.Lower(rd.Method) + deprecated := rd.Deprecated || core.Trim(rd.SunsetDate) != "" || core.Trim(rd.Replacement) != "" deprecationHeaders := deprecationResponseHeaders(deprecated, rd.SunsetDate, rd.Replacement) isPublic := isPublicPathForList(fullPath, publicPaths) security := rd.Security @@ -443,8 +443,8 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { // OpenAPI path without duplicate or missing separators. Gin-style parameters // such as :id and *path are converted to OpenAPI template parameters. func joinOpenAPIPath(basePath, routePath string) string { - basePath = strings.TrimSpace(basePath) - routePath = strings.TrimSpace(routePath) + basePath = core.Trim(basePath) + routePath = core.Trim(routePath) if basePath == "" { basePath = "/" @@ -460,28 +460,28 @@ func joinOpenAPIPath(basePath, routePath string) string { return routePath } - return strings.TrimRight(basePath, "/") + "/" + strings.TrimPrefix(routePath, "/") + return strings.TrimRight(basePath, "/") + "/" + core.TrimPrefix(routePath, "/") } // normaliseOpenAPIPath trims whitespace and collapses trailing separators // while preserving the root path and converting Gin-style path parameters. func normaliseOpenAPIPath(path string) string { - path = strings.TrimSpace(path) + path = core.Trim(path) if path == "" { return "/" } - segments := strings.Split(path, "/") + segments := core.Split(path, "/") cleaned := make([]string, 0, len(segments)) for _, segment := range segments { - segment = strings.TrimSpace(segment) + segment = core.Trim(segment) if segment == "" { continue } switch { - case strings.HasPrefix(segment, ":") && len(segment) > 1: + case core.HasPrefix(segment, ":") && len(segment) > 1: segment = "{" + segment[1:] + "}" - case strings.HasPrefix(segment, "*") && len(segment) > 1: + case core.HasPrefix(segment, "*") && len(segment) > 1: segment = "{" + segment[1:] + "}" } cleaned = append(cleaned, segment) @@ -491,7 +491,7 @@ func normaliseOpenAPIPath(path string) string { return "/" } - return "/" + strings.Join(cleaned, "/") + return "/" + core.Join("/", cleaned...) } // operationResponses builds the standard response set for a documented API @@ -570,7 +570,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any }, } - if deprecated && (strings.TrimSpace(sunsetDate) != "" || strings.TrimSpace(replacement) != "") { + if deprecated && (core.Trim(sunsetDate) != "" || core.Trim(replacement) != "") { responses["410"] = map[string]any{ "description": "Gone", "content": map[string]any{ @@ -686,8 +686,8 @@ func healthResponses() map[string]any { // deprecationResponseHeaders documents the standard deprecation headers for // deprecated or sunsetted operations. func deprecationResponseHeaders(deprecated bool, sunsetDate, replacement string) map[string]any { - sunsetDate = strings.TrimSpace(sunsetDate) - replacement = strings.TrimSpace(replacement) + sunsetDate = core.Trim(sunsetDate) + replacement = core.Trim(replacement) if !deprecated && sunsetDate == "" && replacement == "" { return nil @@ -835,7 +835,7 @@ func securitySchemeComponents(overrides map[string]any) map[string]any { } for name, scheme := range overrides { - name = strings.TrimSpace(name) + name = core.Trim(name) if name == "" || scheme == nil { continue } @@ -877,7 +877,7 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any { } for _, g := range groups { - name := strings.TrimSpace(g.name) + name := core.Trim(g.name) if name != "" && !seen[name] { tags = append(tags, map[string]any{ "name": name, @@ -888,7 +888,7 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any { for _, rd := range g.descs { for _, tag := range rd.Tags { - tag = strings.TrimSpace(tag) + tag = core.Trim(tag) if tag == "" || seen[tag] { continue } @@ -913,17 +913,21 @@ func sortTags(tags []map[string]any) { return } - sort.SliceStable(tags, func(i, j int) bool { - left, _ := tags[i]["name"].(string) - right, _ := tags[j]["name"].(string) + slices.SortStableFunc(tags, func(a, b map[string]any) int { + left, _ := a["name"].(string) + right, _ := b["name"].(string) switch { case left == "system": - return true + return -1 case right == "system": - return false + return 1 + case left < right: + return -1 + case left > right: + return 1 default: - return left < right + return 0 } }) } @@ -1753,7 +1757,7 @@ func resolvedOperationTags(groupName string, rd RouteDescription) []string { return tags } - if name := strings.TrimSpace(groupName); name != "" { + if name := core.Trim(groupName); name != "" { return []string{name} } @@ -1770,7 +1774,7 @@ func cleanTags(tags []string) []string { cleaned := make([]string, 0, len(tags)) seen := make(map[string]struct{}, len(tags)) for _, tag := range tags { - tag = strings.TrimSpace(tag) + tag = core.Trim(tag) if tag == "" { continue } @@ -1916,7 +1920,7 @@ func (sb *SpecBuilder) effectiveGraphQLPath() string { if !sb.GraphQLEnabled && !sb.GraphQLPlayground { return "" } - graphqlPath := strings.TrimSpace(sb.GraphQLPath) + graphqlPath := core.Trim(sb.GraphQLPath) if graphqlPath == "" { return defaultGraphQLPath } @@ -1930,7 +1934,7 @@ func (sb *SpecBuilder) effectiveGraphQLPlaygroundPath() string { return "" } - path := strings.TrimSpace(sb.GraphQLPlaygroundPath) + path := core.Trim(sb.GraphQLPlaygroundPath) if path != "" { return path } @@ -1950,7 +1954,7 @@ func (sb *SpecBuilder) effectiveSwaggerPath() string { if !sb.SwaggerEnabled { return "" } - swaggerPath := strings.TrimSpace(sb.SwaggerPath) + swaggerPath := core.Trim(sb.SwaggerPath) if swaggerPath == "" { return defaultSwaggerPath } @@ -1964,7 +1968,7 @@ func (sb *SpecBuilder) effectiveWSPath() string { if !sb.WSEnabled { return "" } - wsPath := strings.TrimSpace(sb.WSPath) + wsPath := core.Trim(sb.WSPath) if wsPath == "" { return defaultWSPath } @@ -1978,7 +1982,7 @@ func (sb *SpecBuilder) effectiveSSEPath() string { if !sb.SSEEnabled { return "" } - ssePath := strings.TrimSpace(sb.SSEPath) + ssePath := core.Trim(sb.SSEPath) if ssePath == "" { return defaultSSEPath } @@ -1988,7 +1992,7 @@ func (sb *SpecBuilder) effectiveSSEPath() string { // effectiveCacheTTL returns a normalised cache TTL when it parses to a // positive duration. func (sb *SpecBuilder) effectiveCacheTTL() string { - ttl := strings.TrimSpace(sb.CacheTTL) + ttl := core.Trim(sb.CacheTTL) if ttl == "" { return "" } @@ -2024,25 +2028,25 @@ func (sb *SpecBuilder) snapshot() *SpecBuilder { } out := *sb - out.Title = strings.TrimSpace(out.Title) - out.Summary = strings.TrimSpace(out.Summary) - out.Description = strings.TrimSpace(out.Description) - out.Version = strings.TrimSpace(out.Version) - out.SwaggerPath = strings.TrimSpace(out.SwaggerPath) - out.GraphQLPath = strings.TrimSpace(out.GraphQLPath) - out.GraphQLPlaygroundPath = strings.TrimSpace(out.GraphQLPlaygroundPath) - out.WSPath = strings.TrimSpace(out.WSPath) - out.SSEPath = strings.TrimSpace(out.SSEPath) - out.TermsOfService = strings.TrimSpace(out.TermsOfService) - out.ContactName = strings.TrimSpace(out.ContactName) - out.ContactURL = strings.TrimSpace(out.ContactURL) - out.ContactEmail = strings.TrimSpace(out.ContactEmail) - out.LicenseName = strings.TrimSpace(out.LicenseName) - out.LicenseURL = strings.TrimSpace(out.LicenseURL) - out.ExternalDocsDescription = strings.TrimSpace(out.ExternalDocsDescription) - out.ExternalDocsURL = strings.TrimSpace(out.ExternalDocsURL) - out.CacheTTL = strings.TrimSpace(out.CacheTTL) - out.I18nDefaultLocale = strings.TrimSpace(out.I18nDefaultLocale) + out.Title = core.Trim(out.Title) + out.Summary = core.Trim(out.Summary) + out.Description = core.Trim(out.Description) + out.Version = core.Trim(out.Version) + out.SwaggerPath = core.Trim(out.SwaggerPath) + out.GraphQLPath = core.Trim(out.GraphQLPath) + out.GraphQLPlaygroundPath = core.Trim(out.GraphQLPlaygroundPath) + out.WSPath = core.Trim(out.WSPath) + out.SSEPath = core.Trim(out.SSEPath) + out.TermsOfService = core.Trim(out.TermsOfService) + out.ContactName = core.Trim(out.ContactName) + out.ContactURL = core.Trim(out.ContactURL) + out.ContactEmail = core.Trim(out.ContactEmail) + out.LicenseName = core.Trim(out.LicenseName) + out.LicenseURL = core.Trim(out.LicenseURL) + out.ExternalDocsDescription = core.Trim(out.ExternalDocsDescription) + out.ExternalDocsURL = core.Trim(out.ExternalDocsURL) + out.CacheTTL = core.Trim(out.CacheTTL) + out.I18nDefaultLocale = core.Trim(out.I18nDefaultLocale) out.Servers = slices.Clone(sb.Servers) out.I18nSupportedLocales = slices.Clone(sb.I18nSupportedLocales) out.AuthentikPublicPaths = normalisePublicPaths(sb.AuthentikPublicPaths) @@ -2064,8 +2068,8 @@ func (sb *SpecBuilder) hasAuthentikMetadata() bool { return false } - return strings.TrimSpace(sb.AuthentikIssuer) != "" || - strings.TrimSpace(sb.AuthentikClientID) != "" || + return core.Trim(sb.AuthentikIssuer) != "" || + core.Trim(sb.AuthentikClientID) != "" || sb.AuthentikTrustedProxy || len(sb.AuthentikPublicPaths) > 0 } @@ -2109,7 +2113,7 @@ func documentedResponseHeaders(headers map[string]string) map[string]any { out := make(map[string]any, len(headers)) for name, description := range headers { - name = strings.TrimSpace(name) + name = core.Trim(name) if name == "" { continue } @@ -2153,7 +2157,7 @@ func mergeHeaders(sets ...map[string]any) map[string]any { // operationID builds a stable OpenAPI operationId from the HTTP method and path. // The generated identifier is lower snake_case and preserves path parameter names. func operationID(method, path string, operationIDs map[string]int) string { - var b strings.Builder + b := core.NewBuilder() b.Grow(len(method) + len(path) + 1) lastUnderscore := false diff --git a/options.go b/options.go index 715a524..ae3b97c 100644 --- a/options.go +++ b/options.go @@ -7,9 +7,10 @@ import ( "log/slog" "net/http" "slices" - "strings" "time" + core "dappco.re/go/core" + "github.com/99designs/gqlgen/graphql" "github.com/casbin/casbin/v2" "github.com/gin-contrib/authz" @@ -204,9 +205,9 @@ func WithSunset(sunsetDate, replacement string) Option { // api.New(api.WithSwagger("Service", "Public API", "1.0.0")) func WithSwagger(title, description, version string) Option { return func(e *Engine) { - e.swaggerTitle = strings.TrimSpace(title) - e.swaggerDesc = strings.TrimSpace(description) - e.swaggerVersion = strings.TrimSpace(version) + e.swaggerTitle = core.Trim(title) + e.swaggerDesc = core.Trim(description) + e.swaggerVersion = core.Trim(version) e.swaggerEnabled = true } } @@ -218,7 +219,7 @@ func WithSwagger(title, description, version string) Option { // api.WithSwaggerSummary("Service overview") func WithSwaggerSummary(summary string) Option { return func(e *Engine) { - if summary = strings.TrimSpace(summary); summary != "" { + if summary = core.Trim(summary); summary != "" { e.swaggerSummary = summary } } @@ -244,7 +245,7 @@ func WithSwaggerPath(path string) Option { // api.WithSwaggerTermsOfService("https://example.com/terms") func WithSwaggerTermsOfService(url string) Option { return func(e *Engine) { - if url = strings.TrimSpace(url); url != "" { + if url = core.Trim(url); url != "" { e.swaggerTermsOfService = url } } @@ -258,13 +259,13 @@ func WithSwaggerTermsOfService(url string) Option { // api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com") func WithSwaggerContact(name, url, email string) Option { return func(e *Engine) { - if name = strings.TrimSpace(name); name != "" { + if name = core.Trim(name); name != "" { e.swaggerContactName = name } - if url = strings.TrimSpace(url); url != "" { + if url = core.Trim(url); url != "" { e.swaggerContactURL = url } - if email = strings.TrimSpace(email); email != "" { + if email = core.Trim(email); email != "" { e.swaggerContactEmail = email } } @@ -291,10 +292,10 @@ func WithSwaggerServers(servers ...string) Option { // api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/") func WithSwaggerLicense(name, url string) Option { return func(e *Engine) { - if name = strings.TrimSpace(name); name != "" { + if name = core.Trim(name); name != "" { e.swaggerLicenseName = name } - if url = strings.TrimSpace(url); url != "" { + if url = core.Trim(url); url != "" { e.swaggerLicenseURL = url } } @@ -322,7 +323,7 @@ func WithSwaggerSecuritySchemes(schemes map[string]any) Option { e.swaggerSecuritySchemes = make(map[string]any, len(schemes)) } for name, scheme := range schemes { - name = strings.TrimSpace(name) + name = core.Trim(name) if name == "" || scheme == nil { continue } @@ -340,10 +341,10 @@ func WithSwaggerSecuritySchemes(schemes map[string]any) Option { // api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs") func WithSwaggerExternalDocs(description, url string) Option { return func(e *Engine) { - if description = strings.TrimSpace(description); description != "" { + if description = core.Trim(description); description != "" { e.swaggerExternalDocsDescription = description } - if url = strings.TrimSpace(url); url != "" { + if url = core.Trim(url); url != "" { e.swaggerExternalDocsURL = url } } diff --git a/pkg/provider/proxy.go b/pkg/provider/proxy.go index e2ef86b..5b77e37 100644 --- a/pkg/provider/proxy.go +++ b/pkg/provider/proxy.go @@ -3,11 +3,11 @@ package provider import ( - "fmt" "net/http" "net/http/httputil" "net/url" - "strings" + + core "dappco.re/go/core" coreapi "dappco.re/go/core/api" "github.com/gin-gonic/gin" @@ -63,7 +63,7 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider { if target.Scheme == "" || target.Host == "" { return &ProxyProvider{ config: cfg, - err: fmt.Errorf("upstream %q must include a scheme and host (e.g. http://127.0.0.1:9901)", cfg.Upstream), + err: core.E("ProxyProvider.New", core.Sprintf("upstream %q must include a scheme and host (e.g. http://127.0.0.1:9901)", cfg.Upstream), nil), } } @@ -72,7 +72,7 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider { // Preserve the original Director but strip the base path so the // upstream receives clean paths (e.g. /items instead of /api/v1/cool-widget/items). defaultDirector := proxy.Director - basePath := strings.TrimSuffix(cfg.BasePath, "/") + basePath := core.TrimSuffix(cfg.BasePath, "/") proxy.Director = func(req *http.Request) { defaultDirector(req) @@ -102,7 +102,7 @@ func (p *ProxyProvider) Err() error { // It only strips when the path matches the base path itself or lives under // the base path boundary, so "/api" will not accidentally trim "/api-v2". func stripBasePath(path, basePath string) string { - basePath = strings.TrimSuffix(strings.TrimSpace(basePath), "/") + basePath = core.TrimSuffix(core.Trim(basePath), "/") if basePath == "" || basePath == "/" { if path == "" { return "/" @@ -115,8 +115,8 @@ func stripBasePath(path, basePath string) string { } prefix := basePath + "/" - if strings.HasPrefix(path, prefix) { - trimmed := strings.TrimPrefix(path, basePath) + if core.HasPrefix(path, prefix) { + trimmed := core.TrimPrefix(path, basePath) if trimmed == "" { return "/" } diff --git a/ratelimit.go b/ratelimit.go index 308ce7e..b59b5b0 100644 --- a/ratelimit.go +++ b/ratelimit.go @@ -8,10 +8,11 @@ import ( "math" "net/http" "strconv" - "strings" "sync" "time" + core "dappco.re/go/core" + "github.com/gin-gonic/gin" ) @@ -241,7 +242,7 @@ func clientRateLimitKey(c *gin.Context) string { // Fall back to credential headers before the IP so that different API // keys coming from the same NAT address are bucketed independently. The // raw secret is never stored — it is hashed with SHA-256 first. - if apiKey := strings.TrimSpace(c.GetHeader("X-API-Key")); apiKey != "" { + if apiKey := core.Trim(c.GetHeader("X-API-Key")); apiKey != "" { h := sha256.Sum256([]byte(apiKey)) return "cred:sha256:" + hex.EncodeToString(h[:]) } @@ -262,15 +263,15 @@ func clientRateLimitKey(c *gin.Context) string { } func bearerTokenFromHeader(header string) string { - header = strings.TrimSpace(header) + header = core.Trim(header) if header == "" { return "" } - parts := strings.SplitN(header, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + parts := core.SplitN(header, " ", 2) + if len(parts) != 2 || core.Lower(parts[0]) != "bearer" { return "" } - return strings.TrimSpace(parts[1]) + return core.Trim(parts[1]) } diff --git a/response_meta.go b/response_meta.go index 74f9e8a..e9de002 100644 --- a/response_meta.go +++ b/response_meta.go @@ -11,9 +11,10 @@ import ( "net" "net/http" "strconv" - "strings" "time" + core "dappco.re/go/core" + "github.com/gin-gonic/gin" ) @@ -270,17 +271,17 @@ func shouldAttachResponseMeta(contentType string, body []byte) bool { } func isJSONContentType(contentType string) bool { - if strings.TrimSpace(contentType) == "" { + if core.Trim(contentType) == "" { return false } mediaType, _, err := mime.ParseMediaType(contentType) if err != nil { - mediaType = strings.TrimSpace(contentType) + mediaType = core.Trim(contentType) } - mediaType = strings.ToLower(mediaType) + mediaType = core.Lower(mediaType) return mediaType == "application/json" || - strings.HasSuffix(mediaType, "+json") || - strings.HasSuffix(mediaType, "/json") + core.HasSuffix(mediaType, "+json") || + core.HasSuffix(mediaType, "/json") } diff --git a/servers.go b/servers.go index 676eafe..7113fae 100644 --- a/servers.go +++ b/servers.go @@ -2,7 +2,11 @@ package api -import "strings" +import ( + "strings" + + core "dappco.re/go/core" +) // normaliseServers trims whitespace, removes empty entries, and preserves // the first occurrence of each server URL. @@ -36,7 +40,7 @@ func normaliseServers(servers []string) []string { // normaliseServer trims surrounding whitespace and removes a trailing slash // from non-root server URLs so equivalent metadata collapses to one entry. func normaliseServer(server string) string { - server = strings.TrimSpace(server) + server = core.Trim(server) if server == "" { return "" } diff --git a/spec_builder_helper.go b/spec_builder_helper.go index 5eaa8e6..1be7551 100644 --- a/spec_builder_helper.go +++ b/spec_builder_helper.go @@ -5,7 +5,8 @@ package api import ( "reflect" "slices" - "strings" + + core "dappco.re/go/core" ) // SwaggerConfig captures the configured Swagger/OpenAPI metadata for an Engine. @@ -125,7 +126,7 @@ func (e *Engine) SwaggerConfig() SwaggerConfig { ExternalDocsURL: e.swaggerExternalDocsURL, } - if strings.TrimSpace(e.swaggerPath) != "" { + if core.Trim(e.swaggerPath) != "" { cfg.Path = normaliseSwaggerPath(e.swaggerPath) } diff --git a/sse.go b/sse.go index 8430f34..87aa91b 100644 --- a/sse.go +++ b/sse.go @@ -4,11 +4,12 @@ package api import ( "encoding/json" - "fmt" "net/http" "strings" "sync" + core "dappco.re/go/core" + "github.com/gin-gonic/gin" ) @@ -59,7 +60,7 @@ func NewSSEBroker() *SSEBroker { // normaliseSSEPath coerces custom SSE paths into a stable form. // The path always begins with a single slash and never ends with one. func normaliseSSEPath(path string) string { - path = strings.TrimSpace(path) + path = core.Trim(path) if path == "" { return defaultSSEPath } @@ -75,7 +76,7 @@ func normaliseSSEPath(path string) string { // resolveSSEPath returns the configured SSE path or the default path when // no override has been provided. func resolveSSEPath(path string) string { - if strings.TrimSpace(path) == "" { + if core.Trim(path) == "" { return defaultSSEPath } return normaliseSSEPath(path) @@ -178,7 +179,7 @@ func (b *SSEBroker) Handler() gin.HandlerFunc { if !ok { return } - _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", evt.Event, evt.Data) + _, err := c.Writer.Write([]byte(core.Sprintf("event: %s\ndata: %s\n\n", evt.Event, evt.Data))) if err != nil { return } diff --git a/sunset.go b/sunset.go index 24d2709..d9163b2 100644 --- a/sunset.go +++ b/sunset.go @@ -4,9 +4,10 @@ package api import ( "net/http" - "strings" "time" + core "dappco.re/go/core" + "github.com/gin-gonic/gin" ) @@ -21,8 +22,8 @@ import ( // // rg.Use(api.ApiSunset("2025-06-01", "/api/v2/users")) func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc { - sunsetDate = strings.TrimSpace(sunsetDate) - replacement = strings.TrimSpace(replacement) + sunsetDate = core.Trim(sunsetDate) + replacement = core.Trim(replacement) formatted := formatSunsetDate(sunsetDate) warning := "This endpoint is deprecated." if sunsetDate != "" { @@ -44,11 +45,11 @@ func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc { } func formatSunsetDate(sunsetDate string) string { - sunsetDate = strings.TrimSpace(sunsetDate) + sunsetDate = core.Trim(sunsetDate) if sunsetDate == "" { return "" } - if strings.Contains(sunsetDate, ",") { + if core.Contains(sunsetDate, ",") { return sunsetDate } diff --git a/swagger.go b/swagger.go index 36ec01b..94879af 100644 --- a/swagger.go +++ b/swagger.go @@ -3,12 +3,13 @@ package api import ( - "fmt" "net/http" "strings" "sync" "sync/atomic" + core "dappco.re/go/core" + "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" @@ -58,7 +59,7 @@ func (s *swaggerSpec) ReadDoc() string { func registerSwagger(g *gin.Engine, e *Engine, groups []RouteGroup) { swaggerPath := resolveSwaggerPath(e.swaggerPath) spec := newSwaggerSpec(e.OpenAPISpecBuilder(), groups) - name := fmt.Sprintf("swagger_%d", swaggerSeq.Add(1)) + name := core.Sprintf("swagger_%d", swaggerSeq.Add(1)) swag.Register(name, spec) g.GET(swaggerPath, func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, swaggerPath+"/") @@ -69,7 +70,7 @@ func registerSwagger(g *gin.Engine, e *Engine, groups []RouteGroup) { // normaliseSwaggerPath coerces custom Swagger paths into a stable form. // The path always begins with a single slash and never ends with one. func normaliseSwaggerPath(path string) string { - path = strings.TrimSpace(path) + path = core.Trim(path) if path == "" { return defaultSwaggerPath } @@ -85,7 +86,7 @@ func normaliseSwaggerPath(path string) string { // resolveSwaggerPath returns the configured Swagger path or the default path // when no override has been provided. func resolveSwaggerPath(path string) string { - if strings.TrimSpace(path) == "" { + if core.Trim(path) == "" { return defaultSwaggerPath } return normaliseSwaggerPath(path) diff --git a/transport.go b/transport.go index a090729..32943be 100644 --- a/transport.go +++ b/transport.go @@ -2,7 +2,7 @@ package api -import "strings" +import core "dappco.re/go/core" // TransportConfig captures the configured transport endpoints and flags for an Engine. // @@ -52,16 +52,16 @@ func (e *Engine) TransportConfig() TransportConfig { cfg.GraphQLPlayground = gql.Playground cfg.GraphQLPlaygroundPath = gql.PlaygroundPath - if e.swaggerEnabled || strings.TrimSpace(e.swaggerPath) != "" { + if e.swaggerEnabled || core.Trim(e.swaggerPath) != "" { cfg.SwaggerPath = resolveSwaggerPath(e.swaggerPath) } if gql.Path != "" { cfg.GraphQLPath = gql.Path } - if e.wsHandler != nil || strings.TrimSpace(e.wsPath) != "" { + if e.wsHandler != nil || core.Trim(e.wsPath) != "" { cfg.WSPath = resolveWSPath(e.wsPath) } - if e.sseBroker != nil || strings.TrimSpace(e.ssePath) != "" { + if e.sseBroker != nil || core.Trim(e.ssePath) != "" { cfg.SSEPath = resolveSSEPath(e.ssePath) } diff --git a/websocket.go b/websocket.go index fc5bedc..480b04e 100644 --- a/websocket.go +++ b/websocket.go @@ -6,6 +6,8 @@ import ( "net/http" "strings" + core "dappco.re/go/core" + "github.com/gin-gonic/gin" ) @@ -23,7 +25,7 @@ func wrapWSHandler(h http.Handler) gin.HandlerFunc { // normaliseWSPath coerces custom WebSocket paths into a stable form. // The path always begins with a single slash and never ends with one. func normaliseWSPath(path string) string { - path = strings.TrimSpace(path) + path = core.Trim(path) if path == "" { return defaultWSPath } @@ -39,7 +41,7 @@ func normaliseWSPath(path string) string { // resolveWSPath returns the configured WebSocket path or the default path // when no override has been provided. func resolveWSPath(path string) string { - if strings.TrimSpace(path) == "" { + if core.Trim(path) == "" { return defaultWSPath } return normaliseWSPath(path)