diff --git a/cmd/e2e/api_test.go b/cmd/e2e/api_test.go index 884370d0..cde6f100 100644 --- a/cmd/e2e/api_test.go +++ b/cmd/e2e/api_test.go @@ -1,10 +1,13 @@ package e2e_test import ( + "bytes" + "fmt" "net/http" "github.com/hookdeck/outpost/cmd/e2e/httpclient" "github.com/hookdeck/outpost/internal/idgen" + "github.com/stretchr/testify/require" ) func (suite *basicSuite) TestHealthzAPI() { @@ -267,10 +270,215 @@ func (suite *basicSuite) TestTenantsAPI() { }, }, }, + // Metadata tests + { + Name: "PUT /:tenantID with metadata", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPUT, + Path: "/" + tenantID, + Body: map[string]interface{}{ + "metadata": map[string]interface{}{ + "environment": "production", + "team": "platform", + "region": "us-east-1", + }, + }, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusOK, + Body: map[string]interface{}{ + "id": tenantID, + "metadata": map[string]interface{}{ + "environment": "production", + "team": "platform", + "region": "us-east-1", + }, + }, + }, + }, + }, + { + Name: "GET /:tenantID retrieves metadata", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodGET, + Path: "/" + tenantID, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusOK, + Body: map[string]interface{}{ + "id": tenantID, + "metadata": map[string]interface{}{ + "environment": "production", + "team": "platform", + "region": "us-east-1", + }, + }, + }, + }, + }, + { + Name: "PUT /:tenantID replaces metadata (full replacement)", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPUT, + Path: "/" + tenantID, + Body: map[string]interface{}{ + "metadata": map[string]interface{}{ + "team": "engineering", + "owner": "alice", + }, + }, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusOK, + Body: map[string]interface{}{ + "id": tenantID, + "metadata": map[string]interface{}{ + "team": "engineering", + "owner": "alice", + // Note: environment and region are gone (full replacement) + }, + }, + }, + }, + }, + { + Name: "GET /:tenantID verifies metadata was replaced", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodGET, + Path: "/" + tenantID, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusOK, + Body: map[string]interface{}{ + "id": tenantID, + "metadata": map[string]interface{}{ + "team": "engineering", + "owner": "alice", + }, + }, + }, + }, + }, + { + Name: "PUT /:tenantID without metadata clears it", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPUT, + Path: "/" + tenantID, + Body: map[string]interface{}{}, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusOK, + }, + }, + }, + { + Name: "GET /:tenantID verifies metadata is nil", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodGET, + Path: "/" + tenantID, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusOK, + Body: map[string]interface{}{ + "id": tenantID, + "destinations_count": 0, + "topics": []string{}, + // metadata field should not be present (omitempty) + }, + }, + }, + }, + { + Name: "Create new tenant with metadata", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPUT, + Path: "/" + idgen.String(), + Body: map[string]interface{}{ + "metadata": map[string]interface{}{ + "stage": "development", + }, + }, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusCreated, + Body: map[string]interface{}{ + "metadata": map[string]interface{}{ + "stage": "development", + }, + }, + }, + }, + }, + { + Name: "PUT /:tenantID with metadata value auto-converted (number to string)", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPUT, + Path: "/" + idgen.String(), + Body: map[string]interface{}{ + "metadata": map[string]interface{}{ + "count": 42, + "enabled": true, + "ratio": 3.14, + }, + }, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusCreated, + Body: map[string]interface{}{ + "metadata": map[string]interface{}{ + "count": "42", + "enabled": "true", + "ratio": "3.14", + }, + }, + }, + }, + }, + { + Name: "PUT /:tenantID with empty body (no metadata)", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPUT, + Path: "/" + idgen.String(), + Body: map[string]interface{}{}, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusCreated, + }, + }, + }, } suite.RunAPITests(suite.T(), tests) } +func (suite *basicSuite) TestTenantAPIInvalidJSON() { + t := suite.T() + tenantID := idgen.String() + baseURL := fmt.Sprintf("http://localhost:%d/api/v1", suite.config.APIPort) + + // Create tenant with malformed JSON (send raw bytes) + jsonBody := []byte(`{"metadata": invalid json}`) + req, err := http.NewRequest(httpclient.MethodPUT, baseURL+"/"+tenantID, bytes.NewReader(jsonBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+suite.config.APIKey) + + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "Malformed JSON should return 400") +} + func (suite *basicSuite) TestDestinationsAPI() { tenantID := idgen.String() sampleDestinationID := idgen.Destination() @@ -796,6 +1004,37 @@ func (suite *basicSuite) TestDestinationsAPI() { Validate: makeDestinationListValidator(2), }, }, + { + Name: "POST /:tenantID/destinations with metadata auto-conversion", + Request: suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPOST, + Path: "/" + tenantID + "/destinations", + Body: map[string]interface{}{ + "type": "webhook", + "topics": "*", + "config": map[string]interface{}{ + "url": "http://host.docker.internal:4444", + }, + "metadata": map[string]interface{}{ + "priority": 10, + "enabled": true, + "version": 1.5, + }, + }, + }), + Expected: APITestExpectation{ + Match: &httpclient.Response{ + StatusCode: http.StatusCreated, + Body: map[string]interface{}{ + "metadata": map[string]interface{}{ + "priority": "10", + "enabled": "true", + "version": "1.5", + }, + }, + }, + }, + }, } suite.RunAPITests(suite.T(), tests) } diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 673dd600..5c448ced 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -43,11 +43,26 @@ components: type: string description: List of subscribed topics across all destinations for this tenant. example: ["user.created", "user.deleted"] + metadata: + type: object + additionalProperties: + type: string + nullable: true + description: Arbitrary key-value pairs for storing contextual information about the tenant. created_at: type: string format: date-time description: ISO Date when the tenant was created. example: "2024-01-01T00:00:00Z" + TenantUpsert: + type: object + properties: + metadata: + type: object + additionalProperties: + type: string + nullable: true + description: Optional metadata to store with the tenant. PortalRedirect: type: object properties: @@ -1643,6 +1658,13 @@ paths: summary: Create or Update Tenant description: Idempotently creates or updates a tenant. Required before associating destinations. operationId: upsertTenant + requestBody: + description: Optional tenant metadata + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/TenantUpsert" responses: "200": description: Tenant updated details. @@ -1650,26 +1672,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Tenant" - examples: - TenantExample: - value: - id: "tenant_123" - destinations_count: 5 - topics: ["user.created", "user.deleted"] - created_at: "2024-01-01T00:00:00Z" "201": description: Tenant created details. content: application/json: schema: $ref: "#/components/schemas/Tenant" - examples: - TenantExample: - value: - id: "tenant_123" - destinations_count: 5 - topics: ["user.created", "user.deleted"] - created_at: "2024-01-01T00:00:00Z" # Add error responses get: tags: [Tenants] @@ -1683,13 +1691,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Tenant" - examples: - TenantExample: - value: - id: "tenant_123" - destinations_count: 5 - topics: ["user.created", "user.deleted"] - created_at: "2024-01-01T00:00:00Z" "404": description: Tenant not found. # Add other error responses diff --git a/internal/models/entity.go b/internal/models/entity.go index 2821359f..edbd5d34 100644 --- a/internal/models/entity.go +++ b/internal/models/entity.go @@ -150,8 +150,23 @@ func (s *entityStoreImpl) UpsertTenant(ctx context.Context, tenant Tenant) error return err } - // Set tenant data - return s.redisClient.HSet(ctx, key, tenant).Err() + // Set tenant data (basic fields) + if err := s.redisClient.HSet(ctx, key, tenant).Err(); err != nil { + return err + } + + // Store metadata if present, otherwise delete field + if tenant.Metadata != nil { + if err := s.redisClient.HSet(ctx, key, "metadata", &tenant.Metadata).Err(); err != nil { + return err + } + } else { + if err := s.redisClient.HDel(ctx, key, "metadata").Err(); err != nil && err != redis.Nil { + return err + } + } + + return nil } func (s *entityStoreImpl) DeleteTenant(ctx context.Context, tenantID string) error { diff --git a/internal/models/entitysuite_test.go b/internal/models/entitysuite_test.go index 6486d5d8..5f034efb 100644 --- a/internal/models/entitysuite_test.go +++ b/internal/models/entitysuite_test.go @@ -117,6 +117,47 @@ func (s *EntityTestSuite) TestTenantCRUD() { assert.Equal(s.T(), input.ID, actual.ID) assert.True(s.T(), input.CreatedAt.Equal(actual.CreatedAt)) }) + + t.Run("upserts with metadata", func(t *testing.T) { + input.Metadata = map[string]string{ + "environment": "production", + "team": "platform", + } + + err := s.entityStore.UpsertTenant(s.ctx, input) + require.NoError(s.T(), err) + + retrieved, err := s.entityStore.RetrieveTenant(s.ctx, input.ID) + require.NoError(s.T(), err) + assert.Equal(s.T(), input.ID, retrieved.ID) + assert.Equal(s.T(), input.Metadata, retrieved.Metadata) + }) + + t.Run("updates metadata", func(t *testing.T) { + input.Metadata = map[string]string{ + "environment": "staging", + "team": "engineering", + "region": "us-west-2", + } + + err := s.entityStore.UpsertTenant(s.ctx, input) + require.NoError(s.T(), err) + + retrieved, err := s.entityStore.RetrieveTenant(s.ctx, input.ID) + require.NoError(s.T(), err) + assert.Equal(s.T(), input.Metadata, retrieved.Metadata) + }) + + t.Run("handles nil metadata", func(t *testing.T) { + input.Metadata = nil + + err := s.entityStore.UpsertTenant(s.ctx, input) + require.NoError(s.T(), err) + + retrieved, err := s.entityStore.RetrieveTenant(s.ctx, input.ID) + require.NoError(s.T(), err) + assert.Nil(s.T(), retrieved.Metadata) + }) } func (s *EntityTestSuite) TestDestinationCRUD() { diff --git a/internal/models/event.go b/internal/models/event.go index e0f545f0..27b99311 100644 --- a/internal/models/event.go +++ b/internal/models/event.go @@ -30,30 +30,7 @@ func (d *Data) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, d) } -type Metadata map[string]string - -var _ fmt.Stringer = &Metadata{} -var _ encoding.BinaryMarshaler = &Metadata{} -var _ encoding.BinaryUnmarshaler = &Metadata{} - -func (m *Metadata) String() string { - metadata, err := json.Marshal(m) - if err != nil { - return "" - } - return string(metadata) -} - -func (m *Metadata) MarshalBinary() ([]byte, error) { - return json.Marshal(m) -} - -func (m *Metadata) UnmarshalBinary(metadata []byte) error { - if string(metadata) == "" { - return nil - } - return json.Unmarshal(metadata, m) -} +type Metadata = MapStringString type EventTelemetry struct { TraceID string diff --git a/internal/models/tenant.go b/internal/models/tenant.go index e6b338d0..dce9a2f8 100644 --- a/internal/models/tenant.go +++ b/internal/models/tenant.go @@ -9,6 +9,7 @@ type Tenant struct { ID string `json:"id" redis:"id"` DestinationsCount int `json:"destinations_count" redis:"-"` Topics []string `json:"topics" redis:"-"` + Metadata Metadata `json:"metadata,omitempty" redis:"-"` CreatedAt time.Time `json:"created_at" redis:"created_at"` } @@ -28,5 +29,14 @@ func (t *Tenant) parseRedisHash(hash map[string]string) error { return err } t.CreatedAt = createdAt + + // Deserialize metadata if present + if metadataStr, exists := hash["metadata"]; exists && metadataStr != "" { + err = t.Metadata.UnmarshalBinary([]byte(metadataStr)) + if err != nil { + return fmt.Errorf("invalid metadata: %w", err) + } + } + return nil } diff --git a/internal/models/tenant_test.go b/internal/models/tenant_test.go new file mode 100644 index 00000000..926ebdee --- /dev/null +++ b/internal/models/tenant_test.go @@ -0,0 +1,81 @@ +package models_test + +import ( + "encoding/json" + "testing" + + "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/util/testutil" + "github.com/stretchr/testify/assert" +) + +func TestTenant_JSONMarshalWithMetadata(t *testing.T) { + t.Parallel() + + tenant := testutil.TenantFactory.Any( + testutil.TenantFactory.WithID("tenant_123"), + testutil.TenantFactory.WithMetadata(map[string]string{ + "environment": "production", + "team": "platform", + "region": "us-east-1", + }), + ) + + // Marshal to JSON + jsonBytes, err := json.Marshal(tenant) + assert.NoError(t, err) + + // Unmarshal back + var unmarshaled models.Tenant + err = json.Unmarshal(jsonBytes, &unmarshaled) + assert.NoError(t, err) + + // Verify metadata is preserved + assert.Equal(t, tenant.Metadata, unmarshaled.Metadata) + assert.Equal(t, "production", unmarshaled.Metadata["environment"]) + assert.Equal(t, "platform", unmarshaled.Metadata["team"]) + assert.Equal(t, "us-east-1", unmarshaled.Metadata["region"]) + + // Verify other fields still work + assert.Equal(t, tenant.ID, unmarshaled.ID) +} + +func TestTenant_JSONMarshalWithoutMetadata(t *testing.T) { + t.Parallel() + + tenant := testutil.TenantFactory.Any( + testutil.TenantFactory.WithID("tenant_123"), + ) + + // Marshal to JSON + jsonBytes, err := json.Marshal(tenant) + assert.NoError(t, err) + + // Unmarshal back + var unmarshaled models.Tenant + err = json.Unmarshal(jsonBytes, &unmarshaled) + assert.NoError(t, err) + + // Verify metadata is nil when not provided + assert.Nil(t, unmarshaled.Metadata) +} + +func TestTenant_JSONUnmarshalEmptyMetadata(t *testing.T) { + t.Parallel() + + jsonData := `{ + "id": "tenant_123", + "destinations_count": 0, + "topics": [], + "metadata": {}, + "created_at": "2024-01-01T00:00:00Z" + }` + + var tenant models.Tenant + err := json.Unmarshal([]byte(jsonData), &tenant) + assert.NoError(t, err) + + // Empty maps should be preserved as empty, not nil + assert.NotNil(t, tenant.Metadata) + assert.Empty(t, tenant.Metadata) +} diff --git a/internal/services/api/tenant_handlers.go b/internal/services/api/tenant_handlers.go index 115aaeeb..088b7aef 100644 --- a/internal/services/api/tenant_handlers.go +++ b/internal/services/api/tenant_handlers.go @@ -37,23 +37,41 @@ func (h *TenantHandlers) Upsert(c *gin.Context) { return } + // Parse request body for metadata + var input struct { + Metadata models.Metadata `json:"metadata,omitempty"` + } + // Only attempt to parse JSON if there's a request body + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&input); err != nil { + AbortWithValidationError(c, err) + return + } + } + // Check existing tenant. - tenant, err := h.entityStore.RetrieveTenant(c.Request.Context(), tenantID) + existingTenant, err := h.entityStore.RetrieveTenant(c.Request.Context(), tenantID) if err != nil && err != models.ErrTenantDeleted { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return } - // If tenant already exists, return. - if tenant != nil { - c.JSON(http.StatusOK, tenant) + // If tenant already exists, update it (PUT replaces metadata) + if existingTenant != nil { + existingTenant.Metadata = input.Metadata + if err := h.entityStore.UpsertTenant(c.Request.Context(), *existingTenant); err != nil { + AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) + return + } + c.JSON(http.StatusOK, existingTenant) return } // Create new tenant. - tenant = &models.Tenant{ + tenant := &models.Tenant{ ID: tenantID, Topics: []string{}, + Metadata: input.Metadata, CreatedAt: time.Now(), } if err := h.entityStore.UpsertTenant(c.Request.Context(), *tenant); err != nil { diff --git a/internal/util/testutil/tenant.go b/internal/util/testutil/tenant.go new file mode 100644 index 00000000..76ca89d0 --- /dev/null +++ b/internal/util/testutil/tenant.go @@ -0,0 +1,49 @@ +package testutil + +import ( + "time" + + "github.com/hookdeck/outpost/internal/idgen" + "github.com/hookdeck/outpost/internal/models" +) + +// ============================== Mock Tenant ============================== + +var TenantFactory = &mockTenantFactory{} + +type mockTenantFactory struct { +} + +func (f *mockTenantFactory) Any(opts ...func(*models.Tenant)) models.Tenant { + tenant := models.Tenant{ + ID: idgen.String(), + DestinationsCount: 0, + Topics: []string{}, + Metadata: nil, + CreatedAt: time.Now(), + } + + for _, opt := range opts { + opt(&tenant) + } + + return tenant +} + +func (f *mockTenantFactory) WithID(id string) func(*models.Tenant) { + return func(tenant *models.Tenant) { + tenant.ID = id + } +} + +func (f *mockTenantFactory) WithMetadata(metadata map[string]string) func(*models.Tenant) { + return func(tenant *models.Tenant) { + tenant.Metadata = metadata + } +} + +func (f *mockTenantFactory) WithCreatedAt(createdAt time.Time) func(*models.Tenant) { + return func(tenant *models.Tenant) { + tenant.CreatedAt = createdAt + } +}