diff --git a/platform-api/internal/apperror/apperror.go b/platform-api/internal/apperror/apperror.go new file mode 100644 index 0000000000..67cb25318a --- /dev/null +++ b/platform-api/internal/apperror/apperror.go @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// Package apperror defines the typed application error that handlers, +// services, and repositories return instead of writing HTTP error responses +// inline. A single mapper at the routing layer (middleware.MapErrors) catches +// the error, logs it, and serializes it onto the wire via WriteHTTP — see +// resources/ERROR_HANDLING_IMPLEMENTATION_PLAN.md. +package apperror + +import ( + "errors" + "fmt" + "net/http" + "runtime" + "strings" + + "github.com/go-playground/validator/v10" +) + +// Error carries everything the error mapper needs to log a failure and write +// the client-facing ErrorResponse: the catalog code and HTTP status, +// the sanitized client message, optional field-level validation errors and +// structured details, plus internal-only diagnostics (log message, wrapped +// cause, and the call stack captured at construction time). +type Error struct { + Code string // catalog code, e.g. CodeCommonValidationFailed + HTTPStatus int // status to write + Message string // client-facing message + FieldErrors []FieldError // optional, for validation failures + Details any // optional structured metadata + LogMessage string // internal-only detail; never sent to client + Cause error // wrapped original error, for logging/unwrapping + Stack []uintptr // captured at construction time, see New +} + +// Error satisfies the error interface. It intentionally includes only the +// code and client message — internal diagnostics stay in LogMessage/Cause. +func (e *Error) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Cause) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Unwrap exposes the wrapped cause to errors.Is/errors.As chains. +func (e *Error) Unwrap() error { return e.Cause } + +// NewValidation converts a request-validation failure into an Error, +// mirroring NewValidationErrorResponse: validator.ValidationErrors +// become field-level errors under VALIDATION_FAILED; anything else +// maps to the generic "Invalid input." message with the original error kept +// as the cause for internal logging. +func NewValidation(err error) *Error { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + fieldErrors := make([]FieldError, 0, len(ve)) + for _, fe := range ve { + fieldErrors = append(fieldErrors, FieldError{ + Field: fe.Field(), + Message: fmt.Sprintf("The field %s is %s", fe.Field(), fe.Tag()), + }) + } + return newWithSkip(3, CodeCommonValidationFailed, http.StatusBadRequest, + "Validation failed for the request parameters.").WithFieldErrors(fieldErrors) + } + return newWithSkip(3, CodeCommonValidationFailed, http.StatusBadRequest, "Invalid input.").WithCause(err) +} + +// newWithSkip captures the stack skipping the given number of frames so the +// first frame is the exported constructor's caller. It is the only way an +// *Error comes into existence — exported construction goes through the +// catalog (Def.New / Def.Wrap, see catalog.go) or NewValidation, so the +// stack capture can never be skipped and code/status/message always come +// from a declared catalog entry. +// +// LogMessage defaults to the client message so the mapper always has +// something to log, even if the call site never chains WithLogMessage. +// WithLogMessage overrides this default with a more specific internal +// detail when the client message is too generic to be useful for diagnosis +// (e.g. Unauthorized's fixed message). +func newWithSkip(skip int, code string, status int, message string) *Error { + var pcs [32]uintptr + n := runtime.Callers(skip, pcs[:]) + return &Error{Code: code, HTTPStatus: status, Message: message, LogMessage: message, Stack: pcs[:n]} +} + +// WithLogMessage overrides the default internal-only detail message (see +// newWithSkip) that the mapper logs but never sends to the client. +func (e *Error) WithLogMessage(msg string) *Error { + e.LogMessage = msg + return e +} + +// WithFieldErrors attaches field-level validation errors surfaced to the +// client in the errors[] array. +func (e *Error) WithFieldErrors(fe []FieldError) *Error { + e.FieldErrors = fe + return e +} + +// WithDetails attaches optional structured metadata surfaced to the client +// in the details field. +func (e *Error) WithDetails(details any) *Error { + e.Details = details + return e +} + +// WithCause wraps the original error for logging and errors.Is/As chains. +func (e *Error) WithCause(err error) *Error { + e.Cause = err + return e +} + +// Origin symbolizes just the first frame of the stack captured at +// construction time — the file:line of the Def.New/Def.Wrap call site where +// the error actually originated, e.g. "/internal/handler/application.go:142". +// The path is trimmed to start at "/internal/" so logs carry a short, +// repo-relative path instead of the full build-machine absolute path. This is +// distinct from slog's own "source" attribute, which always points at the +// mapper's log call site (middleware/error_mapper.go) regardless of where +// the error was created, and is therefore useless for locating the throw +// site. Returns "" if no stack was captured. +func (e *Error) Origin() string { + if len(e.Stack) == 0 { + return "" + } + frames := runtime.CallersFrames(e.Stack) + frame, _ := frames.Next() + if frame.File == "" { + return "" + } + file := frame.File + if idx := strings.Index(file, "/internal/"); idx != -1 { + file = file[idx:] + } + return fmt.Sprintf("%s:%d", file, frame.Line) +} + +// StackString symbolizes the stack captured at construction time. Kept as +// raw []uintptr on the struct (cheap to capture) and only symbolized lazily +// when the mapper actually logs it, rather than paying runtime.CallersFrames +// cost on every construction. +func (e *Error) StackString() string { + if len(e.Stack) == 0 { + return "" + } + var sb strings.Builder + frames := runtime.CallersFrames(e.Stack) + for { + frame, more := frames.Next() + fmt.Fprintf(&sb, "%s\n\t%s:%d\n", frame.Function, frame.File, frame.Line) + if !more { + break + } + } + return sb.String() +} diff --git a/platform-api/internal/apperror/apperror_test.go b/platform-api/internal/apperror/apperror_test.go new file mode 100644 index 0000000000..5b94f5420b --- /dev/null +++ b/platform-api/internal/apperror/apperror_test.go @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package apperror + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDefNewCapturesStack(t *testing.T) { + e := Internal.New() + if len(e.Stack) == 0 { + t.Fatal("expected non-empty stack captured at construction time") + } + stack := e.StackString() + if !strings.Contains(stack, "TestDefNewCapturesStack") { + t.Errorf("expected stack trace to include the calling function, got:\n%s", stack) + } +} + +func TestDefNewFormatsMessage(t *testing.T) { + e := ValidationFailed.New("API name is required") + if e.Message != "API name is required" { + t.Errorf("unexpected message %q", e.Message) + } + if e.Code != CodeCommonValidationFailed || e.HTTPStatus != http.StatusBadRequest { + t.Errorf("Def did not carry its declared code/status: %+v", e) + } + + // Fixed-message entries called with no args return the template verbatim. + if got := RESTAPINotFound.New().Message; got != "The specified REST API could not be found." { + t.Errorf("unexpected fixed message %q", got) + } + + // Templates with verbs interpolate args. + if got := DeploymentNotActive.New("API").Message; got != "No active deployment found for this API on the gateway." { + t.Errorf("unexpected formatted message %q", got) + } +} + +func TestDefWrapAttachesCause(t *testing.T) { + cause := errors.New("db connection refused") + e := Internal.Wrap(cause) + if !errors.Is(e, cause) { + t.Error("expected errors.Is to find the wrapped cause") + } + if len(e.Stack) == 0 { + t.Error("expected Wrap to capture the stack too") + } + + // A wrapped *Error must still be discoverable via errors.As — the + // propagation contract relies on this. + wrapped := fmt.Errorf("service layer context: %w", e) + var appErr *Error + if !errors.As(wrapped, &appErr) { + t.Fatal("expected errors.As to find *Error through a %w wrap") + } + if appErr.Code != CodeCommonInternalError { + t.Errorf("unexpected code %q", appErr.Code) + } +} + +func TestDefIs(t *testing.T) { + err := fmt.Errorf("outer: %w", ProjectNotFound.New()) + if !ProjectNotFound.Is(err) { + t.Error("expected Def.Is to match through a %w wrap") + } + if RESTAPINotFound.Is(err) { + t.Error("Def.Is matched a different catalog entry") + } + if ProjectNotFound.Is(errors.New("plain")) { + t.Error("Def.Is matched a plain error") + } +} + +func TestNewValidationFallback(t *testing.T) { + e := NewValidation(errors.New("bad json")) + if e.HTTPStatus != http.StatusBadRequest { + t.Errorf("expected 400, got %d", e.HTTPStatus) + } + if e.Code != CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", CodeCommonValidationFailed, e.Code) + } + if e.Message != "Invalid input." { + t.Errorf("unexpected message %q", e.Message) + } +} + +func TestWriteHTTP(t *testing.T) { + rec := httptest.NewRecorder() + WriteHTTP(rec, ProjectNotFound.Wrap(errors.New("sql: no rows in result set")). + WithLogMessage("internal detail that must not leak"), "") + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } + var body ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON body: %v", err) + } + if body.Status != "error" || body.Code != CodeProjectNotFound { + t.Errorf("unexpected envelope: %+v", body) + } + if strings.Contains(rec.Body.String(), "sql:") || strings.Contains(rec.Body.String(), "internal detail") { + t.Error("internal diagnostics leaked into the client response") + } + if strings.Contains(rec.Body.String(), "trackingId") { + t.Error("empty trackingId must be omitted from the response") + } + + rec = httptest.NewRecorder() + WriteHTTP(rec, Internal.New(), "d2f1c0aa-0000-0000-0000-000000000000") + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON body: %v", err) + } + if body.TrackingID != "d2f1c0aa-0000-0000-0000-000000000000" { + t.Errorf("expected trackingId echoed in body, got %+v", body) + } +} diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go new file mode 100644 index 0000000000..b9cbeffead --- /dev/null +++ b/platform-api/internal/apperror/catalog.go @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package apperror + +import ( + "net/http" +) + +// This file is the error catalog: every client-facing error condition is +// declared here exactly once, binding its code, HTTP status, and message +// template together (see resources/ERROR_CATALOG_PLAN.md). Codes reference +// the string constants in codes.go, which stay the single source for the +// code values while handlers not yet on the error-mapper pattern still +// build responses from them directly. +// +// Message templates are client-sterile: no internal detail, no file paths, +// no raw error text. A template of "%s" marks a code whose human message is +// genuinely call-site-specific (validation-style codes); interpolated args +// must be user-supplied values (names, handles, IDs), never internal error +// strings. Fixed-message entries are called with no args. +// +// allDefs feeds the catalog integrity test (catalog_test.go): unique codes, +// valid statuses, and message/verb sanity are asserted there. + +var allDefs []Def + +func def(code string, status int, messageFmt string) Def { + d := Def{Code: code, HTTPStatus: status, MessageFmt: messageFmt} + allDefs = append(allDefs, d) + return d +} + +// Common, domain-agnostic entries. Unauthorized is intentionally the ONLY +// 401 entry, with a fixed message: every authentication failure (missing, +// expired, invalid, revoked) returns the identical payload per the unified +// auth-failure rule in error-handling.md; the specific reason travels in the +// wrapped cause / log message only. +var ( + Unauthorized = def(CodeCommonUnauthorized, http.StatusUnauthorized, "Invalid or expired credentials.") + Forbidden = def(CodeCommonForbidden, http.StatusForbidden, "You do not have permission to perform this action.") + ValidationFailed = def(CodeCommonValidationFailed, http.StatusBadRequest, "%s") + NotFound = def(CodeCommonNotFound, http.StatusNotFound, "The requested resource could not be found.") + Conflict = def(CodeCommonConflict, http.StatusConflict, "The request conflicts with the current state of the resource.") + NotAcceptable = def(CodeCommonNotAcceptable, http.StatusNotAcceptable, "The requested media type is not supported.") + UnprocessableEntity = def(CodeCommonUnprocessableEntity, http.StatusUnprocessableEntity, "The request could not be processed.") + Internal = def(CodeCommonInternalError, http.StatusInternalServerError, "An unexpected error occurred.") + ServiceUnavailable = def(CodeCommonServiceUnavailable, http.StatusServiceUnavailable, "The service is temporarily unavailable.") + TooManyRequests = def(CodeCommonTooManyRequests, http.StatusTooManyRequests, "%s") +) + +// REST API entries. +var ( + RESTAPINotFound = def(CodeRESTAPINotFound, http.StatusNotFound, "The specified REST API could not be found.") + // RESTAPIExists covers three distinct conflicts (handle, name+version, + // project) — the call site supplies which one. + RESTAPIExists = def(CodeRESTAPIExists, http.StatusConflict, "%s") + RESTAPIDeploymentValidationFailed = def(CodeRESTAPIDeploymentValidationFailed, http.StatusBadRequest, "%s") +) + +// LLM provider / proxy entries. +var ( + LLMProviderNotFound = def(CodeLLMProviderNotFound, http.StatusNotFound, "The specified LLM provider could not be found.") + LLMProviderRefNotFound = def(CodeLLMProviderRefNotFound, http.StatusBadRequest, "The referenced LLM provider could not be found.") + LLMProviderExists = def(CodeLLMProviderExists, http.StatusConflict, "An LLM provider with this ID already exists.") + LLMProviderLimitReached = def(CodeLLMProviderLimitReached, http.StatusConflict, "LLM provider limit reached for the organization.") + LLMProviderAPIKeyNotFound = def(CodeLLMProviderAPIKeyNotFound, http.StatusNotFound, "The specified API key could not be found.") + LLMProviderAPIKeyForbidden = def(CodeLLMProviderAPIKeyForbidden, http.StatusForbidden, "You do not have permission to access this API key.") + LLMProviderDeploymentValidationFailed = def(CodeLLMProviderDeploymentValidationFailed, http.StatusBadRequest, "%s") + + LLMProxyNotFound = def(CodeLLMProxyNotFound, http.StatusNotFound, "The specified LLM proxy could not be found.") + LLMProxyExists = def(CodeLLMProxyExists, http.StatusConflict, "An LLM proxy with this ID already exists.") + LLMProxyLimitReached = def(CodeLLMProxyLimitReached, http.StatusConflict, "LLM proxy limit reached for the organization.") + LLMProxyAPIKeyNotFound = def(CodeLLMProxyAPIKeyNotFound, http.StatusNotFound, "The specified API key could not be found.") + LLMProxyAPIKeyForbidden = def(CodeLLMProxyAPIKeyForbidden, http.StatusForbidden, "You do not have permission to access this API key.") + LLMProxyDeploymentValidationFailed = def(CodeLLMProxyDeploymentValidationFailed, http.StatusBadRequest, "%s") +) + +// LLM provider template entries. +var ( + LLMProviderTemplateNotFound = def(CodeLLMProviderTemplateNotFound, http.StatusNotFound, "The specified LLM provider template could not be found.") + LLMProviderTemplateVersionNotFound = def(CodeLLMProviderTemplateVersionNotFound, http.StatusNotFound, "The specified LLM provider template version could not be found.") + LLMProviderTemplateRefNotFound = def(CodeLLMProviderTemplateRefNotFound, http.StatusBadRequest, "The referenced LLM provider template could not be found.") + LLMProviderTemplateExists = def(CodeLLMProviderTemplateExists, http.StatusConflict, "An LLM provider template with this ID already exists.") + LLMProviderTemplateVersionExists = def(CodeLLMProviderTemplateVersionExists, http.StatusConflict, "This template version already exists.") + LLMProviderTemplateManagedByReserved = def(CodeLLMProviderTemplateManagedByReserved, http.StatusBadRequest, "'wso2' is reserved and cannot be used as managedBy on custom templates.") + LLMProviderTemplateInUse = def(CodeLLMProviderTemplateInUse, http.StatusConflict, "This template version is in use by one or more providers.") + LLMProviderTemplateReadOnly = def(CodeLLMProviderTemplateReadOnly, http.StatusForbidden, "Built-in templates are read-only and cannot be modified.") + LLMProviderTemplateNotToggleable = def(CodeLLMProviderTemplateNotToggleable, http.StatusForbidden, "Only built-in templates can be enabled or disabled.") +) + +// Gateway entries. +var ( + GatewayNotFound = def(CodeGatewayNotFound, http.StatusNotFound, "The specified gateway could not be found.") + GatewayTokenNotFound = def(CodeGatewayTokenNotFound, http.StatusNotFound, "The specified gateway token could not be found.") + GatewayConnectionUnavailable = def(CodeGatewayConnectionUnavailable, http.StatusServiceUnavailable, "No gateway connections are currently available.") + GatewayHasActiveDeployments = def(CodeGatewayHasActiveDeployments, http.StatusConflict, "The gateway has active deployments and cannot be deleted.") + GatewayNameConflict = def(CodeGatewayNameConflict, http.StatusConflict, "A gateway with this name already exists.") + GatewayTokenLimitReached = def(CodeGatewayTokenLimitReached, http.StatusConflict, "Gateway token limit reached.") +) + +// Deployment entries, shared across REST API / LLM provider / LLM proxy / +// MCP proxy deployment operations. DeploymentNotActive's verb is the artifact +// kind, e.g. "API", "LLM provider". +var ( + DeploymentBaseNotFound = def(CodeDeploymentBaseNotFound, http.StatusNotFound, "The specified base deployment could not be found.") + DeploymentRestoreConflict = def(CodeDeploymentRestoreConflict, http.StatusConflict, "Cannot restore the currently deployed deployment, or the deployment is invalid.") + DeploymentNotFound = def(CodeDeploymentNotFound, http.StatusNotFound, "The specified deployment could not be found.") + DeploymentNotActive = def(CodeDeploymentNotActive, http.StatusConflict, "No active deployment found for this %s on the gateway.") + DeploymentGatewayMismatch = def(CodeDeploymentGatewayMismatch, http.StatusBadRequest, "Deployment is bound to a different gateway.") + DeploymentActive = def(CodeDeploymentActive, http.StatusConflict, "Cannot delete an active deployment - undeploy it first.") + DeploymentInvalidStatus = def(CodeDeploymentInvalidStatus, http.StatusBadRequest, "The specified deployment status filter is invalid.") +) + +// MCP proxy entries. +var ( + MCPProxyNotFound = def(CodeMCPProxyNotFound, http.StatusNotFound, "The specified MCP proxy could not be found.") + MCPProxyExists = def(CodeMCPProxyExists, http.StatusConflict, "An MCP proxy with this ID already exists.") + MCPProxyLimitReached = def(CodeMCPProxyLimitReached, http.StatusConflict, "MCP proxy limit reached for the organization.") + MCPProxyDeploymentValidationFailed = def(CodeMCPProxyDeploymentValidationFailed, http.StatusBadRequest, "%s") +) + +// Organization / project / application entries. +var ( + OrganizationNotFound = def(CodeOrganizationNotFound, http.StatusNotFound, "The specified organization could not be found.") + OrganizationExists = def(CodeOrganizationExists, http.StatusConflict, "An organization with this name already exists.") + ProjectNotFound = def(CodeProjectNotFound, http.StatusNotFound, "The specified project could not be found.") + ProjectRefNotFound = def(CodeProjectRefNotFound, http.StatusBadRequest, "The referenced project could not be found.") + ProjectExists = def(CodeProjectExists, http.StatusConflict, "A project with this name already exists in the organization.") + ApplicationNotFound = def(CodeApplicationNotFound, http.StatusNotFound, "The specified application could not be found.") + ApplicationExists = def(CodeApplicationExists, http.StatusConflict, "An application with this name already exists.") +) + +// Subscription entries. +var ( + SubscriptionNotFound = def(CodeSubscriptionNotFound, http.StatusNotFound, "The specified subscription could not be found.") + SubscriptionExists = def(CodeSubscriptionExists, http.StatusConflict, "A subscription for this application and API already exists.") + SubscriptionForbidden = def(CodeSubscriptionForbidden, http.StatusForbidden, "You do not have permission to access this subscription.") + SubscriptionPlanNotFound = def(CodeSubscriptionPlanNotFound, http.StatusNotFound, "The specified subscription plan could not be found.") + SubscriptionPlanExists = def(CodeSubscriptionPlanExists, http.StatusConflict, "A subscription plan with this name already exists.") +) + +// Custom policy entries. +var ( + PolicyVersionConflict = def(CodePolicyVersionConflict, http.StatusConflict, "The policy version conflicts with an existing version.") + PolicyInvalidState = def(CodePolicyInvalidState, http.StatusConflict, "The policy is not in a valid state for this operation.") + PolicyInUse = def(CodePolicyInUse, http.StatusConflict, "The policy is in use and cannot be deleted.") + CustomPolicyNotFound = def(CodeCustomPolicyNotFound, http.StatusNotFound, "The specified policy could not be found.") + CustomPolicyVersionNotFnd = def(CodeCustomPolicyVersionNotFound, http.StatusNotFound, "The specified policy version could not be found.") +) + +// Secret entries. +var ( + SecretNotFound = def(CodeSecretNotFound, http.StatusNotFound, "The specified secret could not be found.") + SecretExists = def(CodeSecretExists, http.StatusConflict, "A secret with this name already exists.") + SecretInUse = def(CodeSecretInUse, http.StatusConflict, "The secret is in use and cannot be deleted.") +) + +// Artifact entries — generic artifact-reference flows and the +// data-plane-origin guard. The guard produces client-appropriate messages at +// the call site, so these are passthrough templates. +var ( + ArtifactNotFound = def(CodeArtifactNotFound, http.StatusNotFound, "The specified artifact could not be found.") + ArtifactExists = def(CodeArtifactExists, http.StatusConflict, "The artifact already exists.") + ArtifactReadOnly = def(CodeArtifactReadOnly, http.StatusForbidden, "%s") + ArtifactRuntimeImmutable = def(CodeArtifactRuntimeImmutable, http.StatusForbidden, "%s") + ArtifactDeployed = def(CodeArtifactDeployed, http.StatusConflict, "%s") +) + +// Application API key entries. +var ( + ApplicationAPIKeyNotFound = def(CodeApplicationAPIKeyNotFound, http.StatusNotFound, "The specified API key could not be found.") + ApplicationAPIKeyForbidden = def(CodeApplicationAPIKeyForbidden, http.StatusForbidden, "You do not have permission to access this API key.") +) diff --git a/platform-api/internal/apperror/catalog_test.go b/platform-api/internal/apperror/catalog_test.go new file mode 100644 index 0000000000..05334ba3cb --- /dev/null +++ b/platform-api/internal/apperror/catalog_test.go @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package apperror + +import ( + "net/http" + "regexp" + "strings" + "testing" +) + +// codeShape is the format every catalog code must follow: uppercase +// SCREAMING_SNAKE_CASE, stable and client-visible. +var codeShape = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +// fmtVerb matches fmt verbs in message templates (ignoring literal %%). +var fmtVerb = regexp.MustCompile(`%[^%]`) + +// messageArity declares, for every catalog entry whose MessageFmt contains +// fmt verbs, how many arguments call sites must pass. Adding a verb to a +// template without updating this table fails the test — the arity is part of +// the entry's contract, mirroring the argument list of a Java ExceptionCodes +// enum constant. +var messageArity = map[string]int{ + CodeOf(ValidationFailed): 1, + CodeOf(RESTAPIExists): 1, + CodeOf(RESTAPIDeploymentValidationFailed): 1, + CodeOf(LLMProviderDeploymentValidationFailed): 1, + CodeOf(LLMProxyDeploymentValidationFailed): 1, + CodeOf(MCPProxyDeploymentValidationFailed): 1, + CodeOf(DeploymentNotActive): 1, + CodeOf(ArtifactReadOnly): 1, + CodeOf(ArtifactRuntimeImmutable): 1, + CodeOf(ArtifactDeployed): 1, + CodeOf(TooManyRequests): 1, +} + +// CodeOf makes the arity table read as a set of catalog references rather +// than duplicated string literals. +func CodeOf(d Def) string { return d.Code } + +// internalMarkers are substrings that must never appear in a client-facing +// message template (error-handling.md: zero internal details). +var internalMarkers = []string{"sql", "pq:", "goroutine", ".go:", "/internal/", "stack", "panic"} + +func TestCatalogIntegrity(t *testing.T) { + if len(allDefs) == 0 { + t.Fatal("catalog is empty — def() registration broken?") + } + + seen := make(map[string]Def, len(allDefs)) + for _, d := range allDefs { + if !codeShape.MatchString(d.Code) { + t.Errorf("%s: code is not SCREAMING_SNAKE_CASE", d.Code) + } + if prev, dup := seen[d.Code]; dup { + t.Errorf("%s: declared twice (statuses %d and %d) — codes must be unique", d.Code, prev.HTTPStatus, d.HTTPStatus) + } + seen[d.Code] = d + + if d.HTTPStatus < 400 || d.HTTPStatus > 599 { + t.Errorf("%s: status %d is not a 4xx/5xx error status", d.Code, d.HTTPStatus) + } + if http.StatusText(d.HTTPStatus) == "" { + t.Errorf("%s: status %d is not a registered HTTP status", d.Code, d.HTTPStatus) + } + + if strings.TrimSpace(d.MessageFmt) == "" { + t.Errorf("%s: empty message template", d.Code) + } + lower := strings.ToLower(d.MessageFmt) + for _, marker := range internalMarkers { + if strings.Contains(lower, marker) { + t.Errorf("%s: message template contains internal marker %q: %q", d.Code, marker, d.MessageFmt) + } + } + + verbs := len(fmtVerb.FindAllString(strings.ReplaceAll(d.MessageFmt, "%%", ""), -1)) + want, declared := messageArity[d.Code] + if verbs > 0 && !declared { + t.Errorf("%s: template has %d fmt verb(s) but no entry in messageArity", d.Code, verbs) + } + if declared && verbs != want { + t.Errorf("%s: messageArity declares %d arg(s) but template has %d verb(s)", d.Code, want, verbs) + } + } +} + +// TestUnauthorizedIsUnified pins the unified-auth-failure rule: exactly one +// 401 entry exists and its message is the standard generic one, so no auth +// path can leak why authentication failed. +func TestUnauthorizedIsUnified(t *testing.T) { + var unauthorized []Def + for _, d := range allDefs { + if d.HTTPStatus == http.StatusUnauthorized { + unauthorized = append(unauthorized, d) + } + } + if len(unauthorized) != 1 { + t.Fatalf("expected exactly one 401 catalog entry, found %d", len(unauthorized)) + } + if unauthorized[0].MessageFmt != "Invalid or expired credentials." { + t.Errorf("401 message must be the standard generic one, got %q", unauthorized[0].MessageFmt) + } + if strings.Contains(unauthorized[0].MessageFmt, "%") { + t.Error("401 message must not be parameterizable") + } +} diff --git a/platform-api/internal/apperror/codes.go b/platform-api/internal/apperror/codes.go new file mode 100644 index 0000000000..28a7f018fd --- /dev/null +++ b/platform-api/internal/apperror/codes.go @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package apperror + +// Common, domain-agnostic catalog codes. These mirror the codes documented +// as shared response examples in resources/openapi.yaml, and are +// the fallback used by NewErrorResponse when a handler hasn't been +// migrated to a more specific domain code via NewErrorResponseWithCode. +const ( + CodeCommonValidationFailed = "VALIDATION_FAILED" + CodeCommonUnauthorized = "UNAUTHORIZED" + CodeCommonForbidden = "FORBIDDEN" + CodeCommonNotFound = "NOT_FOUND" + CodeCommonConflict = "CONFLICT" + CodeCommonNotAcceptable = "NOT_ACCEPTABLE" + CodeCommonUnprocessableEntity = "UNPROCESSABLE_ENTITY" + CodeCommonInternalError = "INTERNAL_ERROR" + CodeCommonServiceUnavailable = "SERVICE_UNAVAILABLE" + CodeCommonTooManyRequests = "TOO_MANY_REQUESTS" +) + +// LLM provider/proxy domain codes, matching the examples documented in +// resources/openapi.yaml for the corresponding operations. +const ( + CodeLLMProviderNotFound = "LLM_PROVIDER_NOT_FOUND" + CodeLLMProviderExists = "LLM_PROVIDER_EXISTS" + CodeLLMProviderLimitReached = "LLM_PROVIDER_LIMIT_REACHED" + CodeLLMProviderAPIKeyNotFound = "LLM_PROVIDER_API_KEY_NOT_FOUND" + CodeLLMProviderDeploymentValidationFailed = "LLM_PROVIDER_DEPLOYMENT_VALIDATION_FAILED" + CodeLLMProxyNotFound = "LLM_PROXY_NOT_FOUND" + CodeLLMProxyExists = "LLM_PROXY_EXISTS" + CodeLLMProxyLimitReached = "LLM_PROXY_LIMIT_REACHED" + CodeLLMProxyAPIKeyNotFound = "LLM_PROXY_API_KEY_NOT_FOUND" + CodeLLMProviderRefNotFound = "LLM_PROVIDER_REF_NOT_FOUND" + CodeLLMProxyDeploymentValidationFailed = "LLM_PROXY_DEPLOYMENT_VALIDATION_FAILED" + CodeLLMProviderAPIKeyForbidden = "LLM_PROVIDER_API_KEY_FORBIDDEN" + CodeLLMProxyAPIKeyForbidden = "LLM_PROXY_API_KEY_FORBIDDEN" +) + +// LLM provider template domain codes. The *_VERSION_* and *_REF_* codes keep +// one HTTP status per code in the apperror catalog: NOT_FOUND codes are 404s +// for the resource targeted by the URL, REF_NOT_FOUND codes are 400s for a +// resource referenced in the request body, and VERSION codes distinguish a +// missing/duplicate version from a missing/duplicate template. +const ( + CodeLLMProviderTemplateNotFound = "LLM_PROVIDER_TEMPLATE_NOT_FOUND" + CodeLLMProviderTemplateVersionNotFound = "LLM_PROVIDER_TEMPLATE_VERSION_NOT_FOUND" + CodeLLMProviderTemplateRefNotFound = "LLM_PROVIDER_TEMPLATE_REF_NOT_FOUND" + CodeLLMProviderTemplateExists = "LLM_PROVIDER_TEMPLATE_EXISTS" + CodeLLMProviderTemplateVersionExists = "LLM_PROVIDER_TEMPLATE_VERSION_EXISTS" + CodeLLMProviderTemplateManagedByReserved = "LLM_PROVIDER_TEMPLATE_MANAGED_BY_RESERVED" + CodeLLMProviderTemplateInUse = "LLM_PROVIDER_TEMPLATE_IN_USE" + CodeLLMProviderTemplateReadOnly = "LLM_PROVIDER_TEMPLATE_READ_ONLY" + CodeLLMProviderTemplateNotToggleable = "LLM_PROVIDER_TEMPLATE_NOT_TOGGLEABLE" +) + +// Gateway domain codes, matching the examples documented in resources/openapi.yaml. +const ( + CodeGatewayNotFound = "GATEWAY_NOT_FOUND" + CodeGatewayTokenNotFound = "GATEWAY_TOKEN_NOT_FOUND" + CodeGatewayConnectionUnavailable = "GATEWAY_CONNECTION_UNAVAILABLE" + CodeGatewayHasActiveDeployments = "GATEWAY_HAS_ACTIVE_DEPLOYMENTS" + CodeGatewayNameConflict = "GATEWAY_NAME_CONFLICT" + CodeGatewayTokenLimitReached = "GATEWAY_TOKEN_LIMIT_REACHED" +) + +// Deployment domain codes, shared across REST API / LLM provider / LLM proxy / +// MCP proxy deployment operations (identical conditions across all four). +const ( + CodeDeploymentBaseNotFound = "DEPLOYMENT_BASE_NOT_FOUND" + CodeDeploymentRestoreConflict = "DEPLOYMENT_RESTORE_CONFLICT" + CodeDeploymentNotFound = "DEPLOYMENT_NOT_FOUND" + CodeDeploymentNotActive = "DEPLOYMENT_NOT_ACTIVE" + CodeDeploymentGatewayMismatch = "DEPLOYMENT_GATEWAY_MISMATCH" + CodeDeploymentActive = "DEPLOYMENT_ACTIVE" + CodeDeploymentInvalidStatus = "DEPLOYMENT_INVALID_STATUS" +) + +// REST API domain codes. +const ( + CodeRESTAPINotFound = "REST_API_NOT_FOUND" + CodeRESTAPIExists = "REST_API_EXISTS" + CodeRESTAPIDeploymentValidationFailed = "REST_API_DEPLOYMENT_VALIDATION_FAILED" +) + +// MCP proxy domain codes. +const ( + CodeMCPProxyNotFound = "MCP_PROXY_NOT_FOUND" + CodeMCPProxyExists = "MCP_PROXY_EXISTS" + CodeMCPProxyLimitReached = "MCP_PROXY_LIMIT_REACHED" + CodeMCPProxyDeploymentValidationFailed = "MCP_PROXY_DEPLOYMENT_VALIDATION_FAILED" +) + +// Organization domain codes. +const ( + CodeOrganizationNotFound = "ORGANIZATION_NOT_FOUND" + CodeOrganizationExists = "ORGANIZATION_EXISTS" +) + +// Project domain codes. PROJECT_REF_NOT_FOUND is the 400 counterpart of +// PROJECT_NOT_FOUND for a project referenced in a request body (e.g. the +// projectId in a create request) rather than targeted by the URL. +const ( + CodeProjectNotFound = "PROJECT_NOT_FOUND" + CodeProjectRefNotFound = "PROJECT_REF_NOT_FOUND" + CodeProjectExists = "PROJECT_EXISTS" +) + +// Application domain codes. +const ( + CodeApplicationNotFound = "APPLICATION_NOT_FOUND" + CodeApplicationExists = "APPLICATION_EXISTS" +) + +// Subscription domain codes. +const ( + CodeSubscriptionNotFound = "SUBSCRIPTION_NOT_FOUND" + CodeSubscriptionExists = "SUBSCRIPTION_EXISTS" + CodeSubscriptionForbidden = "SUBSCRIPTION_FORBIDDEN" + CodeSubscriptionPlanNotFound = "SUBSCRIPTION_PLAN_NOT_FOUND" + CodeSubscriptionPlanExists = "SUBSCRIPTION_PLAN_EXISTS" +) + +// Custom policy domain codes. +const ( + CodePolicyVersionConflict = "POLICY_VERSION_CONFLICT" + CodePolicyInvalidState = "POLICY_INVALID_STATE" + CodePolicyInUse = "POLICY_IN_USE" +) + +// Secret domain codes. +const ( + CodeSecretNotFound = "SECRET_NOT_FOUND" + CodeSecretExists = "SECRET_EXISTS" + CodeSecretInUse = "SECRET_IN_USE" +) + +// Artifact domain codes. Used by flows that operate on a generic artifact +// reference (REST API / LLM provider / LLM proxy / MCP proxy) — API keys, +// subscriptions, and application associations — where the caller shouldn't +// need to know which concrete artifact kind was targeted, and by the +// data-plane-origin guard that protects DP-originated artifacts from +// control-plane mutation. +const ( + CodeArtifactNotFound = "ARTIFACT_NOT_FOUND" + CodeArtifactExists = "ARTIFACT_EXISTS" + CodeArtifactReadOnly = "ARTIFACT_READ_ONLY" + CodeArtifactRuntimeImmutable = "ARTIFACT_RUNTIME_IMMUTABLE" + CodeArtifactDeployed = "ARTIFACT_DEPLOYED" +) + +// Custom policy domain codes. +const ( + CodeCustomPolicyNotFound = "CUSTOM_POLICY_NOT_FOUND" + CodeCustomPolicyVersionNotFound = "CUSTOM_POLICY_VERSION_NOT_FOUND" +) + +// Application API key domain codes (application-scoped API key mappings, +// distinct from the LLM provider/proxy API key codes above). +const ( + CodeApplicationAPIKeyNotFound = "APPLICATION_API_KEY_NOT_FOUND" + CodeApplicationAPIKeyForbidden = "APPLICATION_API_KEY_FORBIDDEN" +) diff --git a/platform-api/internal/apperror/def.go b/platform-api/internal/apperror/def.go new file mode 100644 index 0000000000..398bf28dea --- /dev/null +++ b/platform-api/internal/apperror/def.go @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package apperror + +import ( + "errors" + "fmt" +) + +// Def is a catalog entry: the code, HTTP status, and client-facing message +// template are bound together at declaration time (see catalog.go) so call +// sites cannot pair a code with the wrong status or a drifting message. It is +// the Go equivalent of a Java ExceptionCodes enum constant. Errors are +// created only through Def.New / Def.Wrap (plus NewValidation for +// validator.ValidationErrors), never by picking a code/status/message triple +// at the call site. +type Def struct { + Code string // stable, client-visible catalog code, e.g. "REST_API_EXISTS" + HTTPStatus int + MessageFmt string // client-facing message template; may contain fmt verbs +} + +// New instantiates the catalog entry for a business-rule failure with no +// underlying error. args fill the MessageFmt verbs, if any; they must be +// user-supplied values (names, handles, IDs), never internal error text. The +// call stack is captured automatically. +func (d Def) New(args ...any) *Error { + return newWithSkip(3, d.Code, d.HTTPStatus, d.message(args)) +} + +// Wrap instantiates the catalog entry for a failure caused by a lower-level +// error (DB, IO, downstream call). The cause is a required positional +// parameter — not a fluent afterthought — so it cannot be forgotten; it feeds +// the mapper's log line and errors.Is/As chains, and is never sent to the +// client. +func (d Def) Wrap(cause error, args ...any) *Error { + e := newWithSkip(3, d.Code, d.HTTPStatus, d.message(args)) + e.Cause = cause + return e +} + +// Is reports whether err is (or wraps) an *Error carrying this Def's code, +// letting callers branch on catalog entries without sentinel errors. +func (d Def) Is(err error) bool { + var e *Error + return errors.As(err, &e) && e.Code == d.Code +} + +func (d Def) message(args []any) string { + if len(args) == 0 { + return d.MessageFmt + } + return fmt.Sprintf(d.MessageFmt, args...) +} diff --git a/platform-api/internal/apperror/error.go b/platform-api/internal/apperror/error.go new file mode 100644 index 0000000000..a37fc5fdcf --- /dev/null +++ b/platform-api/internal/apperror/error.go @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package apperror + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + + "github.com/go-playground/validator/v10" +) + +// ErrorResponse is the standard error response shape mandated by APR-004: +// { status, code, message, errors[], details }. status is always "error"; +// code is a stable, machine-readable string from the error catalog (see +// codes.go). Details carries optional structured metadata whose shape is +// specific to the code (e.g. the resources referencing a secret that +// blocked its deletion) — it is not a substitute for errors[], which is +// reserved for field-level validation failures. +type ErrorResponse struct { + Status string `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + Errors []FieldError `json:"errors,omitempty"` + Details any `json:"details,omitempty"` + // TrackingID correlates a 5xx response with its server-side log line (a + // bare UUID, no source markers). Set only for 5xx responses — 4xx + // responses already tell the client exactly what was wrong. + TrackingID string `json:"trackingId,omitempty"` +} + +// FieldError describes a single field-level validation failure. +type FieldError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// commonCodeByStatus maps an HTTP status code to its fallback catalog code, +// for handlers that build an ErrorResponse via NewErrorResponse instead of +// picking a specific catalog code via NewErrorResponseWithCode. 400 maps to +// CodeCommonValidationFailed (not a separate BAD_REQUEST) to match the +// single code documented for the shared BadRequest response in +// resources/openapi.yaml. +var commonCodeByStatus = map[int]string{ + http.StatusBadRequest: CodeCommonValidationFailed, + http.StatusUnauthorized: CodeCommonUnauthorized, + http.StatusForbidden: CodeCommonForbidden, + http.StatusNotFound: CodeCommonNotFound, + http.StatusConflict: CodeCommonConflict, + http.StatusNotAcceptable: CodeCommonNotAcceptable, + http.StatusUnprocessableEntity: CodeCommonUnprocessableEntity, + http.StatusInternalServerError: CodeCommonInternalError, + http.StatusServiceUnavailable: CodeCommonServiceUnavailable, + http.StatusTooManyRequests: CodeCommonTooManyRequests, +} + +// codeForStatus returns the fallback catalog code for an HTTP status, +// defaulting to CodeCommonInternalError for unmapped statuses. +func codeForStatus(httpStatus int) string { + if code, ok := commonCodeByStatus[httpStatus]; ok { + return code + } + return CodeCommonInternalError +} + +// NewErrorResponse builds a standard ErrorResponse for the given HTTP status. +// title is retained for call-site compatibility but is no longer surfaced in +// the response body; the catalog code is derived from httpStatus (see +// codeForStatus above), and description[0], when given, becomes the +// human-readable message. Handlers that need a specific catalog code should +// use NewErrorResponseWithCode instead. +func NewErrorResponse(httpStatus int, title string, description ...string) ErrorResponse { + message := title + if len(description) > 0 && description[0] != "" { + message = description[0] + } + return NewErrorResponseWithCode(codeForStatus(httpStatus), message) +} + +// NewErrorResponseWithCode builds a standard ErrorResponse using an explicit +// catalog code (see codes.go), for handlers that know the precise failure +// reason rather than just the HTTP status. +func NewErrorResponseWithCode(code, message string) ErrorResponse { + return ErrorResponse{ + Status: "error", + Code: code, + Message: message, + } +} + +// NewValidationErrorResponse writes a 400 JSON error response for validation errors. +func NewValidationErrorResponse(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + var ve validator.ValidationErrors + if errors.As(err, &ve) { + fieldErrors := make([]FieldError, 0, len(ve)) + for _, fe := range ve { + fieldErrors = append(fieldErrors, FieldError{ + Field: fe.Field(), + Message: fmt.Sprintf("The field %s is %s", fe.Field(), fe.Tag()), + }) + } + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(ErrorResponse{ + Status: "error", + Code: CodeCommonValidationFailed, + Message: "Validation failed for the request parameters.", + Errors: fieldErrors, + }) + return + } + log.Printf("[ERROR] Request validation fallback error: %v", err) + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(ErrorResponse{ + Status: "error", + Code: CodeCommonValidationFailed, + Message: "Invalid input.", + }) +} diff --git a/platform-api/internal/apperror/write.go b/platform-api/internal/apperror/write.go new file mode 100644 index 0000000000..3026213ce3 --- /dev/null +++ b/platform-api/internal/apperror/write.go @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package apperror + +import ( + "net/http" + + "github.com/wso2/go-httpkit/httputil" +) + +// WriteHTTP is the one place in the codebase that serializes an *Error onto +// an http.ResponseWriter as the standard ErrorResponse shape. Both the +// error mapper (middleware.MapErrors) and the pre-routing auth middleware +// call it, so the wire format cannot drift between the two paths. +// +// trackingID, when non-empty, is echoed in the response body so the client +// can quote it back for log correlation; the mapper passes it only for 5xx +// responses. Callers with no correlation ID to expose pass "". +func WriteHTTP(w http.ResponseWriter, e *Error, trackingID string) { + httputil.WriteJSON(w, e.HTTPStatus, ErrorResponse{ + Status: "error", + Code: e.Code, + Message: e.Message, + Errors: e.FieldErrors, + Details: e.Details, + TrackingID: trackingID, + }) +} diff --git a/platform-api/internal/dto/secret.go b/platform-api/internal/dto/secret.go index 9b6b2e5607..ac1aef88e4 100644 --- a/platform-api/internal/dto/secret.go +++ b/platform-api/internal/dto/secret.go @@ -88,15 +88,16 @@ type SecretSyncListResponse struct { Count int `json:"count"` } -// SecretDeleteConflictResponse is returned with 409 when a secret has active references. -type SecretDeleteConflictResponse struct { - Error string `json:"error"` - References []SecretReferenceDTO `json:"references"` -} - -// SecretReferenceDTO identifies a resource that references a secret. +// SecretReferenceDTO identifies a resource that references a secret. Carried +// in the standard error response's `details.references` when a delete is +// blocked by SECRET_IN_USE (see apperror.ErrorResponse.Details). type SecretReferenceDTO struct { Type string `json:"type"` Handle string `json:"handle"` Name string `json:"name"` } + +// SecretInUseDetails is the `details` payload for a SECRET_IN_USE error. +type SecretInUseDetails struct { + References []SecretReferenceDTO `json:"references"` +} diff --git a/platform-api/internal/handler/api.go b/platform-api/internal/handler/api.go index a7a7c2181e..164ba62f12 100644 --- a/platform-api/internal/handler/api.go +++ b/platform-api/internal/handler/api.go @@ -20,14 +20,16 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" + "strings" + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" - "strings" "github.com/wso2/go-httpkit/httputil" ) @@ -47,196 +49,148 @@ func NewAPIHandler(apiService *service.APIService, identity *service.IdentitySer } // CreateAPI handles POST /api/v0.9/rest-apis and creates a new API -func (h *APIHandler) CreateAPI(w http.ResponseWriter, r *http.Request) { +func (h *APIHandler) CreateAPI(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req api.CreateRESTAPIRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.NewValidationErrorResponse(w, err) - return + return apperror.NewValidation(err) } // Validate required fields if req.DisplayName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API name is required")) - return + return apperror.ValidationFailed.New("API name is required") } if req.Context == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API context is required")) - return + return apperror.ValidationFailed.New("API context is required") } if req.Version == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API version is required")) - return + return apperror.ValidationFailed.New("API version is required") } if strings.TrimSpace(req.ProjectId) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project ID is required")) - return + return apperror.ValidationFailed.New("Project ID is required") } if isEmptyUpstreamDefinition(req.Upstream.Main) && (req.Upstream.Sandbox == nil || isEmptyUpstreamDefinition(*req.Upstream.Sandbox)) { - h.slogger.Error("Validation failed: No upstream endpoints provided", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "At least one upstream endpoint (main or sandbox) is required")) - return + return apperror.ValidationFailed.New("At least one upstream endpoint (main or sandbox) is required"). + WithLogMessage(fmt.Sprintf("no upstream endpoints provided for org %s", orgId)) } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "create API") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "create API") + if err != nil { + return err } apiResponse, err := h.apiService.CreateAPI(&req, orgId, createdBy) if err != nil { if errors.Is(err, constants.ErrHandleExists) { - h.slogger.Error("API handle already exists", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "API handle already exists")) - return + return apperror.RESTAPIExists.Wrap(err, "An API with this handle already exists."). + WithLogMessage(fmt.Sprintf("API handle already exists in org %s", orgId)) } if errors.Is(err, constants.ErrAPINameVersionAlreadyExists) { - h.slogger.Error("API with same name and version already exists", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "API with same name and version already exists in the organization")) - return + return apperror.RESTAPIExists.Wrap(err, "An API with the same name and version already exists in the organization."). + WithLogMessage(fmt.Sprintf("API with same name and version already exists in org %s", orgId)) } if errors.Is(err, constants.ErrAPIAlreadyExists) { - h.slogger.Error("API already exists in the project", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "API already exists in the project")) - return + return apperror.RESTAPIExists.Wrap(err, "An API already exists in the project."). + WithLogMessage(fmt.Sprintf("API already exists in the project in org %s", orgId)) } if errors.Is(err, constants.ErrProjectNotFound) { - h.slogger.Error("Project not found", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Project not found")) - return + return apperror.ProjectNotFound.Wrap(err). + WithLogMessage(fmt.Sprintf("project not found in org %s", orgId)) } if errors.Is(err, constants.ErrInvalidAPIName) { - h.slogger.Error("Invalid API name format", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid API name format")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid API name format"). + WithLogMessage(fmt.Sprintf("invalid API name format in org %s", orgId)) } if errors.Is(err, constants.ErrInvalidAPIContext) { - h.slogger.Error("Invalid API context format", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid API context format")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid API context format"). + WithLogMessage(fmt.Sprintf("invalid API context format in org %s", orgId)) } if errors.Is(err, constants.ErrInvalidAPIVersion) { - h.slogger.Error("Invalid API version format", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid API version format")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid API version format"). + WithLogMessage(fmt.Sprintf("invalid API version format in org %s", orgId)) } if errors.Is(err, constants.ErrInvalidLifecycleState) { - h.slogger.Error("Invalid lifecycle status", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid lifecycle status")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid lifecycle status"). + WithLogMessage(fmt.Sprintf("invalid lifecycle status in org %s", orgId)) } if errors.Is(err, constants.ErrInvalidAPIType) { - h.slogger.Error("Invalid API type", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid API type")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid API type"). + WithLogMessage(fmt.Sprintf("invalid API type in org %s", orgId)) } if errors.Is(err, constants.ErrInvalidTransport) { - h.slogger.Error("Invalid transport protocol", "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid transport protocol")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid transport protocol"). + WithLogMessage(fmt.Sprintf("invalid transport protocol in org %s", orgId)) } if errors.Is(err, constants.ErrInvalidPolicyVersion) { - h.slogger.Error("Invalid policy version format", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid policy version format"). + WithLogMessage(fmt.Sprintf("invalid policy version format in org %s", orgId)) } if errors.Is(err, constants.ErrSubscriptionPlanNotFoundOrInactive) { - h.slogger.Error("Subscription plan not found or not active", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Subscription plan not found or not active"). + WithLogMessage(fmt.Sprintf("subscription plan not found or not active in org %s", orgId)) } - h.slogger.Error("Failed to create API", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to create API")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create API in org %s", orgId)) } + setLocation(w, "rest-apis", strOrEmpty(apiResponse.Id)) httputil.WriteJSON(w, http.StatusCreated, apiResponse) + return nil } // GetAPI handles GET /api/v0.9/rest-apis/:apiId and retrieves an API by its handle -func (h *APIHandler) GetAPI(w http.ResponseWriter, r *http.Request) { +func (h *APIHandler) GetAPI(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } apiResponse, err := h.apiService.GetAPIByHandle(apiId, orgId) if err != nil { if errors.Is(err, constants.ErrAPINotFound) { - h.slogger.Error("API not found", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err). + WithLogMessage(fmt.Sprintf("API %s not found in org %s", apiId, orgId)) } - h.slogger.Error("Failed to get API", "apiId", apiId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to get API")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get API %s in org %s", apiId, orgId)) } httputil.WriteJSON(w, http.StatusOK, apiResponse) + return nil } // ListAPIs handles GET /api/v0.9/rest-apis and lists APIs for an organization filtered by project -func (h *APIHandler) ListAPIs(w http.ResponseWriter, r *http.Request) { +func (h *APIHandler) ListAPIs(w http.ResponseWriter, r *http.Request) error { // Get organization from JWT token orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } projectId := strings.TrimSpace(r.URL.Query().Get("projectId")) if projectId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "projectId query parameter is required")) - return + return apperror.ValidationFailed.New("projectId query parameter is required") } apis, err := h.apiService.GetAPIsByOrganization(orgId, projectId) if err != nil { if errors.Is(err, constants.ErrProjectNotFound) { - h.slogger.Error("Project not found", "organizationId", orgId, "projectId", projectId) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Project not found")) - return + return apperror.ProjectNotFound.Wrap(err). + WithLogMessage(fmt.Sprintf("project %s not found in org %s", projectId, orgId)) } - h.slogger.Error("Failed to get APIs", "organizationId", orgId, "projectId", projectId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to get APIs")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get APIs for project %s in org %s", projectId, orgId)) } response := api.RESTAPIListResponse{ @@ -250,162 +204,129 @@ func (h *APIHandler) ListAPIs(w http.ResponseWriter, r *http.Request) { } httputil.WriteJSON(w, http.StatusOK, response) + return nil } // UpdateAPI updates an existing API identified by handle -func (h *APIHandler) UpdateAPI(w http.ResponseWriter, r *http.Request) { +func (h *APIHandler) UpdateAPI(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } var req api.RESTAPI if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - err.Error())) - return + return apperror.NewValidation(err) } // Validate upstream configuration if provided if isEmptyUpstreamDefinition(req.Upstream.Main) && (req.Upstream.Sandbox == nil || isEmptyUpstreamDefinition(*req.Upstream.Sandbox)) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "At least one upstream endpoint (main or sandbox) is required")) - return + return apperror.ValidationFailed.New("At least one upstream endpoint (main or sandbox) is required") } - updatedBy, ok := resolveActor(w, r, h.identity, h.slogger, "update API") - if !ok { - return + updatedBy, err := resolveActorErr(r, h.identity, "update API") + if err != nil { + return err } apiResponse, err := h.apiService.UpdateAPIByHandle(apiId, &req, orgId, updatedBy) if err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } if errors.Is(err, constants.ErrAPINotFound) { - h.slogger.Error("API not found", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err). + WithLogMessage(fmt.Sprintf("API %s not found in org %s", apiId, orgId)) } if errors.Is(err, constants.ErrHandleImmutable) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "The id is immutable and cannot be changed") } if errors.Is(err, constants.ErrInvalidLifecycleState) { - h.slogger.Error("Invalid lifecycle status", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid lifecycle status")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid lifecycle status"). + WithLogMessage(fmt.Sprintf("invalid lifecycle status for API %s in org %s", apiId, orgId)) } if errors.Is(err, constants.ErrInvalidAPIType) { - h.slogger.Error("Invalid API type", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid API type")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid API type"). + WithLogMessage(fmt.Sprintf("invalid API type for API %s in org %s", apiId, orgId)) } if errors.Is(err, constants.ErrInvalidTransport) { - h.slogger.Error("Invalid transport protocol", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid transport protocol")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid transport protocol"). + WithLogMessage(fmt.Sprintf("invalid transport protocol for API %s in org %s", apiId, orgId)) } if errors.Is(err, constants.ErrInvalidPolicyVersion) { - h.slogger.Error("Invalid policy version format", "apiId", apiId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid policy version format"). + WithLogMessage(fmt.Sprintf("invalid policy version format for API %s in org %s", apiId, orgId)) } if errors.Is(err, constants.ErrSubscriptionPlanNotFoundOrInactive) { - h.slogger.Error("Subscription plan not found or not active", "apiId", apiId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Subscription plan not found or not active"). + WithLogMessage(fmt.Sprintf("subscription plan not found or not active for API %s in org %s", apiId, orgId)) } - h.slogger.Error("Failed to update API", "apiId", apiId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to update API")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update API %s in org %s", apiId, orgId)) } httputil.WriteJSON(w, http.StatusOK, apiResponse) + return nil } // DeleteAPI handles DELETE /api/v0.9/rest-apis/:apiId and deletes an API by its handle -func (h *APIHandler) DeleteAPI(w http.ResponseWriter, r *http.Request) { +func (h *APIHandler) DeleteAPI(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } - deletedBy, ok := resolveActor(w, r, h.identity, h.slogger, "delete API") - if !ok { - return - } - err := h.apiService.DeleteAPIByHandle(apiId, orgId, deletedBy) + deletedBy, err := resolveActorErr(r, h.identity, "delete API") if err != nil { - if respondArtifactGuardError(w, err) { - return + return err + } + if err := h.apiService.DeleteAPIByHandle(apiId, orgId, deletedBy); err != nil { + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } if errors.Is(err, constants.ErrAPINotFound) { - h.slogger.Error("API not found", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err). + WithLogMessage(fmt.Sprintf("API %s not found in org %s", apiId, orgId)) } - h.slogger.Error("Failed to delete API", "apiId", apiId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to delete API")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete API %s in org %s", apiId, orgId)) } httputil.WriteJSON(w, http.StatusNoContent, nil) + return nil } // AddGatewaysToAPI handles POST /api/v0.9/rest-apis/:apiId/gateways to associate gateways with an API -func (h *APIHandler) AddGatewaysToAPI(w http.ResponseWriter, r *http.Request) { +func (h *APIHandler) AddGatewaysToAPI(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } var req []api.AddGatewayToRESTAPIRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.NewValidation(err) } if len(req) == 0 { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "At least one gateway ID is required")) - return + return apperror.ValidationFailed.New("At least one gateway ID is required") } // Extract gateway IDs from request @@ -417,70 +338,59 @@ func (h *APIHandler) AddGatewaysToAPI(w http.ResponseWriter, r *http.Request) { gatewaysResponse, err := h.apiService.AddGatewaysToAPIByHandle(apiId, gatewayIds, orgId) if err != nil { if errors.Is(err, constants.ErrAPINotFound) { - h.slogger.Error("API not found", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err). + WithLogMessage(fmt.Sprintf("API %s not found in org %s", apiId, orgId)) } if errors.Is(err, constants.ErrGatewayNotFound) { - h.slogger.Error("One or more gateways not found", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "One or more gateways not found")) - return + return apperror.GatewayNotFound.Wrap(err). + WithLogMessage(fmt.Sprintf("one or more gateways not found for API %s in org %s", apiId, orgId)) } - h.slogger.Error("Failed to associate gateways with API", "apiId", apiId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to associate gateways with API")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to associate gateways with API %s in org %s", apiId, orgId)) } httputil.WriteJSON(w, http.StatusOK, gatewaysResponse) + return nil } // GetAPIGateways handles GET /api/v0.9/rest-apis/:apiId/gateways to get gateways associated with an API including deployment details -func (h *APIHandler) GetAPIGateways(w http.ResponseWriter, r *http.Request) { +func (h *APIHandler) GetAPIGateways(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } gatewaysResponse, err := h.apiService.GetAPIGatewaysByHandle(apiId, orgId) if err != nil { if errors.Is(err, constants.ErrAPINotFound) { - h.slogger.Error("API not found", "apiId", apiId, "organizationId", orgId) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err). + WithLogMessage(fmt.Sprintf("API %s not found in org %s", apiId, orgId)) } - h.slogger.Error("Failed to get API gateways", "apiId", apiId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to get API gateways")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get gateways for API %s in org %s", apiId, orgId)) } httputil.WriteJSON(w, http.StatusOK, gatewaysResponse) + return nil } // RegisterRoutes registers all API routes func (h *APIHandler) RegisterRoutes(mux *http.ServeMux) { h.slogger.Debug("Registering REST API routes") base := constants.APIBasePath + "/rest-apis" - mux.HandleFunc("POST "+base, h.CreateAPI) - mux.HandleFunc("GET "+base, h.ListAPIs) - mux.HandleFunc("GET "+base+"/{restApiId}", h.GetAPI) - mux.HandleFunc("PUT "+base+"/{restApiId}", h.UpdateAPI) - mux.HandleFunc("DELETE "+base+"/{restApiId}", h.DeleteAPI) - mux.HandleFunc("GET "+base+"/{restApiId}/gateways", h.GetAPIGateways) - mux.HandleFunc("POST "+base+"/{restApiId}/gateways", h.AddGatewaysToAPI) + mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateAPI)) + mux.HandleFunc("GET "+base, middleware.MapErrors(h.slogger, h.ListAPIs)) + mux.HandleFunc("GET "+base+"/{restApiId}", middleware.MapErrors(h.slogger, h.GetAPI)) + mux.HandleFunc("PUT "+base+"/{restApiId}", middleware.MapErrors(h.slogger, h.UpdateAPI)) + mux.HandleFunc("DELETE "+base+"/{restApiId}", middleware.MapErrors(h.slogger, h.DeleteAPI)) + mux.HandleFunc("GET "+base+"/{restApiId}/gateways", middleware.MapErrors(h.slogger, h.GetAPIGateways)) + mux.HandleFunc("POST "+base+"/{restApiId}/gateways", middleware.MapErrors(h.slogger, h.AddGatewaysToAPI)) } func isEmptyUpstreamDefinition(definition api.UpstreamDefinition) bool { diff --git a/platform-api/internal/handler/api_deployment.go b/platform-api/internal/handler/api_deployment.go index 6dd2516c7c..056b448325 100644 --- a/platform-api/internal/handler/api_deployment.go +++ b/platform-api/internal/handler/api_deployment.go @@ -20,15 +20,16 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -49,365 +50,278 @@ func NewDeploymentHandler(deploymentService *service.DeploymentService, identity // DeployAPI handles POST /api/v0.9/rest-apis/:apiId/deployments // Creates a new immutable deployment artifact and deploys it to a gateway -func (h *DeploymentHandler) DeployAPI(w http.ResponseWriter, r *http.Request) { +func (h *DeploymentHandler) DeployAPI(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } var req api.DeployRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.NewValidation(err) } // Validate required fields if req.Name == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "name is required")) - return + return apperror.RESTAPIDeploymentValidationFailed.New("name is required") } if req.Base == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "base is required (use 'current' or a deploymentId)")) - return + return apperror.RESTAPIDeploymentValidationFailed.New("base is required (use 'current' or a deploymentId)") } if strings.TrimSpace(req.GatewayId) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId is required")) - return + return apperror.RESTAPIDeploymentValidationFailed.New("gatewayId is required") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "deploy API") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "deploy API") + if err != nil { + return err } deployment, err := h.deploymentService.DeployAPIByHandle(apiId, &req, orgId, createdBy) if err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err) } if errors.Is(err, constants.ErrGatewayNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) } if errors.Is(err, constants.ErrBaseDeploymentNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Base deployment not found")) - return + return apperror.DeploymentBaseNotFound.Wrap(err) } if errors.Is(err, constants.ErrDeploymentNameRequired) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment name is required")) - return + return apperror.RESTAPIDeploymentValidationFailed.Wrap(err, "Deployment name is required") } if errors.Is(err, constants.ErrDeploymentBaseRequired) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Base is required (use 'current' or a deploymentId)")) - return + return apperror.RESTAPIDeploymentValidationFailed.Wrap(err, "Base is required (use 'current' or a deploymentId)") } if errors.Is(err, constants.ErrDeploymentGatewayIDRequired) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.RESTAPIDeploymentValidationFailed.Wrap(err, "Gateway ID is required") } if errors.Is(err, constants.ErrAPINoBackendServices) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API must have at least one backend service attached before deployment")) - return + return apperror.RESTAPIDeploymentValidationFailed.Wrap(err, "API must have at least one backend service attached before deployment") } - h.slogger.Error("Failed to deploy API", "apiId", apiId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to deploy API")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to deploy API %s", apiId)) } + setLocation(w, "rest-apis", apiId, "deployments", deployment.DeploymentId.String()) httputil.WriteJSON(w, http.StatusCreated, deployment) + return nil } // UndeployDeployment handles POST /api/v0.9/rest-apis/:apiId/deployments/:deploymentId/undeploy -func (h *DeploymentHandler) UndeployDeployment(w http.ResponseWriter, r *http.Request) { +func (h *DeploymentHandler) UndeployDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") deploymentId := r.PathValue("deploymentId") gatewayId := r.URL.Query().Get("gatewayId") if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "deploymentId is required")) - return + return apperror.ValidationFailed.New("deploymentId is required") } if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId is required")) - return + return apperror.ValidationFailed.New("gatewayId is required") } if deploymentId == "00000000-0000-0000-0000-000000000000" || gatewayId == "00000000-0000-0000-0000-000000000000" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "deploymentId/gatewayId cannot be zero-value UUID")) - return + return apperror.ValidationFailed.New("deploymentId/gatewayId cannot be zero-value UUID") } if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "undeploy API") - if !ok { - return + actor, err := resolveActorErr(r, h.identity, "undeploy API") + if err != nil { + return err } deployment, err := h.deploymentService.UndeployDeploymentByHandle(apiId, deploymentId, gatewayId, orgId, actor) if err != nil { // DP-originated artifacts are read-only: undeployment cannot be initiated from the CP. - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err) } if errors.Is(err, constants.ErrDeploymentNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) } if errors.Is(err, constants.ErrGatewayNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) } if errors.Is(err, constants.ErrDeploymentNotActive) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "No active deployment found for this API on the gateway")) - return + return apperror.DeploymentNotActive.Wrap(err, "API") } if errors.Is(err, constants.ErrGatewayIDMismatch) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment is bound to a different gateway")) - return + return apperror.DeploymentGatewayMismatch.Wrap(err) } - h.slogger.Error("Failed to undeploy", "apiId", apiId, "deploymentId", deploymentId, "gatewayId", gatewayId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to undeploy deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to undeploy API %s deployment %s from gateway %s", apiId, deploymentId, gatewayId)) } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // RestoreDeployment handles POST /api/v0.9/rest-apis/:apiId/deployments/:deploymentId/restore -func (h *DeploymentHandler) RestoreDeployment(w http.ResponseWriter, r *http.Request) { +func (h *DeploymentHandler) RestoreDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") deploymentId := r.PathValue("deploymentId") gatewayId := r.URL.Query().Get("gatewayId") if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "deploymentId is required")) - return + return apperror.ValidationFailed.New("deploymentId is required") } if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId is required")) - return + return apperror.ValidationFailed.New("gatewayId is required") } if deploymentId == "00000000-0000-0000-0000-000000000000" || gatewayId == "00000000-0000-0000-0000-000000000000" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "deploymentId/gatewayId cannot be zero-value UUID")) - return + return apperror.ValidationFailed.New("deploymentId/gatewayId cannot be zero-value UUID") } if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "restore API deployment") - if !ok { - return + actor, err := resolveActorErr(r, h.identity, "restore API deployment") + if err != nil { + return err } deployment, err := h.deploymentService.RestoreDeploymentByHandle(apiId, deploymentId, gatewayId, orgId, actor) if err != nil { // DP-originated artifacts are read-only: restore cannot be initiated from the CP. - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err) } if errors.Is(err, constants.ErrDeploymentNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) } if errors.Is(err, constants.ErrGatewayNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) } if errors.Is(err, constants.ErrDeploymentAlreadyDeployed) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Cannot restore currently deployed deployment")) - return + return apperror.DeploymentRestoreConflict.Wrap(err) } if errors.Is(err, constants.ErrGatewayIDMismatch) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment is bound to a different gateway")) - return + return apperror.DeploymentGatewayMismatch.Wrap(err) } - h.slogger.Error("Failed to restore deployment", "apiId", apiId, "deploymentId", deploymentId, "gatewayId", gatewayId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to restore deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to restore API %s deployment %s on gateway %s", apiId, deploymentId, gatewayId)) } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // DeleteDeployment handles DELETE /api/v0.9/rest-apis/:apiId/deployments/:deploymentId // Permanently deletes an undeployed deployment artifact -func (h *DeploymentHandler) DeleteDeployment(w http.ResponseWriter, r *http.Request) { +func (h *DeploymentHandler) DeleteDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") deploymentId := r.PathValue("deploymentId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment ID is required")) - return + return apperror.ValidationFailed.New("Deployment ID is required") } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "delete API deployment") - if !ok { - return - } - err := h.deploymentService.DeleteDeploymentByHandle(apiId, deploymentId, orgId, actor) + actor, err := resolveActorErr(r, h.identity, "delete API deployment") if err != nil { + return err + } + if err := h.deploymentService.DeleteDeploymentByHandle(apiId, deploymentId, orgId, actor); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err) } if errors.Is(err, constants.ErrDeploymentNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) } if errors.Is(err, constants.ErrDeploymentIsDeployed) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Cannot delete an active deployment - undeploy it first")) - return + return apperror.DeploymentActive.Wrap(err) } - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } - h.slogger.Error("Failed to delete deployment", "apiId", apiId, "deploymentId", deploymentId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete API %s deployment %s", apiId, deploymentId)) } w.WriteHeader(http.StatusNoContent) + return nil } // GetDeployment handles GET /api/v0.9/rest-apis/:apiId/deployments/:deploymentId // Retrieves metadata for a specific deployment artifact -func (h *DeploymentHandler) GetDeployment(w http.ResponseWriter, r *http.Request) { +func (h *DeploymentHandler) GetDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") deploymentId := r.PathValue("deploymentId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment ID is required")) - return + return apperror.ValidationFailed.New("Deployment ID is required") } deployment, err := h.deploymentService.GetDeploymentByHandle(apiId, deploymentId, orgId) if err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err) } if errors.Is(err, constants.ErrDeploymentNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) } - h.slogger.Error("Failed to get deployment", "apiId", apiId, "deploymentId", deploymentId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get API %s deployment %s", apiId, deploymentId)) } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // GetDeployments handles GET /api/v0.9/rest-apis/:apiId/deployments // Retrieves all deployment records for an API with optional filters -func (h *DeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Request) { +func (h *DeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } apiId := r.PathValue("restApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } var params api.GetDeploymentsParams @@ -431,32 +345,27 @@ func (h *DeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Reques deployments, err := h.deploymentService.GetDeploymentsByHandle(apiId, gatewayId, status, orgId) if err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + return apperror.RESTAPINotFound.Wrap(err) } if errors.Is(err, constants.ErrInvalidDeploymentStatus) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid deployment status")) - return + return apperror.DeploymentInvalidStatus.Wrap(err) } - h.slogger.Error("Failed to get deployments", "apiId", apiId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve deployments")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get deployments for API %s", apiId)) } httputil.WriteJSON(w, http.StatusOK, deployments) + return nil } // RegisterRoutes registers all deployment-related routes func (h *DeploymentHandler) RegisterRoutes(mux *http.ServeMux) { h.slogger.Debug("Registering deployment routes") base := constants.APIBasePath + "/rest-apis/{restApiId}" - mux.HandleFunc("POST "+base+"/deployments", h.DeployAPI) - mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/undeploy", h.UndeployDeployment) - mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/restore", h.RestoreDeployment) - mux.HandleFunc("GET "+base+"/deployments", h.GetDeployments) - mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", h.GetDeployment) - mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", h.DeleteDeployment) + mux.HandleFunc("POST "+base+"/deployments", middleware.MapErrors(h.slogger, h.DeployAPI)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/undeploy", middleware.MapErrors(h.slogger, h.UndeployDeployment)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/restore", middleware.MapErrors(h.slogger, h.RestoreDeployment)) + mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetDeployments)) + mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetDeployment)) + mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteDeployment)) } diff --git a/platform-api/internal/handler/api_key.go b/platform-api/internal/handler/api_key.go index 9d137ec7fb..200b00c8d4 100644 --- a/platform-api/internal/handler/api_key.go +++ b/platform-api/internal/handler/api_key.go @@ -25,6 +25,7 @@ import ( "net/http" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -51,41 +52,34 @@ func NewAPIKeyHandler(apiKeyService *service.APIKeyService, identity *service.Id // CreateAPIKey handles POST /rest-apis/{restApiId}/api-keys // This endpoint allows users to inject external API keys to all the gateways where the API is deployed -func (h *APIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *APIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) error { // Extract organization from JWT token orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } - userId, ok := resolveActor(w, r, h.identity, h.slogger, "create API key") - if !ok { - return + userId, err := resolveActorErr(r, h.identity, "create API key") + if err != nil { + return err } // Extract API handle from path parameter (parameter named apiId for backward compatibility, but contains handle) apiHandle := r.PathValue("restApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API handle is required")) - return + return apperror.ValidationFailed.New("API handle is required") } // Parse and validate request body var req api.CreateAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("Invalid API key creation request", "userId", userId, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid API key creation request for user %s", userId)) } if req.ApiKey == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key value is required")) - return + return apperror.ValidationFailed.New("API key value is required") } // If user has provided an id, use it. Otherwise, generate one from the display name. @@ -95,37 +89,20 @@ func (h *APIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { } else { generatedName, err := utils.GenerateHandle(req.DisplayName, nil) if err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Failed to generate API key name")) - return + return apperror.ValidationFailed.Wrap(err, "Failed to generate API key name") } name = generatedName req.Id = &name } // Create the API key and broadcast to gateways - err := h.apiKeyService.CreateAPIKey(r.Context(), apiHandle, constants.RestApi, orgId, userId, &req) - if err != nil { - // Handle specific error cases - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + if err := h.apiKeyService.CreateAPIKey(r.Context(), apiHandle, constants.RestApi, orgId, userId, &req); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", - "No gateway connections available for API")) - return - } - - keyName := "" - if req.Id != nil { - keyName = *req.Id - } - h.slogger.Error("Failed to create API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to create API key")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create API key %q for API %s in org %s by user %s", name, apiHandle, orgId, userId)) } keyName := "" @@ -135,87 +112,68 @@ func (h *APIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { h.slogger.Info("Successfully created API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) // Return success response + setLocation(w, "rest-apis", apiHandle, "api-keys", name) httputil.WriteJSON(w, http.StatusCreated, api.CreateAPIKeyResponse{ Status: api.CreateAPIKeyResponseStatusSuccess, KeyId: req.Id, Message: "API key created and broadcasted to gateways successfully", }) + return nil } // UpdateAPIKey handles PUT /rest-apis/{restApiId}/api-keys/{apiKeyId} // This endpoint allows external platforms to update/regenerate external API keys on hybrid gateways -func (h *APIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *APIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) error { // Extract organization from JWT token orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } - userId, ok := resolveActor(w, r, h.identity, h.slogger, "update API key") - if !ok { - return + userId, err := resolveActorErr(r, h.identity, "update API key") + if err != nil { + return err } // Extract API ID and key name from path parameters apiHandle := r.PathValue("restApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API handle is required")) - return + return apperror.ValidationFailed.New("API handle is required") } keyName := r.PathValue("apiKeyId") if keyName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key name is required")) - return + return apperror.ValidationFailed.New("API key name is required") } // Parse and validate request body var req api.UpdateAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Warn("Invalid API key update request", "userId", userId, "orgId", orgId, "apiHandle", apiHandle, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid request body: "+err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid API key update request for key %s of API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) } // Validate new API key value if req.ApiKey == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key value is required")) - return + return apperror.ValidationFailed.New("API key value is required") } // Validate that the name in the request body (if provided) matches the URL path parameter if err := utils.ValidateHandleImmutable(keyName, req.Name); err != nil { h.slogger.Warn("API key name mismatch", "userId", userId, "orgId", orgId, "apiHandle", apiHandle, "urlKeyName", keyName, "bodyKeyName", *req.Name) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - fmt.Sprintf("API key name mismatch: name in request body '%s' must match the key name in URL '%s'", *req.Name, keyName))) - return + return apperror.ValidationFailed.New(fmt.Sprintf("API key name mismatch: name in request body '%s' must match the key name in URL '%s'", *req.Name, keyName)). + WithLogMessage(fmt.Sprintf("API key name mismatch for API %s in org %s by user %s", apiHandle, orgId, userId)) } // Update the API key and broadcast to gateways - err := h.apiKeyService.UpdateAPIKey(r.Context(), apiHandle, constants.RestApi, orgId, keyName, userId, &req) - if err != nil { - // Handle specific error cases - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return + if err := h.apiKeyService.UpdateAPIKey(r.Context(), apiHandle, constants.RestApi, orgId, keyName, userId, &req); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", - "No gateway connections available for API")) - return - } - - h.slogger.Error("Failed to update API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to update API key")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update API key %s for API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) } h.slogger.Info("Successfully updated API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) @@ -226,71 +184,57 @@ func (h *APIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) { Message: "API key updated and broadcasted to gateways successfully", KeyId: &keyName, }) + return nil } // RevokeAPIKey handles DELETE /rest-apis/{restApiId}/api-keys/{apiKeyId} // This endpoint allows Cloud APIM to revoke external API keys on hybrid gateways -func (h *APIKeyHandler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *APIKeyHandler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) error { // Extract organization from JWT token orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } // Extract API ID and key name from path parameters apiHandle := r.PathValue("restApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API handle is required")) - return + return apperror.ValidationFailed.New("API handle is required") } keyName := r.PathValue("apiKeyId") if keyName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key name is required")) - return + return apperror.ValidationFailed.New("API key name is required") } - userId, ok := resolveActor(w, r, h.identity, h.slogger, "revoke API key") - if !ok { - return + userId, err := resolveActorErr(r, h.identity, "revoke API key") + if err != nil { + return err } // Revoke the API key and broadcast to gateways - err := h.apiKeyService.RevokeAPIKey(r.Context(), apiHandle, constants.RestApi, orgId, keyName, userId) - if err != nil { - // Handle specific error cases - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return - } - if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", - "No gateway connections available for API")) - return + if err := h.apiKeyService.RevokeAPIKey(r.Context(), apiHandle, constants.RestApi, orgId, keyName, userId); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - - h.slogger.Error("Failed to revoke API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to revoke API key in one or more gateways")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to revoke API key %s for API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) } h.slogger.Info("Successfully revoked API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) // Return success response (204 No Content) w.WriteHeader(http.StatusNoContent) + return nil } // RegisterRoutes registers API key routes with the router func (h *APIKeyHandler) RegisterRoutes(mux *http.ServeMux) { h.slogger.Debug("Registering API key routes") base := constants.APIBasePath + "/rest-apis/{restApiId}/api-keys" - mux.HandleFunc("POST "+base, h.CreateAPIKey) - mux.HandleFunc("PUT "+base+"/{apiKeyId}", h.UpdateAPIKey) - mux.HandleFunc("DELETE "+base+"/{apiKeyId}", h.RevokeAPIKey) + mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateAPIKey)) + mux.HandleFunc("PUT "+base+"/{apiKeyId}", middleware.MapErrors(h.slogger, h.UpdateAPIKey)) + mux.HandleFunc("DELETE "+base+"/{apiKeyId}", middleware.MapErrors(h.slogger, h.RevokeAPIKey)) } diff --git a/platform-api/internal/handler/apikey_user.go b/platform-api/internal/handler/apikey_user.go index ff17cfda23..2f84529f35 100644 --- a/platform-api/internal/handler/apikey_user.go +++ b/platform-api/internal/handler/apikey_user.go @@ -22,10 +22,10 @@ import ( "net/http" "strings" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -47,17 +47,16 @@ func NewAPIKeyUserHandler(apiKeyUserService *service.APIKeyUserService, identity } // ListUserAPIKeys handles GET /api/v0.9/me/api-keys -func (h *APIKeyUserHandler) ListUserAPIKeys(w http.ResponseWriter, r *http.Request) { +func (h *APIKeyUserHandler) ListUserAPIKeys(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } - callerUserID, ok := resolveActor(w, r, h.identity, h.slogger, "list user API keys") - if !ok { - return + callerUserID, err := resolveActorErr(r, h.identity, "list user API keys") + if err != nil { + return err } var types []string @@ -67,16 +66,15 @@ func (h *APIKeyUserHandler) ListUserAPIKeys(w http.ResponseWriter, r *http.Reque response, err := h.apiKeyUserService.ListAPIKeysByUser(r.Context(), orgID, callerUserID, types) if err != nil { - h.slogger.Error("Failed to list API keys for user", "orgId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to list API keys")) - return + return apperror.Internal.Wrap(err). + WithLogMessage("failed to list API keys for user in org " + orgID) } httputil.WriteJSON(w, http.StatusOK, response) + return nil } // RegisterRoutes registers the user API key routes. func (h *APIKeyUserHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET "+constants.APIBasePath+"/me/api-keys", h.ListUserAPIKeys) + mux.HandleFunc("GET "+constants.APIBasePath+"/me/api-keys", middleware.MapErrors(h.slogger, h.ListUserAPIKeys)) } diff --git a/platform-api/internal/handler/application.go b/platform-api/internal/handler/application.go index f99778ca97..23b5494efd 100644 --- a/platform-api/internal/handler/application.go +++ b/platform-api/internal/handler/application.go @@ -20,16 +20,17 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strconv" "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -44,78 +45,75 @@ func NewApplicationHandler(applicationService *service.ApplicationService, ident return &ApplicationHandler{applicationService: applicationService, identity: identity, slogger: slogger} } -func (h *ApplicationHandler) CreateApplication(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) CreateApplication(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req api.CreateApplicationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.NewValidationErrorResponse(w, err) - return + return apperror.NewValidation(err) } if strings.TrimSpace(req.DisplayName) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "displayName is required")) - return + return apperror.ValidationFailed.New("displayName is required") } if strings.TrimSpace(req.ProjectId) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Project ID is required")) - return + return apperror.ValidationFailed.New("Project ID is required") } if strings.TrimSpace(string(req.Type)) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application type is required")) - return + return apperror.ValidationFailed.New("Application type is required") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "create application") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "create application") + if err != nil { + return err } app, err := h.applicationService.CreateApplication(&req, orgID, createdBy) if err != nil { - h.writeApplicationError(w, r, err, "Failed to create application") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to create application in project %s for org %s by user %s", req.ProjectId, orgID, createdBy)) } + setLocation(w, "applications", app.Id) httputil.WriteJSON(w, http.StatusCreated, app) + return nil } -func (h *ApplicationHandler) GetApplication(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) GetApplication(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } app, err := h.applicationService.GetApplicationByID(appID, orgID) if err != nil { - h.writeApplicationError(w, r, err, "Failed to get application") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to get application %s in org %s", appID, orgID)) } httputil.WriteJSON(w, http.StatusOK, app) + return nil } -func (h *ApplicationHandler) ListApplications(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) ListApplications(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } projectID := strings.TrimSpace(r.URL.Query().Get("projectId")) if projectID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Project ID is required")) - return + return apperror.ValidationFailed.New("Project ID is required") } var limitStr string @@ -146,81 +144,80 @@ func (h *ApplicationHandler) ListApplications(w http.ResponseWriter, r *http.Req apps, err := h.applicationService.GetApplicationsByOrganization(orgID, projectID, limit, offset) if err != nil { - h.writeApplicationError(w, r, err, "Failed to list applications") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to list applications for project %s in org %s", projectID, orgID)) } httputil.WriteJSON(w, http.StatusOK, apps) + return nil } -func (h *ApplicationHandler) UpdateApplication(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) UpdateApplication(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } - userID, ok := resolveActor(w, r, h.identity, h.slogger, "update application") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "update application") + if err != nil { + return err } var req api.Application if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.NewValidationErrorResponse(w, err) - return + return apperror.NewValidation(err) } app, err := h.applicationService.UpdateApplication(appID, &req, orgID, userID) if err != nil { - h.writeApplicationError(w, r, err, "Failed to update application") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to update application %s in org %s by user %s", appID, orgID, userID)) } httputil.WriteJSON(w, http.StatusOK, app) + return nil } -func (h *ApplicationHandler) DeleteApplication(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) DeleteApplication(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } - userID, ok := resolveActor(w, r, h.identity, h.slogger, "delete application") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "delete application") + if err != nil { + return err } if err := h.applicationService.DeleteApplication(appID, orgID, userID); err != nil { - h.writeApplicationError(w, r, err, "Failed to delete application") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to delete application %s in org %s by user %s", appID, orgID, userID)) } httputil.WriteJSON(w, http.StatusNoContent, nil) + return nil } -func (h *ApplicationHandler) ListApplicationAssociations(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) ListApplicationAssociations(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } limit := 20 @@ -244,82 +241,79 @@ func (h *ApplicationHandler) ListApplicationAssociations(w http.ResponseWriter, associations, err := h.applicationService.ListApplicationAssociations(appID, orgID, limit, offset) if err != nil { - h.writeApplicationError(w, r, err, "Failed to list application associations") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to list associations for application %s in org %s", appID, orgID)) } httputil.WriteJSON(w, http.StatusOK, associations) + return nil } -func (h *ApplicationHandler) AddApplicationAssociations(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) AddApplicationAssociations(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } var req service.AddApplicationAssociationsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.NewValidationErrorResponse(w, err) - return + return apperror.NewValidation(err) } if len(req.Associations) == 0 { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "At least one association is required")) - return + return apperror.ValidationFailed.New("At least one association is required") } associations, err := h.applicationService.AddApplicationAssociations(appID, &req, orgID) if err != nil { - h.writeApplicationError(w, r, err, "Failed to add application associations") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to add associations for application %s in org %s", appID, orgID)) } httputil.WriteJSON(w, http.StatusOK, associations) + return nil } -func (h *ApplicationHandler) RemoveApplicationAssociation(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) RemoveApplicationAssociation(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") associationID := r.PathValue("associationId") if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } if strings.TrimSpace(associationID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Association ID is required")) - return + return apperror.ValidationFailed.New("Association ID is required") } if err := h.applicationService.RemoveApplicationAssociation(appID, associationID, orgID); err != nil { - h.writeApplicationError(w, r, err, "Failed to remove application association") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to remove association %s for application %s in org %s", associationID, appID, orgID)) } httputil.WriteJSON(w, http.StatusNoContent, nil) + return nil } -func (h *ApplicationHandler) ListApplicationAPIKeys(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) ListApplicationAPIKeys(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } limit := 20 @@ -343,29 +337,28 @@ func (h *ApplicationHandler) ListApplicationAPIKeys(w http.ResponseWriter, r *ht keys, err := h.applicationService.ListMappedAPIKeys(appID, orgID, limit, offset) if err != nil { - h.writeApplicationError(w, r, err, "Failed to list mapped API keys") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to list mapped API keys for application %s in org %s", appID, orgID)) } httputil.WriteJSON(w, http.StatusOK, keys) + return nil } -func (h *ApplicationHandler) ListApplicationAssociationAPIKeys(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) ListApplicationAssociationAPIKeys(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") associationID := r.PathValue("associationId") if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } if strings.TrimSpace(associationID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Association ID is required")) - return + return apperror.ValidationFailed.New("Association ID is required") } limit := 20 @@ -389,148 +382,138 @@ func (h *ApplicationHandler) ListApplicationAssociationAPIKeys(w http.ResponseWr keys, err := h.applicationService.ListMappedAPIKeysForAssociation(appID, associationID, orgID, limit, offset) if err != nil { - h.writeApplicationError(w, r, err, "Failed to list mapped API keys for association") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to list mapped API keys for association %s of application %s in org %s", associationID, appID, orgID)) } httputil.WriteJSON(w, http.StatusOK, keys) + return nil } -func (h *ApplicationHandler) AddApplicationAPIKeys(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) AddApplicationAPIKeys(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") - userID, ok := resolveActor(w, r, h.identity, h.slogger, "add application API keys") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "add application API keys") + if err != nil { + return err } if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } var req api.AddApplicationAPIKeysRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.NewValidationErrorResponse(w, err) - return + return apperror.NewValidation(err) } if len(req.ApiKeys) == 0 { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "At least one API key mapping is required")) - return + return apperror.ValidationFailed.New("At least one API key mapping is required") } keys, err := h.applicationService.AddMappedAPIKeys(appID, &req, orgID, userID) if err != nil { - h.writeApplicationError(w, r, err, "Failed to add mapped API keys") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to add mapped API keys for application %s in org %s by user %s", appID, orgID, userID)) } httputil.WriteJSON(w, http.StatusOK, keys) + return nil } -func (h *ApplicationHandler) RemoveApplicationAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *ApplicationHandler) RemoveApplicationAPIKey(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } appID := r.PathValue("applicationId") keyID := r.PathValue("apiKeyId") entityID := strings.TrimSpace(r.URL.Query().Get("entityID")) - userID, ok := resolveActor(w, r, h.identity, h.slogger, "remove mapped application API key") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "remove mapped application API key") + if err != nil { + return err } if strings.TrimSpace(appID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application ID is required")) - return + return apperror.ValidationFailed.New("Application ID is required") } if strings.TrimSpace(keyID) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API key id is required")) - return + return apperror.ValidationFailed.New("API key id is required") } if entityID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Entity ID is required")) - return + return apperror.ValidationFailed.New("Entity ID is required") } if err := h.applicationService.RemoveMappedAPIKey(appID, keyID, entityID, orgID, userID); err != nil { - h.writeApplicationError(w, r, err, "Failed to remove mapped API key") - return + return h.mapApplicationError(err). + WithLogMessage(fmt.Sprintf("failed to remove mapped API key %s for application %s in org %s by user %s", keyID, appID, orgID, userID)) } httputil.WriteJSON(w, http.StatusNoContent, nil) + return nil } func (h *ApplicationHandler) RegisterRoutes(mux *http.ServeMux) { base := constants.APIBasePath + "/applications" - mux.HandleFunc("GET "+base, h.ListApplications) - mux.HandleFunc("POST "+base, h.CreateApplication) - mux.HandleFunc("GET "+base+"/{applicationId}", h.GetApplication) - mux.HandleFunc("PUT "+base+"/{applicationId}", h.UpdateApplication) - mux.HandleFunc("DELETE "+base+"/{applicationId}", h.DeleteApplication) - - mux.HandleFunc("GET "+base+"/{applicationId}/api-keys", h.ListApplicationAPIKeys) - mux.HandleFunc("POST "+base+"/{applicationId}/api-keys", h.AddApplicationAPIKeys) - mux.HandleFunc("DELETE "+base+"/{applicationId}/api-keys/{apiKeyId}", h.RemoveApplicationAPIKey) - mux.HandleFunc("GET "+base+"/{applicationId}/associations", h.ListApplicationAssociations) - mux.HandleFunc("POST "+base+"/{applicationId}/associations", h.AddApplicationAssociations) - mux.HandleFunc("GET "+base+"/{applicationId}/associations/{associationId}/api-keys", h.ListApplicationAssociationAPIKeys) - mux.HandleFunc("DELETE "+base+"/{applicationId}/associations/{associationId}", h.RemoveApplicationAssociation) + mux.HandleFunc("GET "+base, middleware.MapErrors(h.slogger, h.ListApplications)) + mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateApplication)) + mux.HandleFunc("GET "+base+"/{applicationId}", middleware.MapErrors(h.slogger, h.GetApplication)) + mux.HandleFunc("PUT "+base+"/{applicationId}", middleware.MapErrors(h.slogger, h.UpdateApplication)) + mux.HandleFunc("DELETE "+base+"/{applicationId}", middleware.MapErrors(h.slogger, h.DeleteApplication)) + + mux.HandleFunc("GET "+base+"/{applicationId}/api-keys", middleware.MapErrors(h.slogger, h.ListApplicationAPIKeys)) + mux.HandleFunc("POST "+base+"/{applicationId}/api-keys", middleware.MapErrors(h.slogger, h.AddApplicationAPIKeys)) + mux.HandleFunc("DELETE "+base+"/{applicationId}/api-keys/{apiKeyId}", middleware.MapErrors(h.slogger, h.RemoveApplicationAPIKey)) + mux.HandleFunc("GET "+base+"/{applicationId}/associations", middleware.MapErrors(h.slogger, h.ListApplicationAssociations)) + mux.HandleFunc("POST "+base+"/{applicationId}/associations", middleware.MapErrors(h.slogger, h.AddApplicationAssociations)) + mux.HandleFunc("GET "+base+"/{applicationId}/associations/{associationId}/api-keys", middleware.MapErrors(h.slogger, h.ListApplicationAssociationAPIKeys)) + mux.HandleFunc("DELETE "+base+"/{applicationId}/associations/{associationId}", middleware.MapErrors(h.slogger, h.RemoveApplicationAssociation)) } -func (h *ApplicationHandler) writeApplicationError(w http.ResponseWriter, r *http.Request, err error, fallback string) { - if h.slogger != nil { - h.slogger.Error(fallback, - "error", err, - "path", r.URL.Path, - "method", r.Method, - "id", r.PathValue("applicationId"), - ) - } - +// mapApplicationError maps service errors to *apperror.Error values for the +// centralized error mapper, preserving the exact status/code/message each +// error produced before the migration. +func (h *ApplicationHandler) mapApplicationError(err error) *apperror.Error { switch { case errors.Is(err, constants.ErrApplicationNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Application not found")) + return apperror.ApplicationNotFound.Wrap(err) case errors.Is(err, constants.ErrProjectNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Project not found")) + return apperror.ProjectNotFound.Wrap(err) case errors.Is(err, constants.ErrOrganizationNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Organization not found")) + return apperror.OrganizationNotFound.Wrap(err) case errors.Is(err, constants.ErrApplicationExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Application already exists in project")) + return apperror.ApplicationExists.Wrap(err) case errors.Is(err, constants.ErrHandleExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Application handle already exists in organization")) + return apperror.ApplicationExists.Wrap(err) case errors.Is(err, constants.ErrAPIKeyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "API key not found")) + return apperror.ApplicationAPIKeyNotFound.Wrap(err) case errors.Is(err, constants.ErrAPIKeyForbidden): - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "Only the key creator can perform this action")) + return apperror.ApplicationAPIKeyForbidden.Wrap(err) case errors.Is(err, constants.ErrInvalidApplicationName): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "displayName is required")) + return apperror.ValidationFailed.Wrap(err, "displayName is required") case errors.Is(err, constants.ErrInvalidApplicationType): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Application type is required")) + return apperror.ValidationFailed.Wrap(err, "Application type is required") case errors.Is(err, constants.ErrUnsupportedApplicationType): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid application type. Only 'genai' is supported")) + return apperror.ValidationFailed.Wrap(err, "Invalid application type. Only 'genai' is supported") case errors.Is(err, constants.ErrInvalidHandle): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid application handle format")) + return apperror.ValidationFailed.Wrap(err, "Invalid application handle format") case errors.Is(err, constants.ErrInvalidApplicationID): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid application id")) + return apperror.ValidationFailed.Wrap(err, "Invalid application id") case errors.Is(err, constants.ErrInvalidAPIKey): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid API key id")) + return apperror.ValidationFailed.Wrap(err, "Invalid API key id") case errors.Is(err, constants.ErrArtifactNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Association target not found")) + return apperror.ArtifactNotFound.Wrap(err) case errors.Is(err, constants.ErrArtifactInvalidKind): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid association kind. Only LlmProvider and LlmProxy are supported")) + return apperror.ValidationFailed.Wrap(err, "Invalid association kind. Only LlmProvider and LlmProxy are supported") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid application association input")) + return apperror.ValidationFailed.Wrap(err, "Invalid application association input") case errors.Is(err, constants.ErrHandleImmutable): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "The id is immutable and must match the application being updated")) + return apperror.ValidationFailed.Wrap(err, "The id is immutable and must match the application being updated") default: - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", fallback)) + return apperror.Internal.Wrap(err) } } diff --git a/platform-api/internal/handler/artifact_guard_response.go b/platform-api/internal/handler/artifact_guard_response.go index 024c328d43..cc5e863846 100644 --- a/platform-api/internal/handler/artifact_guard_response.go +++ b/platform-api/internal/handler/artifact_guard_response.go @@ -19,34 +19,29 @@ package handler import ( "errors" - "net/http" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" - "github.com/wso2/api-platform/platform-api/internal/utils" - - "github.com/wso2/go-httpkit/httputil" ) -// respondArtifactGuardError writes the appropriate HTTP response for read-only / -// deletion-guard errors raised when a mutating operation targets a -// data-plane-originated (origin=DP) artifact. It returns true when it handled the -// error (and wrote a response), so callers can simply `return`. +// mapArtifactGuardError maps read-only / deletion-guard errors raised when a +// mutating operation targets a data-plane-originated (origin=DP) artifact to +// the corresponding *apperror.Error, for the MapErrors middleware to log and +// serialize. It returns nil when err is not a guard error, so callers can +// `if guardErr := mapArtifactGuardError(err); guardErr != nil { return guardErr }`. // // - ErrArtifactReadOnly -> 403 Forbidden (update/deploy of a DP artifact) // - ErrArtifactRuntimeImmutable -> 403 Forbidden (edit that would change a DP artifact's runtime config) // - ErrArtifactDeployed -> 409 Conflict (delete of a still-deployed DP artifact) -func respondArtifactGuardError(w http.ResponseWriter, err error) bool { +func mapArtifactGuardError(err error) error { switch { case errors.Is(err, constants.ErrArtifactReadOnly): - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", err.Error())) - return true + return apperror.ArtifactReadOnly.Wrap(err, "Artifact is read-only: it originated from a data-plane gateway") case errors.Is(err, constants.ErrArtifactRuntimeImmutable): - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", err.Error())) - return true + return apperror.ArtifactRuntimeImmutable.Wrap(err, "Runtime configuration of this artifact cannot be changed") case errors.Is(err, constants.ErrArtifactDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", err.Error())) - return true + return apperror.ArtifactDeployed.Wrap(err, "Artifact is still deployed on a gateway and cannot be deleted") default: - return false + return nil } } diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index 40b8360a80..aab14a85ee 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -18,10 +18,13 @@ package handler import ( + "log/slog" "net/http" "time" "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/golang-jwt/jwt/v5" "github.com/wso2/go-httpkit/httputil" @@ -40,28 +43,27 @@ type loginResponse struct { // AuthLoginHandler issues JWT tokens for locally-configured users (file-based auth mode). type AuthLoginHandler struct { - cfg *config.Server + cfg *config.Server + slogger *slog.Logger } func NewAuthLoginHandler(cfg *config.Server) *AuthLoginHandler { - return &AuthLoginHandler{cfg: cfg} + return &AuthLoginHandler{cfg: cfg, slogger: slog.Default()} } func (h *AuthLoginHandler) RegisterPublicRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /api/portal/v0.9/auth/login", h.Login) + mux.HandleFunc("POST /api/portal/v0.9/auth/login", middleware.MapErrors(h.slogger, h.Login)) } -func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) { +func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { var req loginRequest if err := r.ParseForm(); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "username and password are required"}) - return + return apperror.ValidationFailed.New("username and password are required") } req.Username = r.PostForm.Get("username") req.Password = r.PostForm.Get("password") if req.Username == "" || req.Password == "" { - httputil.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "username and password are required"}) - return + return apperror.ValidationFailed.New("username and password are required") } fileBasedAuth := &h.cfg.Auth.FileBased @@ -77,13 +79,11 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) { // timing-based username enumeration. if matched == nil { _ = bcrypt.CompareHashAndPassword([]byte("$2a$10$notarealhashjustpadding000000000000000000000000000"), []byte(req.Password)) - httputil.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid credentials"}) - return + return apperror.Unauthorized.New().WithLogMessage("login failed: user not found") } if err := bcrypt.CompareHashAndPassword([]byte(matched.PasswordHash), []byte(req.Password)); err != nil { - httputil.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid credentials"}) - return + return apperror.Unauthorized.New().WithLogMessage("login failed: password mismatch") } expiry := time.Now().Add(8 * time.Hour) @@ -102,12 +102,12 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString([]byte(h.cfg.Auth.JWT.SecretKey)) if err != nil { - httputil.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "failed to issue token"}) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to issue token") } httputil.WriteJSON(w, http.StatusOK, loginResponse{ Token: signed, ExpiresAt: expiry.Unix(), }) + return nil } diff --git a/platform-api/internal/handler/gateway.go b/platform-api/internal/handler/gateway.go index fbbfb3ff58..db5c879097 100644 --- a/platform-api/internal/handler/gateway.go +++ b/platform-api/internal/handler/gateway.go @@ -20,13 +20,16 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" - "github.com/wso2/api-platform/platform-api/api" - "github.com/wso2/api-platform/platform-api/internal/constants" "regexp" "strings" + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" "github.com/wso2/api-platform/platform-api/internal/utils" @@ -63,18 +66,15 @@ type manifestSyncResponse struct { } // CreateGateway handles POST /api/v0.9/gateways -func (h *GatewayHandler) CreateGateway(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) CreateGateway(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } var req api.CreateGatewayRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.NewValidation(err) } // Convert functionality type to string @@ -102,124 +102,84 @@ func (h *GatewayHandler) CreateGateway(w http.ResponseWriter, r *http.Request) { version = strings.TrimSpace(*req.Version) } if version != "" && !gatewayVersionPattern.MatchString(version) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "version must be in 'major.minor' format (e.g. '1.0') or CalVer 'YYYY.MM.DD' format (e.g. '2026.05.13')")) - return + return apperror.ValidationFailed.New( + "version must be in 'major.minor' format (e.g. '1.0') or CalVer 'YYYY.MM.DD' format (e.g. '2026.05.13')") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "register gateway") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "register gateway") + if err != nil { + return err } gateway, err := h.gatewayService.RegisterGateway(orgId, req.Id, req.DisplayName, description, req.Endpoints, isCritical, functionalityType, version, createdBy, properties) if err != nil { - errMsg := err.Error() - - // Check for specific error types - if strings.Contains(errMsg, "organization not found") { - h.slogger.Error("Organization not found during gateway creation", "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", errMsg)) - return - } - - if strings.Contains(errMsg, "already exists") { - h.slogger.Error("Gateway already exists", "error", err) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", errMsg)) - return - } - - if strings.Contains(errMsg, "required") || strings.Contains(errMsg, "invalid") || - strings.Contains(errMsg, "must") || strings.Contains(errMsg, "cannot") { - h.slogger.Error("Invalid gateway creation request", "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", errMsg)) - return + // The service constructs typed catalog errors (not found, conflict, + // validation) at the point of failure — pass them through untouched. + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - - // Internal server error - h.slogger.Error("Failed to register gateway", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to register gateway")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to register gateway") } // Return 201 Created with response + setLocation(w, "gateways", strOrEmpty(gateway.Id)) httputil.WriteJSON(w, http.StatusCreated, gateway) + return nil } // ListGateways handles GET /api/v0.9/gateways with constitution-compliant response -func (h *GatewayHandler) ListGateways(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) ListGateways(w http.ResponseWriter, r *http.Request) error { organizationID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } gateways, err := h.gatewayService.ListGateways(&organizationID) if err != nil { - h.slogger.Error("Failed to list gateways", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to list gateways")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to list gateways") } // Return 200 OK with constitution-compliant envelope structure httputil.WriteJSON(w, http.StatusOK, gateways) + return nil } // GetGateway handles GET /api/v0.9/gateways/:gatewayId -func (h *GatewayHandler) GetGateway(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) GetGateway(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } // Extract UUID path parameter gatewayId := r.PathValue("gatewayId") if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.ValidationFailed.New("Gateway ID is required") } gateway, err := h.gatewayService.GetGateway(gatewayId, orgId) if err != nil { - errMsg := err.Error() - - // Check for specific error types - if strings.Contains(errMsg, "not found") { - h.slogger.Error("Gateway not found", "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", errMsg)) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - - if strings.Contains(errMsg, "invalid UUID") { - h.slogger.Error("Invalid gateway UUID", "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", errMsg)) - return + if strings.Contains(err.Error(), "invalid UUID") { + return apperror.ValidationFailed.Wrap(err, "Invalid gateway ID format") } - - // Internal server error - h.slogger.Error("Failed to retrieve gateway", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve gateway")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to retrieve gateway") } // Return 200 OK with gateway details httputil.WriteJSON(w, http.StatusOK, gateway) + return nil } // GetGatewayStatus retrieves gateway status, optionally filtered by gatewayId query param. -func (h *GatewayHandler) GetGatewayStatus(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) GetGatewayStatus(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } gatewayId := r.URL.Query().Get("gatewayId") @@ -230,306 +190,227 @@ func (h *GatewayHandler) GetGatewayStatus(w http.ResponseWriter, r *http.Request status, err := h.gatewayService.GetGatewayStatus(orgId, gatewayIdPtr) if err != nil { - if strings.Contains(err.Error(), "gateway not found") { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to get gateway status")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to get gateway status") } httputil.WriteJSON(w, http.StatusOK, status) + return nil } // UpdateGateway handles PUT /api/v0.9/gateways/:gatewayId -func (h *GatewayHandler) UpdateGateway(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) UpdateGateway(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } gatewayId := r.PathValue("gatewayId") if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.ValidationFailed.New("Gateway ID is required") } var req api.GatewayResponse if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.NewValidation(err) } if err := utils.ValidateHandleImmutable(gatewayId, req.Id); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway id is immutable and cannot be changed")) - return + return apperror.ValidationFailed.Wrap(err, "Gateway id is immutable and cannot be changed") } if req.DisplayName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "displayName is required")) - return + return apperror.ValidationFailed.New("displayName is required") } - updatedBy, ok := resolveActor(w, r, h.identity, h.slogger, "update gateway") - if !ok { - return + updatedBy, err := resolveActorErr(r, h.identity, "update gateway") + if err != nil { + return err } response, err := h.gatewayService.UpdateGateway(gatewayId, orgId, updatedBy, &req) if err != nil { if errors.Is(err, constants.ErrGatewayNotFound) { - h.slogger.Error("Gateway not found during update", "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) } - h.slogger.Error("Failed to update gateway", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to update gateway")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to update gateway") } httputil.WriteJSON(w, http.StatusOK, response) + return nil } // DeleteGateway handles DELETE /api/v0.9/gateways/:gatewayId -func (h *GatewayHandler) DeleteGateway(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) DeleteGateway(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } // Extract UUID path parameter gatewayId := r.PathValue("gatewayId") if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.ValidationFailed.New("Gateway ID is required") } - deletedBy, ok := resolveActor(w, r, h.identity, h.slogger, "delete gateway") - if !ok { - return - } - err := h.gatewayService.DeleteGateway(gatewayId, orgId, deletedBy) + deletedBy, err := resolveActorErr(r, h.identity, "delete gateway") if err != nil { + return err + } + if err := h.gatewayService.DeleteGateway(gatewayId, orgId, deletedBy); err != nil { // Check for specific error types if errors.Is(err, constants.ErrGatewayNotFound) { - h.slogger.Error("Gateway not found during deletion", "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "The specified resource does not exist")) - return + return apperror.GatewayNotFound.Wrap(err) } if errors.Is(err, constants.ErrGatewayHasAssociatedAPIs) { - h.slogger.Error("Gateway has associated APIs during deletion", "error", err) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "The gateway has associated APIs. Please remove all API associations before deleting the gateway")) - return + return apperror.GatewayHasActiveDeployments.Wrap(err) } if strings.Contains(err.Error(), "invalid UUID") { - h.slogger.Error("Invalid UUID during gateway deletion", "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid gateway ID format")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid gateway ID format") } // Internal server error - h.slogger.Error("Failed to delete gateway", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "The server encountered an internal error. Please contact administrator.")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to delete gateway") } // Return 204 No Content on successful deletion w.WriteHeader(http.StatusNoContent) + return nil } // ListTokens handles GET /api/v0.9/gateways/:gatewayId/tokens -func (h *GatewayHandler) ListTokens(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) ListTokens(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } gatewayId := r.PathValue("gatewayId") if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.ValidationFailed.New("Gateway ID is required") } tokens, err := h.gatewayService.ListTokens(gatewayId, orgId) if err != nil { - errMsg := err.Error() - - if strings.Contains(errMsg, "gateway not found") { - h.slogger.Error("Gateway not found during token listing", "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", errMsg)) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - - h.slogger.Error("Failed to list tokens", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to list tokens")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to list tokens") } httputil.WriteJSON(w, http.StatusOK, tokens) + return nil } // RotateToken handles POST /api/v0.9/gateways/:gatewayId/tokens -func (h *GatewayHandler) RotateToken(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) RotateToken(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } // Extract ID path parameter gatewayId := r.PathValue("gatewayId") if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.ValidationFailed.New("Gateway ID is required") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "rotate gateway token") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "rotate gateway token") + if err != nil { + return err } response, err := h.gatewayService.RotateToken(gatewayId, orgId, createdBy) if err != nil { - errMsg := err.Error() - - // Check for specific error types - if strings.Contains(errMsg, "gateway not found") { - h.slogger.Error("Gateway not found during token rotation", "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", errMsg)) - return - } - - if strings.Contains(errMsg, "maximum") || strings.Contains(errMsg, "Revoke") { - h.slogger.Error("Token rotation request validation failed", "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", errMsg)) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - - // Internal server error - h.slogger.Error("Failed to rotate token", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to rotate token")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to rotate token") } // Return 201 Created with response + tokenId := "" + if response.Id != nil { + tokenId = response.Id.String() + } + setLocation(w, "gateways", gatewayId, "tokens", tokenId) httputil.WriteJSON(w, http.StatusCreated, response) + return nil } // RevokeToken handles DELETE /api/v0.9/gateways/:gatewayId/tokens/:tokenId -func (h *GatewayHandler) RevokeToken(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) RevokeToken(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } gatewayId := r.PathValue("gatewayId") if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.ValidationFailed.New("Gateway ID is required") } tokenId := r.PathValue("tokenId") if tokenId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Token ID is required")) - return + return apperror.ValidationFailed.New("Token ID is required") } - revokedBy, ok := resolveActor(w, r, h.identity, h.slogger, "revoke gateway token") - if !ok { - return - } - err := h.gatewayService.RevokeToken(gatewayId, tokenId, orgId, revokedBy) + revokedBy, err := resolveActorErr(r, h.identity, "revoke gateway token") if err != nil { - errMsg := err.Error() - - if strings.Contains(errMsg, "not found") { - h.slogger.Error("Resource not found during token revocation", "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", errMsg)) - return + return err + } + if err := h.gatewayService.RevokeToken(gatewayId, tokenId, orgId, revokedBy); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - - h.slogger.Error("Failed to revoke token", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to revoke token")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to revoke token") } httputil.WriteJSON(w, http.StatusOK, map[string]any{"message": "Token revoked successfully"}) + return nil } // GetGatewayManifest handles GET /api/v0.9/gateways/{gatewayId}/manifest // Called by APIM to retrieve the manifest pushed by the gateway controller on connect. -func (h *GatewayHandler) GetGatewayManifest(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) GetGatewayManifest(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } gatewayId := r.PathValue("gatewayId") if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.ValidationFailed.New("Gateway ID is required") } dataFromDb, err := h.gatewayService.GetStoredManifest(gatewayId, orgId) if err != nil { if strings.Contains(err.Error(), "invalid UUID") { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid gateway ID format")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid gateway ID format") } - if strings.Contains(err.Error(), "gateway not found") { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + if errors.Is(err, constants.ErrGatewayNotFound) { + return apperror.GatewayNotFound.Wrap(err) } - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve gateway manifest")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to retrieve gateway manifest") } httputil.WriteJSON(w, http.StatusOK, manifestSyncResponse{ Policies: dataFromDb.Policies, }) + return nil } // SyncCustomPolicy handles POST /api/v0.9/gateway-custom-policies/sync // It upserts a custom policy from the gateway's stored manifest into the gateway_custom_policies table. -func (h *GatewayHandler) SyncCustomPolicy(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) SyncCustomPolicy(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } gatewayId := r.URL.Query().Get("gatewayId") @@ -537,151 +418,112 @@ func (h *GatewayHandler) SyncCustomPolicy(w http.ResponseWriter, r *http.Request version := r.URL.Query().Get("policyVersion") if gatewayId == "" || policyName == "" || version == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId, policyName and policyVersion are required")) - return + return apperror.ValidationFailed.New("gatewayId, policyName and policyVersion are required") } policy, err := h.gatewayService.SyncCustomPolicy(gatewayId, orgId, policyName, version) if err != nil { - msg := err.Error() - if strings.Contains(msg, "gateway not found") { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", msg)) - return - } - if strings.Contains(msg, "not found in gateway manifest") { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", msg)) - return - } - if strings.Contains(msg, "not a custom policy") || strings.Contains(msg, "manifest is not available") { - httputil.WriteJSON(w, http.StatusUnprocessableEntity, utils.NewErrorResponse(422, "Unprocessable Entity", msg)) - return - } - if strings.Contains(msg, "already exists") || strings.Contains(msg, "patch version updates are not allowed") || strings.Contains(msg, "cannot downgrade") { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", msg)) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to sync custom policy", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to sync custom policy")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to sync custom policy") } httputil.WriteJSON(w, http.StatusOK, policy) + return nil } // GetCustomPolicy handles GET /api/v0.9/gateway-custom-policies/:customPolicyUuid/versions/:version -func (h *GatewayHandler) GetCustomPolicy(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) GetCustomPolicy(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } policyUUID := r.PathValue("gatewayCustomPolicyId") version := r.PathValue("version") if policyUUID == "" || version == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "customPolicyUuid and version are required")) - return + return apperror.ValidationFailed.New("customPolicyUuid and version are required") } policy, err := h.gatewayService.GetCustomPolicyByUUID(orgId, policyUUID, version) if err != nil { - if errors.Is(err, constants.ErrCustomPolicyNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Custom policy not found")) - return - } - if errors.Is(err, constants.ErrCustomPolicyVersionMismatch) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Custom policy not found with the specified version")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to get custom policy", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to get custom policy")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to get custom policy") } httputil.WriteJSON(w, http.StatusOK, policy) + return nil } // DeleteCustomPolicy handles DELETE /api/v0.9/gateway-custom-policies/:customPolicyUuid/versions/:version -func (h *GatewayHandler) DeleteCustomPolicy(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) DeleteCustomPolicy(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } policyUUID := r.PathValue("gatewayCustomPolicyId") version := r.PathValue("version") if policyUUID == "" || version == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "customPolicyUuid and version are required")) - return + return apperror.ValidationFailed.New("customPolicyUuid and version are required") } - err := h.gatewayService.DeleteCustomPolicyByUUID(orgId, policyUUID, version) - if err != nil { - if errors.Is(err, constants.ErrCustomPolicyNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Custom policy not found")) - return + if err := h.gatewayService.DeleteCustomPolicyByUUID(orgId, policyUUID, version); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - if errors.Is(err, constants.ErrCustomPolicyVersionMismatch) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Custom policy not found with the specified version")) - return + // Repository-origin sentinels (delete-if-unused path) are still untyped. + if errors.Is(err, constants.ErrCustomPolicyNotFound) { + return apperror.CustomPolicyNotFound.Wrap(err) } if errors.Is(err, constants.ErrCustomPolicyInUse) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Custom policy is in use by one or more APIs and cannot be deleted")) - return + return apperror.PolicyInUse.Wrap(err) } - h.slogger.Error("Failed to delete custom policy", "org_id", orgId, "policy_uuid", policyUUID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to delete custom policy")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete custom policy, orgID=%s, policyUUID=%s", orgId, policyUUID)) } w.WriteHeader(http.StatusNoContent) + return nil } // ListCustomPolicies handles GET /api/v0.9/gateway-custom-policies -func (h *GatewayHandler) ListCustomPolicies(w http.ResponseWriter, r *http.Request) { +func (h *GatewayHandler) ListCustomPolicies(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } policies, err := h.gatewayService.ListCustomPolicies(orgId) if err != nil { - h.slogger.Error("Failed to list custom policies", "org_id", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to list custom policies")) - return + return apperror.Internal.Wrap(err).WithLogMessage(fmt.Sprintf("failed to list custom policies, orgID=%s", orgId)) } httputil.WriteJSON(w, http.StatusOK, policies) + return nil } // RegisterRoutes registers gateway routes with the router func (h *GatewayHandler) RegisterRoutes(mux *http.ServeMux) { h.slogger.Debug("Registering gateway routes") - mux.HandleFunc("POST "+constants.APIBasePath+"/gateways", h.CreateGateway) - mux.HandleFunc("GET "+constants.APIBasePath+"/gateways", h.ListGateways) - mux.HandleFunc("GET "+constants.APIBasePath+"/gateways/{gatewayId}", h.GetGateway) - mux.HandleFunc("PUT "+constants.APIBasePath+"/gateways/{gatewayId}", h.UpdateGateway) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/gateways/{gatewayId}", h.DeleteGateway) - mux.HandleFunc("GET "+constants.APIBasePath+"/gateways/{gatewayId}/tokens", h.ListTokens) - mux.HandleFunc("POST "+constants.APIBasePath+"/gateways/{gatewayId}/tokens", h.RotateToken) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/gateways/{gatewayId}/tokens/{tokenId}", h.RevokeToken) - mux.HandleFunc("GET "+constants.APIBasePath+"/gateways/{gatewayId}/manifest", h.GetGatewayManifest) - - mux.HandleFunc("GET "+constants.APIBasePath+"/gateway-custom-policies", h.ListCustomPolicies) - mux.HandleFunc("POST "+constants.APIBasePath+"/gateway-custom-policies/sync", h.SyncCustomPolicy) - mux.HandleFunc("GET "+constants.APIBasePath+"/gateway-custom-policies/{gatewayCustomPolicyId}/versions/{version}", h.GetCustomPolicy) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/gateway-custom-policies/{gatewayCustomPolicyId}/versions/{version}", h.DeleteCustomPolicy) + mux.HandleFunc("POST "+constants.APIBasePath+"/gateways", middleware.MapErrors(h.slogger, h.CreateGateway)) + mux.HandleFunc("GET "+constants.APIBasePath+"/gateways", middleware.MapErrors(h.slogger, h.ListGateways)) + mux.HandleFunc("GET "+constants.APIBasePath+"/gateways/{gatewayId}", middleware.MapErrors(h.slogger, h.GetGateway)) + mux.HandleFunc("PUT "+constants.APIBasePath+"/gateways/{gatewayId}", middleware.MapErrors(h.slogger, h.UpdateGateway)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/gateways/{gatewayId}", middleware.MapErrors(h.slogger, h.DeleteGateway)) + mux.HandleFunc("GET "+constants.APIBasePath+"/gateways/{gatewayId}/tokens", middleware.MapErrors(h.slogger, h.ListTokens)) + mux.HandleFunc("POST "+constants.APIBasePath+"/gateways/{gatewayId}/tokens", middleware.MapErrors(h.slogger, h.RotateToken)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/gateways/{gatewayId}/tokens/{tokenId}", middleware.MapErrors(h.slogger, h.RevokeToken)) + mux.HandleFunc("GET "+constants.APIBasePath+"/gateways/{gatewayId}/manifest", middleware.MapErrors(h.slogger, h.GetGatewayManifest)) + + mux.HandleFunc("GET "+constants.APIBasePath+"/gateway-custom-policies", middleware.MapErrors(h.slogger, h.ListCustomPolicies)) + mux.HandleFunc("POST "+constants.APIBasePath+"/gateway-custom-policies/sync", middleware.MapErrors(h.slogger, h.SyncCustomPolicy)) + mux.HandleFunc("GET "+constants.APIBasePath+"/gateway-custom-policies/{gatewayCustomPolicyId}/versions/{version}", middleware.MapErrors(h.slogger, h.GetCustomPolicy)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/gateway-custom-policies/{gatewayCustomPolicyId}/versions/{version}", middleware.MapErrors(h.slogger, h.DeleteCustomPolicy)) } diff --git a/platform-api/internal/handler/gateway_internal.go b/platform-api/internal/handler/gateway_internal.go index c0cbaa7a35..ae67b01c34 100644 --- a/platform-api/internal/handler/gateway_internal.go +++ b/platform-api/internal/handler/gateway_internal.go @@ -21,12 +21,13 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" - "net/http" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/dto" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/utils" + "log/slog" + "net/http" "strconv" "strings" "time" @@ -93,15 +94,15 @@ func (h *GatewayInternalAPIHandler) authenticateRequest(w http.ResponseWriter, r if err != nil { if errors.Is(err, constants.ErrMissingAPIKey) { h.slogger.Warn("Unauthorized access attempt - Missing API key", "clientIP", clientIP) - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "API key is required. Provide 'api-key' header.")) } else if errors.Is(err, constants.ErrInvalidAPIToken) { h.slogger.Warn("Authentication failed - Invalid API key", "clientIP", clientIP) - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Invalid or expired API key")) } else { h.slogger.Error("Authentication failed", "clientIP", clientIP, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Error while validating API key")) } return "", "", false @@ -118,7 +119,7 @@ func (h *GatewayInternalAPIHandler) GetAPI(w http.ResponseWriter, r *http.Reques apiID := r.PathValue("apiId") if apiID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } @@ -126,16 +127,16 @@ func (h *GatewayInternalAPIHandler) GetAPI(w http.ResponseWriter, r *http.Reques api, err := h.gatewayInternalService.GetActiveDeploymentByGateway(apiID, orgID, gatewayID) if err != nil { if errors.Is(err, constants.ErrDeploymentNotActive) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "No active deployment found for this API on this gateway")) return } if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "API not found")) return } - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get API")) return } @@ -144,7 +145,7 @@ func (h *GatewayInternalAPIHandler) GetAPI(w http.ResponseWriter, r *http.Reques zipData, err := utils.CreateAPIYamlZip(api) if err != nil { h.slogger.Error("Failed to create ZIP file", "apiID", apiID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create API package")) return } @@ -180,7 +181,7 @@ func (h *GatewayInternalAPIHandler) ImportGatewayArtifacts(w http.ResponseWriter clientIP = clientIP[:i] } h.slogger.Warn("Invalid import-gateway-artifacts request", "clientIP", clientIP, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) return } // 'total' is advisory: log a mismatch but proceed with what the zip actually contained. @@ -206,7 +207,7 @@ func (h *GatewayInternalAPIHandler) GetLLMProvider(w http.ResponseWriter, r *htt providerID := r.PathValue("providerId") if providerID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Provider ID is required")) return } @@ -214,16 +215,16 @@ func (h *GatewayInternalAPIHandler) GetLLMProvider(w http.ResponseWriter, r *htt provider, err := h.gatewayInternalService.GetActiveLLMProviderDeploymentByGateway(providerID, orgID, gatewayID) if err != nil { if errors.Is(err, constants.ErrDeploymentNotActive) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "No active deployment found for this LLM provider on this gateway")) return } if errors.Is(err, constants.ErrLLMProviderNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "LLM provider not found")) return } - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get LLM provider")) return } @@ -232,7 +233,7 @@ func (h *GatewayInternalAPIHandler) GetLLMProvider(w http.ResponseWriter, r *htt zipData, err := utils.CreateLLMProviderYamlZip(provider) if err != nil { h.slogger.Error("Failed to create ZIP file", "providerID", providerID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create LLM provider package")) return } @@ -256,7 +257,7 @@ func (h *GatewayInternalAPIHandler) GetLLMProxy(w http.ResponseWriter, r *http.R proxyID := r.PathValue("proxyId") if proxyID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Proxy ID is required")) return } @@ -264,16 +265,16 @@ func (h *GatewayInternalAPIHandler) GetLLMProxy(w http.ResponseWriter, r *http.R proxy, err := h.gatewayInternalService.GetActiveLLMProxyDeploymentByGateway(proxyID, orgID, gatewayID) if err != nil { if errors.Is(err, constants.ErrDeploymentNotActive) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "No active deployment found for this LLM proxy on this gateway")) return } if errors.Is(err, constants.ErrLLMProxyNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "LLM proxy not found")) return } - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get LLM proxy")) return } @@ -282,7 +283,7 @@ func (h *GatewayInternalAPIHandler) GetLLMProxy(w http.ResponseWriter, r *http.R zipData, err := utils.CreateLLMProxyYamlZip(proxy) if err != nil { h.slogger.Error("Failed to create ZIP file", "proxyID", proxyID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create LLM proxy package")) return } @@ -311,7 +312,7 @@ func (h *GatewayInternalAPIHandler) GetGatewayDeployments(w http.ResponseWriter, if sinceStr != "" { parsedTime, err := time.Parse(time.RFC3339, sinceStr) if err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid 'since' parameter. Expected ISO 8601 format (e.g., 2026-03-04T10:00:00Z)")) return } @@ -321,12 +322,12 @@ func (h *GatewayInternalAPIHandler) GetGatewayDeployments(w http.ResponseWriter, deployments, err := h.gatewayInternalService.GetDeploymentsByGateway(orgID, gatewayID, since) if err != nil { if errors.Is(err, constants.ErrGatewayNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Gateway not found")) return } h.slogger.Error("Failed to get gateway deployments", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get deployments")) return } @@ -344,7 +345,7 @@ func (h *GatewayInternalAPIHandler) BatchFetchDeployments(w http.ResponseWriter, // Enforce Accept header - only application/x-tar+gzip is supported if accept := r.Header.Get("Accept"); accept != "application/x-tar+gzip" { - httputil.WriteJSON(w, http.StatusNotAcceptable, utils.NewErrorResponse(406, "Not Acceptable", + httputil.WriteJSON(w, http.StatusNotAcceptable, apperror.NewErrorResponse(406, "Not Acceptable", "This endpoint only supports Accept: application/x-tar+gzip")) return } @@ -352,13 +353,13 @@ func (h *GatewayInternalAPIHandler) BatchFetchDeployments(w http.ResponseWriter, // Parse request body var req dto.DeploymentsBatchFetchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body: "+err.Error())) return } if len(req.DeploymentIDs) == 0 { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "At least one deployment ID is required")) return } @@ -367,12 +368,12 @@ func (h *GatewayInternalAPIHandler) BatchFetchDeployments(w http.ResponseWriter, contentMap, err := h.gatewayInternalService.GetDeploymentContentBatch(orgID, gatewayID, req.DeploymentIDs) if err != nil { if errors.Is(err, constants.ErrGatewayNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Gateway not found")) return } h.slogger.Error("Failed to get deployment content batch", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get deployment content")) return } @@ -381,7 +382,7 @@ func (h *GatewayInternalAPIHandler) BatchFetchDeployments(w http.ResponseWriter, tarGzData, err := utils.CreateBatchDeploymentTarGz(contentMap) if err != nil { h.slogger.Error("Failed to create batch TAR.GZ archive", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create deployment package")) return } @@ -413,7 +414,7 @@ func (h *GatewayInternalAPIHandler) GetSubscriptions(w http.ResponseWriter, r *h "clientIP", clientIP, "organizationId", orgID, "apiId", apiID) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } @@ -425,7 +426,7 @@ func (h *GatewayInternalAPIHandler) GetSubscriptions(w http.ResponseWriter, r *h "organizationId", orgID, "gatewayId", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "API not found")) return } @@ -434,7 +435,7 @@ func (h *GatewayInternalAPIHandler) GetSubscriptions(w http.ResponseWriter, r *h "apiId", apiID, "organizationId", orgID, "gatewayId", gatewayID) - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", + httputil.WriteJSON(w, http.StatusForbidden, apperror.NewErrorResponse(403, "Forbidden", "API is not associated with this gateway")) return } @@ -443,7 +444,7 @@ func (h *GatewayInternalAPIHandler) GetSubscriptions(w http.ResponseWriter, r *h "organizationId", orgID, "gatewayId", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to verify API deployment")) return } @@ -455,7 +456,7 @@ func (h *GatewayInternalAPIHandler) GetSubscriptions(w http.ResponseWriter, r *h "apiId", apiID, "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "API not found")) return } @@ -463,7 +464,7 @@ func (h *GatewayInternalAPIHandler) GetSubscriptions(w http.ResponseWriter, r *h "apiId", apiID, "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get subscriptions")) return } @@ -483,7 +484,7 @@ func (h *GatewayInternalAPIHandler) GetSubscriptionPlans(w http.ResponseWriter, h.slogger.Error("Failed to list subscription plans", "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get subscription plans")) return } @@ -500,7 +501,7 @@ func (h *GatewayInternalAPIHandler) GetMCPProxy(w http.ResponseWriter, r *http.R } proxyID := r.PathValue("proxyId") if proxyID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Proxy ID is required")) return } @@ -513,18 +514,18 @@ func (h *GatewayInternalAPIHandler) GetMCPProxy(w http.ResponseWriter, r *http.R } if errors.Is(err, constants.ErrDeploymentNotActive) { h.slogger.Error("No active deployment found for MCP proxy", "clientIP", clientIP, "proxyID", proxyID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "No active deployment found for this MCP proxy on this gateway")) return } if errors.Is(err, constants.ErrMCPProxyNotFound) { h.slogger.Error("MCP proxy not found", "clientIP", clientIP, "proxyID", proxyID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "MCP proxy not found")) return } h.slogger.Error("Failed to get MCP proxy", "clientIP", clientIP, "proxyID", proxyID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get MCP proxy")) return } @@ -533,7 +534,7 @@ func (h *GatewayInternalAPIHandler) GetMCPProxy(w http.ResponseWriter, r *http.R zipData, err := utils.CreateMCPProxyYamlZip(proxy) if err != nil { h.slogger.Error("Failed to create ZIP file", "proxyID", proxyID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create MCP proxy package")) return } @@ -557,7 +558,7 @@ func (h *GatewayInternalAPIHandler) GetWebSubAPI(w http.ResponseWriter, r *http. apiID := r.PathValue("apiId") if apiID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } @@ -570,18 +571,18 @@ func (h *GatewayInternalAPIHandler) GetWebSubAPI(w http.ResponseWriter, r *http. } if errors.Is(err, constants.ErrDeploymentNotActive) { h.slogger.Error("No active deployment found for WebSub API", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "No active deployment found for this WebSub API on this gateway")) return } if errors.Is(err, constants.ErrWebSubAPINotFound) { h.slogger.Error("WebSub API not found", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebSub API not found")) return } h.slogger.Error("Failed to get WebSub API", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get WebSub API")) return } @@ -590,7 +591,7 @@ func (h *GatewayInternalAPIHandler) GetWebSubAPI(w http.ResponseWriter, r *http. zipData, err := utils.CreateWebSubAPIYamlZip(api) if err != nil { h.slogger.Error("Failed to create ZIP file", "apiID", apiID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create WebSub API package")) return } @@ -614,7 +615,7 @@ func (h *GatewayInternalAPIHandler) GetWebBrokerAPI(w http.ResponseWriter, r *ht apiID := r.PathValue("apiId") if apiID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } @@ -627,18 +628,18 @@ func (h *GatewayInternalAPIHandler) GetWebBrokerAPI(w http.ResponseWriter, r *ht } if errors.Is(err, constants.ErrDeploymentNotActive) { h.slogger.Error("No active deployment found for WebBroker API", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "No active deployment found for this WebBroker API on this gateway")) return } if errors.Is(err, constants.ErrWebBrokerAPINotFound) { h.slogger.Error("WebBroker API not found", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebBroker API not found")) return } h.slogger.Error("Failed to get WebBroker API", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get WebBroker API")) return } @@ -647,7 +648,7 @@ func (h *GatewayInternalAPIHandler) GetWebBrokerAPI(w http.ResponseWriter, r *ht zipData, err := utils.CreateWebBrokerAPIYamlZip(api) if err != nil { h.slogger.Error("Failed to create ZIP file", "apiID", apiID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create WebBroker API package")) return } @@ -676,27 +677,27 @@ func (h *GatewayInternalAPIHandler) ReceiveGatewayManifest(w http.ResponseWriter Policies []service.GatewayPolicyInput `json:"policies"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) return } if err := h.gatewayService.ReceiveGatewayManifest(orgID, gatewayID, body.Version, body.FunctionalityType, body.Policies); err != nil { if errors.Is(err, constants.ErrGatewayVersionMismatch) { h.slogger.Warn("Gateway manifest rejected: version mismatch", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", err.Error())) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", err.Error())) return } if errors.Is(err, constants.ErrGatewayFunctionalityTypeMismatch) { h.slogger.Warn("Gateway manifest rejected: functionality type mismatch", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", err.Error())) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", err.Error())) return } if errors.Is(err, constants.ErrGatewayNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", err.Error())) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", err.Error())) return } h.slogger.Error("Failed to store gateway manifest", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to store gateway manifest")) return } @@ -714,7 +715,7 @@ func (h *GatewayInternalAPIHandler) GetRestAPIAPIKeys(w http.ResponseWriter, r * keys, err := h.gatewayInternalService.GetAPIKeysByKind(gatewayID, orgID, constants.RestApi, issuer) if err != nil { h.slogger.Error("Failed to get API keys for REST APIs", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) return } httputil.WriteJSON(w, http.StatusOK, keys) @@ -730,7 +731,7 @@ func (h *GatewayInternalAPIHandler) GetLLMProviderAPIKeys(w http.ResponseWriter, keys, err := h.gatewayInternalService.GetAPIKeysByKind(gatewayID, orgID, constants.LLMProvider, issuer) if err != nil { h.slogger.Error("Failed to get API keys for LLM providers", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) return } httputil.WriteJSON(w, http.StatusOK, keys) @@ -746,7 +747,7 @@ func (h *GatewayInternalAPIHandler) GetLLMProxyAPIKeys(w http.ResponseWriter, r keys, err := h.gatewayInternalService.GetAPIKeysByKind(gatewayID, orgID, constants.LLMProxy, issuer) if err != nil { h.slogger.Error("Failed to get API keys for LLM proxies", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) return } httputil.WriteJSON(w, http.StatusOK, keys) @@ -762,7 +763,7 @@ func (h *GatewayInternalAPIHandler) GetWebSubAPIAPIKeys(w http.ResponseWriter, r keys, err := h.gatewayInternalService.GetAPIKeysByKind(gatewayID, orgID, constants.WebSubApi, issuer) if err != nil { h.slogger.Error("Failed to get API keys for WebSub APIs", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) return } httputil.WriteJSON(w, http.StatusOK, keys) @@ -778,7 +779,7 @@ func (h *GatewayInternalAPIHandler) GetWebBrokerAPIAPIKeys(w http.ResponseWriter keys, err := h.gatewayInternalService.GetAPIKeysByKind(gatewayID, orgID, constants.WebBrokerApi, issuer) if err != nil { h.slogger.Error("Failed to get API keys for WebBroker APIs", "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get API keys")) return } httputil.WriteJSON(w, http.StatusOK, keys) @@ -796,7 +797,7 @@ func (h *GatewayInternalAPIHandler) CheckArtifactsExist(w http.ResponseWriter, r var req dto.ArtifactsExistRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body: artifactIds is required and must be a non-empty array")) return } @@ -804,7 +805,7 @@ func (h *GatewayInternalAPIHandler) CheckArtifactsExist(w http.ResponseWriter, r existingIDs, err := h.gatewayInternalService.CheckArtifactsExist(orgID, req.ArtifactIDs) if err != nil { h.slogger.Error("Failed to check artifact existence", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to check artifact existence")) return } @@ -840,20 +841,20 @@ func (h *GatewayInternalAPIHandler) GetWebSubAPIHmacSecrets(w http.ResponseWrite apiID := r.PathValue("apiId") if apiID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API ID is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } if h.hmacSecretService == nil { h.slogger.Warn("HMAC secret service not configured", "apiID", apiID) - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "HMAC secret management is not configured on this server")) + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "HMAC secret management is not configured on this server")) return } secrets, err := h.hmacSecretService.ListByArtifactUUID(apiID) if err != nil { h.slogger.Error("Failed to list HMAC secrets for WebSub API", "apiID", apiID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get HMAC secrets")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to get HMAC secrets")) return } @@ -862,7 +863,7 @@ func (h *GatewayInternalAPIHandler) GetWebSubAPIHmacSecrets(w http.ResponseWrite plaintext, err := h.hmacSecretService.DecryptSecret(s) if err != nil { h.slogger.Error("Failed to decrypt HMAC secret", "apiID", apiID, "secretName", s.Handle, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to decrypt HMAC secret")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to decrypt HMAC secret")) return } items = append(items, dto.GatewayHmacSecretInfo{Name: s.Handle, Secret: plaintext}) @@ -886,7 +887,7 @@ func (h *GatewayInternalAPIHandler) GetGatewaySecrets(w http.ResponseWriter, r * if s := r.URL.Query().Get("updatedAfter"); s != "" { t, err := time.Parse(time.RFC3339, s) if err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid 'updatedAfter' parameter. Expected RFC3339 format.")) return } @@ -898,7 +899,7 @@ func (h *GatewayInternalAPIHandler) GetGatewaySecrets(w http.ResponseWriter, r * secrets, err := h.gatewayInternalService.GetSecretsByGateway(orgID, gatewayID, updatedAfter) if err != nil { h.slogger.Error("Failed to list gateway secrets", "orgID", orgID, "gatewayID", gatewayID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to retrieve secrets")) return } @@ -920,7 +921,7 @@ func (h *GatewayInternalAPIHandler) GetGatewaySecrets(w http.ResponseWriter, r * plaintext, err := h.secretService.DecryptCiphertext(s.Ciphertext) if err != nil { h.slogger.Error("Failed to decrypt secret for bulk fetch", "orgID", orgID, "handle", s.Handle, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to decrypt secret")) return } @@ -943,7 +944,7 @@ func (h *GatewayInternalAPIHandler) GetGatewaySecretValue(w http.ResponseWriter, handle := r.PathValue("handle") if handle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Secret handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Secret handle is required")) return } @@ -951,22 +952,22 @@ func (h *GatewayInternalAPIHandler) GetGatewaySecretValue(w http.ResponseWriter, deployed, err := h.gatewayInternalService.IsSecretDeployedOnGateway(orgID, gatewayID, handle) if err != nil { h.slogger.Error("Failed to check secret deployment scope", "orgID", orgID, "gatewayID", gatewayID, "handle", handle, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to verify secret access")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to verify secret access")) return } if !deployed { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Secret not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Secret not found")) return } plaintext, err := h.secretService.Decrypt(orgID, handle) if err != nil { if errors.Is(err, constants.ErrSecretNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Secret not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Secret not found")) return } h.slogger.Error("Failed to decrypt secret for gateway", "orgID", orgID, "handle", handle, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to decrypt secret")) return } diff --git a/platform-api/internal/handler/identity_helper.go b/platform-api/internal/handler/identity_helper.go index 0eb6b69701..53219076c7 100644 --- a/platform-api/internal/handler/identity_helper.go +++ b/platform-api/internal/handler/identity_helper.go @@ -18,28 +18,24 @@ package handler import ( - "log/slog" "net/http" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" - - "github.com/wso2/go-httpkit/httputil" ) -// resolveActor resolves the internal platform UUID for the actor behind r via -// identity, for use in audit columns (created_by/updated_by/revoked_by/ +// resolveActorErr resolves the internal platform UUID for the actor behind r +// via identity, for use in audit columns (created_by/updated_by/revoked_by/ // performed_by). Identity is always resolved from the token claim (sub, // falling back to the configured claim / user_id) — a missing identity mints // an anonymous UUID rather than failing (see IdentityService.InternalUserID), -// so this only fails (500) on a genuine DB error. -func resolveActor(w http.ResponseWriter, r *http.Request, identity *service.IdentityService, slogger *slog.Logger, action string) (actor string, ok bool) { +// so this only fails (500) on a genuine DB error. Returns an *apperror.Error +// for the mapper to log and serialize. +func resolveActorErr(r *http.Request, identity *service.IdentityService, action string) (string, error) { actor, err := identity.InternalUserID(r) if err != nil { - slogger.Error("Failed to resolve user identity", "action", action, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to resolve user identity")) - return "", false + return "", apperror.Internal.Wrap(err). + WithLogMessage("failed to resolve user identity for action: " + action) } - return actor, true + return actor, nil } diff --git a/platform-api/internal/handler/llm.go b/platform-api/internal/handler/llm.go index 4646ab5263..d341d17318 100644 --- a/platform-api/internal/handler/llm.go +++ b/platform-api/internal/handler/llm.go @@ -20,6 +20,7 @@ package handler import ( "encoding/json" "errors" + "fmt" "io" "log/slog" "net/http" @@ -27,6 +28,7 @@ import ( "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -55,28 +57,28 @@ func NewLLMHandler( func (h *LLMHandler) RegisterRoutes(mux *http.ServeMux) { // LLM Provider Templates - mux.HandleFunc("POST "+constants.APIBasePath+"/llm-provider-templates", h.CreateLLMProviderTemplate) - mux.HandleFunc("POST "+constants.APIBasePath+"/llm-provider-templates/copy", h.CopyLLMProviderTemplateVersion) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-provider-templates", h.ListLLMProviderTemplates) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-provider-templates/{llmProviderTemplateId}", h.GetLLMProviderTemplate) - mux.HandleFunc("PUT "+constants.APIBasePath+"/llm-provider-templates/{llmProviderTemplateId}", h.UpdateLLMProviderTemplate) - mux.HandleFunc("PATCH "+constants.APIBasePath+"/llm-provider-templates/{llmProviderTemplateId}", h.SetLLMProviderTemplateVersionEnabled) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-provider-templates/{llmProviderTemplateId}", h.DeleteLLMProviderTemplateVersion) + mux.HandleFunc("POST "+constants.APIBasePath+"/llm-provider-templates", middleware.MapErrors(h.slogger, h.CreateLLMProviderTemplate)) + mux.HandleFunc("POST "+constants.APIBasePath+"/llm-provider-templates/copy", middleware.MapErrors(h.slogger, h.CopyLLMProviderTemplateVersion)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-provider-templates", middleware.MapErrors(h.slogger, h.ListLLMProviderTemplates)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-provider-templates/{llmProviderTemplateId}", middleware.MapErrors(h.slogger, h.GetLLMProviderTemplate)) + mux.HandleFunc("PUT "+constants.APIBasePath+"/llm-provider-templates/{llmProviderTemplateId}", middleware.MapErrors(h.slogger, h.UpdateLLMProviderTemplate)) + mux.HandleFunc("PATCH "+constants.APIBasePath+"/llm-provider-templates/{llmProviderTemplateId}", middleware.MapErrors(h.slogger, h.SetLLMProviderTemplateVersionEnabled)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-provider-templates/{llmProviderTemplateId}", middleware.MapErrors(h.slogger, h.DeleteLLMProviderTemplateVersion)) // LLM Providers - mux.HandleFunc("POST "+constants.APIBasePath+"/llm-providers", h.CreateLLMProvider) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-providers", h.ListLLMProviders) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-providers/{llmProviderId}", h.GetLLMProvider) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-providers/{llmProviderId}/llm-proxies", h.ListLLMProxiesByProvider) - mux.HandleFunc("PUT "+constants.APIBasePath+"/llm-providers/{llmProviderId}", h.UpdateLLMProvider) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-providers/{llmProviderId}", h.DeleteLLMProvider) + mux.HandleFunc("POST "+constants.APIBasePath+"/llm-providers", middleware.MapErrors(h.slogger, h.CreateLLMProvider)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-providers", middleware.MapErrors(h.slogger, h.ListLLMProviders)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-providers/{llmProviderId}", middleware.MapErrors(h.slogger, h.GetLLMProvider)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-providers/{llmProviderId}/llm-proxies", middleware.MapErrors(h.slogger, h.ListLLMProxiesByProvider)) + mux.HandleFunc("PUT "+constants.APIBasePath+"/llm-providers/{llmProviderId}", middleware.MapErrors(h.slogger, h.UpdateLLMProvider)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-providers/{llmProviderId}", middleware.MapErrors(h.slogger, h.DeleteLLMProvider)) // LLM Proxies - mux.HandleFunc("POST "+constants.APIBasePath+"/llm-proxies", h.CreateLLMProxy) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-proxies", h.ListLLMProxies) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-proxies/{llmProxyId}", h.GetLLMProxy) - mux.HandleFunc("PUT "+constants.APIBasePath+"/llm-proxies/{llmProxyId}", h.UpdateLLMProxy) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-proxies/{llmProxyId}", h.DeleteLLMProxy) + mux.HandleFunc("POST "+constants.APIBasePath+"/llm-proxies", middleware.MapErrors(h.slogger, h.CreateLLMProxy)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-proxies", middleware.MapErrors(h.slogger, h.ListLLMProxies)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-proxies/{llmProxyId}", middleware.MapErrors(h.slogger, h.GetLLMProxy)) + mux.HandleFunc("PUT "+constants.APIBasePath+"/llm-proxies/{llmProxyId}", middleware.MapErrors(h.slogger, h.UpdateLLMProxy)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-proxies/{llmProxyId}", middleware.MapErrors(h.slogger, h.DeleteLLMProxy)) } // templateQuery holds the fields parsed from the ?query= search DSL used by the @@ -106,44 +108,41 @@ func parseTemplateQuery(raw string) (q templateQuery, found bool) { return q, found } -func (h *LLMHandler) CreateLLMProviderTemplate(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) CreateLLMProviderTemplate(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req api.LLMProviderTemplate if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "create LLM provider template") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "create LLM provider template") + if err != nil { + return err } created, err := h.templateService.Create(orgID, createdBy, &req) if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderTemplateExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "LLM provider template already exists")) - return + return apperror.LLMProviderTemplateExists.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateManagedByReserved): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "'wso2' is reserved and cannot be used as managedBy on custom templates")) - return + return apperror.LLMProviderTemplateManagedByReserved.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to create LLM provider template", "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create LLM provider template")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create LLM provider template in org %s", orgID)) } } + setLocation(w, "llm-provider-templates", strOrEmpty(created.Id)) httputil.WriteJSON(w, http.StatusCreated, created) + return nil } // CopyLLMProviderTemplateVersion creates a new version within a family by @@ -151,24 +150,23 @@ func (h *LLMHandler) CreateLLMProviderTemplate(w http.ResponseWriter, r *http.Re // handle and the new version are given as query params // (fromTemplateId, toTemplateId, toVersion); an optional body overrides fields // on top of the copied config. -func (h *LLMHandler) CopyLLMProviderTemplateVersion(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) CopyLLMProviderTemplateVersion(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "copy LLM provider template version") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "copy LLM provider template version") + if err != nil { + return err } fromTemplateID := strings.TrimSpace(r.URL.Query().Get("fromTemplateId")) toTemplateID := strings.TrimSpace(r.URL.Query().Get("toTemplateId")) toVersion := strings.TrimSpace(r.URL.Query().Get("toVersion")) if fromTemplateID == "" || toVersion == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "fromTemplateId and toVersion are required")) - return + return apperror.ValidationFailed.New("fromTemplateId and toVersion are required") } // The body is optional; only decode overrides when one is present. @@ -176,8 +174,7 @@ func (h *LLMHandler) CopyLLMProviderTemplateVersion(w http.ResponseWriter, r *ht if r.Body != nil && r.ContentLength != 0 { var vreq api.CreateLLMProviderTemplateVersionRequest if err := json.NewDecoder(r.Body).Decode(&vreq); err != nil && !errors.Is(err, io.EOF) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } overrides = &vreq } @@ -186,32 +183,29 @@ func (h *LLMHandler) CopyLLMProviderTemplateVersion(w http.ResponseWriter, r *ht if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Source LLM provider template version not found")) - return + return apperror.LLMProviderTemplateVersionNotFound.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateVersionExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "The version already exists")) - return + return apperror.LLMProviderTemplateVersionExists.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateManagedByReserved): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "'wso2' is reserved and cannot be used as managedBy on custom templates")) - return + return apperror.LLMProviderTemplateManagedByReserved.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input. toVersion must match the v. pattern starting from v1.0 (e.g. v1.0), and toTemplateId must match the family")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid input. toVersion must match the v. pattern starting from v1.0 (e.g. v1.0), and toTemplateId must match the family") default: - h.slogger.Error("Failed to copy LLM provider template version", "organizationId", orgID, "fromTemplateId", fromTemplateID, "toVersion", toVersion, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to copy LLM provider template version")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to copy LLM provider template version from %s to version %s in org %s", fromTemplateID, toVersion, orgID)) } } + setLocation(w, "llm-provider-templates", strOrEmpty(created.Id)) httputil.WriteJSON(w, http.StatusCreated, created) + return nil } -func (h *LLMHandler) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } limitStr := r.URL.Query().Get("limit") @@ -240,8 +234,7 @@ func (h *LLMHandler) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Req if familyScoped { groupID := q.GroupID if groupID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid groupId")) - return + return apperror.ValidationFailed.New("Invalid groupId") } version := q.Version if version != "" { @@ -249,55 +242,49 @@ func (h *LLMHandler) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Req if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template version not found")) - return + return apperror.LLMProviderTemplateVersionNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid version. Version must match the v. pattern (e.g. v1.0)")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid version. Version must match the v. pattern (e.g. v1.0)") default: - h.slogger.Error("Failed to get LLM provider template version", "organizationId", orgID, "groupId", groupID, "version", version, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get LLM provider template version")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get LLM provider template version %s of group %s in org %s", version, groupID, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) - return + return nil } resp, err := h.templateService.ListVersions(orgID, groupID, limit, offset) if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template not found")) - return + return apperror.LLMProviderTemplateNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid groupId")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid groupId") default: - h.slogger.Error("Failed to list LLM provider template versions", "organizationId", orgID, "groupId", groupID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list LLM provider template versions")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list LLM provider template versions of group %s in org %s", groupID, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) - return + return nil } latestOnly := q.Latest resp, err := h.templateService.List(orgID, limit, offset, latestOnly) if err != nil { - h.slogger.Error("Failed to list LLM provider templates", "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list LLM provider templates")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list LLM provider templates in org %s", orgID)) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) GetLLMProviderTemplate(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) GetLLMProviderTemplate(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProviderTemplateId") @@ -305,25 +292,23 @@ func (h *LLMHandler) GetLLMProviderTemplate(w http.ResponseWriter, r *http.Reque if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template not found")) - return + return apperror.LLMProviderTemplateNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid template id")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid template id") default: - h.slogger.Error("Failed to get LLM provider template", "organizationId", orgID, "templateId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get LLM provider template")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get LLM provider template %s in org %s", id, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) SetLLMProviderTemplateVersionEnabled(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) SetLLMProviderTemplateVersionEnabled(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProviderTemplateId") @@ -331,175 +316,152 @@ func (h *LLMHandler) SetLLMProviderTemplateVersionEnabled(w http.ResponseWriter, Enabled *bool `json:"enabled"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Enabled == nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Request body must include a boolean 'enabled' field")) - return + return apperror.ValidationFailed.New("Request body must include a boolean 'enabled' field") } resp, err := h.templateService.SetEnabledByHandle(orgID, id, *body.Enabled) if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template version not found")) - return + return apperror.LLMProviderTemplateVersionNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid template id")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid template id") case errors.Is(err, constants.ErrLLMProviderTemplateInUse): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Cannot disable template version while providers are using it")) - return + return apperror.LLMProviderTemplateInUse.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateNotToggleable): - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "Only built-in templates can be enabled or disabled")) - return + return apperror.LLMProviderTemplateNotToggleable.Wrap(err) default: - h.slogger.Error("Failed to set LLM provider template version enabled", "organizationId", orgID, "templateId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update template version")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to set LLM provider template version enabled for template %s in org %s", id, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) UpdateLLMProviderTemplate(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) UpdateLLMProviderTemplate(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProviderTemplateId") var req api.LLMProviderTemplate if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } if err := utils.ValidateHandleImmutable(id, req.Id); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider template id is immutable and cannot be changed")) - return + return apperror.ValidationFailed.Wrap(err, "LLM provider template id is immutable and cannot be changed") } - updatedBy, ok := resolveActor(w, r, h.identity, h.slogger, "update LLM provider template") - if !ok { - return + updatedBy, err := resolveActorErr(r, h.identity, "update LLM provider template") + if err != nil { + return err } resp, err := h.templateService.Update(orgID, id, updatedBy, &req) if err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template not found")) - return + return apperror.LLMProviderTemplateNotFound.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateReadOnly): - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "Built-in templates are read-only and cannot be edited")) - return + return apperror.LLMProviderTemplateReadOnly.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateManagedByReserved): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "'wso2' is reserved and cannot be used as managedBy on custom templates")) - return + return apperror.LLMProviderTemplateManagedByReserved.Wrap(err) case errors.Is(err, constants.ErrHandleImmutable): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "The id is immutable and cannot be changed") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to update LLM provider template", "organizationId", orgID, "templateId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update LLM provider template")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update LLM provider template %s in org %s", id, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } // DeleteLLMProviderTemplateVersion removes a single version of a template. -func (h *LLMHandler) DeleteLLMProviderTemplateVersion(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) DeleteLLMProviderTemplateVersion(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProviderTemplateId") if err := h.templateService.DeleteByHandle(orgID, id); err != nil { switch { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template version not found")) - return + return apperror.LLMProviderTemplateVersionNotFound.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateInUse): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Template version cannot be deleted while providers are using it")) - return + return apperror.LLMProviderTemplateInUse.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateReadOnly): - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "Built-in template versions are read-only and cannot be deleted")) - return + return apperror.LLMProviderTemplateReadOnly.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid template id")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid template id") default: - h.slogger.Error("Failed to delete LLM provider template version", "organizationId", orgID, "templateId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete template version")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete LLM provider template version %s in org %s", id, orgID)) } } w.WriteHeader(http.StatusNoContent) + return nil } // ---- Providers ---- -func (h *LLMHandler) CreateLLMProvider(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) CreateLLMProvider(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req api.LLMProvider if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "create LLM provider") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "create LLM provider") + if err != nil { + return err } created, err := h.providerService.Create(orgID, createdBy, &req) if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderLimitReached): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "LLM provider limit reached for organization")) - return + return apperror.LLMProviderLimitReached.Wrap(err) case errors.Is(err, constants.ErrLLMProviderExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "LLM provider already exists")) - return + return apperror.LLMProviderExists.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Referenced template not found")) - return + return apperror.LLMProviderTemplateRefNotFound.Wrap(err) case errors.Is(err, constants.ErrSecretRefMissing): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "One or more referenced secrets do not exist") case errors.Is(err, constants.ErrInvalidPolicyVersion): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid policy version format") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to create LLM provider", "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create LLM provider")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create LLM provider in org %s", orgID)) } } + setLocation(w, "llm-providers", strOrEmpty(created.Id)) httputil.WriteJSON(w, http.StatusCreated, created) + return nil } -func (h *LLMHandler) ListLLMProviders(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) ListLLMProviders(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } limitStr := r.URL.Query().Get("limit") @@ -526,18 +488,18 @@ func (h *LLMHandler) ListLLMProviders(w http.ResponseWriter, r *http.Request) { resp, err := h.providerService.List(orgID, limit, offset) if err != nil { - h.slogger.Error("Failed to list LLM providers", "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list LLM providers")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list LLM providers in org %s", orgID)) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) GetLLMProvider(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) GetLLMProvider(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProviderId") @@ -545,172 +507,151 @@ func (h *LLMHandler) GetLLMProvider(w http.ResponseWriter, r *http.Request) { if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid provider id")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid provider id") default: - h.slogger.Error("Failed to get LLM provider", "organizationId", orgID, "providerId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get LLM provider")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get LLM provider %s in org %s", id, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) UpdateLLMProvider(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) UpdateLLMProvider(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProviderId") var req api.LLMProvider if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } if err := utils.ValidateHandleImmutable(id, req.Id); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider id is immutable and cannot be changed")) - return + return apperror.ValidationFailed.Wrap(err, "LLM provider id is immutable and cannot be changed") } - updatedBy, ok := resolveActor(w, r, h.identity, h.slogger, "update LLM provider") - if !ok { - return + updatedBy, err := resolveActorErr(r, h.identity, "update LLM provider") + if err != nil { + return err } resp, err := h.providerService.Update(orgID, id, updatedBy, &req) if err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Referenced template not found")) - return + return apperror.LLMProviderTemplateRefNotFound.Wrap(err) case errors.Is(err, constants.ErrSecretRefMissing): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "One or more referenced secrets do not exist") case errors.Is(err, constants.ErrHandleImmutable): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "The id is immutable and cannot be changed") case errors.Is(err, constants.ErrInvalidPolicyVersion): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid policy version format") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to update LLM provider", "organizationId", orgID, "providerId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update LLM provider")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update LLM provider %s in org %s", id, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) DeleteLLMProvider(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) DeleteLLMProvider(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProviderId") - deletedBy, ok := resolveActor(w, r, h.identity, h.slogger, "delete LLM provider") - if !ok { - return + deletedBy, err := resolveActorErr(r, h.identity, "delete LLM provider") + if err != nil { + return err } if err := h.providerService.Delete(orgID, id, deletedBy); err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid provider id")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid provider id") default: - h.slogger.Error("Failed to delete LLM provider", "organizationId", orgID, "providerId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete LLM provider")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete LLM provider %s in org %s", id, orgID)) } } w.WriteHeader(http.StatusNoContent) + return nil } // ---- Proxies ---- -func (h *LLMHandler) CreateLLMProxy(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) CreateLLMProxy(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req api.LLMProxy if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } if req.ProjectId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Project ID is required")) - return + return apperror.ValidationFailed.New("Project ID is required") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "create LLM proxy") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "create LLM proxy") + if err != nil { + return err } created, err := h.proxyService.Create(orgID, createdBy, &req) if err != nil { switch { case errors.Is(err, constants.ErrLLMProxyLimitReached): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "LLM proxy limit reached for organization")) - return + return apperror.LLMProxyLimitReached.Wrap(err) case errors.Is(err, constants.ErrLLMProxyExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "LLM proxy already exists")) - return + return apperror.LLMProxyExists.Wrap(err) case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Referenced provider not found")) - return + return apperror.LLMProviderRefNotFound.Wrap(err) case errors.Is(err, constants.ErrProjectNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Project not found")) - return + return apperror.ProjectNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidPolicyVersion): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid policy version format") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to create LLM proxy", "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create LLM proxy")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create LLM proxy in org %s", orgID)) } } + setLocation(w, "llm-proxies", strOrEmpty(created.Id)) httputil.WriteJSON(w, http.StatusCreated, created) + return nil } -func (h *LLMHandler) ListLLMProxies(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) ListLLMProxies(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } projectID := strings.TrimSpace(r.URL.Query().Get("projectId")) if projectID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "projectId query parameter is required")) - return + return apperror.ValidationFailed.New("projectId query parameter is required") } limitStr := r.URL.Query().Get("limit") @@ -738,21 +679,20 @@ func (h *LLMHandler) ListLLMProxies(w http.ResponseWriter, r *http.Request) { resp, err := h.proxyService.List(orgID, &projectID, limit, offset) if err != nil { if errors.Is(err, constants.ErrProjectNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Project not found")) - return + return apperror.ProjectNotFound.Wrap(err) } - h.slogger.Error("Failed to list LLM proxies", "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list LLM proxies")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list LLM proxies in org %s", orgID)) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) ListLLMProxiesByProvider(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) ListLLMProxiesByProvider(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerID := r.PathValue("llmProviderId") @@ -782,25 +722,23 @@ func (h *LLMHandler) ListLLMProxiesByProvider(w http.ResponseWriter, r *http.Req if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid provider id")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid provider id") default: - h.slogger.Error("Failed to list LLM proxies by provider", "organizationId", orgID, "providerId", providerID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list LLM proxies")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list LLM proxies by provider %s in org %s", providerID, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) GetLLMProxy(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) GetLLMProxy(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProxyId") @@ -808,102 +746,90 @@ func (h *LLMHandler) GetLLMProxy(w http.ResponseWriter, r *http.Request) { if err != nil { switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid proxy id")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid proxy id") default: - h.slogger.Error("Failed to get LLM proxy", "organizationId", orgID, "proxyId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get LLM proxy")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get LLM proxy %s in org %s", id, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) UpdateLLMProxy(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) UpdateLLMProxy(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProxyId") var req api.LLMProxy if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } if err := utils.ValidateHandleImmutable(id, req.Id); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy id is immutable and cannot be changed")) - return + return apperror.ValidationFailed.Wrap(err, "LLM proxy id is immutable and cannot be changed") } - updatedBy, ok := resolveActor(w, r, h.identity, h.slogger, "update LLM proxy") - if !ok { - return + updatedBy, err := resolveActorErr(r, h.identity, "update LLM proxy") + if err != nil { + return err } resp, err := h.proxyService.Update(orgID, id, updatedBy, &req) if err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Referenced provider not found")) - return + return apperror.LLMProviderRefNotFound.Wrap(err) case errors.Is(err, constants.ErrHandleImmutable): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "The id is immutable and cannot be changed") case errors.Is(err, constants.ErrInvalidPolicyVersion): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid policy version format") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to update LLM proxy", "organizationId", orgID, "proxyId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update LLM proxy")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update LLM proxy %s in org %s", id, orgID)) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *LLMHandler) DeleteLLMProxy(w http.ResponseWriter, r *http.Request) { +func (h *LLMHandler) DeleteLLMProxy(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("llmProxyId") - deletedBy, ok := resolveActor(w, r, h.identity, h.slogger, "delete LLM proxy") - if !ok { - return + deletedBy, err := resolveActorErr(r, h.identity, "delete LLM proxy") + if err != nil { + return err } if err := h.proxyService.Delete(orgID, id, deletedBy); err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid proxy id")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid proxy id") default: - h.slogger.Error("Failed to delete LLM proxy", "organizationId", orgID, "proxyId", id, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete LLM proxy")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete LLM proxy %s in org %s", id, orgID)) } } w.WriteHeader(http.StatusNoContent) + return nil } diff --git a/platform-api/internal/handler/llm_apikey.go b/platform-api/internal/handler/llm_apikey.go index bb9973556d..c85261b867 100644 --- a/platform-api/internal/handler/llm_apikey.go +++ b/platform-api/internal/handler/llm_apikey.go @@ -20,15 +20,16 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -50,161 +51,130 @@ func NewLLMProviderAPIKeyHandler(apiKeyService *service.LLMProviderAPIKeyService } // ListAPIKeys handles GET /api/v0.9/llm-providers/{llmProviderId}/api-keys -func (h *LLMProviderAPIKeyHandler) ListAPIKeys(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderAPIKeyHandler) ListAPIKeys(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerID := r.PathValue("llmProviderId") if providerID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } - callerUserID, ok := resolveActor(w, r, h.identity, h.slogger, "list LLM provider API keys") - if !ok { - return + callerUserID, err := resolveActorErr(r, h.identity, "list LLM provider API keys") + if err != nil { + return err } response, err := h.apiKeyService.ListLLMProviderAPIKeys(r.Context(), providerID, orgID, callerUserID) if err != nil { - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to list LLM provider API keys", "providerId", providerID, "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to list API keys")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list LLM provider API keys for provider %s in org %s", providerID, orgID)) } httputil.WriteJSON(w, http.StatusOK, response) + return nil } // DeleteAPIKey handles DELETE /api/v0.9/llm-providers/{llmProviderId}/api-keys/{apiKeyId} -func (h *LLMProviderAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerID := r.PathValue("llmProviderId") if providerID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } keyName := r.PathValue("apiKeyId") if keyName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key name is required")) - return + return apperror.ValidationFailed.New("API key name is required") } - callerUserID, ok := resolveActor(w, r, h.identity, h.slogger, "delete LLM provider API key") - if !ok { - return + callerUserID, err := resolveActorErr(r, h.identity, "delete LLM provider API key") + if err != nil { + return err } - err := h.apiKeyService.DeleteLLMProviderAPIKey(r.Context(), providerID, orgID, callerUserID, keyName) - if err != nil { + if err := h.apiKeyService.DeleteLLMProviderAPIKey(r.Context(), providerID, orgID, callerUserID, keyName); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return + return apperror.ArtifactNotFound.Wrap(err) } if errors.Is(err, constants.ErrAPIKeyNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API key not found")) - return + return apperror.LLMProviderAPIKeyNotFound.Wrap(err) } if errors.Is(err, constants.ErrAPIKeyForbidden) { - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", - "Only the key creator can delete this API key")) - return + return apperror.LLMProviderAPIKeyForbidden.Wrap(err) } - h.slogger.Error("Failed to delete LLM provider API key", "providerId", providerID, "keyName", keyName, "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to delete API key")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete LLM provider API key %s for provider %s in org %s", keyName, providerID, orgID)) } h.slogger.Info("Successfully deleted LLM provider API key", "providerId", providerID, "keyName", keyName, "organizationId", orgID) w.WriteHeader(http.StatusNoContent) + return nil } // CreateAPIKey handles POST /api/v0.9/llm-providers/{llmProviderId}/api-keys -func (h *LLMProviderAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerID := r.PathValue("llmProviderId") if providerID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } var req api.CreateLLMProviderAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("Invalid LLM provider API key creation request", "providerId", providerID, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid LLM provider API key creation request for provider %s", providerID)) } // Validate that displayName is provided (name is optional; auto-generated from displayName if absent) req.DisplayName = strings.TrimSpace(req.DisplayName) if req.DisplayName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "'displayName' is required")) - return + return apperror.ValidationFailed.New("'displayName' is required") } - userID, ok := resolveActor(w, r, h.identity, h.slogger, "create LLM provider API key") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "create LLM provider API key") + if err != nil { + return err } response, err := h.apiKeyService.CreateLLMProviderAPIKey(r.Context(), providerID, orgID, userID, &req) if err != nil { - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return - } - if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", - "No gateway connections available")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to create LLM provider API key", "providerId", providerID, "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to create API key")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create LLM provider API key for provider %s in org %s", providerID, orgID)) } h.slogger.Info("Successfully created LLM provider API key", "providerId", providerID, "organizationId", orgID, "keyId", response.Id) + setLocation(w, "llm-providers", providerID, "api-keys", response.Id) httputil.WriteJSON(w, http.StatusCreated, response) + return nil } // RegisterRoutes registers LLM provider API key routes with the router func (h *LLMProviderAPIKeyHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST "+constants.APIBasePath+"/llm-providers/{llmProviderId}/api-keys", h.CreateAPIKey) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-providers/{llmProviderId}/api-keys", h.ListAPIKeys) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-providers/{llmProviderId}/api-keys/{apiKeyId}", h.DeleteAPIKey) + mux.HandleFunc("POST "+constants.APIBasePath+"/llm-providers/{llmProviderId}/api-keys", middleware.MapErrors(h.slogger, h.CreateAPIKey)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-providers/{llmProviderId}/api-keys", middleware.MapErrors(h.slogger, h.ListAPIKeys)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-providers/{llmProviderId}/api-keys/{apiKeyId}", middleware.MapErrors(h.slogger, h.DeleteAPIKey)) } diff --git a/platform-api/internal/handler/llm_deployment.go b/platform-api/internal/handler/llm_deployment.go index 017e5764f0..c062684c9b 100644 --- a/platform-api/internal/handler/llm_deployment.go +++ b/platform-api/internal/handler/llm_deployment.go @@ -20,15 +20,16 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -56,99 +57,73 @@ func NewLLMProxyDeploymentHandler(deploymentService *service.LLMProxyDeploymentS } // DeployLLMProvider handles POST /api/v0.9/llm-providers/{llmProviderId}/deployments -func (h *LLMProviderDeploymentHandler) DeployLLMProvider(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderDeploymentHandler) DeployLLMProvider(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerId := r.PathValue("llmProviderId") if providerId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } var req api.DeployRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid LLM provider deployment request body for provider %s", providerId)) } if req.Name == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "name is required")) - return + return apperror.LLMProviderDeploymentValidationFailed.New("name is required") } if req.Base == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "base is required (use 'current' or a deploymentId)")) - return + return apperror.LLMProviderDeploymentValidationFailed.New("base is required (use 'current' or a deploymentId)") } if strings.TrimSpace(req.GatewayId) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId is required")) - return + return apperror.LLMProviderDeploymentValidationFailed.New("gatewayId is required") } deployment, err := h.deploymentService.DeployLLMProvider(providerId, &req, orgId) if err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrBaseDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Base deployment not found")) - return + return apperror.DeploymentBaseNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNameRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment name is required")) - return + return apperror.LLMProviderDeploymentValidationFailed.Wrap(err, "Deployment name is required") case errors.Is(err, constants.ErrDeploymentBaseRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Base is required (use 'current' or a deploymentId)")) - return + return apperror.LLMProviderDeploymentValidationFailed.Wrap(err, "Base is required (use 'current' or a deploymentId)") case errors.Is(err, constants.ErrDeploymentGatewayIDRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.LLMProviderDeploymentValidationFailed.Wrap(err, "Gateway ID is required") case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Referenced template not found")) - return + return apperror.LLMProviderDeploymentValidationFailed.Wrap(err, "Referenced template not found") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid input")) - return + return apperror.LLMProviderDeploymentValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to deploy LLM provider", "providerId", providerId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to deploy LLM provider")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to deploy LLM provider %s", providerId)) } } + setLocation(w, "llm-providers", providerId, "deployments", deployment.DeploymentId.String()) httputil.WriteJSON(w, http.StatusCreated, deployment) + return nil } // UndeployLLMProviderDeployment handles POST /api/v0.9/llm-providers/{llmProviderId}/deployments/{deploymentId}/undeploy -func (h *LLMProviderDeploymentHandler) UndeployLLMProviderDeployment(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderDeploymentHandler) UndeployLLMProviderDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerId := r.PathValue("llmProviderId") @@ -156,54 +131,41 @@ func (h *LLMProviderDeploymentHandler) UndeployLLMProviderDeployment(w http.Resp gatewayId := r.URL.Query().Get("gatewayId") if providerId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } deployment, err := h.deploymentService.UndeployLLMProviderDeployment(providerId, deploymentId, gatewayId, orgId) if err != nil { // DP-originated artifacts are read-only: undeployment cannot be initiated from the CP. - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotActive): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "No active deployment found for this LLM provider on the gateway")) - return + return apperror.DeploymentNotActive.Wrap(err, "LLM provider") case errors.Is(err, constants.ErrGatewayIDMismatch): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment is bound to a different gateway")) - return + return apperror.DeploymentGatewayMismatch.Wrap(err) default: - h.slogger.Error("Failed to undeploy LLM provider", "providerId", providerId, "deploymentId", deploymentId, "gatewayId", gatewayId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to undeploy deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to undeploy LLM provider %s deployment %s on gateway %q", providerId, deploymentId, gatewayId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // RestoreLLMProviderDeployment handles POST /api/v0.9/llm-providers/{llmProviderId}/deployments/{deploymentId}/restore -func (h *LLMProviderDeploymentHandler) RestoreLLMProviderDeployment(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderDeploymentHandler) RestoreLLMProviderDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerId := r.PathValue("llmProviderId") @@ -211,154 +173,118 @@ func (h *LLMProviderDeploymentHandler) RestoreLLMProviderDeployment(w http.Respo gatewayId := r.URL.Query().Get("gatewayId") if providerId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } deployment, err := h.deploymentService.RestoreLLMProviderDeployment(providerId, deploymentId, gatewayId, orgId) if err != nil { // DP-originated artifacts are read-only: restore cannot be initiated from the CP. - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentAlreadyDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Cannot restore currently deployed deployment")) - return + return apperror.DeploymentRestoreConflict.Wrap(err) case errors.Is(err, constants.ErrGatewayIDMismatch): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment is bound to a different gateway")) - return + return apperror.DeploymentGatewayMismatch.Wrap(err) default: - h.slogger.Error("Failed to restore LLM provider deployment", "providerId", providerId, "deploymentId", deploymentId, "gatewayId", gatewayId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to restore deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to restore LLM provider %s deployment %s on gateway %q", providerId, deploymentId, gatewayId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // DeleteLLMProviderDeployment handles DELETE /api/v0.9/llm-providers/{llmProviderId}/deployments/{deploymentId} -func (h *LLMProviderDeploymentHandler) DeleteLLMProviderDeployment(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderDeploymentHandler) DeleteLLMProviderDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerId := r.PathValue("llmProviderId") deploymentId := r.PathValue("deploymentId") if providerId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment ID is required")) - return + return apperror.ValidationFailed.New("Deployment ID is required") } err := h.deploymentService.DeleteLLMProviderDeployment(providerId, deploymentId, orgId) if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentIsDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Cannot delete an active deployment - undeploy it first")) - return + return apperror.DeploymentActive.Wrap(err) default: - h.slogger.Error("Failed to delete LLM provider deployment", "providerId", providerId, "deploymentId", deploymentId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete LLM provider %s deployment %s", providerId, deploymentId)) } } w.WriteHeader(http.StatusNoContent) + return nil } // GetLLMProviderDeployment handles GET /api/v0.9/llm-providers/{llmProviderId}/deployments/{deploymentId} -func (h *LLMProviderDeploymentHandler) GetLLMProviderDeployment(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderDeploymentHandler) GetLLMProviderDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerId := r.PathValue("llmProviderId") deploymentId := r.PathValue("deploymentId") if providerId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment ID is required")) - return + return apperror.ValidationFailed.New("Deployment ID is required") } deployment, err := h.deploymentService.GetLLMProviderDeployment(providerId, deploymentId, orgId) if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) default: - h.slogger.Error("Failed to get LLM provider deployment", "providerId", providerId, "deploymentId", deploymentId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get LLM provider %s deployment %s", providerId, deploymentId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // GetLLMProviderDeployments handles GET /api/v0.9/llm-providers/{llmProviderId}/deployments -func (h *LLMProviderDeploymentHandler) GetLLMProviderDeployments(w http.ResponseWriter, r *http.Request) { +func (h *LLMProviderDeploymentHandler) GetLLMProviderDeployments(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } providerId := r.PathValue("llmProviderId") if providerId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM provider ID is required")) - return + return apperror.ValidationFailed.New("LLM provider ID is required") } q := r.URL.Query() @@ -374,125 +300,96 @@ func (h *LLMProviderDeploymentHandler) GetLLMProviderDeployments(w http.Response if err != nil { switch { case errors.Is(err, constants.ErrLLMProviderNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM provider not found")) - return + return apperror.LLMProviderNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidDeploymentStatus): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid deployment status")) - return + return apperror.DeploymentInvalidStatus.Wrap(err) default: - h.slogger.Error("Failed to get LLM provider deployments", "providerId", providerId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve deployments")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get LLM provider %s deployments", providerId)) } } httputil.WriteJSON(w, http.StatusOK, deployments) + return nil } // RegisterRoutes registers all LLM provider deployment-related routes func (h *LLMProviderDeploymentHandler) RegisterRoutes(mux *http.ServeMux) { base := constants.APIBasePath + "/llm-providers/{llmProviderId}" - mux.HandleFunc("POST "+base+"/deployments", h.DeployLLMProvider) - mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/undeploy", h.UndeployLLMProviderDeployment) - mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/restore", h.RestoreLLMProviderDeployment) - mux.HandleFunc("GET "+base+"/deployments", h.GetLLMProviderDeployments) - mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", h.GetLLMProviderDeployment) - mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", h.DeleteLLMProviderDeployment) + mux.HandleFunc("POST "+base+"/deployments", middleware.MapErrors(h.slogger, h.DeployLLMProvider)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/undeploy", middleware.MapErrors(h.slogger, h.UndeployLLMProviderDeployment)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/restore", middleware.MapErrors(h.slogger, h.RestoreLLMProviderDeployment)) + mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetLLMProviderDeployments)) + mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetLLMProviderDeployment)) + mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteLLMProviderDeployment)) } // DeployLLMProxy handles POST /api/v0.9/llm-proxies/{llmProxyId}/deployments -func (h *LLMProxyDeploymentHandler) DeployLLMProxy(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyDeploymentHandler) DeployLLMProxy(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("llmProxyId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } var req api.DeployRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid LLM proxy deployment request body for proxy %s", proxyId)) } if req.Name == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "name is required")) - return + return apperror.LLMProxyDeploymentValidationFailed.New("name is required") } if req.Base == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "base is required (use 'current' or a deploymentId)")) - return + return apperror.LLMProxyDeploymentValidationFailed.New("base is required (use 'current' or a deploymentId)") } if strings.TrimSpace(req.GatewayId) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId is required")) - return + return apperror.LLMProxyDeploymentValidationFailed.New("gatewayId is required") } deployment, err := h.deploymentService.DeployLLMProxy(proxyId, &req, orgId) if err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrBaseDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Base deployment not found")) - return + return apperror.DeploymentBaseNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNameRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment name is required")) - return + return apperror.LLMProxyDeploymentValidationFailed.Wrap(err, "Deployment name is required") case errors.Is(err, constants.ErrDeploymentBaseRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Base is required (use 'current' or a deploymentId)")) - return + return apperror.LLMProxyDeploymentValidationFailed.Wrap(err, "Base is required (use 'current' or a deploymentId)") case errors.Is(err, constants.ErrDeploymentGatewayIDRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.LLMProxyDeploymentValidationFailed.Wrap(err, "Gateway ID is required") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid input")) - return + return apperror.LLMProxyDeploymentValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to deploy LLM proxy", "proxyId", proxyId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to deploy LLM proxy")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to deploy LLM proxy %s", proxyId)) } } + setLocation(w, "llm-proxies", proxyId, "deployments", deployment.DeploymentId.String()) httputil.WriteJSON(w, http.StatusCreated, deployment) + return nil } // UndeployLLMProxyDeployment handles POST /api/v0.9/llm-proxies/{llmProxyId}/deployments/{deploymentId}/undeploy -func (h *LLMProxyDeploymentHandler) UndeployLLMProxyDeployment(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyDeploymentHandler) UndeployLLMProxyDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("llmProxyId") @@ -500,54 +397,41 @@ func (h *LLMProxyDeploymentHandler) UndeployLLMProxyDeployment(w http.ResponseWr gatewayId := r.URL.Query().Get("gatewayId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } deployment, err := h.deploymentService.UndeployLLMProxyDeployment(proxyId, deploymentId, gatewayId, orgId) if err != nil { // DP-originated artifacts are read-only: undeployment cannot be initiated from the CP. - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotActive): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "No active deployment found for this LLM proxy on the gateway")) - return + return apperror.DeploymentNotActive.Wrap(err, "LLM proxy") case errors.Is(err, constants.ErrGatewayIDMismatch): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment is bound to a different gateway")) - return + return apperror.DeploymentGatewayMismatch.Wrap(err) default: - h.slogger.Error("Failed to undeploy LLM proxy", "proxyId", proxyId, "deploymentId", deploymentId, "gatewayId", gatewayId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to undeploy deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to undeploy LLM proxy %s deployment %s on gateway %q", proxyId, deploymentId, gatewayId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // RestoreLLMProxyDeployment handles POST /api/v0.9/llm-proxies/{llmProxyId}/deployments/{deploymentId}/restore -func (h *LLMProxyDeploymentHandler) RestoreLLMProxyDeployment(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyDeploymentHandler) RestoreLLMProxyDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("llmProxyId") @@ -555,154 +439,118 @@ func (h *LLMProxyDeploymentHandler) RestoreLLMProxyDeployment(w http.ResponseWri gatewayId := r.URL.Query().Get("gatewayId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } deployment, err := h.deploymentService.RestoreLLMProxyDeployment(proxyId, deploymentId, gatewayId, orgId) if err != nil { // DP-originated artifacts are read-only: restore cannot be initiated from the CP. - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentAlreadyDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Cannot restore currently deployed deployment")) - return + return apperror.DeploymentRestoreConflict.Wrap(err) case errors.Is(err, constants.ErrGatewayIDMismatch): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment is bound to a different gateway")) - return + return apperror.DeploymentGatewayMismatch.Wrap(err) default: - h.slogger.Error("Failed to restore LLM proxy deployment", "proxyId", proxyId, "deploymentId", deploymentId, "gatewayId", gatewayId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to restore deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to restore LLM proxy %s deployment %s on gateway %q", proxyId, deploymentId, gatewayId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // DeleteLLMProxyDeployment handles DELETE /api/v0.9/llm-proxies/{llmProxyId}/deployments/{deploymentId} -func (h *LLMProxyDeploymentHandler) DeleteLLMProxyDeployment(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyDeploymentHandler) DeleteLLMProxyDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("llmProxyId") deploymentId := r.PathValue("deploymentId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment ID is required")) - return + return apperror.ValidationFailed.New("Deployment ID is required") } err := h.deploymentService.DeleteLLMProxyDeployment(proxyId, deploymentId, orgId) if err != nil { switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentIsDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Cannot delete an active deployment - undeploy it first")) - return + return apperror.DeploymentActive.Wrap(err) default: - h.slogger.Error("Failed to delete LLM proxy deployment", "proxyId", proxyId, "deploymentId", deploymentId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete LLM proxy %s deployment %s", proxyId, deploymentId)) } } w.WriteHeader(http.StatusNoContent) + return nil } // GetLLMProxyDeployment handles GET /api/v0.9/llm-proxies/{llmProxyId}/deployments/{deploymentId} -func (h *LLMProxyDeploymentHandler) GetLLMProxyDeployment(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyDeploymentHandler) GetLLMProxyDeployment(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("llmProxyId") deploymentId := r.PathValue("deploymentId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment ID is required")) - return + return apperror.ValidationFailed.New("Deployment ID is required") } deployment, err := h.deploymentService.GetLLMProxyDeployment(proxyId, deploymentId, orgId) if err != nil { switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) default: - h.slogger.Error("Failed to get LLM proxy deployment", "proxyId", proxyId, "deploymentId", deploymentId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get LLM proxy %s deployment %s", proxyId, deploymentId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // GetLLMProxyDeployments handles GET /api/v0.9/llm-proxies/{llmProxyId}/deployments -func (h *LLMProxyDeploymentHandler) GetLLMProxyDeployments(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyDeploymentHandler) GetLLMProxyDeployments(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("llmProxyId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } q := r.URL.Query() @@ -718,31 +566,26 @@ func (h *LLMProxyDeploymentHandler) GetLLMProxyDeployments(w http.ResponseWriter if err != nil { switch { case errors.Is(err, constants.ErrLLMProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return + return apperror.LLMProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidDeploymentStatus): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid deployment status")) - return + return apperror.DeploymentInvalidStatus.Wrap(err) default: - h.slogger.Error("Failed to get LLM proxy deployments", "proxyId", proxyId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve deployments")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get LLM proxy %s deployments", proxyId)) } } httputil.WriteJSON(w, http.StatusOK, deployments) + return nil } // RegisterRoutes registers all LLM proxy deployment-related routes func (h *LLMProxyDeploymentHandler) RegisterRoutes(mux *http.ServeMux) { base := constants.APIBasePath + "/llm-proxies/{llmProxyId}" - mux.HandleFunc("POST "+base+"/deployments", h.DeployLLMProxy) - mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/undeploy", h.UndeployLLMProxyDeployment) - mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/restore", h.RestoreLLMProxyDeployment) - mux.HandleFunc("GET "+base+"/deployments", h.GetLLMProxyDeployments) - mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", h.GetLLMProxyDeployment) - mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", h.DeleteLLMProxyDeployment) + mux.HandleFunc("POST "+base+"/deployments", middleware.MapErrors(h.slogger, h.DeployLLMProxy)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/undeploy", middleware.MapErrors(h.slogger, h.UndeployLLMProxyDeployment)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/restore", middleware.MapErrors(h.slogger, h.RestoreLLMProxyDeployment)) + mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetLLMProxyDeployments)) + mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetLLMProxyDeployment)) + mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteLLMProxyDeployment)) } diff --git a/platform-api/internal/handler/llm_provider_integration_test.go b/platform-api/internal/handler/llm_provider_integration_test.go new file mode 100644 index 0000000000..789eb62cb5 --- /dev/null +++ b/platform-api/internal/handler/llm_provider_integration_test.go @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "bytes" + "database/sql" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/utils" + "github.com/wso2/api-platform/platform-api/internal/vault" + + _ "github.com/mattn/go-sqlite3" +) + +const provBase = "/api/v0.9/llm-providers" +const provOrg = "org-prov-it-001" + +// setupLLMProviderEnv builds the real route -> handler -> service -> repository +// stack over an in-memory SQLite DB, seeded with the shipped built-in templates +// and a test organization. maxProviders <= 0 means unlimited (see +// config.ArtifactLimits), letting individual tests exercise the limit-reached path. +func setupLLMProviderEnv(t *testing.T, maxProviders int) (http.Handler, func()) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.db") + sqlDB, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlDB.Exec("PRAGMA foreign_keys = ON") + db := &database.DB{DB: sqlDB} + + schema, err := os.ReadFile(filepath.Join("..", "database", "schema.sqlite.sql")) + if err != nil { + t.Fatalf("read schema: %v", err) + } + if _, err = db.Exec(string(schema)); err != nil { + t.Fatalf("apply schema: %v", err) + } + if _, err = db.Exec(`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES ('` + provOrg + `', 'test-prov-org', 'Test Prov Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`); err != nil { + t.Fatalf("insert org: %v", err) + } + + templateRepo := repository.NewLLMProviderTemplateRepo(db) + providerRepo := repository.NewLLMProviderRepo(db) + orgRepo := repository.NewOrganizationRepo(db) + + builtins, err := utils.LoadLLMProviderTemplatesFromDirectory( + filepath.Join("..", "..", "resources", "default-llm-provider-templates")) + if err != nil { + t.Fatalf("load built-ins: %v", err) + } + seeder := service.NewLLMTemplateSeeder(templateRepo, builtins) + if err := seeder.SeedForOrg(provOrg); err != nil { + t.Fatalf("seed built-ins: %v", err) + } + + identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) + cfg := &config.Server{ArtifactLimits: config.ArtifactLimits{MaxLLMProvidersPerOrg: maxProviders}} + + providerService := service.NewLLMProviderService( + providerRepo, templateRepo, orgRepo, seeder, + nil, nil, nil, // deploymentRepo, gatewayRepo, gatewayEventsService: unused by Create in these tests + slog.Default(), noopAudit{}, cfg, identityService, + ) + + secretRepo := repository.NewSecretRepo(db) + v, err := vault.NewInHouseVault([]byte("12345678901234567890123456789012")) + if err != nil { + t.Fatalf("create vault: %v", err) + } + secretService := service.NewSecretService(secretRepo, v, identityService) + providerService.SetSecretService(secretService) + + h := NewLLMHandler(nil, providerService, nil, identityService, slog.Default()) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + return middleware.NewTestContextMiddleware(mux), func() { _ = sqlDB.Close() } +} + +// doProviderJSON issues an HTTP request against r, scoped to provOrg (auth=true +// sets X-Test-Org/X-Test-User; auth=false omits them to exercise the 401 path). +// Self-contained rather than reusing llm_template_integration_test.go's doJSON, +// which hardcodes a different org constant. +func doProviderJSON(t *testing.T, r http.Handler, method, path, body string, auth bool) *httptest.ResponseRecorder { + t.Helper() + req, _ := http.NewRequest(method, path, bytes.NewBufferString(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if auth { + req.Header.Set("X-Test-Org", provOrg) + req.Header.Set("X-Test-User", "alice") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +// validProviderBody returns a minimal, valid create-provider JSON body backed +// by the built-in "openai" template. id is left auto-generated unless idSuffix +// is non-empty, so successive calls in the same test don't collide on handle. +func validProviderBody(idSuffix string) string { + name := "Test Provider" + if idSuffix != "" { + name = "Test Provider " + idSuffix + } + return fmt.Sprintf(`{ + "displayName": %q, + "version": "v1.0", + "template": "openai", + "upstream": {"main": {"url": "https://api.openai.com/v1"}}, + "accessControl": {"mode": "allow_all"} + }`, name) +} + +// ---- Auth ------------------------------------------------------------- + +func TestLLMProviderHTTP_CreateRequiresOrg_401(t *testing.T) { + r, cleanup := setupLLMProviderEnv(t, 0) + defer cleanup() + + w := doProviderJSON(t, r, http.MethodPost, provBase, validProviderBody(""), false) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without org, got %d: %s", w.Code, w.Body.String()) + } + body := bodyMap(t, w) + if body["status"] != "error" { + t.Errorf("expected status=error, got %v", body["status"]) + } + if body["code"] != "UNAUTHORIZED" { + t.Errorf("expected code=UNAUTHORIZED, got %v", body["code"]) + } +} + +// ---- Create: validation errors ----------------------------------------- + +func TestLLMProviderHTTP_Create_InvalidBody_400(t *testing.T) { + r, cleanup := setupLLMProviderEnv(t, 0) + defer cleanup() + + w := doProviderJSON(t, r, http.MethodPost, provBase, `not-json`, true) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid JSON: expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestLLMProviderHTTP_Create_MissingRequiredFields_400(t *testing.T) { + r, cleanup := setupLLMProviderEnv(t, 0) + defer cleanup() + + // Missing displayName/version/template entirely. + w := doProviderJSON(t, r, http.MethodPost, provBase, `{"upstream":{"main":{"url":"https://x"}},"accessControl":{"mode":"allow_all"}}`, true) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing required fields: expected 400, got %d: %s", w.Code, w.Body.String()) + } + body := bodyMap(t, w) + if body["status"] != "error" || body["code"] == nil || body["message"] == nil { + t.Errorf("expected standard error shape, got %v", body) + } +} + +func TestLLMProviderHTTP_Create_UnknownTemplate_400(t *testing.T) { + r, cleanup := setupLLMProviderEnv(t, 0) + defer cleanup() + + body := `{ + "displayName": "Bad Template Provider", + "version": "v1.0", + "template": "does-not-exist", + "upstream": {"main": {"url": "https://api.example.com"}}, + "accessControl": {"mode": "allow_all"} + }` + w := doProviderJSON(t, r, http.MethodPost, provBase, body, true) + if w.Code != http.StatusBadRequest { + t.Fatalf("unknown template: expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +// ---- Create: conflicts -------------------------------------------------- + +func TestLLMProviderHTTP_Create_DuplicateHandle_409(t *testing.T) { + r, cleanup := setupLLMProviderEnv(t, 0) + defer cleanup() + + body := `{ + "id": "dup-provider", + "displayName": "Dup Provider", + "version": "v1.0", + "template": "openai", + "upstream": {"main": {"url": "https://api.openai.com/v1"}}, + "accessControl": {"mode": "allow_all"} + }` + if w := doProviderJSON(t, r, http.MethodPost, provBase, body, true); w.Code != http.StatusCreated { + t.Fatalf("first create: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + w := doProviderJSON(t, r, http.MethodPost, provBase, body, true) + if w.Code != http.StatusConflict { + t.Fatalf("duplicate handle: expected 409, got %d: %s", w.Code, w.Body.String()) + } + respBody := bodyMap(t, w) + if respBody["status"] != "error" { + t.Errorf("expected status=error, got %v", respBody["status"]) + } +} + +func TestLLMProviderHTTP_Create_LimitReached_409(t *testing.T) { + r, cleanup := setupLLMProviderEnv(t, 1) // only one provider allowed for this org + defer cleanup() + + if w := doProviderJSON(t, r, http.MethodPost, provBase, validProviderBody("A"), true); w.Code != http.StatusCreated { + t.Fatalf("first create: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + w := doProviderJSON(t, r, http.MethodPost, provBase, validProviderBody("B"), true) + if w.Code != http.StatusConflict { + t.Fatalf("limit reached: expected 409, got %d: %s", w.Code, w.Body.String()) + } +} + +// ---- Create: secret placeholder validation ------------------------------ + +func TestLLMProviderHTTP_Create_MissingSecretRef_400(t *testing.T) { + r, cleanup := setupLLMProviderEnv(t, 0) + defer cleanup() + + body := `{ + "displayName": "Secret Ref Provider", + "version": "v1.0", + "template": "openai", + "upstream": { + "main": { + "url": "https://api.openai.com/v1", + "auth": {"type": "api-key", "header": "Authorization", "value": "{{ secret \"does-not-exist\" }}"} + } + }, + "accessControl": {"mode": "allow_all"} + }` + w := doProviderJSON(t, r, http.MethodPost, provBase, body, true) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing secret ref: expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +// ---- Create: happy path (sanity check for the error tests above) ------- + +func TestLLMProviderHTTP_Create_Success(t *testing.T) { + r, cleanup := setupLLMProviderEnv(t, 0) + defer cleanup() + + w := doProviderJSON(t, r, http.MethodPost, provBase, validProviderBody(""), true) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + body := bodyMap(t, w) + if body["displayName"] != "Test Provider" { + t.Errorf("expected displayName echoed back, got %v", body["displayName"]) + } +} diff --git a/platform-api/internal/handler/llm_proxy_apikey.go b/platform-api/internal/handler/llm_proxy_apikey.go index d9ed497d17..ccb85e8a61 100644 --- a/platform-api/internal/handler/llm_proxy_apikey.go +++ b/platform-api/internal/handler/llm_proxy_apikey.go @@ -20,15 +20,16 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -50,161 +51,130 @@ func NewLLMProxyAPIKeyHandler(apiKeyService *service.LLMProxyAPIKeyService, iden } // ListAPIKeys handles GET /api/v0.9/llm-proxies/{llmProxyId}/api-keys -func (h *LLMProxyAPIKeyHandler) ListAPIKeys(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyAPIKeyHandler) ListAPIKeys(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyID := r.PathValue("llmProxyId") if proxyID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } - callerUserID, ok := resolveActor(w, r, h.identity, h.slogger, "list LLM proxy API keys") - if !ok { - return + callerUserID, err := resolveActorErr(r, h.identity, "list LLM proxy API keys") + if err != nil { + return err } response, err := h.apiKeyService.ListLLMProxyAPIKeys(r.Context(), proxyID, orgID, callerUserID) if err != nil { - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to list LLM proxy API keys", "proxyId", proxyID, "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to list API keys")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list LLM proxy API keys for proxy %s in org %s", proxyID, orgID)) } httputil.WriteJSON(w, http.StatusOK, response) + return nil } // DeleteAPIKey handles DELETE /api/v0.9/llm-proxies/{llmProxyId}/api-keys/{apiKeyId} -func (h *LLMProxyAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyID := r.PathValue("llmProxyId") if proxyID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } keyName := r.PathValue("apiKeyId") if keyName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key name is required")) - return + return apperror.ValidationFailed.New("API key name is required") } - callerUserID, ok := resolveActor(w, r, h.identity, h.slogger, "delete LLM proxy API key") - if !ok { - return + callerUserID, err := resolveActorErr(r, h.identity, "delete LLM proxy API key") + if err != nil { + return err } - err := h.apiKeyService.DeleteLLMProxyAPIKey(r.Context(), proxyID, orgID, callerUserID, keyName) - if err != nil { + if err := h.apiKeyService.DeleteLLMProxyAPIKey(r.Context(), proxyID, orgID, callerUserID, keyName); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return + return apperror.ArtifactNotFound.Wrap(err) } if errors.Is(err, constants.ErrAPIKeyNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API key not found")) - return + return apperror.LLMProxyAPIKeyNotFound.Wrap(err) } if errors.Is(err, constants.ErrAPIKeyForbidden) { - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", - "Only the key creator can delete this API key")) - return + return apperror.LLMProxyAPIKeyForbidden.Wrap(err) } - h.slogger.Error("Failed to delete LLM proxy API key", "proxyId", proxyID, "keyName", keyName, "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to delete API key")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete LLM proxy API key %s for proxy %s in org %s", keyName, proxyID, orgID)) } h.slogger.Info("Successfully deleted LLM proxy API key", "proxyId", proxyID, "keyName", keyName, "organizationId", orgID) w.WriteHeader(http.StatusNoContent) + return nil } // CreateAPIKey handles POST /api/v0.9/llm-proxies/{llmProxyId}/api-keys -func (h *LLMProxyAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { +func (h *LLMProxyAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyID := r.PathValue("llmProxyId") if proxyID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "LLM proxy ID is required")) - return + return apperror.ValidationFailed.New("LLM proxy ID is required") } var req api.CreateLLMProxyAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("Invalid LLM proxy API key creation request", "proxyId", proxyID, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid LLM proxy API key creation request for proxy %s", proxyID)) } // Validate that displayName is provided (name is optional; auto-generated from displayName if absent) req.DisplayName = strings.TrimSpace(req.DisplayName) if req.DisplayName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "'displayName' is required")) - return + return apperror.ValidationFailed.New("'displayName' is required") } - userID, ok := resolveActor(w, r, h.identity, h.slogger, "create LLM proxy API key") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "create LLM proxy API key") + if err != nil { + return err } response, err := h.apiKeyService.CreateLLMProxyAPIKey(r.Context(), proxyID, orgID, userID, &req) if err != nil { - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "LLM proxy not found")) - return - } - if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", - "No gateway connections available")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to create LLM proxy API key", "proxyId", proxyID, "organizationId", orgID, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to create API key")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create LLM proxy API key for proxy %s in org %s", proxyID, orgID)) } h.slogger.Info("Successfully created LLM proxy API key", "proxyId", proxyID, "organizationId", orgID, "keyId", response.Id) + setLocation(w, "llm-proxies", proxyID, "api-keys", response.Id) httputil.WriteJSON(w, http.StatusCreated, response) + return nil } // RegisterRoutes registers LLM proxy API key routes with the router func (h *LLMProxyAPIKeyHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST "+constants.APIBasePath+"/llm-proxies/{llmProxyId}/api-keys", h.CreateAPIKey) - mux.HandleFunc("GET "+constants.APIBasePath+"/llm-proxies/{llmProxyId}/api-keys", h.ListAPIKeys) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-proxies/{llmProxyId}/api-keys/{apiKeyId}", h.DeleteAPIKey) + mux.HandleFunc("POST "+constants.APIBasePath+"/llm-proxies/{llmProxyId}/api-keys", middleware.MapErrors(h.slogger, h.CreateAPIKey)) + mux.HandleFunc("GET "+constants.APIBasePath+"/llm-proxies/{llmProxyId}/api-keys", middleware.MapErrors(h.slogger, h.ListAPIKeys)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/llm-proxies/{llmProxyId}/api-keys/{apiKeyId}", middleware.MapErrors(h.slogger, h.DeleteAPIKey)) } diff --git a/platform-api/internal/handler/location.go b/platform-api/internal/handler/location.go new file mode 100644 index 0000000000..2af102a817 --- /dev/null +++ b/platform-api/internal/handler/location.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + "net/url" + "strings" + + "github.com/wso2/api-platform/platform-api/internal/constants" +) + +// setLocation sets the Location response header to the path-absolute URL of a +// newly created resource under the API base path. It is a no-op when any +// segment is empty so a missing identifier never yields a malformed URL. +func setLocation(w http.ResponseWriter, segments ...string) { + var b strings.Builder + b.WriteString(constants.APIBasePath) + for _, s := range segments { + if s == "" { + return + } + b.WriteString("/") + b.WriteString(url.PathEscape(s)) + } + w.Header().Set("Location", b.String()) +} + +// strOrEmpty returns the dereferenced string, or "" for nil. +func strOrEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/platform-api/internal/handler/location_test.go b/platform-api/internal/handler/location_test.go new file mode 100644 index 0000000000..75aee5875f --- /dev/null +++ b/platform-api/internal/handler/location_test.go @@ -0,0 +1,56 @@ +package handler + +import ( + "net/http/httptest" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/constants" +) + +func TestSetLocation(t *testing.T) { + tests := []struct { + name string + segments []string + want string + }{ + { + name: "single resource", + segments: []string{"rest-apis", "my-api"}, + want: constants.APIBasePath + "/rest-apis/my-api", + }, + { + name: "nested resource", + segments: []string{"gateways", "gw-1", "tokens", "550e8400-e29b-41d4-a716-446655440000"}, + want: constants.APIBasePath + "/gateways/gw-1/tokens/550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "segment needing escaping", + segments: []string{"projects", "a b/c"}, + want: constants.APIBasePath + "/projects/a%20b%2Fc", + }, + { + name: "empty segment suppresses header", + segments: []string{"projects", ""}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + setLocation(w, tt.segments...) + if got := w.Header().Get("Location"); got != tt.want { + t.Errorf("Location = %q, want %q", got, tt.want) + } + }) + } +} + +func TestStrOrEmpty(t *testing.T) { + if got := strOrEmpty(nil); got != "" { + t.Errorf("strOrEmpty(nil) = %q, want empty", got) + } + s := "id-1" + if got := strOrEmpty(&s); got != "id-1" { + t.Errorf("strOrEmpty(&s) = %q, want %q", got, s) + } +} diff --git a/platform-api/internal/handler/mcp.go b/platform-api/internal/handler/mcp.go index 4b20ed77fe..35f85aa442 100644 --- a/platform-api/internal/handler/mcp.go +++ b/platform-api/internal/handler/mcp.go @@ -20,12 +20,14 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strconv" "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -49,33 +51,31 @@ func NewMCPProxyHandler(service *service.MCPProxyService, identity *service.Iden } func (h *MCPProxyHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies", h.CreateMCPProxy) - mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies", h.ListMCPProxies) - mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies/fetch-server-info", h.FetchMCPProxyServerInfo) - mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}", h.GetMCPProxy) - mux.HandleFunc("PUT "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}", h.UpdateMCPProxy) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}", h.DeleteMCPProxy) + mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies", middleware.MapErrors(h.slogger, h.CreateMCPProxy)) + mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies", middleware.MapErrors(h.slogger, h.ListMCPProxies)) + mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies/fetch-server-info", middleware.MapErrors(h.slogger, h.FetchMCPProxyServerInfo)) + mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}", middleware.MapErrors(h.slogger, h.GetMCPProxy)) + mux.HandleFunc("PUT "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}", middleware.MapErrors(h.slogger, h.UpdateMCPProxy)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}", middleware.MapErrors(h.slogger, h.DeleteMCPProxy)) } // CreateMCPProxy handles POST /api/v0.9/mcp-proxies -func (h *MCPProxyHandler) CreateMCPProxy(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyHandler) CreateMCPProxy(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - h.slogger.Error("MCP request validation failed", "reason", "Organization claim not found in token") - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req api.MCPProxy if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("MCP request validation failed", "org_id", orgID, "reason", "Invalid request body", "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid MCP proxy creation request body for org %s", orgID)) } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "create MCP proxy") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "create MCP proxy") + if err != nil { + return err } if req.ProjectId == nil { @@ -84,26 +84,25 @@ func (h *MCPProxyHandler) CreateMCPProxy(w http.ResponseWriter, r *http.Request) resp, err := h.service.Create(orgID, createdBy, &req) if err != nil { - h.handleServiceError(w, err) - return + return h.mapServiceError(err) } + setLocation(w, "mcp-proxies", strOrEmpty(resp.Id)) httputil.WriteJSON(w, http.StatusCreated, resp) + return nil } // ListMCPProxies handles GET /api/v0.9/mcp-proxies -func (h *MCPProxyHandler) ListMCPProxies(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyHandler) ListMCPProxies(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - h.slogger.Error("MCP request validation failed", "reason", "Organization claim not found in token") - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } projectID := strings.TrimSpace(r.URL.Query().Get("projectId")) if projectID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "projectId query parameter is required")) - return + return apperror.ValidationFailed.New("projectId query parameter is required") } limitStr := r.URL.Query().Get("limit") @@ -131,163 +130,153 @@ func (h *MCPProxyHandler) ListMCPProxies(w http.ResponseWriter, r *http.Request) } resp, err := h.service.ListByProject(orgID, projectID, limit, offset) - if err != nil { - h.handleServiceError(w, err) - return + return h.mapServiceError(err) } + httputil.WriteJSON(w, http.StatusOK, resp) + return nil } // GetMCPProxy handles GET /api/v0.9/mcp-proxies/:id -func (h *MCPProxyHandler) GetMCPProxy(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyHandler) GetMCPProxy(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - h.slogger.Error("MCP request validation failed", "reason", "Organization claim not found in token") - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("mcpProxyId") resp, err := h.service.Get(orgID, id) if err != nil { - h.handleServiceError(w, err) - return + return h.mapServiceError(err) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } // UpdateMCPProxy handles PUT /api/v0.9/mcp-proxies/:id -func (h *MCPProxyHandler) UpdateMCPProxy(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyHandler) UpdateMCPProxy(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - h.slogger.Error("MCP request validation failed", "reason", "Organization claim not found in token") - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("mcpProxyId") var req api.MCPProxy if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("MCP request validation failed", "org_id", orgID, "proxy_id", id, "reason", "Invalid request body", "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid MCP proxy update request body for proxy %s in org %s", id, orgID)) } if err := utils.ValidateHandleImmutable(id, req.Id); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "MCP proxy id is immutable and cannot be changed")) - return + return apperror.ValidationFailed.Wrap(err, "MCP proxy id is immutable and cannot be changed") } - updatedBy, ok := resolveActor(w, r, h.identity, h.slogger, "update MCP proxy") - if !ok { - return + updatedBy, err := resolveActorErr(r, h.identity, "update MCP proxy") + if err != nil { + return err } resp, err := h.service.Update(orgID, id, updatedBy, &req) if err != nil { - h.handleServiceError(w, err) - return + return h.mapServiceError(err) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } // DeleteMCPProxy handles DELETE /api/v0.9/mcp-proxies/:id -func (h *MCPProxyHandler) DeleteMCPProxy(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyHandler) DeleteMCPProxy(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - h.slogger.Error("MCP request validation failed", "reason", "Organization claim not found in token") - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } id := r.PathValue("mcpProxyId") - deletedBy, ok := resolveActor(w, r, h.identity, h.slogger, "delete MCP proxy") - if !ok { - return + deletedBy, err := resolveActorErr(r, h.identity, "delete MCP proxy") + if err != nil { + return err } if err := h.service.Delete(orgID, id, deletedBy); err != nil { - h.handleServiceError(w, err) - return + return h.mapServiceError(err) } w.WriteHeader(http.StatusNoContent) + return nil } // FetchMCPProxyServerInfo handles POST /api/v0.9/mcp-proxies/fetch-server-info -func (h *MCPProxyHandler) FetchMCPProxyServerInfo(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyHandler) FetchMCPProxyServerInfo(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - h.slogger.Error("MCP request validation failed", "reason", "Organization claim not found in token") - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req api.MCPServerInfoFetchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("MCP request validation failed", "org_id", orgID, "reason", "Invalid request body", "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid MCP server info fetch request body for org %s", orgID)) } resp, err := h.service.FetchServerInfo(orgID, &req) if err != nil { + reqURL := "" + if req.Url != nil { + reqURL = *req.Url + } switch { case errors.Is(err, constants.ErrInvalidURL): - h.slogger.Error("Invalid URL provided for MCP server info fetch", "error", err, "inputUrl", req.Url) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid URL provided"). + WithLogMessage(fmt.Sprintf("invalid URL provided for MCP server info fetch: %s", reqURL)) case errors.Is(err, constants.ErrURLUnreachable): - h.slogger.Error("MCP server URL is unreachable", "error", err, "inputUrl", req.Url) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", strings.Split(err.Error(), ":")[0])) - return + return apperror.ValidationFailed.Wrap(err, "URL is unreachable"). + WithLogMessage(fmt.Sprintf("MCP server URL is unreachable: %s", reqURL)) case errors.Is(err, constants.ErrMCPServerUnauthorized): - h.slogger.Error("MCP server returned 401 Unauthorized", "error", err, "inputUrl", req.Url) - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(400, "Bad Request", "MCP server returned 401 Unauthorized. Check the provided credentials.")) - return + return apperror.ValidationFailed.Wrap(err, "MCP server returned 401 Unauthorized. Check the provided credentials."). + WithLogMessage(fmt.Sprintf("MCP server returned 401 Unauthorized: %s", reqURL)) default: - h.handleServiceError(w, err) - return + return h.mapServiceError(err) } } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -// handleServiceError maps service errors to HTTP responses -func (h *MCPProxyHandler) handleServiceError(w http.ResponseWriter, err error) { - if respondArtifactGuardError(w, err) { - return - } +// mapServiceError maps service errors to *apperror.Error values for the +// centralized error mapper, preserving the exact status/code/message each +// error produced before the migration (including the read-only / +// deletion-guard mapping previously handled by respondArtifactGuardError). +func (h *MCPProxyHandler) mapServiceError(err error) error { switch { + case errors.Is(err, constants.ErrArtifactReadOnly): + return apperror.ArtifactReadOnly.Wrap(err, "Artifact is read-only: it originated from a data-plane gateway") + case errors.Is(err, constants.ErrArtifactRuntimeImmutable): + return apperror.ArtifactRuntimeImmutable.Wrap(err, "Runtime configuration of this artifact cannot be changed") + case errors.Is(err, constants.ErrArtifactDeployed): + return apperror.ArtifactDeployed.Wrap(err, "Artifact is still deployed on a gateway and cannot be deleted") case errors.Is(err, constants.ErrHandleImmutable): - h.slogger.Error("MCP handle immutability violation", "reason", err.Error()) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + return apperror.ValidationFailed.Wrap(err, "The id is immutable and cannot be changed") case errors.Is(err, constants.ErrInvalidInput): - h.slogger.Error("MCP request validation failed", "reason", err.Error()) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + return apperror.ValidationFailed.Wrap(err, "Invalid input parameters") case errors.Is(err, constants.ErrInvalidPolicyVersion): - h.slogger.Error("MCP policy version validation failed", "reason", err.Error()) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + return apperror.ValidationFailed.Wrap(err, "Invalid policy version format") case errors.Is(err, constants.ErrMCPProxyNotFound): - h.slogger.Error("MCP proxy not found", "reason", err.Error()) - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "MCP proxy not found")) + return apperror.MCPProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrMCPProxyExists): - h.slogger.Error("MCP proxy conflict", "reason", err.Error()) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "MCP proxy with this ID already exists")) + return apperror.MCPProxyExists.Wrap(err) case errors.Is(err, constants.ErrProjectNotFound): - h.slogger.Error("MCP request validation failed", "reason", "Project not found") - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Project not found")) + return apperror.ProjectRefNotFound.Wrap(err) case errors.Is(err, constants.ErrMCPProxyLimitReached): - h.slogger.Error("MCP proxy limit reached", "reason", err.Error()) - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "MCP proxy limit reached for the organization")) + return apperror.MCPProxyLimitReached.Wrap(err) case errors.Is(err, constants.ErrSecretRefMissing): - h.slogger.Error("MCP proxy secret ref missing", "reason", err.Error()) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + return apperror.ValidationFailed.Wrap(err, "One or more referenced secrets do not exist") default: - h.slogger.Error("MCP proxy service error", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + return apperror.Internal.Wrap(err) } } diff --git a/platform-api/internal/handler/mcp_deployment.go b/platform-api/internal/handler/mcp_deployment.go index 02a30e5ea1..b44e2d80d2 100644 --- a/platform-api/internal/handler/mcp_deployment.go +++ b/platform-api/internal/handler/mcp_deployment.go @@ -22,15 +22,16 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -53,174 +54,133 @@ func NewMCPProxyDeploymentHandler(deploymentService *service.MCPDeploymentServic // RegisterRoutes registers all MCP proxy deployment-related routes func (h *MCPProxyDeploymentHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments", h.DeployMCPProxy) - mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}/undeploy", h.UndeployMCPProxyDeployment) - mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}/restore", h.RestoreMCPProxyDeployment) - mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments", h.GetMCPProxyDeployments) - mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}", h.GetMCPProxyDeployment) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}", h.DeleteMCPProxyDeployment) + mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments", middleware.MapErrors(h.slogger, h.DeployMCPProxy)) + mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}/undeploy", middleware.MapErrors(h.slogger, h.UndeployMCPProxyDeployment)) + mux.HandleFunc("POST "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}/restore", middleware.MapErrors(h.slogger, h.RestoreMCPProxyDeployment)) + mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments", middleware.MapErrors(h.slogger, h.GetMCPProxyDeployments)) + mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetMCPProxyDeployment)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteMCPProxyDeployment)) } // DeployMCPProxy handles POST /api/v0.9/mcp-proxies/:id/deployments -func (h *MCPProxyDeploymentHandler) DeployMCPProxy(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyDeploymentHandler) DeployMCPProxy(w http.ResponseWriter, r *http.Request) error { orgId, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("mcpProxyId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "MCP proxy ID is required")) - return + return apperror.ValidationFailed.New("MCP proxy ID is required") } var req api.DeployRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid MCP proxy deployment request body for proxy %s", proxyId)) } if req.Name == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "name is required")) - return + return apperror.MCPProxyDeploymentValidationFailed.New("name is required") } if req.Base == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "base is required (use 'current' or a deploymentId)")) - return + return apperror.MCPProxyDeploymentValidationFailed.New("base is required (use 'current' or a deploymentId)") } if strings.TrimSpace(req.GatewayId) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId is required")) - return + return apperror.MCPProxyDeploymentValidationFailed.New("gatewayId is required") } - createdBy, ok := resolveActor(w, r, h.identity, h.slogger, "deploy MCP proxy") - if !ok { - return + createdBy, err := resolveActorErr(r, h.identity, "deploy MCP proxy") + if err != nil { + return err } deployment, err := h.deploymentService.DeployMCPProxyByHandle(proxyId, &req, orgId, createdBy) if err != nil { - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrMCPProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "MCP proxy not found")) - return + return apperror.MCPProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrBaseDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Base deployment not found")) - return + return apperror.DeploymentBaseNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNameRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment name is required")) - return + return apperror.MCPProxyDeploymentValidationFailed.Wrap(err, "Deployment name is required") case errors.Is(err, constants.ErrDeploymentBaseRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Base is required")) - return + return apperror.MCPProxyDeploymentValidationFailed.Wrap(err, "Base is required") case errors.Is(err, constants.ErrDeploymentGatewayIDRequired): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Gateway ID is required")) - return + return apperror.MCPProxyDeploymentValidationFailed.Wrap(err, "Gateway ID is required") case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid input")) - return + return apperror.MCPProxyDeploymentValidationFailed.Wrap(err, "Invalid input") default: - h.slogger.Error("Failed to deploy MCP proxy", "proxyId", proxyId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to deploy MCP proxy")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to deploy MCP proxy %s", proxyId)) } } + setLocation(w, "mcp-proxies", proxyId, "deployments", deployment.DeploymentId.String()) httputil.WriteJSON(w, http.StatusCreated, deployment) + return nil } // UndeployMCPProxyDeployment handles POST /api/v0.9/mcp-proxies/:id/deployments/:deploymentId/undeploy -func (h *MCPProxyDeploymentHandler) UndeployMCPProxyDeployment(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyDeploymentHandler) UndeployMCPProxyDeployment(w http.ResponseWriter, r *http.Request) error { orgId, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("mcpProxyId") deploymentId := r.PathValue("deploymentId") gatewayId := r.URL.Query().Get("gatewayId") if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "deploymentId is required")) - return + return apperror.ValidationFailed.New("deploymentId is required") } if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId is required")) - return + return apperror.ValidationFailed.New("gatewayId is required") } if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "MCP proxy ID is required")) - return + return apperror.ValidationFailed.New("MCP proxy ID is required") } deployment, err := h.deploymentService.UndeployDeploymentByHandle(proxyId, deploymentId, gatewayId, orgId) if err != nil { // DP-originated artifacts are read-only: undeployment cannot be initiated from the CP. - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrMCPProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "MCP proxy not found")) - return + return apperror.MCPProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotActive): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "No active deployment found for this MCP proxy on the gateway")) - return + return apperror.DeploymentNotActive.Wrap(err, "MCP proxy") case errors.Is(err, constants.ErrGatewayIDMismatch): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment is bound to a different gateway")) - return + return apperror.DeploymentGatewayMismatch.Wrap(err) default: - h.slogger.Error("Failed to undeploy MCP proxy", "proxyId", proxyId, "deploymentId", deploymentId, "gatewayId", gatewayId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to undeploy deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to undeploy MCP proxy %s deployment %s on gateway %s", proxyId, deploymentId, gatewayId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // RestoreMCPProxyDeployment handles POST /api/v0.9/mcp-proxies/:id/deployments/:deploymentId/restore -func (h *MCPProxyDeploymentHandler) RestoreMCPProxyDeployment(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyDeploymentHandler) RestoreMCPProxyDeployment(w http.ResponseWriter, r *http.Request) error { orgId, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("mcpProxyId") @@ -228,165 +188,125 @@ func (h *MCPProxyDeploymentHandler) RestoreMCPProxyDeployment(w http.ResponseWri gatewayId := r.URL.Query().Get("gatewayId") if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "deploymentId is required")) - return + return apperror.ValidationFailed.New("deploymentId is required") } if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "gatewayId is required")) - return + return apperror.ValidationFailed.New("gatewayId is required") } if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "MCP proxy ID is required")) - return + return apperror.ValidationFailed.New("MCP proxy ID is required") } deployment, err := h.deploymentService.RestoreMCPDeploymentByHandle(proxyId, deploymentId, gatewayId, orgId) if err != nil { // DP-originated artifacts are read-only: restore cannot be initiated from the CP. - if respondArtifactGuardError(w, err) { - return + if guardErr := mapArtifactGuardError(err); guardErr != nil { + return guardErr } switch { case errors.Is(err, constants.ErrMCPProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "MCP proxy not found")) - return + return apperror.MCPProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Gateway not found")) - return + return apperror.GatewayNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentAlreadyDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Cannot restore currently deployed deployment")) - return + return apperror.DeploymentRestoreConflict.Wrap(err) case errors.Is(err, constants.ErrGatewayIDMismatch): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment is bound to a different gateway")) - return + return apperror.DeploymentGatewayMismatch.Wrap(err) default: - h.slogger.Error("Failed to restore MCP proxy deployment", "proxyId", proxyId, "deploymentId", deploymentId, "gatewayId", gatewayId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to restore deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to restore MCP proxy %s deployment %s on gateway %s", proxyId, deploymentId, gatewayId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // DeleteMCPProxyDeployment handles DELETE /api/v0.9/mcp-proxies/:id/deployments/:deploymentId -func (h *MCPProxyDeploymentHandler) DeleteMCPProxyDeployment(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyDeploymentHandler) DeleteMCPProxyDeployment(w http.ResponseWriter, r *http.Request) error { orgId, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("mcpProxyId") deploymentId := r.PathValue("deploymentId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "MCP proxy ID is required")) - return + return apperror.ValidationFailed.New("MCP proxy ID is required") } if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment ID is required")) - return + return apperror.ValidationFailed.New("Deployment ID is required") } err := h.deploymentService.DeleteDeploymentByHandle(proxyId, deploymentId, orgId) if err != nil { switch { case errors.Is(err, constants.ErrMCPProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "MCP proxy not found")) - return + return apperror.MCPProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentIsDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Cannot delete an active deployment - undeploy it first")) - return + return apperror.DeploymentActive.Wrap(err) default: - h.slogger.Error("Failed to delete MCP proxy deployment", "proxyId", proxyId, "deploymentId", deploymentId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete MCP proxy %s deployment %s", proxyId, deploymentId)) } } w.WriteHeader(http.StatusNoContent) + return nil } // GetMCPProxyDeployment handles GET /api/v0.9/mcp-proxies/:id/deployments/:deploymentId -func (h *MCPProxyDeploymentHandler) GetMCPProxyDeployment(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyDeploymentHandler) GetMCPProxyDeployment(w http.ResponseWriter, r *http.Request) error { orgId, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("mcpProxyId") deploymentId := r.PathValue("deploymentId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "MCP proxy ID is required")) - return + return apperror.ValidationFailed.New("MCP proxy ID is required") } if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Deployment ID is required")) - return + return apperror.ValidationFailed.New("Deployment ID is required") } deployment, err := h.deploymentService.GetDeploymentByHandle(proxyId, deploymentId, orgId) if err != nil { switch { case errors.Is(err, constants.ErrMCPProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "MCP proxy not found")) - return + return apperror.MCPProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Deployment not found")) - return + return apperror.DeploymentNotFound.Wrap(err) default: - h.slogger.Error("Failed to get MCP proxy deployment", "proxyId", proxyId, "deploymentId", deploymentId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve deployment")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get MCP proxy %s deployment %s", proxyId, deploymentId)) } } httputil.WriteJSON(w, http.StatusOK, deployment) + return nil } // GetMCPProxyDeployments handles GET /api/v0.9/mcp-proxies/:id/deployments -func (h *MCPProxyDeploymentHandler) GetMCPProxyDeployments(w http.ResponseWriter, r *http.Request) { +func (h *MCPProxyDeploymentHandler) GetMCPProxyDeployments(w http.ResponseWriter, r *http.Request) error { orgId, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } proxyId := r.PathValue("mcpProxyId") if proxyId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "MCP proxy ID is required")) - return + return apperror.ValidationFailed.New("MCP proxy ID is required") } var params api.GetMCPProxyDeploymentsParams @@ -413,20 +333,15 @@ func (h *MCPProxyDeploymentHandler) GetMCPProxyDeployments(w http.ResponseWriter if err != nil { switch { case errors.Is(err, constants.ErrMCPProxyNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "MCP proxy not found")) - return + return apperror.MCPProxyNotFound.Wrap(err) case errors.Is(err, constants.ErrInvalidDeploymentStatus): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid deployment status")) - return + return apperror.DeploymentInvalidStatus.Wrap(err) default: - h.slogger.Error("Failed to get MCP proxy deployments", "proxyId", proxyId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to retrieve deployments")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get MCP proxy %s deployments", proxyId)) } } httputil.WriteJSON(w, http.StatusOK, deployments) + return nil } diff --git a/platform-api/internal/handler/organization.go b/platform-api/internal/handler/organization.go index 36352fbe20..2dfe162fb0 100644 --- a/platform-api/internal/handler/organization.go +++ b/platform-api/internal/handler/organization.go @@ -20,12 +20,14 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strconv" "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -49,31 +51,26 @@ func NewOrganizationHandler(orgService *service.OrganizationService, identity *s } // RegisterOrganization handles POST /api/v0.9/organizations -func (h *OrganizationHandler) RegisterOrganization(w http.ResponseWriter, r *http.Request) { +func (h *OrganizationHandler) RegisterOrganization(w http.ResponseWriter, r *http.Request) error { var req api.Organization if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } // Validate required fields if req.DisplayName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "displayName is required")) - return + return apperror.ValidationFailed.New("displayName is required") } if req.Region == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Region is required")) - return + return apperror.ValidationFailed.New("Region is required") } // UUID is always server-generated id, genErr := utils.GenerateUUID() if genErr != nil { - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to generate organization ID")) - return + return apperror.Internal.Wrap(genErr). + WithLogMessage("failed to generate organization ID") } // Extract handle from id field (optional — auto-generated from displayName if absent) @@ -82,9 +79,9 @@ func (h *OrganizationHandler) RegisterOrganization(w http.ResponseWriter, r *htt handle = *req.Id } - performedBy, ok := resolveActor(w, r, h.identity, h.slogger, "register organization") - if !ok { - return + performedBy, err := resolveActorErr(r, h.identity, "register organization") + if err != nil { + return err } // The IDP's organization UUID is derived server-side from the token's raw // organization claim (the IDP org id), never client-supplied. This uses the @@ -93,78 +90,69 @@ func (h *OrganizationHandler) RegisterOrganization(w http.ResponseWriter, r *htt idpOrgRefUUID, _ := middleware.GetIdpOrgRefFromRequest(r) org, err := h.orgService.RegisterOrganization(id, handle, req.DisplayName, req.Region, idpOrgRefUUID, performedBy) if err != nil { - if errors.Is(err, constants.ErrHandleExists) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Organization already exists")) - return - } - if errors.Is(err, constants.ErrOrganizationExists) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Organization with the given ID already exists")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to create organization", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to create organization")) - return + return apperror.Internal.Wrap(err). + WithLogMessage("failed to create organization") } + setLocation(w, "organizations", strOrEmpty(org.Id)) httputil.WriteJSON(w, http.StatusCreated, org) + return nil } // HeadOrganization handles HEAD /api/v0.9/organizations/{organizationId} -func (h *OrganizationHandler) HeadOrganization(w http.ResponseWriter, r *http.Request) { +func (h *OrganizationHandler) HeadOrganization(w http.ResponseWriter, r *http.Request) error { organizationIdFromContext, exists := middleware.GetOrganizationFromRequest(r) if !exists { - w.WriteHeader(http.StatusUnauthorized) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } handle := r.PathValue("organizationId") h.slogger.Debug("Organization from token", "organizationId", organizationIdFromContext) - // to do: enable this check after finalizing authentication method - // if orgID != organizationIdFromContext { - // httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", - // "Organization ID in token does not match the requested organization ID")) - // return - // } + if handle != organizationIdFromContext { + return apperror.Forbidden.New(). + WithLogMessage("Organization ID in token does not match the requested organization ID") + } _, err := h.orgService.GetOrganizationByHandle(handle) if err != nil { - if errors.Is(err, constants.ErrOrganizationNotFound) { - w.WriteHeader(http.StatusNotFound) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - w.WriteHeader(http.StatusInternalServerError) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get organization by handle %s", handle)) } w.WriteHeader(http.StatusOK) + return nil } // GetOrganizationByID handles GET /api/v0.9/organizations/{organizationId} -func (h *OrganizationHandler) GetOrganizationByID(w http.ResponseWriter, r *http.Request) { +func (h *OrganizationHandler) GetOrganizationByID(w http.ResponseWriter, r *http.Request) error { handle := r.PathValue("organizationId") org, err := h.orgService.GetOrganizationByHandle(handle) if err != nil { - if errors.Is(err, constants.ErrOrganizationNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Organization not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to get organization by handle", "handle", handle, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to get organization")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get organization by handle %s", handle)) } httputil.WriteJSON(w, http.StatusOK, org) + return nil } // ListOrganizations handles GET /api/v0.9/organizations -func (h *OrganizationHandler) ListOrganizations(w http.ResponseWriter, r *http.Request) { +func (h *OrganizationHandler) ListOrganizations(w http.ResponseWriter, r *http.Request) error { limit := 20 if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { @@ -184,10 +172,8 @@ func (h *OrganizationHandler) ListOrganizations(w http.ResponseWriter, r *http.R orgs, total, err := h.orgService.ListOrganizations(limit, offset) if err != nil { - h.slogger.Error("Failed to list organizations", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to list organizations")) - return + return apperror.Internal.Wrap(err). + WithLogMessage("failed to list organizations") } httputil.WriteJSON(w, http.StatusOK, api.OrganizationListResponse{ @@ -199,11 +185,12 @@ func (h *OrganizationHandler) ListOrganizations(w http.ResponseWriter, r *http.R Limit: limit, }, }) + return nil } func (h *OrganizationHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST "+constants.APIBasePath+"/organizations", h.RegisterOrganization) - mux.HandleFunc("GET "+constants.APIBasePath+"/organizations", h.ListOrganizations) - mux.HandleFunc("HEAD "+constants.APIBasePath+"/organizations/{organizationId}", h.HeadOrganization) - mux.HandleFunc("GET "+constants.APIBasePath+"/organizations/{organizationId}", h.GetOrganizationByID) + mux.HandleFunc("POST "+constants.APIBasePath+"/organizations", middleware.MapErrors(h.slogger, h.RegisterOrganization)) + mux.HandleFunc("GET "+constants.APIBasePath+"/organizations", middleware.MapErrors(h.slogger, h.ListOrganizations)) + mux.HandleFunc("HEAD "+constants.APIBasePath+"/organizations/{organizationId}", middleware.MapErrors(h.slogger, h.HeadOrganization)) + mux.HandleFunc("GET "+constants.APIBasePath+"/organizations/{organizationId}", middleware.MapErrors(h.slogger, h.GetOrganizationByID)) } diff --git a/platform-api/internal/handler/project.go b/platform-api/internal/handler/project.go index 61426a79bf..fe29f667b8 100644 --- a/platform-api/internal/handler/project.go +++ b/platform-api/internal/handler/project.go @@ -20,9 +20,12 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -46,108 +49,84 @@ func NewProjectHandler(projectService *service.ProjectService, identity *service } // CreateProject handles POST /api/v0.9/projects -func (h *ProjectHandler) CreateProject(w http.ResponseWriter, r *http.Request) { +func (h *ProjectHandler) CreateProject(w http.ResponseWriter, r *http.Request) error { organizationID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req api.CreateProjectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.NewValidationErrorResponse(w, err) - return + return apperror.NewValidation(err) } if req.DisplayName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project displayName is required")) - return + return apperror.ValidationFailed.New("Project displayName is required") } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "create project") - if !ok { - return + actor, err := resolveActorErr(r, h.identity, "create project") + if err != nil { + return err } project, err := h.projectService.CreateProject(&req, organizationID, actor) if err != nil { - if errors.Is(err, constants.ErrProjectExists) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Project already exists in organization")) - return - } - if errors.Is(err, constants.ErrOrganizationNotFound) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Organization not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - if errors.Is(err, constants.ErrInvalidProjectName) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project displayName is required")) - return - } - h.slogger.Error("Failed to create project", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to create project")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create project in org %s", organizationID)) } + setLocation(w, "projects", strOrEmpty(project.Id)) httputil.WriteJSON(w, http.StatusCreated, project) + return nil } // GetProject handles GET /api/v0.9/projects/:projectId -func (h *ProjectHandler) GetProject(w http.ResponseWriter, r *http.Request) { +func (h *ProjectHandler) GetProject(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } projectId := r.PathValue("projectId") if projectId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project ID is required")) - return + return apperror.ValidationFailed.New("Project ID is required") } project, err := h.projectService.GetProjectByHandle(projectId, orgID) if err != nil { - if errors.Is(err, constants.ErrProjectNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Project not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to get project", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to get project")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get project %s in org %s", projectId, orgID)) } httputil.WriteJSON(w, http.StatusOK, project) + return nil } // ListProjects handles GET /api/v0.9/projects -func (h *ProjectHandler) ListProjects(w http.ResponseWriter, r *http.Request) { +func (h *ProjectHandler) ListProjects(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } projects, err := h.projectService.GetProjectsByOrganization(orgID) if err != nil { - if errors.Is(err, constants.ErrOrganizationNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Organization not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to list projects", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to get projects")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list projects in org %s", orgID)) } // Return constitution-compliant list response @@ -160,28 +139,25 @@ func (h *ProjectHandler) ListProjects(w http.ResponseWriter, r *http.Request) { Limit: len(projects), }, }) + return nil } // UpdateProject handles PUT /api/v0.9/projects/:projectId -func (h *ProjectHandler) UpdateProject(w http.ResponseWriter, r *http.Request) { +func (h *ProjectHandler) UpdateProject(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } projectId := r.PathValue("projectId") if projectId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project ID is required")) - return + return apperror.ValidationFailed.New("Project ID is required") } var req api.Project if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.NewValidationErrorResponse(w, err) - return + return apperror.NewValidation(err) } var reqId string @@ -189,101 +165,64 @@ func (h *ProjectHandler) UpdateProject(w http.ResponseWriter, r *http.Request) { reqId = *req.Id } if err := utils.ValidateHandleImmutableRequired(projectId, reqId); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project id is immutable and cannot be changed")) - return + return apperror.ValidationFailed.Wrap(err, "Project id is immutable and cannot be changed") } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "update project") - if !ok { - return + actor, err := resolveActorErr(r, h.identity, "update project") + if err != nil { + return err } project, err := h.projectService.UpdateProject(projectId, &req, orgID, actor) if err != nil { - if errors.Is(err, constants.ErrProjectNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Project not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } if errors.Is(err, constants.ErrHandleImmutable) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project id is immutable and cannot be changed")) - return - } - if errors.Is(err, constants.ErrProjectExists) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", - "Project already exists in organization")) - return + return apperror.ValidationFailed.Wrap(err, "Project id is immutable and cannot be changed") } - h.slogger.Error("Failed to update project", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to update project")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update project %s in org %s", projectId, orgID)) } httputil.WriteJSON(w, http.StatusOK, project) + return nil } // DeleteProject handles DELETE /api/v0.9/projects/:projectId -func (h *ProjectHandler) DeleteProject(w http.ResponseWriter, r *http.Request) { +func (h *ProjectHandler) DeleteProject(w http.ResponseWriter, r *http.Request) error { orgID, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } projectId := r.PathValue("projectId") if projectId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project ID is required")) - return + return apperror.ValidationFailed.New("Project ID is required") } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "delete project") - if !ok { - return - } - err := h.projectService.DeleteProject(projectId, orgID, actor) + actor, err := resolveActorErr(r, h.identity, "delete project") if err != nil { - if errors.Is(err, constants.ErrProjectNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "Project not found")) - return - } - if errors.Is(err, constants.ErrOrganizationMustHAveAtLeastOneProject) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Organization must have at least one project")) - return - } - if errors.Is(err, constants.ErrProjectHasAssociatedAPIs) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project has associated APIs")) - return - } - if errors.Is(err, constants.ErrProjectHasAssociatedMCPProxies) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project has associated MCP proxies")) - return - } - if errors.Is(err, constants.ErrProjectHasAssociatedApplications) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Project has associated applications")) - return + return err + } + if err := h.projectService.DeleteProject(projectId, orgID, actor); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to delete project", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to delete project")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete project %s in org %s", projectId, orgID)) } httputil.WriteJSON(w, http.StatusNoContent, nil) + return nil } func (h *ProjectHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET "+constants.APIBasePath+"/projects", h.ListProjects) - mux.HandleFunc("POST "+constants.APIBasePath+"/projects", h.CreateProject) - mux.HandleFunc("GET "+constants.APIBasePath+"/projects/{projectId}", h.GetProject) - mux.HandleFunc("PUT "+constants.APIBasePath+"/projects/{projectId}", h.UpdateProject) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/projects/{projectId}", h.DeleteProject) + mux.HandleFunc("GET "+constants.APIBasePath+"/projects", middleware.MapErrors(h.slogger, h.ListProjects)) + mux.HandleFunc("POST "+constants.APIBasePath+"/projects", middleware.MapErrors(h.slogger, h.CreateProject)) + mux.HandleFunc("GET "+constants.APIBasePath+"/projects/{projectId}", middleware.MapErrors(h.slogger, h.GetProject)) + mux.HandleFunc("PUT "+constants.APIBasePath+"/projects/{projectId}", middleware.MapErrors(h.slogger, h.UpdateProject)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/projects/{projectId}", middleware.MapErrors(h.slogger, h.DeleteProject)) } diff --git a/platform-api/internal/handler/secret.go b/platform-api/internal/handler/secret.go index c2a34aade8..8ad8feb8f3 100644 --- a/platform-api/internal/handler/secret.go +++ b/platform-api/internal/handler/secret.go @@ -24,11 +24,11 @@ import ( "strconv" "time" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/dto" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -45,24 +45,24 @@ func NewSecretHandler(secretService *service.SecretService, identity *service.Id func (h *SecretHandler) RegisterRoutes(mux *http.ServeMux) { for _, version := range []string{"/api/v0.9", "/api/v1"} { - mux.HandleFunc("POST "+version+"/secrets", h.CreateSecret) - mux.HandleFunc("GET "+version+"/secrets", h.ListSecrets) - mux.HandleFunc("GET "+version+"/secrets/{secretId}", h.GetSecret) - mux.HandleFunc("PUT "+version+"/secrets/{secretId}", h.UpdateSecret) - mux.HandleFunc("DELETE "+version+"/secrets/{secretId}", h.DeleteSecret) + mux.HandleFunc("POST "+version+"/secrets", middleware.MapErrors(h.slogger, h.CreateSecret)) + mux.HandleFunc("GET "+version+"/secrets", middleware.MapErrors(h.slogger, h.ListSecrets)) + mux.HandleFunc("GET "+version+"/secrets/{secretId}", middleware.MapErrors(h.slogger, h.GetSecret)) + mux.HandleFunc("PUT "+version+"/secrets/{secretId}", middleware.MapErrors(h.slogger, h.UpdateSecret)) + mux.HandleFunc("DELETE "+version+"/secrets/{secretId}", middleware.MapErrors(h.slogger, h.DeleteSecret)) } } -func (h *SecretHandler) CreateSecret(w http.ResponseWriter, r *http.Request) { +func (h *SecretHandler) CreateSecret(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } - userID, ok := resolveActor(w, r, h.identity, h.slogger, "create secret") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "create secret") + if err != nil { + return err } if err := r.ParseMultipartForm(32 << 20); err != nil { @@ -76,33 +76,32 @@ func (h *SecretHandler) CreateSecret(w http.ResponseWriter, r *http.Request) { Type: r.FormValue("type"), } if req.Handle == "" || req.DisplayName == "" || req.Value == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "id, displayName and value are required")) - return + return apperror.ValidationFailed.New("id, displayName and value are required") } resp, err := h.secretService.Create(orgID, userID, &req) if err != nil { - if errors.Is(err, constants.ErrSecretAlreadyExists) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "A secret with this name already exists in this scope")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - if errors.Is(err, constants.ErrInvalidSecretType) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) - return + // Repository-origin duplicate (unique constraint) still surfaces as a sentinel. + if errors.Is(err, constants.ErrSecretAlreadyExists) { + return apperror.SecretExists.Wrap(err) } - h.slogger.Error("failed to create secret", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create secret")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to create secret") } + setLocation(w, "secrets", resp.Handle) httputil.WriteJSON(w, http.StatusCreated, resp) + return nil } -func (h *SecretHandler) ListSecrets(w http.ResponseWriter, r *http.Request) { +func (h *SecretHandler) ListSecrets(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } limit := 25 @@ -125,65 +124,59 @@ func (h *SecretHandler) ListSecrets(w http.ResponseWriter, r *http.Request) { if ua := r.URL.Query().Get("updatedAfter"); ua != "" { t, err := time.Parse(time.RFC3339, ua) if err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "updatedAfter must be an RFC3339 timestamp")) - return + return apperror.ValidationFailed.Wrap(err, "updatedAfter must be an RFC3339 timestamp") } updatedAfter = &t } resp, err := h.secretService.List(orgID, limit, offset, updatedAfter) if err != nil { - h.slogger.Error("failed to list secrets", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list secrets")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to list secrets") } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *SecretHandler) GetSecret(w http.ResponseWriter, r *http.Request) { +func (h *SecretHandler) GetSecret(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } handle := r.PathValue("secretId") if handle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Secret name is required")) - return + return apperror.ValidationFailed.New("Secret name is required") } summary, err := h.secretService.Get(orgID, handle) if err != nil { if errors.Is(err, constants.ErrSecretNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Secret not found")) - return + return apperror.SecretNotFound.Wrap(err) } - h.slogger.Error("failed to get secret", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get secret")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to get secret") } httputil.WriteJSON(w, http.StatusOK, summary) + return nil } -func (h *SecretHandler) UpdateSecret(w http.ResponseWriter, r *http.Request) { +func (h *SecretHandler) UpdateSecret(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } handle := r.PathValue("secretId") if handle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Secret name is required")) - return + return apperror.ValidationFailed.New("Secret name is required") } - userID, ok := resolveActor(w, r, h.identity, h.slogger, "update secret") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "update secret") + if err != nil { + return err } if err := r.ParseMultipartForm(32 << 20); err != nil { @@ -195,47 +188,42 @@ func (h *SecretHandler) UpdateSecret(w http.ResponseWriter, r *http.Request) { Value: r.FormValue("value"), } if req.Value == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "value is required")) - return + return apperror.ValidationFailed.New("value is required") } resp, err := h.secretService.Update(orgID, handle, userID, &req) if err != nil { if errors.Is(err, constants.ErrSecretNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Secret not found")) - return + return apperror.SecretNotFound.Wrap(err) } - h.slogger.Error("failed to update secret", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update secret")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to update secret") } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } -func (h *SecretHandler) DeleteSecret(w http.ResponseWriter, r *http.Request) { +func (h *SecretHandler) DeleteSecret(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } handle := r.PathValue("secretId") if handle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Secret name is required")) - return + return apperror.ValidationFailed.New("Secret name is required") } - userID, ok := resolveActor(w, r, h.identity, h.slogger, "delete secret") - if !ok { - return + userID, err := resolveActorErr(r, h.identity, "delete secret") + if err != nil { + return err } - err := h.secretService.Delete(orgID, handle, userID) + err = h.secretService.Delete(orgID, handle, userID) if err != nil { if errors.Is(err, constants.ErrSecretNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Secret not found")) - return + return apperror.SecretNotFound.Wrap(err) } var inUseErr *service.SecretInUseError @@ -244,17 +232,12 @@ func (h *SecretHandler) DeleteSecret(w http.ResponseWriter, r *http.Request) { for _, ref := range inUseErr.References { refs = append(refs, dto.SecretReferenceDTO{Type: ref.Type, Handle: ref.Handle, Name: ref.Name}) } - httputil.WriteJSON(w, http.StatusConflict, dto.SecretDeleteConflictResponse{ - Error: "secret is referenced by active resources", - References: refs, - }) - return + return apperror.SecretInUse.New().WithDetails(dto.SecretInUseDetails{References: refs}) } - h.slogger.Error("failed to delete secret", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete secret")) - return + return apperror.Internal.Wrap(err).WithLogMessage("failed to delete secret") } w.WriteHeader(http.StatusNoContent) + return nil } diff --git a/platform-api/internal/handler/secret_integration_test.go b/platform-api/internal/handler/secret_integration_test.go index 9df09b9dfe..50f5b15338 100644 --- a/platform-api/internal/handler/secret_integration_test.go +++ b/platform-api/internal/handler/secret_integration_test.go @@ -510,9 +510,16 @@ func TestSecretHandler_Delete_409_ReferencedByArtifact(t *testing.T) { } resp := parseBody(wDel) - refs, ok := resp["references"].([]interface{}) + if resp["code"] != "SECRET_IN_USE" { + t.Errorf("expected code SECRET_IN_USE, got: %v", resp) + } + details, ok := resp["details"].(map[string]interface{}) + if !ok { + t.Fatalf("expected details object, got: %v", resp) + } + refs, ok := details["references"].([]interface{}) if !ok || len(refs) == 0 { - t.Errorf("expected non-empty references array, got: %v", resp) + t.Errorf("expected non-empty details.references array, got: %v", resp) } } diff --git a/platform-api/internal/handler/subscription_handler.go b/platform-api/internal/handler/subscription_handler.go index 21714ca896..354298b028 100644 --- a/platform-api/internal/handler/subscription_handler.go +++ b/platform-api/internal/handler/subscription_handler.go @@ -20,17 +20,18 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strconv" "strings" api "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -67,71 +68,59 @@ type CreateSubscriptionRequest struct { } // CreateSubscription handles POST /api/v0.9/subscriptions -func (h *SubscriptionHandler) CreateSubscription(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionHandler) CreateSubscription(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - h.slogger.Error("Organization claim not found in token when creating subscription") - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token when creating subscription") } var req CreateSubscriptionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("Invalid create subscription request body", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid create subscription request body for org %s", orgId)) } if req.APIID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API ID is required")) - return + return apperror.ValidationFailed.New("API ID is required") } if req.SubscriberID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "subscriberId is required")) - return + return apperror.ValidationFailed.New("subscriberId is required") } if req.Kind == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "kind is required")) - return + return apperror.ValidationFailed.New("kind is required") } if !constants.ValidArtifactKinds[req.Kind] { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid kind value")) - return + return apperror.ValidationFailed.New("Invalid kind value") } switch req.Status { case "", "ACTIVE", "INACTIVE", "REVOKED": default: - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid status value")) - return + return apperror.ValidationFailed.New("Invalid status value") } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "create subscription") - if !ok { - return + actor, err := resolveActorErr(r, h.identity, "create subscription") + if err != nil { + return err } sub, err := h.subscriptionService.CreateSubscription(req.APIID, req.Kind, orgId, req.SubscriberID, req.ApplicationID, req.SubscriptionPlanID, "", req.Status, actor) if err != nil { - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "API not found")) - return - } - if errors.Is(err, constants.ErrSubscriptionAlreadyExists) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Subscription already exists for this API")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to create subscription", "apiId", req.APIID, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create subscription")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create subscription for api %s in org %s", req.APIID, orgId)) } resp, err := h.toSubscriptionResponse(sub, orgId) if err != nil { - h.slogger.Error("Failed to resolve subscription identity", "apiId", req.APIID, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create subscription")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to resolve subscription identity for api %s in org %s", req.APIID, orgId)) } + setLocation(w, "subscriptions", sub.UUID) httputil.WriteJSON(w, http.StatusCreated, resp) + return nil } // ListSubscriptions handles GET /api/v0.9/subscriptions -func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) error { apiId := r.URL.Query().Get("apiId") subscriberID := r.URL.Query().Get("subscriberId") applicationID := r.URL.Query().Get("applicationId") @@ -139,9 +128,8 @@ func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.R orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var apiIDPtr, subscriberIDPtr, appIDPtr, statusPtr *string @@ -158,8 +146,7 @@ func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.R switch status { case "ACTIVE", "INACTIVE", "REVOKED": default: - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid status value")) - return + return apperror.ValidationFailed.New("Invalid status value") } statusPtr = &status } @@ -188,13 +175,12 @@ func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.R } list, total, err := h.subscriptionService.ListSubscriptionsByFilters(orgId, apiIDPtr, subscriberIDPtr, appIDPtr, statusPtr, limit, offset) if err != nil { - if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "API not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to list subscriptions", "apiId", apiId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list subscriptions")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list subscriptions for api %s in org %s", apiId, orgId)) } // Bulk fetch API handles and plan names to avoid N+1 queries apiUUIDSet := make(map[string]struct{}) @@ -217,15 +203,13 @@ func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.R } artifactMetaMap, err := h.subscriptionService.GetArtifactMetadataMap(apiUUIDs, orgId) if err != nil { - h.slogger.Error("Failed to bulk fetch artifact metadata for list", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list subscriptions")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to bulk fetch artifact metadata for list in org %s", orgId)) } planNameMap, err := h.subscriptionPlanService.GetPlanNameMap(planIDs, orgId) if err != nil { - h.slogger.Error("Failed to bulk fetch plan names for list", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list subscriptions")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to bulk fetch plan names for list in org %s", orgId)) } // Bulk-resolve createdBy UUIDs to their raw identity to avoid N+1 lookups. createdByUUIDs := make([]string, 0, len(list)) @@ -234,9 +218,8 @@ func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.R } createdByMap, err := h.identity.SubsForUUIDs(createdByUUIDs) if err != nil { - h.slogger.Error("Failed to resolve subscription creator identities", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list subscriptions")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to resolve subscription creator identities in org %s", orgId)) } items := make([]map[string]any, 0, len(list)) for _, sub := range list { @@ -251,57 +234,52 @@ func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.R "limit": limit, }, }) + return nil } // GetSubscription handles GET /api/v0.9/subscriptions/:subscriptionId -func (h *SubscriptionHandler) GetSubscription(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionHandler) GetSubscription(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } subscriptionId := r.PathValue("subscriptionId") if subscriptionId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Subscription ID is required")) - return + return apperror.ValidationFailed.New("Subscription ID is required") } sub, err := h.subscriptionService.GetSubscription(subscriptionId, orgId) if err != nil { - if errors.Is(err, constants.ErrSubscriptionNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Subscription not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to get subscription", "subscriptionId", subscriptionId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get subscription")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get subscription %s in org %s", subscriptionId, orgId)) } resp, err := h.toSubscriptionResponse(sub, orgId) if err != nil { - h.slogger.Error("Failed to resolve subscription identity", "subscriptionId", subscriptionId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get subscription")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to resolve subscription identity for subscription %s in org %s", subscriptionId, orgId)) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } // UpdateSubscription handles PUT /api/v0.9/subscriptions/:subscriptionId -func (h *SubscriptionHandler) UpdateSubscription(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionHandler) UpdateSubscription(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } subscriptionId := r.PathValue("subscriptionId") if subscriptionId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Subscription ID is required")) - return + return apperror.ValidationFailed.New("Subscription ID is required") } var req api.Subscription if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body") } var status string if req.Status != nil { @@ -310,93 +288,81 @@ func (h *SubscriptionHandler) UpdateSubscription(w http.ResponseWriter, r *http. switch status { case "", "ACTIVE", "INACTIVE", "REVOKED": default: - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid subscription status")) - return + return apperror.ValidationFailed.New("Invalid subscription status") } - subscriberID, ok := requireSubscriptionSubscriberQuery(w, r) - if !ok { - return + subscriberID, err := requireSubscriptionSubscriberQuery(r) + if err != nil { + return err } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "update subscription") - if !ok { - return + actor, err := resolveActorErr(r, h.identity, "update subscription") + if err != nil { + return err } sub, err := h.subscriptionService.UpdateSubscription(subscriptionId, orgId, subscriberID, status, actor) if err != nil { - if errors.Is(err, constants.ErrSubscriptionNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Subscription not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - if errors.Is(err, constants.ErrSubscriptionSubscriberMismatch) { - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "subscriberId does not match this subscription")) - return - } - h.slogger.Error("Failed to update subscription", "subscriptionId", subscriptionId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update subscription")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update subscription %s in org %s", subscriptionId, orgId)) } resp, err := h.toSubscriptionResponse(sub, orgId) if err != nil { - h.slogger.Error("Failed to resolve subscription identity", "subscriptionId", subscriptionId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update subscription")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to resolve subscription identity for subscription %s in org %s", subscriptionId, orgId)) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } // DeleteSubscription handles DELETE /api/v0.9/subscriptions/:subscriptionId -func (h *SubscriptionHandler) DeleteSubscription(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionHandler) DeleteSubscription(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } subscriptionId := r.PathValue("subscriptionId") if subscriptionId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Subscription ID is required")) - return - } - subscriberID, ok := requireSubscriptionSubscriberQuery(w, r) - if !ok { - return + return apperror.ValidationFailed.New("Subscription ID is required") } - actor, ok := resolveActor(w, r, h.identity, h.slogger, "delete subscription") - if !ok { - return + subscriberID, err := requireSubscriptionSubscriberQuery(r) + if err != nil { + return err } - err := h.subscriptionService.DeleteSubscription(subscriptionId, orgId, subscriberID, actor) + actor, err := resolveActorErr(r, h.identity, "delete subscription") if err != nil { + return err + } + if err := h.subscriptionService.DeleteSubscription(subscriptionId, orgId, subscriberID, actor); err != nil { if errors.Is(err, constants.ErrSubscriptionNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Subscription not found")) - return + return apperror.SubscriptionNotFound.Wrap(err) } if errors.Is(err, constants.ErrSubscriptionSubscriberMismatch) { - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "subscriberId does not match this subscription")) - return + return apperror.SubscriptionForbidden.Wrap(err) } - h.slogger.Error("Failed to delete subscription", "subscriptionId", subscriptionId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete subscription")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete subscription %s in org %s", subscriptionId, orgId)) } w.WriteHeader(http.StatusNoContent) + return nil } -func requireSubscriptionSubscriberQuery(w http.ResponseWriter, r *http.Request) (string, bool) { +func requireSubscriptionSubscriberQuery(r *http.Request) (string, error) { q := strings.TrimSpace(r.URL.Query().Get("subscriberId")) if q == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "subscriberId query parameter is required")) - return "", false + return "", apperror.ValidationFailed.New("subscriberId query parameter is required") } - return q, true + return q, nil } func (h *SubscriptionHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST "+constants.APIBasePath+"/subscriptions", h.CreateSubscription) - mux.HandleFunc("GET "+constants.APIBasePath+"/subscriptions", h.ListSubscriptions) - mux.HandleFunc("GET "+constants.APIBasePath+"/subscriptions/{subscriptionId}", h.GetSubscription) - mux.HandleFunc("PUT "+constants.APIBasePath+"/subscriptions/{subscriptionId}", h.UpdateSubscription) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/subscriptions/{subscriptionId}", h.DeleteSubscription) + mux.HandleFunc("POST "+constants.APIBasePath+"/subscriptions", middleware.MapErrors(h.slogger, h.CreateSubscription)) + mux.HandleFunc("GET "+constants.APIBasePath+"/subscriptions", middleware.MapErrors(h.slogger, h.ListSubscriptions)) + mux.HandleFunc("GET "+constants.APIBasePath+"/subscriptions/{subscriptionId}", middleware.MapErrors(h.slogger, h.GetSubscription)) + mux.HandleFunc("PUT "+constants.APIBasePath+"/subscriptions/{subscriptionId}", middleware.MapErrors(h.slogger, h.UpdateSubscription)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/subscriptions/{subscriptionId}", middleware.MapErrors(h.slogger, h.DeleteSubscription)) } func (h *SubscriptionHandler) toSubscriptionResponse(sub *model.Subscription, orgId string) (map[string]any, error) { diff --git a/platform-api/internal/handler/subscription_plan_handler.go b/platform-api/internal/handler/subscription_plan_handler.go index ba898ebc66..dc9513369c 100644 --- a/platform-api/internal/handler/subscription_plan_handler.go +++ b/platform-api/internal/handler/subscription_plan_handler.go @@ -20,12 +20,14 @@ package handler import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strconv" "time" api "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/model" @@ -141,26 +143,31 @@ type CreateSubscriptionPlanRequest struct { } // CreateSubscriptionPlan handles POST /api/v0.9/subscription-plans -func (h *SubscriptionPlanHandler) CreateSubscriptionPlan(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionPlanHandler) CreateSubscriptionPlan(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var req CreateSubscriptionPlanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("Invalid create subscription plan request body", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid create subscription plan request body for org %s", orgId)) + } + + if req.Id == "" { + return apperror.ValidationFailed.New("id is required") + } + if req.DisplayName == "" { + return apperror.ValidationFailed.New("displayName is required") } if req.Status != "" { switch req.Status { case "ACTIVE", "INACTIVE": default: - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid status value; must be ACTIVE or INACTIVE")) - return + return apperror.ValidationFailed.New("Invalid status value; must be ACTIVE or INACTIVE") } } @@ -172,8 +179,7 @@ func (h *SubscriptionPlanHandler) CreateSubscriptionPlan(w http.ResponseWriter, } if limit := firstLimit(req.Limits); limit != nil { if errMsg := normalizeAndValidateLimit(limit); errMsg != "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", errMsg)) - return + return apperror.ValidationFailed.New(errMsg) } count := limit.LimitCount plan.ThrottleLimitCount = &count @@ -185,48 +191,46 @@ func (h *SubscriptionPlanHandler) CreateSubscriptionPlan(w http.ResponseWriter, if req.ExpiryTime != nil { t, err := time.Parse(time.RFC3339, *req.ExpiryTime) if err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid expiryTime format; use RFC3339")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid expiryTime format; use RFC3339") } plan.ExpiryTime = &t } rawActor, ok := middleware.GetActorIdentityFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "User ID claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("user id claim not found in token") } actor, err := h.identity.ToInternalUUID(rawActor) if err != nil { - h.slogger.Error("Failed to resolve user identity", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to resolve user identity")) - return + return apperror.Internal.Wrap(err). + WithLogMessage("failed to resolve user identity") } created, err := h.planService.CreatePlan(orgId, actor, plan) if err != nil { - if errors.Is(err, constants.ErrSubscriptionPlanAlreadyExists) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", err.Error())) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to create subscription plan", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create subscription plan")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create subscription plan for org %s", orgId)) } resp, err := h.toSubscriptionPlanResponse(created, true) if err != nil { - h.slogger.Error("Failed to resolve subscription plan identity", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create subscription plan")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to resolve subscription plan identity for org %s", orgId)) } + setLocation(w, "subscription-plans", created.Handle) httputil.WriteJSON(w, http.StatusCreated, resp) + return nil } // ListSubscriptionPlans handles GET /api/v0.9/subscription-plans -func (h *SubscriptionPlanHandler) ListSubscriptionPlans(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionPlanHandler) ListSubscriptionPlans(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } var limitStr string @@ -255,87 +259,79 @@ func (h *SubscriptionPlanHandler) ListSubscriptionPlans(w http.ResponseWriter, r list, err := h.planService.ListPlans(orgId, limit, offset) if err != nil { - h.slogger.Error("Failed to list subscription plans", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list subscription plans")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to list subscription plans for org %s", orgId)) } items := make([]map[string]any, 0, len(list)) for _, p := range list { item, err := h.toSubscriptionPlanResponse(p, false) if err != nil { - h.slogger.Error("Failed to resolve subscription plan identity", "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list subscription plans")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to resolve subscription plan identity for org %s", orgId)) } items = append(items, item) } httputil.WriteJSON(w, http.StatusOK, map[string]any{"subscriptionPlans": items, "count": len(items)}) + return nil } // GetSubscriptionPlan handles GET /api/v0.9/subscription-plans/:planId -func (h *SubscriptionPlanHandler) GetSubscriptionPlan(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionPlanHandler) GetSubscriptionPlan(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } planId := r.PathValue("subscriptionPlanId") if planId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Plan ID is required")) - return + return apperror.ValidationFailed.New("Plan ID is required") } plan, err := h.planService.GetPlan(planId, orgId) if err != nil { - if errors.Is(err, constants.ErrSubscriptionPlanNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Subscription plan not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to get subscription plan", "planId", planId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get subscription plan")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to get subscription plan %s in org %s", planId, orgId)) } resp, err := h.toSubscriptionPlanResponse(plan, true) if err != nil { - h.slogger.Error("Failed to resolve subscription plan identity", "planId", planId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get subscription plan")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to resolve subscription plan identity for plan %s in org %s", planId, orgId)) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } // UpdateSubscriptionPlan handles PUT /api/v0.9/subscription-plans/:planId -func (h *SubscriptionPlanHandler) UpdateSubscriptionPlan(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionPlanHandler) UpdateSubscriptionPlan(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } planId := r.PathValue("subscriptionPlanId") if planId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Plan ID is required")) - return + return apperror.ValidationFailed.New("Plan ID is required") } var req api.SubscriptionPlan if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.slogger.Error("Invalid update subscription plan request body", "planId", planId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid update subscription plan request body for plan %s in org %s", planId, orgId)) } if err := utils.ValidateHandleImmutable(planId, req.Id); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "The plan id is immutable and cannot be changed")) - return + return apperror.ValidationFailed.Wrap(err, "The plan id is immutable and cannot be changed") } displayName := req.DisplayName if displayName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "displayName is required")) - return + return apperror.ValidationFailed.New("displayName is required") } update := &model.SubscriptionPlanUpdate{ Name: &displayName, @@ -344,8 +340,7 @@ func (h *SubscriptionPlanHandler) UpdateSubscriptionPlan(w http.ResponseWriter, if req.Limits != nil { if limit := firstLimit(apiLimitsToRequests(*req.Limits)); limit != nil { if errMsg := normalizeAndValidateLimit(limit); errMsg != "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", errMsg)) - return + return apperror.ValidationFailed.New(errMsg) } count := limit.LimitCount update.ThrottleLimitCount = &count @@ -365,95 +360,84 @@ func (h *SubscriptionPlanHandler) UpdateSubscriptionPlan(w http.ResponseWriter, st := model.SubscriptionPlanStatus(*req.Status) update.Status = &st default: - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid status value; must be ACTIVE or INACTIVE")) - return + return apperror.ValidationFailed.New("Invalid status value; must be ACTIVE or INACTIVE") } } rawActor, ok := middleware.GetActorIdentityFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "User ID claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("user id claim not found in token") } actor, err := h.identity.ToInternalUUID(rawActor) if err != nil { - h.slogger.Error("Failed to resolve user identity", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to resolve user identity")) - return + return apperror.Internal.Wrap(err). + WithLogMessage("failed to resolve user identity") } updated, err := h.planService.UpdatePlan(planId, orgId, actor, update) if err != nil { - if errors.Is(err, constants.ErrHandleImmutable) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "The plan id is immutable and cannot be changed")) - return - } - if errors.Is(err, constants.ErrSubscriptionPlanNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Subscription plan not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - if errors.Is(err, constants.ErrSubscriptionPlanAlreadyExists) { - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", err.Error())) - return + if errors.Is(err, constants.ErrHandleImmutable) { + return apperror.ValidationFailed.Wrap(err, "The plan id is immutable and cannot be changed") } - h.slogger.Error("Failed to update subscription plan", "planId", planId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update subscription plan")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update subscription plan %s in org %s", planId, orgId)) } resp, err := h.toSubscriptionPlanResponse(updated, true) if err != nil { - h.slogger.Error("Failed to resolve subscription plan identity", "planId", planId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update subscription plan")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to resolve subscription plan identity for plan %s in org %s", planId, orgId)) } httputil.WriteJSON(w, http.StatusOK, resp) + return nil } // DeleteSubscriptionPlan handles DELETE /api/v0.9/subscription-plans/:planId -func (h *SubscriptionPlanHandler) DeleteSubscriptionPlan(w http.ResponseWriter, r *http.Request) { +func (h *SubscriptionPlanHandler) DeleteSubscriptionPlan(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") } planId := r.PathValue("subscriptionPlanId") if planId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Plan ID is required")) - return + return apperror.ValidationFailed.New("Plan ID is required") } rawActor, ok := middleware.GetActorIdentityFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "User ID claim not found in token")) - return + return apperror.Unauthorized.New(). + WithLogMessage("user id claim not found in token") } actor, err := h.identity.ToInternalUUID(rawActor) if err != nil { - h.slogger.Error("Failed to resolve user identity", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to resolve user identity")) - return + return apperror.Internal.Wrap(err). + WithLogMessage("failed to resolve user identity") } err = h.planService.DeletePlan(planId, orgId, actor) if err != nil { - if errors.Is(err, constants.ErrSubscriptionPlanNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Subscription plan not found")) - return + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err } - h.slogger.Error("Failed to delete subscription plan", "planId", planId, "organizationId", orgId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete subscription plan")) - return + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to delete subscription plan %s in org %s", planId, orgId)) } w.WriteHeader(http.StatusNoContent) + return nil } // RegisterRoutes registers subscription plan routes func (h *SubscriptionPlanHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST "+constants.APIBasePath+"/subscription-plans", h.CreateSubscriptionPlan) - mux.HandleFunc("GET "+constants.APIBasePath+"/subscription-plans", h.ListSubscriptionPlans) - mux.HandleFunc("GET "+constants.APIBasePath+"/subscription-plans/{subscriptionPlanId}", h.GetSubscriptionPlan) - mux.HandleFunc("PUT "+constants.APIBasePath+"/subscription-plans/{subscriptionPlanId}", h.UpdateSubscriptionPlan) - mux.HandleFunc("DELETE "+constants.APIBasePath+"/subscription-plans/{subscriptionPlanId}", h.DeleteSubscriptionPlan) + mux.HandleFunc("POST "+constants.APIBasePath+"/subscription-plans", middleware.MapErrors(h.slogger, h.CreateSubscriptionPlan)) + mux.HandleFunc("GET "+constants.APIBasePath+"/subscription-plans", middleware.MapErrors(h.slogger, h.ListSubscriptionPlans)) + mux.HandleFunc("GET "+constants.APIBasePath+"/subscription-plans/{subscriptionPlanId}", middleware.MapErrors(h.slogger, h.GetSubscriptionPlan)) + mux.HandleFunc("PUT "+constants.APIBasePath+"/subscription-plans/{subscriptionPlanId}", middleware.MapErrors(h.slogger, h.UpdateSubscriptionPlan)) + mux.HandleFunc("DELETE "+constants.APIBasePath+"/subscription-plans/{subscriptionPlanId}", middleware.MapErrors(h.slogger, h.DeleteSubscriptionPlan)) } // toSubscriptionPlanResponse builds the API response for a plan. diff --git a/platform-api/internal/handler/websocket.go b/platform-api/internal/handler/websocket.go index 60b21c0533..c41d42fc3e 100644 --- a/platform-api/internal/handler/websocket.go +++ b/platform-api/internal/handler/websocket.go @@ -25,14 +25,14 @@ import ( "sync" "time" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/dto" + "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" ws "github.com/wso2/api-platform/platform-api/internal/websocket" "github.com/gorilla/websocket" - "github.com/wso2/go-httpkit/httputil" ) // WebSocketHandler handles WebSocket connection upgrades and lifecycle @@ -70,7 +70,7 @@ func NewWebSocketHandler(manager *ws.Manager, gatewayService *service.GatewaySer // Connect handles WebSocket upgrade requests at /api/internal/v1/ws/gateways/connect // This is the entry point for gateway connections. -func (h *WebSocketHandler) Connect(w http.ResponseWriter, r *http.Request) { +func (h *WebSocketHandler) Connect(w http.ResponseWriter, r *http.Request) error { // Extract client IP for rate limiting clientIP := r.RemoteAddr if i := strings.LastIndex(clientIP, ":"); i != -1 { @@ -79,51 +79,45 @@ func (h *WebSocketHandler) Connect(w http.ResponseWriter, r *http.Request) { // Check rate limit if !h.checkRateLimit(clientIP) { - h.slogger.Warn("Rate limit exceeded for IP", "ip", clientIP) h.manager.IncrementFailedConnections() - httputil.WriteJSON(w, http.StatusTooManyRequests, utils.NewErrorResponse(429, "Too Many Requests", - "Connection rate limit exceeded. Please try again later.")) - return + return apperror.TooManyRequests.New("Connection rate limit exceeded. Please try again later."). + WithLogMessage(fmt.Sprintf("rate limit exceeded for IP %s", clientIP)) } - // Extract and validate API key from header + // Extract and validate API key from header. Per the unified auth-failure rule, a missing key + // and an invalid key both surface the identical generic response; the specific reason is + // internal-only via WithLogMessage. apiKey := r.Header.Get("api-key") if apiKey == "" { - h.slogger.Warn("WebSocket connection attempt without API key", "ip", clientIP) h.manager.IncrementFailedConnections() - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "API key is required. Provide 'api-key' header.")) - return + return apperror.Unauthorized.New(). + WithLogMessage(fmt.Sprintf("WebSocket connection attempt without API key from IP %s", clientIP)) } // Authenticate gateway using API key gateway, err := h.gatewayService.VerifyToken(apiKey) if err != nil { - h.slogger.Warn("WebSocket authentication failed", "ip", clientIP, "error", err) h.manager.IncrementFailedConnections() - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Invalid or expired API key")) - return + return apperror.Unauthorized.Wrap(err). + WithLogMessage(fmt.Sprintf("WebSocket authentication failed from IP %s", clientIP)) } // Check organization connection limit before upgrading to WebSocket if !h.manager.CanAcceptOrgConnection(gateway.OrganizationID) { stats := h.manager.GetOrgConnectionStats(gateway.OrganizationID) - h.slogger.Warn("Organization connection limit exceeded", "orgID", gateway.OrganizationID, - "count", stats.CurrentCount, "max", stats.MaxAllowed) h.manager.IncrementFailedConnections() - httputil.WriteJSON(w, http.StatusTooManyRequests, utils.NewErrorResponse(429, "Too Many Requests", - "Organization connection limit reached. Maximum allowed connections: "+ - fmt.Sprintf("%d", stats.MaxAllowed))) - return + return apperror.TooManyRequests.New(fmt.Sprintf( + "Organization connection limit reached. Maximum allowed connections: %d", stats.MaxAllowed)). + WithLogMessage(fmt.Sprintf("organization connection limit exceeded for org %s (count=%d, max=%d)", + gateway.OrganizationID, stats.CurrentCount, stats.MaxAllowed)) } // Upgrade HTTP connection to WebSocket conn, err := h.upgrader.Upgrade(w, r, nil) if err != nil { h.slogger.Error("WebSocket upgrade failed", "gatewayID", gateway.ID, "error", err) - // Upgrade error is already sent by upgrader - return + // Upgrade error response is already written to w by the upgrader itself. + return nil } // Create WebSocket transport @@ -160,7 +154,7 @@ func (h *WebSocketHandler) Connect(w http.ResponseWriter, r *http.Request) { } } conn.Close() - return + return nil } // Send connection acknowledgment @@ -201,6 +195,7 @@ func (h *WebSocketHandler) Connect(w http.ResponseWriter, r *http.Request) { h.slogger.Error("Failed to update gateway active status to false", "gatewayID", gateway.ID, "error", err) } } + return nil } // readLoop reads messages from the WebSocket connection and routes them to handlers. @@ -316,5 +311,5 @@ func (h *WebSocketHandler) checkRateLimit(clientIP string) bool { // RegisterRoutes registers WebSocket routes with the mux. func (h *WebSocketHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /api/internal/v1/ws/gateways/connect", h.Connect) + mux.HandleFunc("GET /api/internal/v1/ws/gateways/connect", middleware.MapErrors(h.slogger, h.Connect)) } diff --git a/platform-api/internal/middleware/auth.go b/platform-api/internal/middleware/auth.go index b4d5f367e9..1c1cc5e3b8 100644 --- a/platform-api/internal/middleware/auth.go +++ b/platform-api/internal/middleware/auth.go @@ -19,14 +19,16 @@ package middleware import ( "context" - "encoding/json" "fmt" + "log/slog" "net/http" "strings" "github.com/wso2/api-platform/common/authenticators" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) // contextKey is an unexported type for context keys in this package. @@ -83,11 +85,30 @@ type PlatformClaimNames struct { RoleScopeMap map[string][]string } -// writeJSONError is a helper to write a JSON error body without depending on httputil. -func writeJSONError(w http.ResponseWriter, status int, msg string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +// writeAuthError writes the unified 401 response. The auth middleware runs +// ahead of routing so it can't return an error through the MapErrors chain; +// instead it logs the specific failure reason internally and serializes +// through the same apperror.WriteHTTP the mapper uses, so both the log shape +// and the wire format have a single owner. Every authentication failure — +// missing header, malformed header, invalid or expired token — produces the +// identical payload (apperror.Unauthorized) per the unified-auth rule in +// error-handling.md; reason is internal-only and must never contain a raw +// token. +func writeAuthError(w http.ResponseWriter, reason string) { + writeError(w, apperror.Unauthorized.New(), reason) +} + +// writeError logs a pre-routing failure (all 4xx — WARN, no stack, per the +// severity split in error_mapper.go) and serializes it through the shared +// apperror.WriteHTTP. reason is internal-only and must never contain a raw +// token. +func writeError(w http.ResponseWriter, appErr *apperror.Error, reason string) { + slog.Warn("request failed", + "trackingId", uuid.NewString(), + "code", appErr.Code, + "status", appErr.HTTPStatus, + "reason", reason) + apperror.WriteHTTP(w, appErr, "") } // LocalJWTAuthMiddleware returns a middleware for locally-issued JWT validation. @@ -104,19 +125,19 @@ func LocalJWTAuthMiddleware(config AuthConfig) func(http.Handler) http.Handler { authHeader := r.Header.Get("Authorization") if authHeader == "" { - writeJSONError(w, http.StatusUnauthorized, "Authorization header is required") + writeAuthError(w, "authorization header missing") return } tokenString := strings.TrimPrefix(authHeader, "Bearer ") if tokenString == authHeader { - writeJSONError(w, http.StatusUnauthorized, "Invalid authorization header format. Expected: Bearer ") + writeAuthError(w, "authorization header is not a Bearer token") return } enriched, err := validateLocalJWT(r, tokenString, config) if err != nil { - writeJSONError(w, http.StatusUnauthorized, err.Error()) + writeAuthError(w, "local JWT validation failed: "+err.Error()) return } @@ -534,18 +555,18 @@ func RequireOrganization(organizationParam string) func(http.Handler) http.Handl return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenOrg, exists := GetOrganizationFromRequest(r) if !exists { - writeJSONError(w, http.StatusForbidden, "No organization found in token") + writeError(w, apperror.Forbidden.New(), "no organization claim in token") return } requestedOrg := r.PathValue(organizationParam) if requestedOrg == "" { - writeJSONError(w, http.StatusBadRequest, "Organization parameter is required") + writeError(w, apperror.ValidationFailed.New("Organization parameter is required"), "missing organization path parameter") return } if tokenOrg != requestedOrg { - writeJSONError(w, http.StatusForbidden, "Access denied for the requested organization") + writeError(w, apperror.Forbidden.New(), "token organization does not match requested organization") return } diff --git a/platform-api/internal/middleware/authorization.go b/platform-api/internal/middleware/authorization.go index 28afc13c13..4e0e295e58 100644 --- a/platform-api/internal/middleware/authorization.go +++ b/platform-api/internal/middleware/authorization.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/wso2/api-platform/common/authenticators" + "github.com/wso2/api-platform/platform-api/internal/apperror" ) const ( @@ -93,7 +94,7 @@ func ScopeEnforcer(registry *ScopeRegistry, cfg ScopeEnforcerConfig) func(http.H } } - writeJSONError(w, http.StatusForbidden, "insufficient permissions") + writeError(w, apperror.Forbidden.New(), "insufficient scopes for route") }) } } diff --git a/platform-api/internal/middleware/error_mapper.go b/platform-api/internal/middleware/error_mapper.go new file mode 100644 index 0000000000..6985936c40 --- /dev/null +++ b/platform-api/internal/middleware/error_mapper.go @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package middleware + +import ( + "errors" + "log/slog" + "net/http" + "runtime/debug" + + "github.com/wso2/api-platform/platform-api/internal/apperror" + + "github.com/google/uuid" +) + +// ErrorHandlerFunc is the handler signature for routes that participate in +// centralized error mapping: instead of writing HTTP error responses inline, +// the handler returns an error (ideally an *apperror.Error) and MapErrors +// logs it and writes the standard apperror.ErrorResponse. Success responses are +// still written directly by the handler — the mapper only owns the error path. +type ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request) error + +// MapErrors adapts an ErrorHandlerFunc to http.HandlerFunc for registration +// on the mux. It is the single catch point for handler errors: it recovers +// panics into a structured 500, maps *apperror.Error values onto the wire +// via apperror.WriteHTTP, and collapses any other error into a generic 500 +// INTERNAL_ERROR so internal details never reach the client. +func MapErrors(slogger *slog.Logger, next ErrorHandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + trackID := uuid.NewString() + slogger.Error("panic recovered", + "trackingId", trackID, + "panic", rec, + "path", r.URL.Path, + "method", r.Method, + "stack", string(debug.Stack())) + apperror.WriteHTTP(w, apperror.Internal.New(), trackID) + } + }() + + if err := next(w, r); err != nil { + writeMappedError(w, r, slogger, err) + } + } +} + +// writeMappedError logs the failure with its internal diagnostics (log +// message, cause, origin stack, tracking ID) and writes the sanitized +// client-facing response. Errors that are not *apperror.Error fall back to a +// generic 500 per the "zero internal details" rule in error-handling.md. +// +// Severity split: a 4xx is a client outcome, not a system fault — it logs at +// WARN without the stack. A 5xx is a system fault — it logs at ERROR with the +// origin stack, and the tracking ID is echoed in the response body so the +// client can quote it back for correlation. +func writeMappedError(w http.ResponseWriter, r *http.Request, slogger *slog.Logger, err error) { + trackID := uuid.NewString() + var appErr *apperror.Error + if !errors.As(err, &appErr) { + appErr = apperror.Internal.Wrap(err) + } + // A handler fallback may have wrapped a more specific typed error produced + // deeper in the stack (service layer) in a generic Internal — prefer the + // specific one so service-origin errors keep their code and status. + for appErr.Code == apperror.CodeCommonInternalError && appErr.Cause != nil { + var inner *apperror.Error + if !errors.As(appErr.Cause, &inner) { + break + } + appErr = inner + } + + logFields := []any{ + "trackingId", trackID, + "code", appErr.Code, + "status", appErr.HTTPStatus, + "path", r.URL.Path, + "method", r.Method, + } + if appErr.LogMessage != "" { + logFields = append(logFields, "detail", appErr.LogMessage) + } + if appErr.Cause != nil { + logFields = append(logFields, "cause", appErr.Cause.Error()) + } + // origin is the file:line where the error was actually constructed + // (Def.New/Def.Wrap call site) — not to be confused with slog's own + // "source" attribute, which always points at this mapper's log call. + if origin := appErr.Origin(); origin != "" { + logFields = append(logFields, "origin", origin) + } + + if appErr.HTTPStatus >= http.StatusInternalServerError { + if len(appErr.Stack) > 0 { + logFields = append(logFields, "stack", appErr.StackString()) + } + slogger.Error("request failed", logFields...) + apperror.WriteHTTP(w, appErr, trackID) + return + } + + slogger.Warn("request failed", logFields...) + apperror.WriteHTTP(w, appErr, "") +} diff --git a/platform-api/internal/middleware/error_mapper_test.go b/platform-api/internal/middleware/error_mapper_test.go new file mode 100644 index 0000000000..6e7f09e8b4 --- /dev/null +++ b/platform-api/internal/middleware/error_mapper_test.go @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package middleware + +import ( + "bytes" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/apperror" +) + +func testLogger(buf *bytes.Buffer) *slog.Logger { + return slog.New(slog.NewJSONHandler(buf, nil)) +} + +func TestMapErrorsAppError(t *testing.T) { + var logBuf bytes.Buffer + h := MapErrors(testLogger(&logBuf), func(w http.ResponseWriter, r *http.Request) error { + return apperror.ProjectNotFound.New(). + WithLogMessage("project p1 missing in org o1") + }) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v0.9/projects/p1", nil)) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } + var body apperror.ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON body: %v", err) + } + if body.Status != "error" || body.Code != apperror.CodeProjectNotFound || + body.Message != "The specified project could not be found." { + t.Errorf("unexpected body: %+v", body) + } + log := logBuf.String() + if !strings.Contains(log, "trackingId") { + t.Error("expected the mapper to log a trackingId") + } + if !strings.Contains(log, "project p1 missing in org o1") { + t.Error("expected the mapper to log the internal detail") + } + if strings.Contains(rec.Body.String(), "project p1 missing") { + t.Error("internal log message leaked into the client response") + } +} + +func TestMapErrorsSeveritySplit(t *testing.T) { + // 4xx: WARN, no stack, no trackingId in the body. + var warnBuf bytes.Buffer + h := MapErrors(testLogger(&warnBuf), func(w http.ResponseWriter, r *http.Request) error { + return apperror.ProjectNotFound.New() + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) + + log := warnBuf.String() + if !strings.Contains(log, `"level":"WARN"`) { + t.Errorf("expected 4xx to log at WARN, got: %s", log) + } + if strings.Contains(log, `"stack"`) { + t.Error("4xx must not log a stack trace") + } + var body apperror.ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON body: %v", err) + } + if body.TrackingID != "" { + t.Error("4xx response must not carry a trackingId") + } + + // 5xx: ERROR, stack logged, trackingId echoed in the body and matching the log. + var errBuf bytes.Buffer + h = MapErrors(testLogger(&errBuf), func(w http.ResponseWriter, r *http.Request) error { + return apperror.Internal.Wrap(errors.New("db down")) + }) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) + + log = errBuf.String() + if !strings.Contains(log, `"level":"ERROR"`) { + t.Errorf("expected 5xx to log at ERROR, got: %s", log) + } + if !strings.Contains(log, `"stack"`) { + t.Error("5xx must log the origin stack trace") + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON body: %v", err) + } + if body.TrackingID == "" { + t.Error("5xx response must carry a trackingId for log correlation") + } + if !strings.Contains(log, body.TrackingID) { + t.Error("trackingId in the response must match the logged one") + } +} + +func TestMapErrorsPlainErrorFallsBackToGeneric500(t *testing.T) { + var logBuf bytes.Buffer + h := MapErrors(testLogger(&logBuf), func(w http.ResponseWriter, r *http.Request) error { + return errors.New("pq: connection reset by peer at 10.0.0.5:5432") + }) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } + var body apperror.ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON body: %v", err) + } + if body.Code != apperror.CodeCommonInternalError || body.Message != "An unexpected error occurred." { + t.Errorf("unexpected body: %+v", body) + } + if strings.Contains(rec.Body.String(), "10.0.0.5") { + t.Error("raw internal error leaked into the client response") + } + if !strings.Contains(logBuf.String(), "10.0.0.5") { + t.Error("expected the raw cause to be logged internally") + } +} + +func TestMapErrorsPrefersInnerTypedErrorOverGenericInternalWrapper(t *testing.T) { + // A service returns a specific typed error; a handler fallback blindly + // wraps it in a generic Internal. The mapper must surface the specific + // error, not the 500 wrapper. + var logBuf bytes.Buffer + h := MapErrors(testLogger(&logBuf), func(w http.ResponseWriter, r *http.Request) error { + serviceErr := apperror.GatewayNotFound.New().WithLogMessage("gateway g1 missing") + return apperror.Internal.Wrap(serviceErr).WithLogMessage("failed to get gateway") + }) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v0.9/gateways/g1", nil)) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 from the inner typed error, got %d", rec.Code) + } + var body apperror.ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON body: %v", err) + } + if body.Code != apperror.CodeGatewayNotFound { + t.Errorf("expected code %q, got %q", apperror.CodeGatewayNotFound, body.Code) + } + if !strings.Contains(logBuf.String(), "gateway g1 missing") { + t.Error("expected the inner error's log message to be logged") + } +} + +func TestMapErrorsRecoversPanic(t *testing.T) { + var logBuf bytes.Buffer + h := MapErrors(testLogger(&logBuf), func(w http.ResponseWriter, r *http.Request) error { + panic("secret internal state: token=abc123") + }) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } + var body apperror.ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON body: %v", err) + } + if body.Code != apperror.CodeCommonInternalError { + t.Errorf("unexpected code %q", body.Code) + } + if strings.Contains(rec.Body.String(), "abc123") { + t.Error("panic value leaked into the client response") + } + log := logBuf.String() + if !strings.Contains(log, "panic recovered") || !strings.Contains(log, "trackingId") { + t.Error("expected a structured panic log with trackingId") + } +} + +func TestMapErrorsSuccessPathWritesNothingExtra(t *testing.T) { + var logBuf bytes.Buffer + h := MapErrors(testLogger(&logBuf), func(w http.ResponseWriter, r *http.Request) error { + w.WriteHeader(http.StatusNoContent) + return nil + }) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("DELETE", "/x", nil)) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("expected empty body, got %q", rec.Body.String()) + } + if strings.Contains(logBuf.String(), "request failed") { + t.Error("mapper logged a failure for a successful request") + } +} diff --git a/platform-api/internal/service/api.go b/platform-api/internal/service/api.go index 73b33e858c..a016cfefc9 100644 --- a/platform-api/internal/service/api.go +++ b/platform-api/internal/service/api.go @@ -28,6 +28,7 @@ import ( "time" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" @@ -184,7 +185,7 @@ func (s *APIService) modelToRESTAPI(apiModel *model.API) (*api.RESTAPI, error) { // GetAPIByUUID retrieves an API by its ID func (s *APIService) GetAPIByUUID(apiUUID, orgUUID string) (*api.RESTAPI, error) { if apiUUID == "" { - return nil, errors.New("API id is required") + return nil, apperror.ValidationFailed.New("API id is required") } apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgUUID) @@ -227,7 +228,7 @@ func (s *APIService) HandleExistsCheck(orgUUID string) func(string) bool { // This is a lightweight operation that only fetches minimal metadata. func (s *APIService) getAPIUUIDByHandle(handle, orgUUID string) (string, error) { if handle == "" { - return "", errors.New("API handle is required") + return "", apperror.ValidationFailed.New("API handle is required") } metadata, err := s.apiRepo.GetAPIMetadataByHandle(handle, orgUUID) @@ -280,7 +281,7 @@ func (s *APIService) GetAPIsByOrganization(orgUUID string, projectHandle string) // UpdateAPI updates an existing API func (s *APIService) UpdateAPI(apiUUID string, req *api.RESTAPI, orgUUID, updatedBy string) (*api.RESTAPI, error) { if apiUUID == "" { - return nil, errors.New("API id is required") + return nil, apperror.ValidationFailed.New("API id is required") } // Get existing API @@ -357,7 +358,7 @@ func (s *APIService) ensureRESTRuntimeArtifactUnchanged(existing, updated *model // DeleteAPI deletes an API func (s *APIService) DeleteAPI(apiUUID, orgUUID, deletedBy string) error { if apiUUID == "" { - return errors.New("API id is required") + return apperror.ValidationFailed.New("API id is required") } // Check if API exists @@ -573,7 +574,7 @@ func (s *APIService) validateCreateAPIRequest(req *api.CreateRESTAPIRequest, org return constants.ErrInvalidAPIVersion } if strings.TrimSpace(req.ProjectId) == "" { - return errors.New("project id is required") + return apperror.ValidationFailed.New("project id is required") } nameVersionExists, err := s.apiRepo.CheckAPIExistsByNameAndVersionInOrganization(req.DisplayName, req.Version, orgUUID, "") @@ -604,12 +605,12 @@ func (s *APIService) validateCreateAPIRequest(req *api.CreateRESTAPIRequest, org case constants.APITypeWebSub: // For WebSub APIs, ensure that at least one channel is defined if req.Operations != nil && len(*req.Operations) > 0 { - return errors.New("WebSub APIs cannot have operations defined") + return apperror.ValidationFailed.New("WebSub APIs cannot have operations defined") } case constants.APITypeHTTP: // For HTTP APIs, ensure that at least one operation is defined if req.Channels != nil && len(*req.Channels) > 0 { - return errors.New("HTTP APIs cannot have channels defined") + return apperror.ValidationFailed.New("HTTP APIs cannot have channels defined") } } diff --git a/platform-api/internal/service/apikey.go b/platform-api/internal/service/apikey.go index e6005aeeb9..a2a562baa3 100644 --- a/platform-api/internal/service/apikey.go +++ b/platform-api/internal/service/apikey.go @@ -29,6 +29,7 @@ import ( "time" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" @@ -287,7 +288,7 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiHandle, kind, orgId } if apiMetadata == nil { s.slogger.Warn("API not found by handle", "apiHandle", apiHandle, "orgId", orgId) - return constants.ErrAPINotFound + return apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } apiId := apiMetadata.ID @@ -297,7 +298,7 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiHandle, kind, orgId return fmt.Errorf("failed to get API deployments for API handle: %s: %w", apiHandle, err) } if len(gateways) == 0 { - return constants.ErrGatewayUnavailable + return apperror.GatewayConnectionUnavailable.Wrap(constants.ErrGatewayUnavailable) } // Resolve key name (required for DB uniqueness; derive from request or generate) @@ -413,7 +414,7 @@ func (s *APIKeyService) UpdateAPIKey(ctx context.Context, apiHandle, kind, orgId } if apiMetadata == nil { s.slogger.Warn("API not found by handle for API key update", "apiHandle", apiHandle) - return constants.ErrAPINotFound + return apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } apiId := apiMetadata.ID @@ -425,7 +426,7 @@ func (s *APIKeyService) UpdateAPIKey(ctx context.Context, apiHandle, kind, orgId } if len(gateways) == 0 { s.slogger.Warn("No gateway deployments found for API", "apiHandle", apiHandle) - return constants.ErrGatewayUnavailable + return apperror.GatewayConnectionUnavailable.Wrap(constants.ErrGatewayUnavailable) } // Hash the API key with all configured algorithms before storage and broadcast @@ -524,7 +525,7 @@ func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiHandle, kind, orgId } if apiMetadata == nil { s.slogger.Warn("API not found by handle for API key revocation", "apiHandle", apiHandle) - return constants.ErrAPINotFound + return apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } apiId := apiMetadata.ID @@ -534,7 +535,7 @@ func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiHandle, kind, orgId return fmt.Errorf("failed to get API deployments: %w", err) } if len(gateways) == 0 { - return constants.ErrGatewayUnavailable + return apperror.GatewayConnectionUnavailable.Wrap(constants.ErrGatewayUnavailable) } // Fetch UUID before revoke for consistent audit record (CREATE uses UUID, not name) diff --git a/platform-api/internal/service/custom_policy_test.go b/platform-api/internal/service/custom_policy_test.go index 9540b0c56c..a9b2780eae 100644 --- a/platform-api/internal/service/custom_policy_test.go +++ b/platform-api/internal/service/custom_policy_test.go @@ -197,7 +197,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.0.0", wantErr: true, - errContains: "gateway not found", + errContains: "GATEWAY_NOT_FOUND", }, { name: "gateway not found - nil returned", @@ -205,7 +205,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.0.0", wantErr: true, - errContains: "gateway not found", + errContains: "GATEWAY_NOT_FOUND", }, { name: "gateway belongs to different org", @@ -213,7 +213,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.0.0", wantErr: true, - errContains: "gateway not found", + errContains: "GATEWAY_NOT_FOUND", }, // manifest validation @@ -233,7 +233,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.0.0", wantErr: true, - errContains: "gateway manifest is not available", + errContains: "POLICY_INVALID_STATE", }, { name: "policy not found in manifest", @@ -244,7 +244,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.0.0", wantErr: true, - errContains: "not found in gateway manifest", + errContains: "CUSTOM_POLICY_VERSION_NOT_FOUND", }, { name: "policy version mismatch in manifest", @@ -255,7 +255,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.0.0", wantErr: true, - errContains: "not found in gateway manifest", + errContains: "CUSTOM_POLICY_VERSION_NOT_FOUND", }, { name: "policy is not a custom policy (wso2 managed)", @@ -266,7 +266,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.0.0", wantErr: true, - errContains: "not a custom policy", + errContains: "POLICY_INVALID_STATE", }, // version conflict rules @@ -280,7 +280,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.2.0", wantErr: true, - errContains: "already exists", + errContains: "POLICY_VERSION_CONFLICT", }, { name: "patch version update is not allowed", @@ -292,7 +292,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.2.1", wantErr: true, - errContains: "patch version updates are not allowed", + errContains: "POLICY_VERSION_CONFLICT", }, { name: "downgrade is not allowed", @@ -304,7 +304,7 @@ func TestSyncCustomPolicy(t *testing.T) { policyName: "rate-limit", version: "1.1.0", wantErr: true, - errContains: "cannot downgrade", + errContains: "POLICY_VERSION_CONFLICT", }, // custom policy successful paths diff --git a/platform-api/internal/service/deployment.go b/platform-api/internal/service/deployment.go index 1e36c92ec4..e8240f0634 100644 --- a/platform-api/internal/service/deployment.go +++ b/platform-api/internal/service/deployment.go @@ -28,6 +28,7 @@ import ( "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/dto" "github.com/wso2/api-platform/platform-api/internal/model" @@ -909,7 +910,7 @@ func (s *DeploymentService) RestoreDeploymentByHandle(apiHandle, deploymentID, g // getUUIDByHandle retrieves the artifact UUID by its handle from the artifact table func (s *DeploymentService) getUUIDByHandle(handle, orgUUID string) (string, error) { if handle == "" { - return "", errors.New("artifact handle is required") + return "", apperror.ValidationFailed.New("artifact handle is required") } artifact, err := s.artifactRepo.GetByHandle(handle, orgUUID) diff --git a/platform-api/internal/service/gateway.go b/platform-api/internal/service/gateway.go index ee0fba72f4..8ee28c3e22 100644 --- a/platform-api/internal/service/gateway.go +++ b/platform-api/internal/service/gateway.go @@ -23,10 +23,10 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" - "errors" "fmt" "log/slog" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" @@ -328,11 +328,15 @@ func (s *GatewayService) SyncCustomPolicy(gatewayID, orgID, policyName, version policyName = strings.ToLower(policyName) gateway, err := s.gatewayRepo.GetByUUID(gatewayID) - if err != nil || gateway == nil { - return nil, errors.New("gateway not found") + if err != nil { + s.slogger.Error("failed to get gateway", slog.String("gateway_id", gatewayID), slog.String("org_id", orgID), slog.String("error", err.Error())) + return nil, apperror.GatewayNotFound.New() + } + if gateway == nil { + return nil, apperror.GatewayNotFound.New() } if gateway.OrganizationID != orgID { - return nil, errors.New("gateway not found") + return nil, apperror.GatewayNotFound.New().WithLogMessage("gateway belongs to a different organization") } raw, err := s.gatewayRepo.GetGatewayManifest(gatewayID) @@ -342,7 +346,7 @@ func (s *GatewayService) SyncCustomPolicy(gatewayID, orgID, policyName, version } if len(raw) == 0 { s.slogger.Error("gateway manifest is not available", slog.String("gateway_id", gatewayID), slog.String("org_id", orgID)) - return nil, errors.New("gateway manifest is not available") + return nil, apperror.PolicyInvalidState.New().WithLogMessage("gateway manifest is not available") } var policies []GatewayPolicyDefinition @@ -359,11 +363,13 @@ func (s *GatewayService) SyncCustomPolicy(gatewayID, orgID, policyName, version } if found == nil { s.slogger.Error("policy not found in gateway manifest", slog.String("gateway_id", gatewayID), slog.String("org_id", orgID), slog.String("policy_name", policyName), slog.String("version", version)) - return nil, fmt.Errorf("policy '%s' version '%s' not found in gateway manifest", policyName, version) + return nil, apperror.CustomPolicyVersionNotFnd.New(). + WithLogMessage(fmt.Sprintf("policy '%s' version '%s' not found in gateway manifest", policyName, version)) } if found.ManagedBy != constants.PolicyManagedByCustomer { s.slogger.Error("policy is not a custom policy", slog.String("gateway_id", gatewayID), slog.String("org_id", orgID), slog.String("policy_name", policyName), slog.String("version", version)) - return nil, fmt.Errorf("policy '%s' version '%s' is not a custom policy", policyName, version) + return nil, apperror.PolicyInvalidState.New(). + WithLogMessage(fmt.Sprintf("policy '%s' version '%s' is not a custom policy", policyName, version)) } var policyDefJSON json.RawMessage @@ -378,7 +384,7 @@ func (s *GatewayService) SyncCustomPolicy(gatewayID, orgID, policyName, version incomingVer, err := parseVersion(version) if err != nil { s.slogger.Error("invalid version format", slog.String("org_id", orgID), slog.String("policy_name", policyName), slog.String("version", version)) - return nil, fmt.Errorf("invalid version '%s': %w", version, err) + return nil, apperror.ValidationFailed.Wrap(err, fmt.Sprintf("Invalid version '%s'", version)) } existingPolicies, err := s.customPolicyRepo.GetCustomPoliciesByName(orgID, policyName) @@ -418,16 +424,19 @@ func (s *GatewayService) SyncCustomPolicy(gatewayID, orgID, policyName, version } if existingVer.Minor == incomingVer.Minor && existingVer.Patch == incomingVer.Patch { // Exact same version — already exists. - return nil, fmt.Errorf("custom policy '%s' version '%s' already exists", policyName, version) + return nil, apperror.PolicyVersionConflict.New(). + WithLogMessage(fmt.Sprintf("custom policy '%s' version '%s' already exists", policyName, version)) } if existingVer.Minor == incomingVer.Minor && existingVer.Patch != incomingVer.Patch { // Same major.minor, different patch — patch update, not allowed. - return nil, fmt.Errorf("patch version updates are not allowed for policy '%s': existing '%s', incoming '%s'", - policyName, sameMajorVersionedPolicy.Version, version) + return nil, apperror.PolicyVersionConflict.New(). + WithLogMessage(fmt.Sprintf("patch version updates are not allowed for policy '%s': existing '%s', incoming '%s'", + policyName, sameMajorVersionedPolicy.Version, version)) } if incomingVer.Minor < existingVer.Minor { - return nil, fmt.Errorf("cannot downgrade policy '%s' from '%s' to '%s'", - policyName, sameMajorVersionedPolicy.Version, version) + return nil, apperror.PolicyVersionConflict.New(). + WithLogMessage(fmt.Sprintf("cannot downgrade policy '%s' from '%s' to '%s'", + policyName, sameMajorVersionedPolicy.Version, version)) } // New minor version — update the existing record. policy.UUID = sameMajorVersionedPolicy.UUID @@ -485,10 +494,10 @@ func (s *GatewayService) GetCustomPolicyByUUID(orgID, policyUUID, version string return nil, fmt.Errorf("failed to retrieve custom policy (org_id=%s, policy_uuid=%s): %w", orgID, policyUUID, err) } if policy == nil { - return nil, constants.ErrCustomPolicyNotFound + return nil, apperror.CustomPolicyNotFound.Wrap(constants.ErrCustomPolicyNotFound) } if policy.Version != version { - return nil, constants.ErrCustomPolicyVersionMismatch + return nil, apperror.CustomPolicyVersionNotFnd.Wrap(constants.ErrCustomPolicyVersionMismatch) } return policy, nil } @@ -500,10 +509,10 @@ func (s *GatewayService) DeleteCustomPolicyByUUID(orgID, policyUUID, version str return fmt.Errorf("failed to retrieve custom policy (org_id=%s, policy_uuid=%s): %w", orgID, policyUUID, err) } if policy == nil { - return constants.ErrCustomPolicyNotFound + return apperror.CustomPolicyNotFound.Wrap(constants.ErrCustomPolicyNotFound) } if policy.Version != version { - return constants.ErrCustomPolicyVersionMismatch + return apperror.CustomPolicyVersionNotFnd.Wrap(constants.ErrCustomPolicyVersionMismatch) } if err := s.customPolicyRepo.DeleteCustomPolicyIfUnused(orgID, policyUUID); err != nil { @@ -567,7 +576,7 @@ func (s *GatewayService) RegisterGateway(orgID string, id *string, displayName, return nil, fmt.Errorf("failed to query organization: %w", err) } if org == nil { - return nil, errors.New("organization not found") + return nil, apperror.OrganizationNotFound.New() } // 3. Check gateway handle uniqueness within organization @@ -576,7 +585,8 @@ func (s *GatewayService) RegisterGateway(orgID string, id *string, displayName, return nil, fmt.Errorf("failed to check gateway handle uniqueness: %w", err) } if existing != nil { - return nil, fmt.Errorf("gateway with handle '%s' already exists in this organization", name) + return nil, apperror.GatewayNameConflict.New(). + WithLogMessage(fmt.Sprintf("gateway with handle '%s' already exists in this organization", name)) } // 4. Generate UUID for gateway @@ -666,7 +676,7 @@ func (s *GatewayService) GetGateway(gatewayId, orgId string) (*api.GatewayRespon } if gateway == nil { - return nil, errors.New("gateway not found") + return nil, apperror.GatewayNotFound.New() } return s.gatewayModelToAPI(gateway) @@ -770,7 +780,7 @@ func (s *GatewayService) ListTokens(gatewayId, orgId string) ([]api.TokenInfoRes return nil, fmt.Errorf("failed to query gateway: %w", err) } if gateway == nil { - return nil, errors.New("gateway not found") + return nil, apperror.GatewayNotFound.New() } activeTokens, err := s.gatewayRepo.GetActiveTokensByGatewayUUID(gateway.ID) @@ -806,7 +816,7 @@ func (s *GatewayService) RotateToken(gatewayId, orgId, createdBy string) (*api.T return nil, fmt.Errorf("failed to query gateway: %w", err) } if gateway == nil { - return nil, errors.New("gateway not found") + return nil, apperror.GatewayNotFound.New() } // 2. Count active tokens @@ -817,7 +827,8 @@ func (s *GatewayService) RotateToken(gatewayId, orgId, createdBy string) (*api.T // 3. Check max 2 active tokens limit if activeCount >= 2 { - return nil, errors.New("maximum 2 active tokens allowed. Revoke old tokens before rotating") + return nil, apperror.GatewayTokenLimitReached.New(). + WithLogMessage("maximum 2 active tokens allowed; revoke old tokens before rotating") } // 4. Generate new plain-text token @@ -861,7 +872,7 @@ func (s *GatewayService) RevokeToken(gatewayId, tokenId, orgId, revokedBy string return fmt.Errorf("failed to query gateway: %w", err) } if gateway == nil { - return errors.New("gateway not found") + return apperror.GatewayNotFound.New() } token, err := s.gatewayRepo.GetTokenByUUID(tokenId) @@ -869,10 +880,10 @@ func (s *GatewayService) RevokeToken(gatewayId, tokenId, orgId, revokedBy string return fmt.Errorf("failed to query token: %w", err) } if token == nil { - return errors.New("token not found") + return apperror.GatewayTokenNotFound.New() } if token.GatewayID != gateway.ID { - return errors.New("token not found") + return apperror.GatewayTokenNotFound.New().WithLogMessage("token belongs to a different gateway") } if err := s.gatewayRepo.RevokeToken(tokenId, revokedBy); err != nil { @@ -886,7 +897,7 @@ func (s *GatewayService) RevokeToken(gatewayId, tokenId, orgId, revokedBy string func (s *GatewayService) GetGatewayStatus(orgID string, gatewayId *string) (*api.GatewayStatusListResponse, error) { // Validate organizationId is provided and valid if strings.TrimSpace(orgID) == "" { - return nil, errors.New("organization ID is required") + return nil, apperror.ValidationFailed.New("organization ID is required") } var gateways []*model.Gateway @@ -899,7 +910,7 @@ func (s *GatewayService) GetGatewayStatus(orgID string, gatewayId *string) (*api return nil, fmt.Errorf("failed to get gateway: %w", err) } if gateway == nil { - return nil, errors.New("gateway not found") + return nil, apperror.GatewayNotFound.New() } gateways = []*model.Gateway{gateway} } else { @@ -939,66 +950,66 @@ func (s *GatewayService) UpdateGatewayActiveStatus(gatewayId string, isActive bo func (s *GatewayService) validateGatewayInput(orgID, name, displayName string, endpoints []string, functionalityType string) error { // Organization ID validation if strings.TrimSpace(orgID) == "" { - return errors.New("organization ID is required") + return apperror.ValidationFailed.New("organization ID is required") } if _, err := uuid.Parse(orgID); err != nil { - return errors.New("invalid organization ID format") + return apperror.ValidationFailed.Wrap(err, "invalid organization ID format") } // Gateway name validation name = strings.TrimSpace(name) if name == "" { - return errors.New("gateway name is required") + return apperror.ValidationFailed.New("gateway name is required") } if len(name) < 3 { - return errors.New("gateway name must be at least 3 characters") + return apperror.ValidationFailed.New("gateway name must be at least 3 characters") } if len(name) > 64 { - return errors.New("gateway name must not exceed 64 characters") + return apperror.ValidationFailed.New("gateway name must not exceed 64 characters") } // Check pattern: ^[a-z0-9-]+$ namePattern := regexp.MustCompile(`^[a-z0-9-]+$`) if !namePattern.MatchString(name) { - return errors.New("gateway name must contain only lowercase letters, numbers, and hyphens") + return apperror.ValidationFailed.New("gateway name must contain only lowercase letters, numbers, and hyphens") } // No leading/trailing hyphens if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") { - return errors.New("gateway name cannot start or end with a hyphen") + return apperror.ValidationFailed.New("gateway name cannot start or end with a hyphen") } // Display name validation displayName = strings.TrimSpace(displayName) if displayName == "" { - return errors.New("display name is required") + return apperror.ValidationFailed.New("display name is required") } if len(displayName) > 128 { - return errors.New("display name must not exceed 128 characters") + return apperror.ValidationFailed.New("display name must not exceed 128 characters") } // Endpoints validation if len(endpoints) == 0 { - return errors.New("at least one endpoint is required") + return apperror.ValidationFailed.New("at least one endpoint is required") } for _, endpoint := range endpoints { endpoint = strings.TrimSpace(endpoint) if endpoint == "" { - return errors.New("endpoint must not be empty") + return apperror.ValidationFailed.New("endpoint must not be empty") } if len(endpoint) > 255 { - return errors.New("endpoint must not exceed 255 characters") + return apperror.ValidationFailed.New("endpoint must not exceed 255 characters") } } // Gateway type validation functionalityType = strings.TrimSpace(functionalityType) if functionalityType == "" { - return errors.New("gateway functionality type is required") + return apperror.ValidationFailed.New("gateway functionality type is required") } if !constants.ValidGatewayFunctionalityType[functionalityType] { - return fmt.Errorf("gateway type must be one of: %s, %s, %s", - constants.GatewayFunctionalityTypeRegular, constants.GatewayFunctionalityTypeAI, constants.GatewayFunctionalityTypeEvent) + return apperror.ValidationFailed.New(fmt.Sprintf("gateway type must be one of: %s, %s, %s", + constants.GatewayFunctionalityTypeRegular, constants.GatewayFunctionalityTypeAI, constants.GatewayFunctionalityTypeEvent)) } return nil @@ -1011,7 +1022,7 @@ func generateToken() (string, error) { tokenBytes := make([]byte, 32) _, err := rand.Read(tokenBytes) if err != nil { - return "", errors.New("failed to generate secure random token") + return "", apperror.Internal.Wrap(err).WithLogMessage("failed to generate secure random token") } token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes) return token, nil diff --git a/platform-api/internal/service/gateway_endpoints_test.go b/platform-api/internal/service/gateway_endpoints_test.go index 04d4b647e3..d3ecee1a0e 100644 --- a/platform-api/internal/service/gateway_endpoints_test.go +++ b/platform-api/internal/service/gateway_endpoints_test.go @@ -61,8 +61,8 @@ func TestRegisterGatewayEndpoints(t *testing.T) { if err == nil { t.Fatal("RegisterGateway() expected error for nil endpoints, got nil") } - if err.Error() != "at least one endpoint is required" { - t.Errorf("error = %q, want %q", err.Error(), "at least one endpoint is required") + if !strings.Contains(err.Error(), "at least one endpoint is required") { + t.Errorf("error = %q, want it to contain %q", err.Error(), "at least one endpoint is required") } }) @@ -72,8 +72,8 @@ func TestRegisterGatewayEndpoints(t *testing.T) { if err == nil { t.Fatal("RegisterGateway() expected error for empty endpoints, got nil") } - if err.Error() != "at least one endpoint is required" { - t.Errorf("error = %q, want %q", err.Error(), "at least one endpoint is required") + if !strings.Contains(err.Error(), "at least one endpoint is required") { + t.Errorf("error = %q, want it to contain %q", err.Error(), "at least one endpoint is required") } }) diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go index 237cb0b90c..691dab3927 100644 --- a/platform-api/internal/service/llm.go +++ b/platform-api/internal/service/llm.go @@ -29,6 +29,7 @@ import ( "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" @@ -225,7 +226,7 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. handle := makeTemplateHandle(baseHandle, version) if req.ManagedBy != nil && strings.TrimSpace(*req.ManagedBy) == constants.PolicyManagedByWSO2 { - return nil, constants.ErrLLMProviderTemplateManagedByReserved + return nil, apperror.LLMProviderTemplateManagedByReserved.Wrap(constants.ErrLLMProviderTemplateManagedByReserved) } exists, err := s.repo.Exists(handle, orgUUID) @@ -233,7 +234,7 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. return nil, fmt.Errorf("failed to check template exists: %w", err) } if exists { - return nil, constants.ErrLLMProviderTemplateExists + return nil, apperror.LLMProviderTemplateExists.Wrap(constants.ErrLLMProviderTemplateExists) } m := &model.LLMProviderTemplate{ @@ -263,7 +264,7 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. if err := s.repo.Create(m); err != nil { if isSQLiteUniqueConstraint(err) { - return nil, constants.ErrLLMProviderTemplateExists + return nil, apperror.LLMProviderTemplateExists.Wrap(constants.ErrLLMProviderTemplateExists) } return nil, fmt.Errorf("failed to create template: %w", err) } @@ -316,7 +317,7 @@ func (s *LLMProviderTemplateService) Get(orgUUID, handle string) (*api.LLMProvid return nil, fmt.Errorf("failed to get template: %w", err) } if m == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return s.toTemplateAPI(m) } @@ -329,7 +330,7 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle, updatedBy string, r return nil, constants.ErrInvalidInput } if req.ManagedBy != nil && strings.TrimSpace(*req.ManagedBy) == constants.PolicyManagedByWSO2 { - return nil, constants.ErrLLMProviderTemplateManagedByReserved + return nil, apperror.LLMProviderTemplateManagedByReserved.Wrap(constants.ErrLLMProviderTemplateManagedByReserved) } existing, err := s.repo.GetByID(handle, orgUUID) @@ -337,10 +338,10 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle, updatedBy string, r return nil, fmt.Errorf("failed to resolve template: %w", err) } if existing == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } if existing.ManagedBy == "wso2" { - return nil, constants.ErrLLMProviderTemplateReadOnly + return nil, apperror.LLMProviderTemplateReadOnly.Wrap(constants.ErrLLMProviderTemplateReadOnly) } if req.Version != "" && req.Version != existing.Version { @@ -392,7 +393,7 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle, updatedBy string, r if err := s.repo.Update(m); err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return nil, fmt.Errorf("failed to update template: %w", err) } @@ -412,7 +413,7 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle, updatedBy string, r return nil, fmt.Errorf("failed to fetch updated template: %w", err) } if updated == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } _ = s.auditRepo.Record("UPDATE", updated.UUID, "llm_provider_template", orgUUID, updatedBy) @@ -501,7 +502,7 @@ func (s *LLMProviderTemplateService) CreateVersion(orgUUID, groupID, createdBy s return nil, fmt.Errorf("failed to check template family: %w", err) } if count == 0 { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } baseHandle := groupID @@ -533,9 +534,9 @@ func (s *LLMProviderTemplateService) CreateVersion(orgUUID, groupID, createdBy s if err := s.repo.CreateNewVersion(m); err != nil { switch { case errors.Is(err, sql.ErrNoRows): - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) case errors.Is(err, constants.ErrLLMProviderTemplateVersionExists): - return nil, constants.ErrLLMProviderTemplateVersionExists + return nil, apperror.LLMProviderTemplateVersionExists.Wrap(constants.ErrLLMProviderTemplateVersionExists) default: return nil, fmt.Errorf("failed to create new template version: %w", err) } @@ -559,7 +560,7 @@ func (s *LLMProviderTemplateService) CopyVersion(orgUUID, fromTemplateID, toTemp return nil, fmt.Errorf("failed to resolve source template version: %w", err) } if source == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } groupID := source.GroupID @@ -631,7 +632,7 @@ func (s *LLMProviderTemplateService) ListVersions(orgUUID, groupID string, limit return nil, fmt.Errorf("failed to count template versions: %w", err) } if total == 0 { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } items, err := s.repo.ListVersions(groupID, orgUUID, limit, offset) if err != nil { @@ -671,7 +672,7 @@ func (s *LLMProviderTemplateService) GetVersion(orgUUID, groupID, version string return nil, fmt.Errorf("failed to get template version: %w", err) } if m == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return s.toTemplateAPI(m) } @@ -692,12 +693,12 @@ func (s *LLMProviderTemplateService) SetVersionEnabled(orgUUID, groupID, version return nil, fmt.Errorf("failed to resolve template version: %w", err) } if target == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } // Enable/disable is reserved for built-in ('wso2') templates only. Custom // templates are managed via update/delete and cannot be toggled. if target.ManagedBy != constants.PolicyManagedByWSO2 { - return nil, constants.ErrLLMProviderTemplateNotToggleable + return nil, apperror.LLMProviderTemplateNotToggleable.Wrap(constants.ErrLLMProviderTemplateNotToggleable) } if err := ensureOriginMutable(target.Origin); err != nil { return nil, err @@ -708,12 +709,12 @@ func (s *LLMProviderTemplateService) SetVersionEnabled(orgUUID, groupID, version return nil, fmt.Errorf("failed to check template version usage: %w", err) } if inUse > 0 { - return nil, constants.ErrLLMProviderTemplateInUse + return nil, apperror.LLMProviderTemplateInUse.Wrap(constants.ErrLLMProviderTemplateInUse) } } if err := s.repo.SetEnabled(groupID, orgUUID, v, enabled); err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return nil, fmt.Errorf("failed to set template version enabled: %w", err) } @@ -722,7 +723,7 @@ func (s *LLMProviderTemplateService) SetVersionEnabled(orgUUID, groupID, version return nil, fmt.Errorf("failed to reload template version: %w", err) } if m == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return s.toTemplateAPI(m) } @@ -742,10 +743,10 @@ func (s *LLMProviderTemplateService) DeleteVersion(orgUUID, groupID, version str return fmt.Errorf("failed to resolve template version: %w", err) } if target == nil { - return constants.ErrLLMProviderTemplateNotFound + return apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } if target.ManagedBy == "wso2" { - return constants.ErrLLMProviderTemplateReadOnly + return apperror.LLMProviderTemplateReadOnly.Wrap(constants.ErrLLMProviderTemplateReadOnly) } // Block deletion while any provider built from this specific version still depends on it. inUse, err := s.repo.CountProvidersUsingTemplate(groupID, orgUUID, v) @@ -753,11 +754,11 @@ func (s *LLMProviderTemplateService) DeleteVersion(orgUUID, groupID, version str return fmt.Errorf("failed to check template version usage: %w", err) } if inUse > 0 { - return constants.ErrLLMProviderTemplateInUse + return apperror.LLMProviderTemplateInUse.Wrap(constants.ErrLLMProviderTemplateInUse) } if err := s.repo.DeleteVersion(groupID, orgUUID, v); err != nil { if errors.Is(err, sql.ErrNoRows) { - return constants.ErrLLMProviderTemplateNotFound + return apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return fmt.Errorf("failed to delete template version: %w", err) } @@ -775,7 +776,7 @@ func (s *LLMProviderTemplateService) SetEnabledByHandle(orgUUID, handle string, return nil, fmt.Errorf("failed to resolve template: %w", err) } if target == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return s.SetVersionEnabled(orgUUID, target.GroupID, target.Version, enabled) } @@ -789,7 +790,7 @@ func (s *LLMProviderTemplateService) DeleteByHandle(orgUUID, handle string) erro return fmt.Errorf("failed to resolve template: %w", err) } if target == nil { - return constants.ErrLLMProviderTemplateNotFound + return apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return s.DeleteVersion(orgUUID, target.GroupID, target.Version) } @@ -846,7 +847,7 @@ func (s *LLMProviderService) Create(orgUUID, createdBy string, req *api.LLMProvi } } if tpl == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } // Determine handle: use provided id or auto-generate from displayName @@ -1072,7 +1073,7 @@ func (s *LLMProviderService) Update(orgUUID, handle, updatedBy string, req *api. return nil, fmt.Errorf("failed to validate template: %w", err) } if tpl == nil { - return nil, constants.ErrLLMProviderTemplateNotFound + return nil, apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } // Validate {{ secret "..." }} placeholders in the upstream config diff --git a/platform-api/internal/service/llm_apikey.go b/platform-api/internal/service/llm_apikey.go index f99ca7cb79..eb936237e0 100644 --- a/platform-api/internal/service/llm_apikey.go +++ b/platform-api/internal/service/llm_apikey.go @@ -25,6 +25,7 @@ import ( "time" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" @@ -72,7 +73,7 @@ func (s *LLMProviderAPIKeyService) ListLLMProviderAPIKeys( return nil, fmt.Errorf("failed to get LLM provider: %w", err) } if provider == nil { - return nil, constants.ErrAPINotFound + return nil, apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } keys, err := s.apiKeyRepo.ListByArtifact(provider.UUID) @@ -123,7 +124,7 @@ func (s *LLMProviderAPIKeyService) DeleteLLMProviderAPIKey( return fmt.Errorf("failed to get LLM provider: %w", err) } if provider == nil { - return constants.ErrAPINotFound + return apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } existingKey, err := s.apiKeyRepo.GetByArtifactAndName(provider.UUID, keyName) @@ -132,11 +133,11 @@ func (s *LLMProviderAPIKeyService) DeleteLLMProviderAPIKey( return fmt.Errorf("failed to look up API key: %w", err) } if existingKey == nil { - return constants.ErrAPIKeyNotFound + return apperror.LLMProviderAPIKeyNotFound.Wrap(constants.ErrAPIKeyNotFound) } if userID != "" && existingKey.CreatedBy != userID { - return constants.ErrAPIKeyForbidden + return apperror.LLMProviderAPIKeyForbidden.Wrap(constants.ErrAPIKeyForbidden) } if err := s.apiKeyRepo.Delete(provider.UUID, keyName); err != nil { @@ -188,7 +189,7 @@ func (s *LLMProviderAPIKeyService) CreateLLMProviderAPIKey( } if provider == nil { s.slogger.Warn("LLM provider not found", "providerId", providerID, "organizationId", orgID) - return nil, constants.ErrAPINotFound + return nil, apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } apiKey, err := utils.GenerateAPIKey() @@ -231,7 +232,7 @@ func (s *LLMProviderAPIKeyService) CreateLLMProviderAPIKey( if len(gateways) == 0 { s.slogger.Warn("No gateways found for organization", "organizationId", orgID) - return nil, constants.ErrGatewayUnavailable + return nil, apperror.GatewayConnectionUnavailable.Wrap(constants.ErrGatewayUnavailable) } apiKeyHashesJSON, err := buildAPIKeyHashesJSON(apiKey, []string{defaultHashingAlgorithm}) diff --git a/platform-api/internal/service/llm_deployment.go b/platform-api/internal/service/llm_deployment.go index 79c5c1ff3f..b64f2cc96e 100644 --- a/platform-api/internal/service/llm_deployment.go +++ b/platform-api/internal/service/llm_deployment.go @@ -28,6 +28,7 @@ import ( "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/deploymenttransform" "github.com/wso2/api-platform/platform-api/internal/dto" @@ -604,24 +605,24 @@ func (s *LLMProviderDeploymentService) GetLLMProviderDeployment(providerID, depl func (s *LLMProviderDeploymentService) getTemplateHandle(templateUUID, orgUUID string) (string, error) { if templateUUID == "" { - return "", constants.ErrLLMProviderTemplateNotFound + return "", apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } tpl, err := s.templateRepo.GetByUUID(templateUUID, orgUUID) if err != nil { return "", fmt.Errorf("failed to resolve template: %w", err) } if tpl == nil { - return "", constants.ErrLLMProviderTemplateNotFound + return "", apperror.LLMProviderTemplateNotFound.Wrap(constants.ErrLLMProviderTemplateNotFound) } return tpl.ID, nil } func generateLLMProviderDeploymentYAML(provider *model.LLMProvider, templateHandle string) (dto.LLMProviderDeploymentYAML, error) { if provider == nil { - return dto.LLMProviderDeploymentYAML{}, errors.New("provider is required") + return dto.LLMProviderDeploymentYAML{}, apperror.Internal.New().WithLogMessage("generateLLMProviderDeploymentYAML: provider is nil") } if templateHandle == "" { - return dto.LLMProviderDeploymentYAML{}, errors.New("template handle is required") + return dto.LLMProviderDeploymentYAML{}, apperror.Internal.New().WithLogMessage("generateLLMProviderDeploymentYAML: template handle is empty") } if provider.Configuration.Upstream == nil || provider.Configuration.Upstream.Main == nil { return dto.LLMProviderDeploymentYAML{}, constants.ErrInvalidInput @@ -1768,7 +1769,7 @@ func (s *LLMProxyDeploymentService) GetLLMProxyDeployment(proxyID, deploymentID, func generateLLMProxyDeploymentYAML(proxy *model.LLMProxy) (dto.LLMProxyDeploymentYAML, error) { if proxy == nil { - return dto.LLMProxyDeploymentYAML{}, errors.New("proxy is required") + return dto.LLMProxyDeploymentYAML{}, apperror.Internal.New().WithLogMessage("generateLLMProxyDeploymentYAML: proxy is nil") } if proxy.Configuration.Provider == "" { return dto.LLMProxyDeploymentYAML{}, constants.ErrInvalidInput diff --git a/platform-api/internal/service/llm_proxy_apikey.go b/platform-api/internal/service/llm_proxy_apikey.go index afda2bcadb..c34638dbad 100644 --- a/platform-api/internal/service/llm_proxy_apikey.go +++ b/platform-api/internal/service/llm_proxy_apikey.go @@ -25,6 +25,7 @@ import ( "time" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" @@ -72,7 +73,7 @@ func (s *LLMProxyAPIKeyService) ListLLMProxyAPIKeys( return nil, fmt.Errorf("failed to get LLM proxy: %w", err) } if proxy == nil { - return nil, constants.ErrAPINotFound + return nil, apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } keys, err := s.apiKeyRepo.ListByArtifact(proxy.UUID) @@ -123,7 +124,7 @@ func (s *LLMProxyAPIKeyService) DeleteLLMProxyAPIKey( return fmt.Errorf("failed to get LLM proxy: %w", err) } if proxy == nil { - return constants.ErrAPINotFound + return apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } existingKey, err := s.apiKeyRepo.GetByArtifactAndName(proxy.UUID, keyName) @@ -132,12 +133,12 @@ func (s *LLMProxyAPIKeyService) DeleteLLMProxyAPIKey( return fmt.Errorf("failed to look up API key: %w", err) } if existingKey == nil { - return constants.ErrAPIKeyNotFound + return apperror.LLMProxyAPIKeyNotFound.Wrap(constants.ErrAPIKeyNotFound) } // Non-admin callers (userID != "") must be the key creator. if userID != "" && existingKey.CreatedBy != userID { - return constants.ErrAPIKeyForbidden + return apperror.LLMProxyAPIKeyForbidden.Wrap(constants.ErrAPIKeyForbidden) } if err := s.apiKeyRepo.Delete(proxy.UUID, keyName); err != nil { @@ -189,7 +190,7 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( } if proxy == nil { s.slogger.Warn("LLM proxy not found", "proxyId", proxyID, "organizationId", orgID) - return nil, constants.ErrAPINotFound + return nil, apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } apiKey, err := utils.GenerateAPIKey() @@ -222,7 +223,7 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( if len(gateways) == 0 { s.slogger.Warn("No gateways found for organization", "organizationId", orgID) - return nil, constants.ErrGatewayUnavailable + return nil, apperror.GatewayConnectionUnavailable.Wrap(constants.ErrGatewayUnavailable) } apiKeyHashesJSON, err := buildAPIKeyHashesJSON(apiKey, []string{defaultHashingAlgorithm}) diff --git a/platform-api/internal/service/mcp_deployment.go b/platform-api/internal/service/mcp_deployment.go index 3b1ae10d2d..a955cd8c66 100644 --- a/platform-api/internal/service/mcp_deployment.go +++ b/platform-api/internal/service/mcp_deployment.go @@ -22,13 +22,14 @@ package service import ( "errors" "fmt" - "log/slog" "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" "github.com/wso2/api-platform/platform-api/internal/utils" + "log/slog" "strings" "time" @@ -697,7 +698,7 @@ func (s *MCPDeploymentService) deleteMCPProxyDeployment(proxyUUID string, deploy // getMCPProxyUUIDByHandle retrieves the artifact UUID by its handle from the artifact table func (s *MCPDeploymentService) getMCPProxyUUIDByHandle(handle, orgUUID string) (string, error) { if handle == "" { - return "", errors.New("artifact handle is required") + return "", apperror.ValidationFailed.New("artifact handle is required") } artifact, err := s.artifactRepo.GetByHandle(handle, orgUUID) diff --git a/platform-api/internal/service/organization.go b/platform-api/internal/service/organization.go index 7534b2c056..244d59ef5f 100644 --- a/platform-api/internal/service/organization.go +++ b/platform-api/internal/service/organization.go @@ -22,7 +22,7 @@ import ( "log/slog" "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/config" - "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" "github.com/wso2/api-platform/platform-api/internal/utils" @@ -92,7 +92,7 @@ func (s *OrganizationService) RegisterOrganization(id string, handle string, nam handle = generated } else { if err := utils.ValidateHandle(handle); err != nil { - return nil, err + return nil, apperror.ValidationFailed.Wrap(err, err.Error()) } } @@ -103,9 +103,9 @@ func (s *OrganizationService) RegisterOrganization(id string, handle string, nam } if existingOrg != nil { if existingOrg.ID == id { - return nil, constants.ErrOrganizationExists + return nil, apperror.OrganizationExists.New().WithLogMessage("an organization with this UUID already exists") } - return nil, constants.ErrHandleExists + return nil, apperror.OrganizationExists.New().WithLogMessage("an organization with this handle already exists") } if name == "" { @@ -196,7 +196,7 @@ func (s *OrganizationService) GetOrganizationByUUID(orgId string) (*api.Organiza } if orgModel == nil { - return nil, constants.ErrOrganizationNotFound + return nil, apperror.OrganizationNotFound.New() } org, convErr := s.modelToAPI(orgModel) @@ -239,7 +239,7 @@ func (s *OrganizationService) GetOrganizationByHandle(handle string) (*api.Organ } if orgModel == nil { - return nil, constants.ErrOrganizationNotFound + return nil, apperror.OrganizationNotFound.New() } org, convErr := s.modelToAPI(orgModel) diff --git a/platform-api/internal/service/project.go b/platform-api/internal/service/project.go index ece7557f55..0d4ecc5b33 100644 --- a/platform-api/internal/service/project.go +++ b/platform-api/internal/service/project.go @@ -19,12 +19,12 @@ package service import ( "fmt" - "log/slog" "github.com/wso2/api-platform/platform-api/api" - "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" "github.com/wso2/api-platform/platform-api/internal/utils" + "log/slog" "time" ) @@ -70,7 +70,7 @@ func (s *ProjectService) RegisterDeletionGuard(guard ProjectDeletionGuard) { func (s *ProjectService) CreateProject(req *api.CreateProjectRequest, organizationID, actor string) (*api.Project, error) { if req.DisplayName == "" { - return nil, constants.ErrInvalidProjectName + return nil, apperror.ValidationFailed.New("Project displayName is required") } org, err := s.orgRepo.GetOrganizationByUUID(organizationID) @@ -78,7 +78,7 @@ func (s *ProjectService) CreateProject(req *api.CreateProjectRequest, organizati return nil, err } if org == nil { - return nil, constants.ErrOrganizationNotFound + return nil, apperror.OrganizationNotFound.New() } // Determine handle: use provided id or auto-generate from displayName @@ -90,7 +90,7 @@ func (s *ProjectService) CreateProject(req *api.CreateProjectRequest, organizati return nil, err } if existing != nil { - return nil, constants.ErrProjectExists + return nil, apperror.ProjectExists.New() } } else { handle, err = utils.GenerateHandle(req.DisplayName, func(h string) bool { @@ -109,7 +109,7 @@ func (s *ProjectService) CreateProject(req *api.CreateProjectRequest, organizati } for _, p := range existingProjects { if p.Name == req.DisplayName { - return nil, constants.ErrProjectExists + return nil, apperror.ProjectExists.New() } } @@ -152,7 +152,7 @@ func (s *ProjectService) GetProjectByHandle(handle, orgId string) (*api.Project, return nil, err } if projectModel == nil { - return nil, constants.ErrProjectNotFound + return nil, apperror.ProjectNotFound.New() } org, err := s.orgRepo.GetOrganizationByUUID(projectModel.OrganizationID) @@ -173,7 +173,7 @@ func (s *ProjectService) GetProjectsByOrganization(organizationID string) ([]api return nil, err } if org == nil { - return nil, constants.ErrOrganizationNotFound + return nil, apperror.OrganizationNotFound.New() } projectModels, err := s.projectRepo.GetProjectsByOrganizationID(organizationID) @@ -204,7 +204,7 @@ func (s *ProjectService) UpdateProject(handle string, req *api.Project, orgId, a return nil, err } if project == nil { - return nil, constants.ErrProjectNotFound + return nil, apperror.ProjectNotFound.New() } if req.DisplayName != project.Name { @@ -214,7 +214,7 @@ func (s *ProjectService) UpdateProject(handle string, req *api.Project, orgId, a } for _, existingProject := range existingProjects { if existingProject.Name == req.DisplayName && existingProject.Handle != handle { - return nil, constants.ErrProjectExists + return nil, apperror.ProjectExists.New() } } project.Name = req.DisplayName @@ -249,7 +249,7 @@ func (s *ProjectService) DeleteProject(handle, orgId, actor string) error { return err } if project == nil { - return constants.ErrProjectNotFound + return apperror.ProjectNotFound.New() } projects, err := s.projectRepo.GetProjectsByOrganizationID(project.OrganizationID) @@ -257,7 +257,7 @@ func (s *ProjectService) DeleteProject(handle, orgId, actor string) error { return err } if len(projects) <= 1 { - return constants.ErrOrganizationMustHAveAtLeastOneProject + return apperror.ValidationFailed.New("Organization must have at least one project") } apis, err := s.apiRepo.GetAPIsByProjectUUID(project.ID, orgId) @@ -265,7 +265,7 @@ func (s *ProjectService) DeleteProject(handle, orgId, actor string) error { return err } if len(apis) > 0 { - return constants.ErrProjectHasAssociatedAPIs + return apperror.ValidationFailed.New("Project has associated APIs") } mcpProxiesCount, err := s.mcpProxyRepo.CountByProject(orgId, project.ID) @@ -273,7 +273,7 @@ func (s *ProjectService) DeleteProject(handle, orgId, actor string) error { return err } if mcpProxiesCount > 0 { - return constants.ErrProjectHasAssociatedMCPProxies + return apperror.ValidationFailed.New("Project has associated MCP proxies") } // applications no longer cascade-delete with the project (the project_uuid foreign key was @@ -284,7 +284,7 @@ func (s *ProjectService) DeleteProject(handle, orgId, actor string) error { return err } if appCount > 0 { - return constants.ErrProjectHasAssociatedApplications + return apperror.ValidationFailed.New("Project has associated applications") } for _, guard := range s.deletionGuards { diff --git a/platform-api/internal/service/secret_service.go b/platform-api/internal/service/secret_service.go index 49332f4757..0f260f6708 100644 --- a/platform-api/internal/service/secret_service.go +++ b/platform-api/internal/service/secret_service.go @@ -25,6 +25,7 @@ import ( "fmt" "time" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/dto" "github.com/wso2/api-platform/platform-api/internal/model" @@ -90,7 +91,7 @@ func (s *SecretService) Create(orgID, createdBy string, req *dto.CreateSecretReq if secretType == "" { secretType = model.SecretTypeGeneric } else if secretType != model.SecretTypeGeneric && secretType != model.SecretTypeCertificate { - return nil, constants.ErrInvalidSecretType + return nil, apperror.ValidationFailed.Wrap(constants.ErrInvalidSecretType, "Invalid secret type: must be GENERIC or CERTIFICATE") } exists, err := s.repo.Exists(orgID, req.Handle) @@ -98,7 +99,7 @@ func (s *SecretService) Create(orgID, createdBy string, req *dto.CreateSecretReq return nil, fmt.Errorf("failed to check secret existence: %w", err) } if exists { - return nil, constants.ErrSecretAlreadyExists + return nil, apperror.SecretExists.Wrap(constants.ErrSecretAlreadyExists) } ciphertext, err := s.vault.Encrypt(context.Background(), req.Value) @@ -237,7 +238,8 @@ func (s *SecretService) ValidateSecretRefs(orgID, configText string) error { } if len(missing) > 0 { - return fmt.Errorf("%w: %v", constants.ErrSecretRefMissing, missing) + return apperror.ValidationFailed.Wrap(fmt.Errorf("%w: %v", constants.ErrSecretRefMissing, missing), + "One or more referenced secrets do not exist") } return nil } diff --git a/platform-api/internal/service/subscription_plan_service.go b/platform-api/internal/service/subscription_plan_service.go index bd2b7828c1..23cf4dcc17 100644 --- a/platform-api/internal/service/subscription_plan_service.go +++ b/platform-api/internal/service/subscription_plan_service.go @@ -23,6 +23,7 @@ import ( "fmt" "log/slog" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" @@ -92,7 +93,7 @@ func (s *SubscriptionPlanService) CreatePlan(orgUUID, actor string, plan *model. return nil, err } if exists { - return nil, constants.ErrSubscriptionPlanAlreadyExists + return nil, apperror.SubscriptionPlanExists.Wrap(constants.ErrSubscriptionPlanAlreadyExists) } plan.OrganizationUUID = orgUUID @@ -102,7 +103,7 @@ func (s *SubscriptionPlanService) CreatePlan(orgUUID, actor string, plan *model. plan.Status = model.SubscriptionPlanStatusActive } if plan.ThrottleLimitUnit != "" && !constants.ValidThrottleLimitUnits[plan.ThrottleLimitUnit] { - return nil, constants.ErrInvalidThrottleLimitUnit + return nil, apperror.ValidationFailed.Wrap(constants.ErrInvalidThrottleLimitUnit, "Invalid throttle limit unit") } if err := s.planRepo.Create(plan); err != nil { @@ -132,12 +133,12 @@ func (s *SubscriptionPlanService) GetPlan(handle, orgUUID string) (*model.Subscr plan, err := s.planRepo.GetByHandleAndOrg(handle, orgUUID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, constants.ErrSubscriptionPlanNotFound + return nil, apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } return nil, err } if plan == nil { - return nil, constants.ErrSubscriptionPlanNotFound + return nil, apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } return plan, nil } @@ -157,12 +158,12 @@ func (s *SubscriptionPlanService) UpdatePlan(handle, orgUUID, actor string, upda existing, err := s.planRepo.GetByHandleAndOrg(handle, orgUUID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, constants.ErrSubscriptionPlanNotFound + return nil, apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } return nil, err } if existing == nil { - return nil, constants.ErrSubscriptionPlanNotFound + return nil, apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } if update.Name != nil { @@ -183,7 +184,7 @@ func (s *SubscriptionPlanService) UpdatePlan(handle, orgUUID, actor string, upda } if update.ThrottleLimitUnit != nil { if !constants.ValidThrottleLimitUnits[*update.ThrottleLimitUnit] { - return nil, constants.ErrInvalidThrottleLimitUnit + return nil, apperror.ValidationFailed.Wrap(constants.ErrInvalidThrottleLimitUnit, "Invalid throttle limit unit") } existing.ThrottleLimitUnit = *update.ThrottleLimitUnit } @@ -227,12 +228,12 @@ func (s *SubscriptionPlanService) DeletePlan(handle, orgUUID, actor string) erro existing, err := s.planRepo.GetByHandleAndOrg(handle, orgUUID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return constants.ErrSubscriptionPlanNotFound + return apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } return err } if existing == nil { - return constants.ErrSubscriptionPlanNotFound + return apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } if err := s.planRepo.Delete(existing.UUID, orgUUID); err != nil { diff --git a/platform-api/internal/service/subscription_service.go b/platform-api/internal/service/subscription_service.go index 5d4427075a..d8923822a2 100644 --- a/platform-api/internal/service/subscription_service.go +++ b/platform-api/internal/service/subscription_service.go @@ -23,6 +23,7 @@ import ( "fmt" "log/slog" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" @@ -84,14 +85,14 @@ func (s *SubscriptionService) ResolveOrgHandle(orgUUID string) string { // where the caller specifies the kind so the handle is resolved against exactly one table. func (s *SubscriptionService) resolveArtifactUUIDByKind(apiId, kind, orgUUID string) (string, error) { if apiId == "" || kind == "" { - return "", constants.ErrAPINotFound + return "", apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } metadata, err := s.artifactRepo.GetAPIMetadataByHandleAndKind(apiId, kind, orgUUID) if err != nil { return "", fmt.Errorf("failed to resolve artifact by handle and kind: %w", err) } if metadata == nil { - return "", constants.ErrAPINotFound + return "", apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } return metadata.ID, nil } @@ -99,7 +100,7 @@ func (s *SubscriptionService) resolveArtifactUUIDByKind(apiId, kind, orgUUID str // resolveAPIUUID resolves apiId (handle or UUID) to rest_apis.uuid for the organization func (s *SubscriptionService) resolveAPIUUID(apiId, orgUUID string) (string, error) { if apiId == "" { - return "", constants.ErrAPINotFound + return "", apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } apiModel, err := s.apiRepo.GetAPIByUUID(apiId, orgUUID) if err != nil { @@ -113,12 +114,12 @@ func (s *SubscriptionService) resolveAPIUUID(apiId, orgUUID string) (string, err metadata, err := s.artifactRepo.GetAPIMetadataByHandle(apiId, orgUUID) if err != nil { if errors.Is(err, constants.ErrAPINotFound) { - return "", constants.ErrAPINotFound + return "", apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } return "", fmt.Errorf("failed to resolve API by handle: %w", err) } if metadata == nil { - return "", constants.ErrAPINotFound + return "", apperror.ArtifactNotFound.Wrap(constants.ErrAPINotFound) } return metadata.ID, nil } @@ -172,14 +173,14 @@ func (s *SubscriptionService) CreateSubscription(apiId, kind, orgUUID string, su } if subscriberID == "" { - return nil, fmt.Errorf("subscriberId is required") + return nil, apperror.ValidationFailed.New("subscriberId is required") } exists, err := s.subscriptionRepo.ExistsByAPIAndSubscriber(apiUUID, subscriberID, orgUUID) if err != nil { return nil, err } if exists { - return nil, constants.ErrSubscriptionAlreadyExists + return nil, apperror.SubscriptionExists.Wrap(constants.ErrSubscriptionAlreadyExists) } // subscriptionPlanId carries the Developer Portal subscription plan handle. Resolve it to the @@ -188,12 +189,12 @@ func (s *SubscriptionService) CreateSubscription(apiId, kind, orgUUID string, su plan, err := s.planRepo.GetByHandleAndOrg(*subscriptionPlanId, orgUUID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, constants.ErrSubscriptionPlanNotFound + return nil, apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } return nil, err } if plan == nil { - return nil, constants.ErrSubscriptionPlanNotFound + return nil, apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } subscriptionPlanId = &plan.UUID } @@ -257,12 +258,12 @@ func (s *SubscriptionService) GetSubscription(subscriptionId, orgUUID string) (* sub, err := s.subscriptionRepo.GetByID(subscriptionId, orgUUID) if err != nil { if errors.Is(err, constants.ErrSubscriptionNotFound) { - return nil, constants.ErrSubscriptionNotFound + return nil, apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } return nil, err } if sub == nil { - return nil, constants.ErrSubscriptionNotFound + return nil, apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } return sub, nil } @@ -316,15 +317,15 @@ func (s *SubscriptionService) UpdateSubscription(subscriptionId, orgUUID, subscr sub, err := s.subscriptionRepo.GetByID(subscriptionId, orgUUID) if err != nil { if errors.Is(err, constants.ErrSubscriptionNotFound) { - return nil, constants.ErrSubscriptionNotFound + return nil, apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } return nil, err } if sub == nil { - return nil, constants.ErrSubscriptionNotFound + return nil, apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } if sub.SubscriberID != subscriberID { - return nil, constants.ErrSubscriptionSubscriberMismatch + return nil, apperror.SubscriptionForbidden.Wrap(constants.ErrSubscriptionSubscriberMismatch) } if status != "" { st := model.SubscriptionStatus(status) @@ -382,15 +383,15 @@ func (s *SubscriptionService) ChangePlan(subscriptionId, orgUUID, subscriberID, sub, err := s.subscriptionRepo.GetByID(subscriptionId, orgUUID) if err != nil { if errors.Is(err, constants.ErrSubscriptionNotFound) { - return nil, constants.ErrSubscriptionNotFound + return nil, apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } return nil, err } if sub == nil { - return nil, constants.ErrSubscriptionNotFound + return nil, apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } if sub.SubscriberID != subscriberID { - return nil, constants.ErrSubscriptionSubscriberMismatch + return nil, apperror.SubscriptionForbidden.Wrap(constants.ErrSubscriptionSubscriberMismatch) } // planHandle carries the Developer Portal subscription plan handle. Resolve it to the plan's @@ -398,12 +399,12 @@ func (s *SubscriptionService) ChangePlan(subscriptionId, orgUUID, subscriberID, planRecord, err := s.planRepo.GetByHandleAndOrg(planHandle, orgUUID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, constants.ErrSubscriptionPlanNotFound + return nil, apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } return nil, err } if planRecord == nil { - return nil, constants.ErrSubscriptionPlanNotFound + return nil, apperror.SubscriptionPlanNotFound.Wrap(constants.ErrSubscriptionPlanNotFound) } plan := planRecord.UUID sub.SubscriptionPlanID = &plan @@ -446,20 +447,20 @@ func (s *SubscriptionService) ChangePlan(subscriptionId, orgUUID, subscriberID, // deployed. subscriberID must match the stored subscriber_id. func (s *SubscriptionService) RegenerateToken(subscriptionId, orgUUID, subscriberID, newToken string) (*model.Subscription, error) { if newToken == "" { - return nil, fmt.Errorf("subscription token is required") + return nil, apperror.ValidationFailed.New("subscription token is required") } sub, err := s.subscriptionRepo.GetByID(subscriptionId, orgUUID) if err != nil { if errors.Is(err, constants.ErrSubscriptionNotFound) { - return nil, constants.ErrSubscriptionNotFound + return nil, apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } return nil, err } if sub == nil { - return nil, constants.ErrSubscriptionNotFound + return nil, apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } if sub.SubscriberID != subscriberID { - return nil, constants.ErrSubscriptionSubscriberMismatch + return nil, apperror.SubscriptionForbidden.Wrap(constants.ErrSubscriptionSubscriberMismatch) } if err := s.subscriptionRepo.UpdateToken(subscriptionId, orgUUID, newToken); err != nil { @@ -503,15 +504,15 @@ func (s *SubscriptionService) DeleteSubscription(subscriptionId, orgUUID, subscr sub, err := s.subscriptionRepo.GetByID(subscriptionId, orgUUID) if err != nil { if errors.Is(err, constants.ErrSubscriptionNotFound) { - return constants.ErrSubscriptionNotFound + return apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } return err } if sub == nil { - return constants.ErrSubscriptionNotFound + return apperror.SubscriptionNotFound.Wrap(constants.ErrSubscriptionNotFound) } if sub.SubscriberID != subscriberID { - return constants.ErrSubscriptionSubscriberMismatch + return apperror.SubscriptionForbidden.Wrap(constants.ErrSubscriptionSubscriberMismatch) } if err := s.subscriptionRepo.Delete(subscriptionId, orgUUID); err != nil { diff --git a/platform-api/internal/utils/error.go b/platform-api/internal/utils/error.go deleted file mode 100644 index 5beaf1611e..0000000000 --- a/platform-api/internal/utils/error.go +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package utils - -import ( - "encoding/json" - "errors" - "fmt" - "log" - "net/http" - - "github.com/go-playground/validator/v10" -) - -// ErrorResponse represents the standard error response format -type ErrorResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Description string `json:"description,omitempty"` -} - -// NewErrorResponse creates a new error response -func NewErrorResponse(code int, message string, description ...string) ErrorResponse { - resp := ErrorResponse{ - Code: code, - Message: message, - } - if len(description) > 0 { - resp.Description = description[0] - } - return resp -} - -// NewValidationErrorResponse writes a 400 JSON error response for validation errors. -func NewValidationErrorResponse(w http.ResponseWriter, err error) { - w.Header().Set("Content-Type", "application/json") - var ve validator.ValidationErrors - if errors.As(err, &ve) { - errorsList := make([]map[string]string, 0, len(ve)) - for _, fe := range ve { - errorsList = append(errorsList, map[string]string{ - "field": fe.Field(), - "reason": fe.Tag(), - "message": fmt.Sprintf("The field %s is %s", fe.Field(), fe.Tag()), - }) - } - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 400, - "title": "Bad Request", - "details": "Validation failed for the request parameters", - "errors": errorsList, - }) - return - } - log.Printf("[ERROR] Request validation fallback error: %v", err) - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 400, - "title": "Bad Request", - "details": "Invalid input", - }) -} diff --git a/platform-api/internal/utils/error_mapper.go b/platform-api/internal/utils/error_mapper.go deleted file mode 100644 index 5fff3a3de5..0000000000 --- a/platform-api/internal/utils/error_mapper.go +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package utils - -import ( - "errors" - "fmt" - "net/http" - "strings" - - "github.com/wso2/api-platform/platform-api/internal/constants" - - "github.com/go-playground/validator/v10" -) - -// makeError creates a standardized error response tuple -func makeError(status int, message string) (int, interface{}) { - return status, NewErrorResponse(status, http.StatusText(status), message) -} - -// FormatValidationError converts validator errors to user-friendly messages (public API) -func FormatValidationError(err error) string { - var validationErrors validator.ValidationErrors - if !errors.As(err, &validationErrors) { - return err.Error() // Not a validation error, return as-is - } - return formatValidationError(validationErrors) -} - -// formatValidationError converts ValidationErrors to user-friendly messages (internal) -func formatValidationError(validationErrors validator.ValidationErrors) string { - var messages []string - for _, fieldError := range validationErrors { - fieldName := getUserFriendlyFieldName(fieldError.Field()) - message := getValidationErrorMessage(fieldName, fieldError.Tag(), fieldError.Param()) - messages = append(messages, message) - } - return strings.Join(messages, "; ") -} - -// getUserFriendlyFieldName maps struct field names to user-friendly field names -func getUserFriendlyFieldName(fieldName string) string { - fieldMap := map[string]string{ - "Name": "name", - "Description": "description", - "APIID": "API ID", - "Provider": "provider", - "APIName": "API name", - "APIHandle": "API handle", - "APIDescription": "API description", - "APIVersion": "API version", - "APIType": "API type", - "APIStatus": "API status", - "ProductionURL": "production URL", - } - - if friendly, exists := fieldMap[fieldName]; exists { - return friendly - } - return strings.ToLower(fieldName) -} - -// getValidationErrorMessage creates user-friendly validation error messages -func getValidationErrorMessage(fieldName, tag, param string) string { - switch tag { - case "required": - return fmt.Sprintf("%s is required", fieldName) - case "min": - return fmt.Sprintf("%s must be at least %s characters long", fieldName, param) - case "max": - return fmt.Sprintf("%s must not exceed %s characters", fieldName, param) - case "url": - return fmt.Sprintf("%s must be a valid URL", fieldName) - case "hostname": - return fmt.Sprintf("%s must be a valid hostname", fieldName) - case "oneof": - return fmt.Sprintf("%s must be one of: %s", fieldName, strings.ReplaceAll(param, " ", ", ")) - default: - return fmt.Sprintf("%s is invalid", fieldName) - } -} - -// GetErrorResponse maps domain errors and validation errors to HTTP status and error response -func GetErrorResponse(err error) (int, interface{}) { - // First check if it's a validation error - var validationErrors validator.ValidationErrors - if errors.As(err, &validationErrors) { - userFriendlyMessage := formatValidationError(validationErrors) - return makeError(http.StatusBadRequest, userFriendlyMessage) - } - - // Handle domain/business logic errors - switch { - // Organization errors - case errors.Is(err, constants.ErrOrganizationNotFound): - return makeError(http.StatusNotFound, "Organization not found") - case errors.Is(err, constants.ErrOrganizationExists): - return makeError(http.StatusConflict, "Organization already exists with the given UUID") - case errors.Is(err, constants.ErrInvalidHandle): - return makeError(http.StatusBadRequest, "Invalid handle format") - case errors.Is(err, constants.ErrMultipleOrganizations): - return makeError(http.StatusInternalServerError, "Multiple organizations found") - - // Project errors - case errors.Is(err, constants.ErrProjectExists): - return makeError(http.StatusConflict, "Project already exists in organization") - case errors.Is(err, constants.ErrProjectNotFound): - return makeError(http.StatusNotFound, "Project not found") - case errors.Is(err, constants.ErrInvalidProjectName): - return makeError(http.StatusBadRequest, "Invalid project name") - case errors.Is(err, constants.ErrOrganizationMustHAveAtLeastOneProject): - return makeError(http.StatusBadRequest, "Organization must have at least one project") - case errors.Is(err, constants.ErrProjectHasAssociatedAPIs): - return makeError(http.StatusBadRequest, "Project has associated APIs") - case errors.Is(err, constants.ErrProjectHasAssociatedApplications): - return makeError(http.StatusBadRequest, "Project has associated applications") - - // API errors - case errors.Is(err, constants.ErrAPINotFound): - return makeError(http.StatusNotFound, "API not found") - case errors.Is(err, constants.ErrAPIAlreadyExists): - return makeError(http.StatusConflict, "API already exists in project") - case errors.Is(err, constants.ErrInvalidAPIContext): - return makeError(http.StatusBadRequest, "Invalid API context format") - case errors.Is(err, constants.ErrInvalidAPIVersion): - return makeError(http.StatusBadRequest, "Invalid API version format") - case errors.Is(err, constants.ErrInvalidAPIName): - return makeError(http.StatusBadRequest, "Invalid API name format") - case errors.Is(err, constants.ErrInvalidLifecycleState): - return makeError(http.StatusBadRequest, "Invalid lifecycle state") - case errors.Is(err, constants.ErrInvalidAPIType): - return makeError(http.StatusBadRequest, "Invalid API type") - case errors.Is(err, constants.ErrInvalidTransport): - return makeError(http.StatusBadRequest, "Invalid transport protocol") - case errors.Is(err, constants.ErrInvalidDeployment): - return makeError(http.StatusBadRequest, "Invalid API deployment") - case errors.Is(err, constants.ErrUpstreamRequired): - return makeError(http.StatusBadRequest, "Upstream configuration is required") - - // Artifact errors - case errors.Is(err, constants.ErrArtifactNotFound): - return makeError(http.StatusNotFound, "Artifact not found") - case errors.Is(err, constants.ErrArtifactExists): - return makeError(http.StatusConflict, "Artifact already exists") - case errors.Is(err, constants.ErrArtifactInvalidKind): - return makeError(http.StatusBadRequest, "Invalid artifact kind") - - // Gateway errors - case errors.Is(err, constants.ErrGatewayNotFound): - return makeError(http.StatusNotFound, "Gateway not found") - - // Default case for unknown errors - default: - return makeError(http.StatusInternalServerError, "Internal Server Error") - } -} diff --git a/platform-api/internal/webhook/receiver.go b/platform-api/internal/webhook/receiver.go index 057981c4f8..c9b9bc8a99 100644 --- a/platform-api/internal/webhook/receiver.go +++ b/platform-api/internal/webhook/receiver.go @@ -20,6 +20,7 @@ package webhook import ( "context" "errors" + "fmt" "io" "log/slog" "net/http" @@ -29,9 +30,10 @@ import ( "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/model" - "github.com/wso2/api-platform/platform-api/internal/utils" ) // RoutePath is the webhook endpoint under the resource API version prefix (constants.APIVersion, @@ -127,31 +129,29 @@ func NewReceiver( // RegisterRoutes registers the webhook endpoint on the mux. Only called when the webhook is enabled. func (r *Receiver) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST "+RoutePath, r.ReceiveEvent) + mux.HandleFunc("POST "+RoutePath, middleware.MapErrors(r.slogger, r.ReceiveEvent)) } // ReceiveEvent runs the full webhook flow: size-limited read -> signature verify -> envelope // decode/validate -> gateway_type filter -> idempotency -> dispatch -> mark processed. -func (r *Receiver) ReceiveEvent(w http.ResponseWriter, req *http.Request) { +func (r *Receiver) ReceiveEvent(w http.ResponseWriter, req *http.Request) error { if !r.cfg.Enabled { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(http.StatusNotFound, "Not Found", "Webhook endpoint is disabled")) - return + return apperror.NotFound.New().WithLogMessage("webhook endpoint is disabled") } // Read the raw body with a size cap. The raw bytes are needed verbatim for HMAC verification. limited := http.MaxBytesReader(w, req.Body, r.cfg.MaxBodySize) body, err := io.ReadAll(limited) if err != nil { - r.slogger.Warn("Webhook body read failed (possibly too large)", "limit", r.cfg.MaxBodySize, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(http.StatusBadRequest, "Bad Request", "Request body too large or unreadable")) - return + return apperror.ValidationFailed.Wrap(err, "Request body too large or unreadable"). + WithLogMessage(fmt.Sprintf("webhook body read failed (possibly too large), limit=%d", r.cfg.MaxBodySize)) } - // 1. Verify HMAC signature over the raw body. + // 1. Verify HMAC signature over the raw body. Per the unified auth-failure rule, this returns the + // same generic response as any other auth failure; the specific reason is internal-only. if err := r.verifier.Verify(req.Header.Get(r.cfg.SignatureHeader), body, time.Now()); err != nil { - r.slogger.Warn("Webhook signature verification failed", "clientIP", req.RemoteAddr, "error", err) - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(http.StatusUnauthorized, "Unauthorized", "Signature verification failed")) - return + return apperror.Unauthorized.Wrap(err). + WithLogMessage(fmt.Sprintf("webhook signature verification failed, clientIP=%s", req.RemoteAddr)) } // 2. Decode + validate the envelope. @@ -160,9 +160,7 @@ func (r *Receiver) ReceiveEvent(w http.ResponseWriter, req *http.Request) { err = env.Validate() } if err != nil { - r.slogger.Warn("Webhook envelope invalid", "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(http.StatusBadRequest, "Bad Request", "Invalid event envelope")) - return + return apperror.ValidationFailed.Wrap(err, "Invalid event envelope") } log := r.slogger.With("eventId", env.EventID, "eventType", env.EventType, "orgHandle", env.OrgID) @@ -172,10 +170,8 @@ func (r *Receiver) ReceiveEvent(w http.ResponseWriter, req *http.Request) { // the UUID. An unknown handle is a terminal 404 (not retryable) since the event references an // organization that does not exist in the control plane. if err := r.resolveOrgUUID(env); err != nil { - status := statusForError(err) - log.Warn("Webhook organization resolution failed", "status", status, "error", err) - httputil.WriteJSON(w, status, utils.NewErrorResponse(status, http.StatusText(status), "Unknown organization")) - return + log.Warn("Webhook organization resolution failed", "error", err) + return mapWebhookError(err) } log = log.With("orgId", env.OrgID) @@ -183,7 +179,7 @@ func (r *Receiver) ReceiveEvent(w http.ResponseWriter, req *http.Request) { if r.cfg.GatewayType != "" && env.GatewayType != "" && env.GatewayType != r.cfg.GatewayType { log.Info("Webhook event for a different gateway_type; accepting as no-op", "eventGatewayType", env.GatewayType) httputil.WriteJSON(w, http.StatusAccepted, map[string]string{"status": "ignored", "reason": "gateway_type mismatch"}) - return + return nil } // 5. Dispatch to the matching handler. Duplicate (at-least-once) deliveries are made safe by @@ -191,19 +187,17 @@ func (r *Receiver) ReceiveEvent(w http.ResponseWriter, req *http.Request) { handle, ok := r.handlers[env.EventType] if !ok { log.Warn("Unsupported webhook event type") - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(http.StatusBadRequest, "Bad Request", "Unsupported event type")) - return + return apperror.ValidationFailed.New("Unsupported event type") } if err := handle(req.Context(), env); err != nil { - status := statusForError(err) - log.Error("Webhook event handling failed", "status", status, "error", err) - httputil.WriteJSON(w, status, utils.NewErrorResponse(status, http.StatusText(status), "Failed to process event")) - return + log.Error("Webhook event handling failed", "error", err) + return mapWebhookError(err) } log.Info("Webhook event processed") httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "processed"}) + return nil } // resolveOrgUUID converts the organization handle carried in the envelope (org.ref_id, normalized @@ -222,20 +216,28 @@ func (r *Receiver) resolveOrgUUID(env *Envelope) error { return nil } -// statusForError maps domain errors returned by the reused services to HTTP status codes. -func statusForError(err error) int { +// mapWebhookError maps domain errors returned by the reused services to the matching apperror +// catalog entry, preserving the same HTTP status classification the old statusForError gave them. +func mapWebhookError(err error) *apperror.Error { + // Services that have migrated to the apperror catalog return typed errors + // directly — pass them through untouched. + var appErr *apperror.Error + if errors.As(err, &appErr) { + return appErr + } switch { case errors.Is(err, ErrInvalidEnvelope), errors.Is(err, ErrUnsupportedEvent), errors.Is(err, ErrDecryptionFailed): - return http.StatusBadRequest - case errors.Is(err, constants.ErrAPINotFound), - errors.Is(err, constants.ErrOrganizationNotFound), - errors.Is(err, constants.ErrSubscriptionNotFound), - errors.Is(err, constants.ErrSubscriptionPlanNotFound), - errors.Is(err, constants.ErrSubscriptionPlanNotFoundOrInactive), - errors.Is(err, constants.ErrAPIKeyNotFound): - return http.StatusNotFound + return apperror.ValidationFailed.Wrap(err, "Failed to process event") + case errors.Is(err, constants.ErrOrganizationNotFound): + return apperror.OrganizationNotFound.Wrap(err) + case errors.Is(err, constants.ErrSubscriptionNotFound): + return apperror.SubscriptionNotFound.Wrap(err) + case errors.Is(err, constants.ErrSubscriptionPlanNotFound), errors.Is(err, constants.ErrSubscriptionPlanNotFoundOrInactive): + return apperror.SubscriptionPlanNotFound.Wrap(err) + case errors.Is(err, constants.ErrAPINotFound), errors.Is(err, constants.ErrAPIKeyNotFound): + return apperror.ArtifactNotFound.Wrap(err) default: // Storage/EventHub failures and unexpected errors are retryable. - return http.StatusInternalServerError + return apperror.Internal.Wrap(err).WithLogMessage("failed to process webhook event") } } diff --git a/platform-api/plugins/eventgateway/handler/artifact_guard_response.go b/platform-api/plugins/eventgateway/handler/artifact_guard_response.go index 6b5c649a87..6babbda30f 100644 --- a/platform-api/plugins/eventgateway/handler/artifact_guard_response.go +++ b/platform-api/plugins/eventgateway/handler/artifact_guard_response.go @@ -23,8 +23,8 @@ import ( "errors" "net/http" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -39,10 +39,10 @@ import ( func respondArtifactGuardError(w http.ResponseWriter, err error) bool { switch { case errors.Is(err, constants.ErrArtifactReadOnly): - httputil.WriteJSON(w, http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", err.Error())) + httputil.WriteJSON(w, http.StatusForbidden, apperror.NewErrorResponse(403, "Forbidden", err.Error())) return true case errors.Is(err, constants.ErrArtifactDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", err.Error())) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", err.Error())) return true default: return false diff --git a/platform-api/plugins/eventgateway/handler/identity_helper.go b/platform-api/plugins/eventgateway/handler/identity_helper.go index c5af825d55..cafdd8cafa 100644 --- a/platform-api/plugins/eventgateway/handler/identity_helper.go +++ b/platform-api/plugins/eventgateway/handler/identity_helper.go @@ -23,8 +23,8 @@ import ( "log/slog" "net/http" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/go-httpkit/httputil" ) @@ -39,7 +39,7 @@ func resolveActor(w http.ResponseWriter, r *http.Request, identity *service.Iden actor, err := identity.InternalUserID(r) if err != nil { slogger.Error("Failed to resolve user identity", "action", action, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to resolve user identity")) return "", false } diff --git a/platform-api/plugins/eventgateway/handler/webbroker_api.go b/platform-api/plugins/eventgateway/handler/webbroker_api.go index 43087858ac..ecba1bde25 100644 --- a/platform-api/plugins/eventgateway/handler/webbroker_api.go +++ b/platform-api/plugins/eventgateway/handler/webbroker_api.go @@ -28,6 +28,7 @@ import ( "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -66,14 +67,14 @@ func (h *WebBrokerAPIHandler) RegisterRoutes(mux *http.ServeMux) { func (h *WebBrokerAPIHandler) CreateWebBrokerAPI(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } var req api.WebBrokerAPI if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.slogger.Error("WebBroker API request validation failed", "org_id", orgID, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body")) return } @@ -95,13 +96,13 @@ func (h *WebBrokerAPIHandler) CreateWebBrokerAPI(w http.ResponseWriter, r *http. func (h *WebBrokerAPIHandler) ListWebBrokerAPIs(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } projectID := strings.TrimSpace(r.URL.Query().Get("projectId")) if projectID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "projectId query parameter is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "projectId query parameter is required")) return } @@ -139,7 +140,7 @@ func (h *WebBrokerAPIHandler) ListWebBrokerAPIs(w http.ResponseWriter, r *http.R func (h *WebBrokerAPIHandler) GetWebBrokerAPI(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -157,7 +158,7 @@ func (h *WebBrokerAPIHandler) GetWebBrokerAPI(w http.ResponseWriter, r *http.Req func (h *WebBrokerAPIHandler) UpdateWebBrokerAPI(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -166,12 +167,12 @@ func (h *WebBrokerAPIHandler) UpdateWebBrokerAPI(w http.ResponseWriter, r *http. var req api.WebBrokerAPI if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.slogger.Error("WebBroker API update validation failed", "org_id", orgID, "api_id", id, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body")) return } if err := utils.ValidateHandleImmutable(id, req.Id); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "WebBroker API id is immutable and cannot be changed")) return } @@ -193,7 +194,7 @@ func (h *WebBrokerAPIHandler) UpdateWebBrokerAPI(w http.ResponseWriter, r *http. func (h *WebBrokerAPIHandler) DeleteWebBrokerAPI(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -218,21 +219,21 @@ func (h *WebBrokerAPIHandler) handleServiceError(w http.ResponseWriter, err erro } switch { case errors.Is(err, constants.ErrHandleImmutable): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) case errors.Is(err, constants.ErrWebBrokerAPINotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebBroker API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebBroker API not found")) case errors.Is(err, constants.ErrWebBrokerAPIExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "WebBroker API with this ID already exists")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "WebBroker API with this ID already exists")) case errors.Is(err, constants.ErrWebBrokerAPILimitReached): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "WebBroker API limit reached for the organization")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "WebBroker API limit reached for the organization")) case errors.Is(err, constants.ErrProjectNotFound): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Project not found")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Project not found")) case errors.Is(err, constants.ErrDevPortalNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "DevPortal not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "DevPortal not found")) default: h.slogger.Error("WebBroker API service error", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) } } diff --git a/platform-api/plugins/eventgateway/handler/webbroker_api_deployment.go b/platform-api/plugins/eventgateway/handler/webbroker_api_deployment.go index 10c0fa30fd..c8249b0406 100644 --- a/platform-api/plugins/eventgateway/handler/webbroker_api_deployment.go +++ b/platform-api/plugins/eventgateway/handler/webbroker_api_deployment.go @@ -27,10 +27,10 @@ import ( "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" egservice "github.com/wso2/api-platform/platform-api/plugins/eventgateway/service" "github.com/wso2/go-httpkit/httputil" @@ -66,32 +66,32 @@ func (h *WebBrokerAPIDeploymentHandler) RegisterRoutes(mux *http.ServeMux) { func (h *WebBrokerAPIDeploymentHandler) DeployWebBrokerAPI(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiId := r.PathValue("webBrokerApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API ID is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } var req api.DeployRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) return } if req.Name == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "name is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "name is required")) return } if req.Base == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "base is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "base is required")) return } if strings.TrimSpace(req.GatewayId) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "gatewayId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "gatewayId is required")) return } @@ -115,7 +115,7 @@ func (h *WebBrokerAPIDeploymentHandler) DeployWebBrokerAPI(w http.ResponseWriter func (h *WebBrokerAPIDeploymentHandler) UndeployDeployment(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -123,11 +123,11 @@ func (h *WebBrokerAPIDeploymentHandler) UndeployDeployment(w http.ResponseWriter deploymentId := r.PathValue("deploymentId") gatewayId := r.URL.Query().Get("gatewayId") if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "deploymentId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "deploymentId is required")) return } if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "gatewayId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "gatewayId is required")) return } @@ -147,7 +147,7 @@ func (h *WebBrokerAPIDeploymentHandler) UndeployDeployment(w http.ResponseWriter func (h *WebBrokerAPIDeploymentHandler) RestoreDeployment(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -155,11 +155,11 @@ func (h *WebBrokerAPIDeploymentHandler) RestoreDeployment(w http.ResponseWriter, deploymentId := r.PathValue("deploymentId") gatewayId := r.URL.Query().Get("gatewayId") if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "deploymentId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "deploymentId is required")) return } if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "gatewayId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "gatewayId is required")) return } @@ -179,13 +179,13 @@ func (h *WebBrokerAPIDeploymentHandler) RestoreDeployment(w http.ResponseWriter, func (h *WebBrokerAPIDeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiId := r.PathValue("webBrokerApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API ID is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } @@ -220,7 +220,7 @@ func (h *WebBrokerAPIDeploymentHandler) GetDeployments(w http.ResponseWriter, r func (h *WebBrokerAPIDeploymentHandler) GetDeployment(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -240,7 +240,7 @@ func (h *WebBrokerAPIDeploymentHandler) GetDeployment(w http.ResponseWriter, r * func (h *WebBrokerAPIDeploymentHandler) DeleteDeployment(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -258,27 +258,27 @@ func (h *WebBrokerAPIDeploymentHandler) DeleteDeployment(w http.ResponseWriter, func (h *WebBrokerAPIDeploymentHandler) handleDeploymentError(w http.ResponseWriter, err error, apiId string) { switch { case errors.Is(err, constants.ErrWebBrokerAPINotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebBroker API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebBroker API not found")) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Gateway not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Gateway not found")) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Deployment not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Deployment not found")) case errors.Is(err, constants.ErrBaseDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Base deployment not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Base deployment not found")) case errors.Is(err, constants.ErrDeploymentNotActive): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "No active deployment found for this API on the gateway")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "No active deployment found for this API on the gateway")) case errors.Is(err, constants.ErrDeploymentIsDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Cannot delete an active deployment - undeploy it first")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "Cannot delete an active deployment - undeploy it first")) case errors.Is(err, constants.ErrDeploymentAlreadyDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Cannot restore currently deployed deployment")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "Cannot restore currently deployed deployment")) case errors.Is(err, constants.ErrInvalidDeploymentRestoreState): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Deployment cannot be restored: only ARCHIVED or UNDEPLOYED deployments are eligible")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "Deployment cannot be restored: only ARCHIVED or UNDEPLOYED deployments are eligible")) case errors.Is(err, constants.ErrGatewayIDMismatch): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Deployment is bound to a different gateway")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Deployment is bound to a different gateway")) case errors.Is(err, constants.ErrAPINoBackendServices): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API must have at least one backend service configured")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API must have at least one backend service configured")) default: h.slogger.Error("WebBroker API deployment error", "apiId", apiId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) } } diff --git a/platform-api/plugins/eventgateway/handler/webbroker_apikey.go b/platform-api/plugins/eventgateway/handler/webbroker_apikey.go index 7412f9d61f..67082b4124 100644 --- a/platform-api/plugins/eventgateway/handler/webbroker_apikey.go +++ b/platform-api/plugins/eventgateway/handler/webbroker_apikey.go @@ -27,6 +27,7 @@ import ( "net/http" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -65,13 +66,13 @@ func (h *WebBrokerAPIKeyHandler) RegisterRoutes(mux *http.ServeMux) { func (h *WebBrokerAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiHandle := r.PathValue("webBrokerApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle is required")) return } @@ -88,12 +89,12 @@ func (h *WebBrokerAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Req var req api.CreateAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body")) return } if req.ApiKey == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API key value is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API key value is required")) return } @@ -103,7 +104,7 @@ func (h *WebBrokerAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Req } else { generatedName, err := utils.GenerateHandle(req.DisplayName, nil) if err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Failed to generate API key name")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Failed to generate API key name")) return } name = generatedName @@ -112,15 +113,15 @@ func (h *WebBrokerAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Req if err := h.apiKeyService.CreateAPIKey(r.Context(), apiHandle, constants.WebBrokerApi, orgID, userId, &req); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebBroker API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebBroker API not found")) return } if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) return } h.slogger.Error("Failed to create API key for WebBroker API", "apiHandle", apiHandle, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create API key")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create API key")) return } @@ -135,19 +136,19 @@ func (h *WebBrokerAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Req func (h *WebBrokerAPIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiHandle := r.PathValue("webBrokerApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle is required")) return } keyName := r.PathValue("apiKeyId") if keyName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Key name is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Key name is required")) return } @@ -165,34 +166,34 @@ func (h *WebBrokerAPIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Req var req api.UpdateAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.slogger.Warn("Invalid API key update request", "orgId", orgID, "apiHandle", apiHandle, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body: "+err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body: "+err.Error())) return } if req.ApiKey == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API key value is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API key value is required")) return } // Validate that the name in the request body (if provided) matches the URL path parameter if err := utils.ValidateHandleImmutable(keyName, req.Name); err != nil { h.slogger.Warn("API key name mismatch", "orgId", orgID, "apiHandle", apiHandle, "urlKeyName", keyName, "bodyKeyName", *req.Name) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", fmt.Sprintf("API key name mismatch: name in request body '%s' must match the key name in URL '%s'", *req.Name, keyName))) return } if err := h.apiKeyService.UpdateAPIKey(r.Context(), apiHandle, constants.WebBrokerApi, orgID, keyName, userId, &req); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebBroker API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebBroker API not found")) return } if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) return } h.slogger.Error("Failed to update API key for WebBroker API", "apiHandle", apiHandle, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update API key")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to update API key")) return } @@ -208,7 +209,7 @@ func (h *WebBrokerAPIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Req func (h *WebBrokerAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -216,11 +217,11 @@ func (h *WebBrokerAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Req keyName := r.PathValue("apiKeyId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle is required")) return } if keyName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Key name is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Key name is required")) return } @@ -231,19 +232,19 @@ func (h *WebBrokerAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Req if err := h.apiKeyService.RevokeAPIKey(r.Context(), apiHandle, constants.WebBrokerApi, orgID, keyName, userId); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebBroker API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebBroker API not found")) return } if errors.Is(err, constants.ErrAPIKeyNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "API key not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "API key not found")) return } if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) return } h.slogger.Error("Failed to delete API key for WebBroker API", "apiHandle", apiHandle, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete API key")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to delete API key")) return } @@ -254,11 +255,11 @@ func (h *WebBrokerAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Req func (h *WebBrokerAPIKeyHandler) handleServiceError(w http.ResponseWriter, err error) { switch { case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) case errors.Is(err, constants.ErrWebBrokerAPINotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebBroker API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebBroker API not found")) default: h.slogger.Error("WebBroker API key service error", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) } } diff --git a/platform-api/plugins/eventgateway/handler/websub_api.go b/platform-api/plugins/eventgateway/handler/websub_api.go index f1ec3305b8..b3a6fdba9e 100644 --- a/platform-api/plugins/eventgateway/handler/websub_api.go +++ b/platform-api/plugins/eventgateway/handler/websub_api.go @@ -28,6 +28,7 @@ import ( "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -66,14 +67,14 @@ func (h *WebSubAPIHandler) RegisterRoutes(mux *http.ServeMux) { func (h *WebSubAPIHandler) CreateWebSubAPI(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } var req api.WebSubAPI if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.slogger.Error("WebSub API request validation failed", "org_id", orgID, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body")) return } @@ -95,13 +96,13 @@ func (h *WebSubAPIHandler) CreateWebSubAPI(w http.ResponseWriter, r *http.Reques func (h *WebSubAPIHandler) ListWebSubAPIs(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } projectID := strings.TrimSpace(r.URL.Query().Get("projectId")) if projectID == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "projectId query parameter is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "projectId query parameter is required")) return } @@ -130,7 +131,7 @@ func (h *WebSubAPIHandler) ListWebSubAPIs(w http.ResponseWriter, r *http.Request func (h *WebSubAPIHandler) GetWebSubAPI(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -148,7 +149,7 @@ func (h *WebSubAPIHandler) GetWebSubAPI(w http.ResponseWriter, r *http.Request) func (h *WebSubAPIHandler) UpdateWebSubAPI(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -157,12 +158,12 @@ func (h *WebSubAPIHandler) UpdateWebSubAPI(w http.ResponseWriter, r *http.Reques var req api.WebSubAPI if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.slogger.Error("WebSub API update validation failed", "org_id", orgID, "api_id", id, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body")) return } if err := utils.ValidateHandleImmutable(id, req.Id); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "WebSub API id is immutable and cannot be changed")) return } @@ -184,7 +185,7 @@ func (h *WebSubAPIHandler) UpdateWebSubAPI(w http.ResponseWriter, r *http.Reques func (h *WebSubAPIHandler) DeleteWebSubAPI(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -209,21 +210,21 @@ func (h *WebSubAPIHandler) handleServiceError(w http.ResponseWriter, err error) } switch { case errors.Is(err, constants.ErrHandleImmutable): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) case errors.Is(err, constants.ErrWebSubAPINotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebSub API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebSub API not found")) case errors.Is(err, constants.ErrWebSubAPIExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "WebSub API with this ID already exists")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "WebSub API with this ID already exists")) case errors.Is(err, constants.ErrWebSubAPILimitReached): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "WebSub API limit reached for the organization")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "WebSub API limit reached for the organization")) case errors.Is(err, constants.ErrProjectNotFound): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Project not found")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Project not found")) case errors.Is(err, constants.ErrDevPortalNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "DevPortal not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "DevPortal not found")) default: h.slogger.Error("WebSub API service error", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) } } diff --git a/platform-api/plugins/eventgateway/handler/websub_api_deployment.go b/platform-api/plugins/eventgateway/handler/websub_api_deployment.go index 18c064851c..fec8db9d8e 100644 --- a/platform-api/plugins/eventgateway/handler/websub_api_deployment.go +++ b/platform-api/plugins/eventgateway/handler/websub_api_deployment.go @@ -27,10 +27,10 @@ import ( "strings" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/utils" egservice "github.com/wso2/api-platform/platform-api/plugins/eventgateway/service" "github.com/wso2/go-httpkit/httputil" @@ -66,32 +66,32 @@ func (h *WebSubAPIDeploymentHandler) RegisterRoutes(mux *http.ServeMux) { func (h *WebSubAPIDeploymentHandler) DeployWebSubAPI(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiId := r.PathValue("webSubApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API ID is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } var req api.DeployRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) return } if req.Name == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "name is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "name is required")) return } if req.Base == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "base is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "base is required")) return } if strings.TrimSpace(req.GatewayId) == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "gatewayId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "gatewayId is required")) return } @@ -115,7 +115,7 @@ func (h *WebSubAPIDeploymentHandler) DeployWebSubAPI(w http.ResponseWriter, r *h func (h *WebSubAPIDeploymentHandler) UndeployDeployment(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -123,11 +123,11 @@ func (h *WebSubAPIDeploymentHandler) UndeployDeployment(w http.ResponseWriter, r deploymentId := r.PathValue("deploymentId") gatewayId := r.URL.Query().Get("gatewayId") if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "deploymentId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "deploymentId is required")) return } if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "gatewayId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "gatewayId is required")) return } @@ -147,7 +147,7 @@ func (h *WebSubAPIDeploymentHandler) UndeployDeployment(w http.ResponseWriter, r func (h *WebSubAPIDeploymentHandler) RestoreDeployment(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -155,11 +155,11 @@ func (h *WebSubAPIDeploymentHandler) RestoreDeployment(w http.ResponseWriter, r deploymentId := r.PathValue("deploymentId") gatewayId := r.URL.Query().Get("gatewayId") if deploymentId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "deploymentId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "deploymentId is required")) return } if gatewayId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "gatewayId is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "gatewayId is required")) return } @@ -179,13 +179,13 @@ func (h *WebSubAPIDeploymentHandler) RestoreDeployment(w http.ResponseWriter, r func (h *WebSubAPIDeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiId := r.PathValue("webSubApiId") if apiId == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API ID is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API ID is required")) return } @@ -220,7 +220,7 @@ func (h *WebSubAPIDeploymentHandler) GetDeployments(w http.ResponseWriter, r *ht func (h *WebSubAPIDeploymentHandler) GetDeployment(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -240,7 +240,7 @@ func (h *WebSubAPIDeploymentHandler) GetDeployment(w http.ResponseWriter, r *htt func (h *WebSubAPIDeploymentHandler) DeleteDeployment(w http.ResponseWriter, r *http.Request) { orgId, exists := middleware.GetOrganizationFromRequest(r) if !exists { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -258,27 +258,27 @@ func (h *WebSubAPIDeploymentHandler) DeleteDeployment(w http.ResponseWriter, r * func (h *WebSubAPIDeploymentHandler) handleDeploymentError(w http.ResponseWriter, err error, apiId string) { switch { case errors.Is(err, constants.ErrWebSubAPINotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebSub API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebSub API not found")) case errors.Is(err, constants.ErrGatewayNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Gateway not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Gateway not found")) case errors.Is(err, constants.ErrDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Deployment not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Deployment not found")) case errors.Is(err, constants.ErrBaseDeploymentNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "Base deployment not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "Base deployment not found")) case errors.Is(err, constants.ErrDeploymentNotActive): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "No active deployment found for this API on the gateway")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "No active deployment found for this API on the gateway")) case errors.Is(err, constants.ErrDeploymentIsDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Cannot delete an active deployment - undeploy it first")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "Cannot delete an active deployment - undeploy it first")) case errors.Is(err, constants.ErrDeploymentAlreadyDeployed): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Cannot restore currently deployed deployment")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "Cannot restore currently deployed deployment")) case errors.Is(err, constants.ErrInvalidDeploymentRestoreState): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Deployment cannot be restored: only ARCHIVED or UNDEPLOYED deployments are eligible")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "Deployment cannot be restored: only ARCHIVED or UNDEPLOYED deployments are eligible")) case errors.Is(err, constants.ErrGatewayIDMismatch): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Deployment is bound to a different gateway")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Deployment is bound to a different gateway")) case errors.Is(err, constants.ErrAPINoBackendServices): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API must have at least one backend service configured")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API must have at least one backend service configured")) default: h.slogger.Error("WebSub API deployment error", "apiId", apiId, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) } } diff --git a/platform-api/plugins/eventgateway/handler/websub_api_hmac_secret.go b/platform-api/plugins/eventgateway/handler/websub_api_hmac_secret.go index e6d499c50a..360e4b70b2 100644 --- a/platform-api/plugins/eventgateway/handler/websub_api_hmac_secret.go +++ b/platform-api/plugins/eventgateway/handler/websub_api_hmac_secret.go @@ -27,6 +27,7 @@ import ( "net/http" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/model" @@ -63,7 +64,7 @@ func (h *WebSubAPIHmacSecretHandler) RegisterRoutes(mux *http.ServeMux) { func (h *WebSubAPIHmacSecretHandler) featureUnavailable(w http.ResponseWriter) bool { if h.secretService == nil { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "HMAC secret management is not configured on this server")) return true } @@ -77,24 +78,24 @@ func (h *WebSubAPIHmacSecretHandler) CreateHmacSecret(w http.ResponseWriter, r * } orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiHandle := r.PathValue("webSubApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle is required")) return } var req api.WebSubAPIHmacSecretRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body")) return } if req.Secret != nil && *req.Secret == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "secret must not be empty; omit the field to auto-generate")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "secret must not be empty; omit the field to auto-generate")) return } @@ -120,7 +121,7 @@ func (h *WebSubAPIHmacSecretHandler) CreateHmacSecret(w http.ResponseWriter, r * info, err := h.toSecretInfo(secret) if err != nil { h.slogger.Error("Failed to resolve HMAC secret identity", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to generate HMAC secret")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to generate HMAC secret")) return } httputil.WriteJSON(w, http.StatusCreated, api.WebSubAPIHmacSecretCreationResponse{ @@ -137,13 +138,13 @@ func (h *WebSubAPIHmacSecretHandler) ListHmacSecrets(w http.ResponseWriter, r *h } orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiHandle := r.PathValue("webSubApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle is required")) return } @@ -158,7 +159,7 @@ func (h *WebSubAPIHmacSecretHandler) ListHmacSecrets(w http.ResponseWriter, r *h info, err := h.toSecretInfo(s) if err != nil { h.slogger.Error("Failed to resolve HMAC secret identity", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list HMAC secrets")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to list HMAC secrets")) return } items = append(items, *info) @@ -173,14 +174,14 @@ func (h *WebSubAPIHmacSecretHandler) DeleteHmacSecret(w http.ResponseWriter, r * } orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiHandle := r.PathValue("webSubApiId") secretName := r.PathValue("secretName") if apiHandle == "" || secretName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle and secret name are required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle and secret name are required")) return } @@ -199,25 +200,25 @@ func (h *WebSubAPIHmacSecretHandler) RegenerateHmacSecret(w http.ResponseWriter, } orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiHandle := r.PathValue("webSubApiId") secretName := r.PathValue("secretName") if apiHandle == "" || secretName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle and secret name are required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle and secret name are required")) return } var req api.WebSubAPIHmacSecretRegenerateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body")) return } if req.Secret != nil && *req.Secret == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "secret must not be empty; omit the field to auto-generate")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "secret must not be empty; omit the field to auto-generate")) return } @@ -243,7 +244,7 @@ func (h *WebSubAPIHmacSecretHandler) RegenerateHmacSecret(w http.ResponseWriter, info, err := h.toSecretInfo(secret) if err != nil { h.slogger.Error("Failed to resolve HMAC secret identity", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to regenerate HMAC secret")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to regenerate HMAC secret")) return } httputil.WriteJSON(w, http.StatusOK, api.WebSubAPIHmacSecretCreationResponse{ @@ -256,19 +257,19 @@ func (h *WebSubAPIHmacSecretHandler) RegenerateHmacSecret(w http.ResponseWriter, func (h *WebSubAPIHmacSecretHandler) handleServiceError(w http.ResponseWriter, err error) { switch { case errors.Is(err, constants.ErrWebSubAPINotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebSub API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebSub API not found")) case errors.Is(err, constants.ErrHmacSecretNotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "HMAC secret not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "HMAC secret not found")) case errors.Is(err, constants.ErrHmacSecretAlreadyExists): - httputil.WriteJSON(w, http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "An HMAC secret with this name already exists")) + httputil.WriteJSON(w, http.StatusConflict, apperror.NewErrorResponse(409, "Conflict", "An HMAC secret with this name already exists")) case errors.Is(err, constants.ErrHmacSecretInvalidValue): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Secret value must be at least 32 characters")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Secret value must be at least 32 characters")) case errors.Is(err, constants.ErrHmacSecretEncryptionKeyMissing): h.slogger.Error("HMAC secret encryption key is not configured") - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "HMAC secret management is not configured on this server")) + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "HMAC secret management is not configured on this server")) default: h.slogger.Error("HMAC secret service error", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) } } diff --git a/platform-api/plugins/eventgateway/handler/websub_apikey.go b/platform-api/plugins/eventgateway/handler/websub_apikey.go index b4df96b9c2..2c5ad5c7cc 100644 --- a/platform-api/plugins/eventgateway/handler/websub_apikey.go +++ b/platform-api/plugins/eventgateway/handler/websub_apikey.go @@ -27,6 +27,7 @@ import ( "net/http" "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/service" @@ -65,13 +66,13 @@ func (h *WebSubAPIKeyHandler) RegisterRoutes(mux *http.ServeMux) { func (h *WebSubAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiHandle := r.PathValue("webSubApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle is required")) return } @@ -88,12 +89,12 @@ func (h *WebSubAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Reques var req api.CreateAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body")) return } if req.ApiKey == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API key value is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API key value is required")) return } @@ -103,7 +104,7 @@ func (h *WebSubAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Reques } else { generatedName, err := utils.GenerateHandle(req.DisplayName, nil) if err != nil { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Failed to generate API key name")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Failed to generate API key name")) return } name = generatedName @@ -112,15 +113,15 @@ func (h *WebSubAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Reques if err := h.apiKeyService.CreateAPIKey(r.Context(), apiHandle, constants.WebSubApi, orgID, userId, &req); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebSub API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebSub API not found")) return } if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) return } h.slogger.Error("Failed to create API key for WebSub API", "apiHandle", apiHandle, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create API key")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to create API key")) return } @@ -135,19 +136,19 @@ func (h *WebSubAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Reques func (h *WebSubAPIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } apiHandle := r.PathValue("webSubApiId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle is required")) return } keyName := r.PathValue("apiKeyId") if keyName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Key name is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Key name is required")) return } @@ -165,34 +166,34 @@ func (h *WebSubAPIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Reques var req api.UpdateAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.slogger.Warn("Invalid API key update request", "orgId", orgID, "apiHandle", apiHandle, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body: "+err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Invalid request body: "+err.Error())) return } if req.ApiKey == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API key value is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API key value is required")) return } // Validate that the name in the request body (if provided) matches the URL path parameter if err := utils.ValidateHandleImmutable(keyName, req.Name); err != nil { h.slogger.Warn("API key name mismatch", "orgId", orgID, "apiHandle", apiHandle, "urlKeyName", keyName, "bodyKeyName", *req.Name) - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", fmt.Sprintf("API key name mismatch: name in request body '%s' must match the key name in URL '%s'", *req.Name, keyName))) return } if err := h.apiKeyService.UpdateAPIKey(r.Context(), apiHandle, constants.WebSubApi, orgID, keyName, userId, &req); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebSub API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebSub API not found")) return } if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) return } h.slogger.Error("Failed to update API key for WebSub API", "apiHandle", apiHandle, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update API key")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to update API key")) return } @@ -209,7 +210,7 @@ func (h *WebSubAPIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Reques func (h *WebSubAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { - httputil.WriteJSON(w, http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + httputil.WriteJSON(w, http.StatusUnauthorized, apperror.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) return } @@ -217,11 +218,11 @@ func (h *WebSubAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Reques keyName := r.PathValue("apiKeyId") if apiHandle == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "API handle is required")) return } if keyName == "" { - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Key name is required")) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", "Key name is required")) return } @@ -232,19 +233,19 @@ func (h *WebSubAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Reques if err := h.apiKeyService.RevokeAPIKey(r.Context(), apiHandle, constants.WebSubApi, orgID, keyName, userId); err != nil { if errors.Is(err, constants.ErrAPINotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebSub API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebSub API not found")) return } if errors.Is(err, constants.ErrAPIKeyNotFound) { - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "API key not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "API key not found")) return } if errors.Is(err, constants.ErrGatewayUnavailable) { - httputil.WriteJSON(w, http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) + httputil.WriteJSON(w, http.StatusServiceUnavailable, apperror.NewErrorResponse(503, "Service Unavailable", "No gateway connections available")) return } h.slogger.Error("Failed to delete API key for WebSub API", "apiHandle", apiHandle, "keyName", keyName, "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete API key")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "Failed to delete API key")) return } @@ -255,11 +256,11 @@ func (h *WebSubAPIKeyHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Reques func (h *WebSubAPIKeyHandler) handleServiceError(w http.ResponseWriter, err error) { switch { case errors.Is(err, constants.ErrInvalidInput): - httputil.WriteJSON(w, http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + httputil.WriteJSON(w, http.StatusBadRequest, apperror.NewErrorResponse(400, "Bad Request", err.Error())) case errors.Is(err, constants.ErrWebSubAPINotFound): - httputil.WriteJSON(w, http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebSub API not found")) + httputil.WriteJSON(w, http.StatusNotFound, apperror.NewErrorResponse(404, "Not Found", "WebSub API not found")) default: h.slogger.Error("WebSub API key service error", "error", err) - httputil.WriteJSON(w, http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + httputil.WriteJSON(w, http.StatusInternalServerError, apperror.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) } } diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index d21acaa391..416d7d9ef2 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -62,6 +62,9 @@ paths: responses: '201': description: Organization registered successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -69,7 +72,7 @@ paths: '400': $ref: '#/components/responses/BadRequest' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -174,7 +177,7 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '404': - description: Organization not found + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -202,6 +205,9 @@ paths: responses: '201': description: Project created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -396,6 +402,9 @@ paths: responses: '201': description: API created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -622,6 +631,9 @@ paths: responses: '201': description: API key created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -635,11 +647,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '503': - description: Service Unavailable - No gateway connections available - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' @@ -691,11 +699,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '503': - description: Service Unavailable - No gateway connections available - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' @@ -735,11 +739,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '503': - description: Service Unavailable - No gateway connections available - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' @@ -772,26 +772,21 @@ paths: responses: '201': description: API deployed successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: $ref: '#/components/schemas/DeploymentResponse' '400': - description: Bad Request. Validation errors (missing name, base, gatewayId, or API has no backend services) - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': - description: Not Found. API, gateway, or base deployment not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -1002,11 +997,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - description: Conflict. Cannot restore currently deployed deployment or deployment is invalid. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -1035,6 +1026,9 @@ paths: responses: '201': description: LLM provider template created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -1183,6 +1177,9 @@ paths: responses: '201': description: New version created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -1440,6 +1437,9 @@ paths: responses: '201': description: LLM provider created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -1631,26 +1631,21 @@ paths: responses: '201': description: LLM provider deployed successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: $ref: '#/components/schemas/DeploymentResponse' '400': - description: Bad Request. Validation errors (missing name, base, gatewayId, or invalid input) - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': - description: Not Found. LLM provider, gateway, or base deployment not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -1880,11 +1875,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - description: Conflict. Cannot restore currently deployed deployment or deployment is invalid. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -1972,6 +1963,9 @@ paths: responses: '201': description: API key created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -1981,17 +1975,9 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '404': - description: LLM provider not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '503': - description: No gateway connections available - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' get: @@ -2024,11 +2010,7 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '404': - description: LLM provider not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -2067,17 +2049,9 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '404': - description: LLM provider or API key not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '503': - description: No gateway connections available - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' @@ -2102,6 +2076,9 @@ paths: responses: '201': description: LLM proxy created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -2112,6 +2089,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '500': @@ -2294,26 +2273,21 @@ paths: responses: '201': description: LLM proxy deployed successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: $ref: '#/components/schemas/DeploymentResponse' '400': - description: Bad Request. Validation errors (missing name, base, gatewayId, or invalid input) - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': - description: Not Found. LLM proxy, gateway, or base deployment not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -2543,11 +2517,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - description: Conflict. Cannot restore currently deployed deployment or deployment is invalid. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -2584,6 +2554,9 @@ paths: responses: '201': description: API key created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -2593,17 +2566,9 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '404': - description: LLM proxy not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '503': - description: No gateway connections available - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' get: @@ -2636,11 +2601,7 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '404': - description: LLM proxy not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -2679,17 +2640,9 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '404': - description: LLM proxy or API key not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '503': - description: No gateway connections available - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' @@ -2714,6 +2667,9 @@ paths: responses: '201': description: MCP proxy created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -2906,24 +2862,19 @@ paths: responses: '201': description: MCP proxy deployed successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: $ref: '#/components/schemas/DeploymentResponse' '400': - description: Bad Request. Validation errors (missing name, base, gatewayId, or MCP proxy has no backend services) - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '404': - description: Not Found. MCP proxy, gateway, or base deployment not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -3153,11 +3104,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - description: Conflict. Cannot restore currently deployed deployment or deployment is invalid. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -3220,6 +3167,9 @@ paths: responses: '201': description: Gateway registered successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -3231,17 +3181,9 @@ paths: '403': $ref: '#/components/responses/Forbidden' '404': - description: Organization not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '409': - description: Gateway name already exists within organization - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -3368,22 +3310,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - description: Conflict. Gateway has active deployments or connections - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - examples: - activeDeployments: - value: - code: 409 - message: Conflict - description: Cannot delete gateway - 3 active API deployment(s) exist - activeConnections: - value: - code: 409 - message: Conflict - description: Cannot delete gateway - 2 active connection(s) exist + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -3436,16 +3363,15 @@ paths: responses: '201': description: New token generated successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: $ref: '#/components/schemas/TokenRotationResponse' '400': - description: Bad request (e.g., maximum active tokens reached) - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -3490,11 +3416,7 @@ paths: '403': $ref: '#/components/responses/Forbidden' '404': - description: Gateway or token not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -3612,17 +3534,17 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - description: Policy already exists, patch version update, or downgrade is not allowed - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Conflict' '422': description: Policy is not a custom policy or manifest is unavailable content: application/json: schema: $ref: '#/components/schemas/Error' + example: + status: error + code: POLICY_INVALID_STATE + message: "The policy is not a custom policy, or its manifest is unavailable." '500': $ref: '#/components/responses/InternalServerError' @@ -3703,11 +3625,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - description: Custom policy is in use by one or more APIs - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -3732,6 +3650,9 @@ paths: responses: '201': description: Application created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -4191,6 +4112,9 @@ paths: responses: '201': description: Subscription plan created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -4348,6 +4272,9 @@ paths: responses: '201': description: Subscription created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -4430,6 +4357,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' @@ -4629,6 +4558,9 @@ paths: responses: '201': description: Secret created successfully. + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: @@ -4807,17 +4739,32 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - description: Secret is referenced by active resources + description: Conflict. The secret is referenced by one or more active resources. content: application/json: schema: - $ref: '#/components/schemas/SecretDeleteConflict' + $ref: '#/components/schemas/Error' + example: + status: error + code: SECRET_IN_USE + message: The secret is referenced by one or more active resources. + details: + references: + - type: llm_provider + handle: wso2-openai-provider + name: WSO2 OpenAI Provider '503': $ref: '#/components/responses/ServiceUnavailable' '500': $ref: '#/components/responses/InternalServerError' components: + headers: + Location: + description: URL of the newly created resource. + schema: + type: string + format: uri securitySchemes: OAuth2Security: type: oauth2 @@ -6661,26 +6608,64 @@ components: $ref: '#/components/schemas/Pagination' Error: - title: Error object returned + title: Standard error response + description: The single error shape returned by every failed request across the API. required: + - status - code - - title + - message type: object properties: + status: + type: string + enum: [error] + description: Always the literal "error". + example: error code: - type: integer - format: int64 - title: type: string - description: Error message - details: + pattern: '^[A-Z][A-Z0-9_]*$' + description: "Stable, machine-readable error code from the error catalog, in the form `_` (e.g. `REST_API_NOT_FOUND`). Clients and agents should branch on this, not on the HTTP status." + example: REST_API_NOT_FOUND + message: type: string - description: A detailed description about the error message + description: Human-readable description of the error. + example: The requested REST API could not be found. errors: type: array - description: List of specific errors (useful for validation errors) + description: Per-field validation failures. Present when the error is a validation failure. items: - type: object + $ref: '#/components/schemas/FieldError' + details: + type: object + description: >- + Optional structured metadata specific to this error condition + (e.g. the resources referencing a secret that blocked its + deletion). Shape varies by `code`; absent when not applicable. + additionalProperties: true + trackingId: + type: string + format: uuid + description: >- + Correlation ID for server-side failures. Present only on 5xx + responses; quote it when reporting the error so operators can + find the corresponding server log entry. + example: 4f1c6f2e-8a4b-4c93-b1de-9f2f6f0c2a11 + + FieldError: + title: Field-level validation error + required: + - field + - message + type: object + properties: + field: + type: string + description: Path of the offending field. + example: spec.context + message: + type: string + description: Why the field failed validation. + example: must start with / DeployRequest: type: object @@ -8474,27 +8459,6 @@ components: pagination: $ref: '#/components/schemas/Pagination' - SecretDeleteConflict: - type: object - properties: - error: - type: string - example: secret is referenced by active resources - references: - type: array - items: - type: object - properties: - type: - type: string - example: llm_provider - handle: - type: string - example: wso2-openai-provider - name: - type: string - example: WSO2 OpenAI Provider - responses: Unauthorized: description: Unauthorized. Authentication credentials are missing or invalid. @@ -8503,9 +8467,9 @@ components: schema: $ref: '#/components/schemas/Error' example: - code: 401 - message: Unauthorized - description: Authorization header is required or token is invalid + status: error + code: UNAUTHORIZED + message: Authorization header is required, or the token is invalid or expired. BadRequest: description: Bad Request. Invalid request or validation error. @@ -8514,9 +8478,12 @@ components: schema: $ref: '#/components/schemas/Error' example: - code: 400 - message: Bad Request - description: Invalid request or validation error + status: error + code: VALIDATION_FAILED + message: The request failed validation. + errors: + - field: spec.context + message: must start with / Forbidden: description: Forbidden. The authenticated user does not have permission to access this resource. content: @@ -8524,9 +8491,9 @@ components: schema: $ref: '#/components/schemas/Error' example: - code: 403 - title: Forbidden - details: You do not have permission to perform this action + status: error + code: FORBIDDEN + message: You do not have permission to perform this action. NotFound: description: Not Found. The specified resource does not exist. content: @@ -8534,9 +8501,9 @@ components: schema: $ref: '#/components/schemas/Error' example: - code: 404 - message: Not Found - description: The specified resource does not exist + status: error + code: NOT_FOUND + message: The specified resource does not exist. Conflict: description: Conflict. Specified resource already exists. content: @@ -8544,9 +8511,9 @@ components: schema: $ref: '#/components/schemas/Error' example: - code: 409 - message: Conflict - description: Specified resource already exists + status: error + code: CONFLICT + message: The specified resource already exists. InternalServerError: description: Internal Server Error. content: @@ -8554,10 +8521,10 @@ components: schema: $ref: '#/components/schemas/Error' example: - code: 500 - message: Internal Server Error - description: The server encountered an internal error. Please contact - administrator. + status: error + code: INTERNAL_ERROR + message: An unexpected error occurred. + trackingId: 4f1c6f2e-8a4b-4c93-b1de-9f2f6f0c2a11 ServiceUnavailable: description: Service Unavailable. The secrets management feature is not configured. content: @@ -8565,9 +8532,32 @@ components: schema: $ref: '#/components/schemas/Error' example: - code: 503 - message: Service Unavailable - description: Secrets management is not configured — set PLATFORM_SECRET_ENCRYPTION_KEY + status: error + code: SERVICE_UNAVAILABLE + message: Secrets management is not configured. Set PLATFORM_SECRET_ENCRYPTION_KEY. + + GatewayConnectionUnavailable: + description: Service Unavailable. No gateway connections are currently available to service this request. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + status: error + code: GATEWAY_CONNECTION_UNAVAILABLE + message: No gateway connections are currently available. + + examples: + GatewayNotFoundExample: + value: + status: error + code: GATEWAY_NOT_FOUND + message: "The specified gateway could not be found." + DeploymentBaseNotFoundExample: + value: + status: error + code: DEPLOYMENT_BASE_NOT_FOUND + message: "The specified base deployment could not be found." parameters: projectId: name: projectId