diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 3fbde2c9..d7c210ba 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -2,7 +2,8 @@ openapi: 3.1.0 info: title: Outpost API version: "0.0.1" - description: The Outpost API is a REST-based JSON API for managing tenants, destinations, and publishing events. + description: | + The Outpost API is a REST-based JSON API for managing tenants, destinations, and publishing events. contact: name: Outpost Support email: support@hookdeck.com @@ -44,6 +45,37 @@ components: "exp": 1704153600 } ``` + responses: + BadRequest: + description: Malformed JSON or invalid request parameters. + content: + application/json: + schema: + $ref: "#/components/schemas/APIErrorResponse" + Unauthorized: + description: Missing or invalid authentication credentials. + content: + application/json: + schema: + $ref: "#/components/schemas/APIErrorResponse" + NotFound: + description: Requested resource not found. + content: + application/json: + schema: + $ref: "#/components/schemas/APIErrorResponse" + ValidationError: + description: Request body fails validation. + content: + application/json: + schema: + $ref: "#/components/schemas/APIErrorResponse" + InternalServerError: + description: Unexpected server error. + content: + application/json: + schema: + $ref: "#/components/schemas/APIErrorResponse" schemas: # Base Schemas Tenant: @@ -1752,12 +1784,6 @@ components: format: date-time description: Time the event was received/processed. example: "2024-01-01T00:00:00Z" - successful_at: - type: string - format: date-time - nullable: true - description: Time the event was successfully delivered. - example: "2024-01-01T00:00:00Z" metadata: type: object nullable: true @@ -2181,9 +2207,11 @@ paths: **Requirements:** This endpoint requires Redis with RediSearch module (e.g., `redis/redis-stack-server`). If RediSearch is not available, this endpoint returns `501 Not Implemented`. - The response includes lightweight tenant objects without computed fields like `destinations_count` and `topics`. - Use `GET /tenants/{tenant_id}` to retrieve full tenant details including these fields. + When authenticated with a Tenant JWT, returns only the authenticated tenant. Pagination is not used in this case. operationId: listTenants + security: + - AdminApiKey: [] + - TenantJwt: [] parameters: - name: limit in: query @@ -2194,14 +2222,6 @@ paths: maximum: 100 default: 20 description: Number of tenants to return per page (1-100, default 20). - - name: order_by - in: query - required: false - schema: - type: string - enum: [created_at] - default: created_at - description: Field to sort by. - name: dir in: query required: false @@ -2210,20 +2230,6 @@ paths: enum: [asc, desc] default: desc description: Sort direction. - - name: created_at[gte] - in: query - required: false - schema: - type: string - format: date-time - description: Filter tenants created at or after this time (RFC3339 or YYYY-MM-DD format). - - name: created_at[lte] - in: query - required: false - schema: - type: string - format: date-time - description: Filter tenants created at or before this time (RFC3339 or YYYY-MM-DD format). - name: next in: query required: false @@ -2262,17 +2268,11 @@ paths: prev: null count: 42 "400": - description: Invalid request parameters (e.g., invalid cursor, both next and prev provided). - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: "invalid cursor format" + $ref: "#/components/responses/BadRequest" "401": - description: Unauthorized (authentication missing or invalid). + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" "501": description: List Tenants feature is not available. Requires Redis with RediSearch module. content: @@ -2316,7 +2316,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Tenant" - # Add error responses + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/ValidationError" + "500": + $ref: "#/components/responses/InternalServerError" get: tags: [Tenants] summary: Get Tenant @@ -2329,9 +2334,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Tenant" + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" delete: tags: [Tenants] summary: Delete Tenant @@ -2348,9 +2356,12 @@ paths: SuccessExample: value: success: true + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /events: get: @@ -2362,6 +2373,9 @@ paths: When authenticated with a Tenant JWT, returns only events belonging to that tenant. When authenticated with Admin API Key, returns events across all tenants. Use `tenant_id` query parameter to filter by tenant. operationId: listEvents + security: + - AdminApiKey: [] + - TenantJwt: [] parameters: - name: tenant_id in: query @@ -2460,13 +2474,9 @@ paths: next: "MTcwNDA2NzIwMA==" prev: null "401": - description: Unauthorized (authentication missing or invalid). - "422": - description: Validation error (invalid query parameters). - content: - application/json: - schema: - $ref: "#/components/schemas/APIErrorResponse" + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" /events/{event_id}: parameters: @@ -2501,8 +2511,12 @@ paths: eligible_for_retry: false metadata: { "source": "crm" } data: { "user_id": "userid", "status": "active" } + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Event not found. + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /attempts: get: @@ -2514,6 +2528,9 @@ paths: When authenticated with a Tenant JWT, returns only attempts belonging to that tenant. When authenticated with Admin API Key, returns attempts across all tenants. Use `tenant_id` query parameter to filter by tenant. operationId: listAttempts + security: + - AdminApiKey: [] + - TenantJwt: [] parameters: - name: tenant_id in: query @@ -2670,13 +2687,9 @@ paths: next: null prev: null "401": - description: Unauthorized (authentication missing or invalid). - "422": - description: Validation error (invalid query parameters). - content: - application/json: - schema: - $ref: "#/components/schemas/APIErrorResponse" + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" /attempts/{attempt_id}: parameters: @@ -2748,8 +2761,12 @@ paths: eligible_for_retry: false metadata: { "source": "crm" } data: { "user_id": "userid", "status": "active" } + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Attempt not found. + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /tenants/{tenant_id}/portal: parameters: @@ -2786,9 +2803,12 @@ paths: value: redirect_url: "https://webhooks.acme.com/?token=JWT_TOKEN&tenant_id=tenant_123" tenant_id: "tenant_123" + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /tenants/{tenant_id}/token: parameters: @@ -2817,9 +2837,12 @@ paths: value: token: "SOME_JWT_TOKEN" tenant_id: "tenant_123" + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" # Destinations /tenants/{tenant_id}/destinations: @@ -2907,9 +2930,12 @@ paths: credentials: key: "AKIAIOSFODNN7EXAMPLE" secret: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" post: tags: [Destinations] summary: Create Destination @@ -2942,11 +2968,14 @@ paths: credentials: secret: "whsec_abc123def456" # previous_secret and previous_secret_invalid_at are absent on creation - "400": - description: Invalid request body or configuration. + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant not found. - # Add other error responses (e.g., max destinations reached) + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationError" + "500": + $ref: "#/components/responses/InternalServerError" /tenants/{tenant_id}/destinations/{destination_id}: parameters: @@ -2989,8 +3018,12 @@ paths: secret: "whsec_abc123def456" previous_secret: "whsec_prev789xyz012" previous_secret_invalid_at: "2024-02-16T10:00:00Z" + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant or Destination not found. + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" patch: tags: [Destinations] summary: Update Destination @@ -3026,11 +3059,14 @@ paths: secret: "whsec_abc123def456" previous_secret: "whsec_prev789xyz012" previous_secret_invalid_at: "2024-02-16T10:00:00Z" - "400": - description: Invalid request body or configuration. + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant or Destination not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationError" + "500": + $ref: "#/components/responses/InternalServerError" delete: tags: [Destinations] summary: Delete Destination @@ -3047,9 +3083,12 @@ paths: SuccessExample: value: success: true + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant or Destination not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /tenants/{tenant_id}/destinations/{destination_id}/enable: parameters: @@ -3092,9 +3131,12 @@ paths: secret: "whsec_abc123def456" previous_secret: "whsec_prev789xyz012" previous_secret_invalid_at: "2024-02-16T10:00:00Z" + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant or Destination not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /tenants/{tenant_id}/destinations/{destination_id}/disable: parameters: @@ -3137,9 +3179,12 @@ paths: secret: "whsec_abc123def456" previous_secret: "whsec_prev789xyz012" previous_secret_invalid_at: "2024-02-16T10:00:00Z" + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant or Destination not found. - # Add other error responses + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" # Destination-scoped Attempts /tenants/{tenant_id}/destinations/{destination_id}/attempts: @@ -3281,14 +3326,12 @@ paths: limit: 100 next: "MTcwNDA2NzIwMA==" prev: null + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant or Destination not found. - "422": - description: Validation error (invalid query parameters). - content: - application/json: - schema: - $ref: "#/components/schemas/APIErrorResponse" + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /tenants/{tenant_id}/destinations/{destination_id}/attempts/{attempt_id}: parameters: @@ -3347,8 +3390,12 @@ paths: attempt_number: 1 event_id: "evt_123" destination_id: "des_456" + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Tenant, Destination, or Attempt not found. + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" # Publish (Admin Only) /publish: @@ -3372,15 +3419,14 @@ paths: application/json: schema: $ref: "#/components/schemas/PublishResponse" - "400": - description: Invalid request body. "401": - description: Unauthorized (Admin API Key missing or invalid). + $ref: "#/components/responses/Unauthorized" "409": description: Conflict. An event with the provided `id` already exists. "422": - description: Unprocessable Entity. The event topic was either required or was invalid. - # Add other error responses + description: The event topic was either required or was invalid. + "500": + $ref: "#/components/responses/InternalServerError" # Retry /retry: @@ -3415,14 +3461,12 @@ paths: Bad request. This can happen when: - The destination is disabled - The destination does not match the event's topic + "401": + $ref: "#/components/responses/Unauthorized" "404": - description: Event or destination not found. - "422": - description: Validation error (missing required fields). - content: - application/json: - schema: - $ref: "#/components/schemas/APIErrorResponse" + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /destination-types: get: @@ -3510,7 +3554,9 @@ paths: }, ] "401": - description: Unauthorized (Tenant JWT missing or invalid). + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" /destination-types/{type}: parameters: @@ -3519,7 +3565,7 @@ paths: required: true schema: type: string - enum: [webhook, aws_sqs, rabbitmq, hookdeck, aws_kinesis, aws_s3] + enum: [webhook, aws_sqs, rabbitmq, hookdeck, aws_kinesis, azure_servicebus, aws_s3, gcp_pubsub] description: The type of the destination. get: tags: [Schemas] @@ -3561,9 +3607,11 @@ paths: }, ] "401": - description: Unauthorized (Tenant JWT missing or invalid). + $ref: "#/components/responses/Unauthorized" "404": - description: Destination type not found. + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /topics: get: @@ -3590,4 +3638,6 @@ paths: "inventory.updated", ] "401": - description: Unauthorized (Tenant JWT missing or invalid). + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" diff --git a/internal/apirouter/destination_handlers.go b/internal/apirouter/destination_handlers.go index fdb6b55f..e6a4287f 100644 --- a/internal/apirouter/destination_handlers.go +++ b/internal/apirouter/destination_handlers.go @@ -146,9 +146,9 @@ func (h *DestinationHandlers) Update(c *gin.Context) { } } shouldRevalidate := false - if input.Type != "" { - shouldRevalidate = true - updatedDestination.Type = input.Type + if input.Type != "" && input.Type != originalDestination.Type { + AbortWithValidationError(c, errors.New("type cannot be updated")) + return } if input.Config != nil { shouldRevalidate = true diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go index 932e36a3..061e7874 100644 --- a/internal/apirouter/destination_handlers_test.go +++ b/internal/apirouter/destination_handlers_test.go @@ -294,6 +294,37 @@ func TestAPI_Destinations(t *testing.T) { require.Equal(t, http.StatusNotFound, resp.Code) }) + + t.Run("changing type returns 422", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"user.created"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "type": "aws_sqs", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("sending same type is allowed", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"user.created"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "type": "webhook", + "topics": []string{"user.deleted"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + }) }) t.Run("Delete", func(t *testing.T) { diff --git a/internal/apirouter/publish_handlers.go b/internal/apirouter/publish_handlers.go index 4cc9df1c..adc1e300 100644 --- a/internal/apirouter/publish_handlers.go +++ b/internal/apirouter/publish_handlers.go @@ -74,7 +74,7 @@ type PublishedEvent struct { EligibleForRetry *bool `json:"eligible_for_retry"` Time time.Time `json:"time"` Metadata map[string]string `json:"metadata"` - Data map[string]interface{} `json:"data"` + Data map[string]interface{} `json:"data" binding:"required"` } func (p *PublishedEvent) toEvent() models.Event { diff --git a/internal/apirouter/publish_handlers_test.go b/internal/apirouter/publish_handlers_test.go index b455591a..e35cc019 100644 --- a/internal/apirouter/publish_handlers_test.go +++ b/internal/apirouter/publish_handlers_test.go @@ -22,6 +22,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(req) @@ -34,6 +35,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withJWT(req, "t1")) @@ -45,6 +47,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -67,6 +70,18 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "topic": "user.created", + "data": map[string]any{"key": "value"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("missing data returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", }) resp := h.do(h.withAPIKey(req)) @@ -91,6 +106,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -103,6 +119,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -121,6 +138,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -139,6 +157,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -153,6 +172,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -171,6 +191,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -189,6 +210,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -203,6 +225,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "id": "custom-id", "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -217,6 +240,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) after := time.Now() @@ -235,6 +259,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", "time": explicit.Format(time.RFC3339), + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -248,6 +273,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -262,6 +288,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", "eligible_for_retry": false, + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -275,6 +302,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "my-tenant", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -289,6 +317,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", "topic": "user.created", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -303,6 +332,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", "destination_id": "dest-1", + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) @@ -317,6 +347,7 @@ func TestAPI_Publish(t *testing.T) { req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ "tenant_id": "t1", "metadata": map[string]string{"env": "prod"}, + "data": map[string]any{"key": "value"}, }) resp := h.do(h.withAPIKey(req)) diff --git a/internal/apirouter/tenant_handlers.go b/internal/apirouter/tenant_handlers.go index 907ab564..a34684ce 100644 --- a/internal/apirouter/tenant_handlers.go +++ b/internal/apirouter/tenant_handlers.go @@ -130,6 +130,10 @@ func (h *TenantHandlers) List(c *gin.Context) { AbortWithError(c, http.StatusBadRequest, NewErrBadRequest(errors.New("invalid limit: must be an integer"))) return } + if limit < 1 || limit > 100 { + AbortWithError(c, http.StatusBadRequest, NewErrBadRequest(errors.New("invalid limit: must be between 1 and 100"))) + return + } req.Limit = limit }