Skip to content

Commit 41a66ed

Browse files
authored
feat: support tenant.metadata (#550)
* test: add tenant metadata tests * feat: implement redis persistence for tenant metadata * feat: add metadata support to tenant API with E2E tests * chore: add tenant.metadata to openapi.yaml * fix: metadata json marshal issue * chore: improve api validation
1 parent 99b2494 commit 41a66ed

File tree

9 files changed

+483
-52
lines changed

9 files changed

+483
-52
lines changed

cmd/e2e/api_test.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package e2e_test
22

33
import (
4+
"bytes"
5+
"fmt"
46
"net/http"
57

68
"github.com/hookdeck/outpost/cmd/e2e/httpclient"
79
"github.com/hookdeck/outpost/internal/idgen"
10+
"github.com/stretchr/testify/require"
811
)
912

1013
func (suite *basicSuite) TestHealthzAPI() {
@@ -267,10 +270,215 @@ func (suite *basicSuite) TestTenantsAPI() {
267270
},
268271
},
269272
},
273+
// Metadata tests
274+
{
275+
Name: "PUT /:tenantID with metadata",
276+
Request: suite.AuthRequest(httpclient.Request{
277+
Method: httpclient.MethodPUT,
278+
Path: "/" + tenantID,
279+
Body: map[string]interface{}{
280+
"metadata": map[string]interface{}{
281+
"environment": "production",
282+
"team": "platform",
283+
"region": "us-east-1",
284+
},
285+
},
286+
}),
287+
Expected: APITestExpectation{
288+
Match: &httpclient.Response{
289+
StatusCode: http.StatusOK,
290+
Body: map[string]interface{}{
291+
"id": tenantID,
292+
"metadata": map[string]interface{}{
293+
"environment": "production",
294+
"team": "platform",
295+
"region": "us-east-1",
296+
},
297+
},
298+
},
299+
},
300+
},
301+
{
302+
Name: "GET /:tenantID retrieves metadata",
303+
Request: suite.AuthRequest(httpclient.Request{
304+
Method: httpclient.MethodGET,
305+
Path: "/" + tenantID,
306+
}),
307+
Expected: APITestExpectation{
308+
Match: &httpclient.Response{
309+
StatusCode: http.StatusOK,
310+
Body: map[string]interface{}{
311+
"id": tenantID,
312+
"metadata": map[string]interface{}{
313+
"environment": "production",
314+
"team": "platform",
315+
"region": "us-east-1",
316+
},
317+
},
318+
},
319+
},
320+
},
321+
{
322+
Name: "PUT /:tenantID replaces metadata (full replacement)",
323+
Request: suite.AuthRequest(httpclient.Request{
324+
Method: httpclient.MethodPUT,
325+
Path: "/" + tenantID,
326+
Body: map[string]interface{}{
327+
"metadata": map[string]interface{}{
328+
"team": "engineering",
329+
"owner": "alice",
330+
},
331+
},
332+
}),
333+
Expected: APITestExpectation{
334+
Match: &httpclient.Response{
335+
StatusCode: http.StatusOK,
336+
Body: map[string]interface{}{
337+
"id": tenantID,
338+
"metadata": map[string]interface{}{
339+
"team": "engineering",
340+
"owner": "alice",
341+
// Note: environment and region are gone (full replacement)
342+
},
343+
},
344+
},
345+
},
346+
},
347+
{
348+
Name: "GET /:tenantID verifies metadata was replaced",
349+
Request: suite.AuthRequest(httpclient.Request{
350+
Method: httpclient.MethodGET,
351+
Path: "/" + tenantID,
352+
}),
353+
Expected: APITestExpectation{
354+
Match: &httpclient.Response{
355+
StatusCode: http.StatusOK,
356+
Body: map[string]interface{}{
357+
"id": tenantID,
358+
"metadata": map[string]interface{}{
359+
"team": "engineering",
360+
"owner": "alice",
361+
},
362+
},
363+
},
364+
},
365+
},
366+
{
367+
Name: "PUT /:tenantID without metadata clears it",
368+
Request: suite.AuthRequest(httpclient.Request{
369+
Method: httpclient.MethodPUT,
370+
Path: "/" + tenantID,
371+
Body: map[string]interface{}{},
372+
}),
373+
Expected: APITestExpectation{
374+
Match: &httpclient.Response{
375+
StatusCode: http.StatusOK,
376+
},
377+
},
378+
},
379+
{
380+
Name: "GET /:tenantID verifies metadata is nil",
381+
Request: suite.AuthRequest(httpclient.Request{
382+
Method: httpclient.MethodGET,
383+
Path: "/" + tenantID,
384+
}),
385+
Expected: APITestExpectation{
386+
Match: &httpclient.Response{
387+
StatusCode: http.StatusOK,
388+
Body: map[string]interface{}{
389+
"id": tenantID,
390+
"destinations_count": 0,
391+
"topics": []string{},
392+
// metadata field should not be present (omitempty)
393+
},
394+
},
395+
},
396+
},
397+
{
398+
Name: "Create new tenant with metadata",
399+
Request: suite.AuthRequest(httpclient.Request{
400+
Method: httpclient.MethodPUT,
401+
Path: "/" + idgen.String(),
402+
Body: map[string]interface{}{
403+
"metadata": map[string]interface{}{
404+
"stage": "development",
405+
},
406+
},
407+
}),
408+
Expected: APITestExpectation{
409+
Match: &httpclient.Response{
410+
StatusCode: http.StatusCreated,
411+
Body: map[string]interface{}{
412+
"metadata": map[string]interface{}{
413+
"stage": "development",
414+
},
415+
},
416+
},
417+
},
418+
},
419+
{
420+
Name: "PUT /:tenantID with metadata value auto-converted (number to string)",
421+
Request: suite.AuthRequest(httpclient.Request{
422+
Method: httpclient.MethodPUT,
423+
Path: "/" + idgen.String(),
424+
Body: map[string]interface{}{
425+
"metadata": map[string]interface{}{
426+
"count": 42,
427+
"enabled": true,
428+
"ratio": 3.14,
429+
},
430+
},
431+
}),
432+
Expected: APITestExpectation{
433+
Match: &httpclient.Response{
434+
StatusCode: http.StatusCreated,
435+
Body: map[string]interface{}{
436+
"metadata": map[string]interface{}{
437+
"count": "42",
438+
"enabled": "true",
439+
"ratio": "3.14",
440+
},
441+
},
442+
},
443+
},
444+
},
445+
{
446+
Name: "PUT /:tenantID with empty body (no metadata)",
447+
Request: suite.AuthRequest(httpclient.Request{
448+
Method: httpclient.MethodPUT,
449+
Path: "/" + idgen.String(),
450+
Body: map[string]interface{}{},
451+
}),
452+
Expected: APITestExpectation{
453+
Match: &httpclient.Response{
454+
StatusCode: http.StatusCreated,
455+
},
456+
},
457+
},
270458
}
271459
suite.RunAPITests(suite.T(), tests)
272460
}
273461

462+
func (suite *basicSuite) TestTenantAPIInvalidJSON() {
463+
t := suite.T()
464+
tenantID := idgen.String()
465+
baseURL := fmt.Sprintf("http://localhost:%d/api/v1", suite.config.APIPort)
466+
467+
// Create tenant with malformed JSON (send raw bytes)
468+
jsonBody := []byte(`{"metadata": invalid json}`)
469+
req, err := http.NewRequest(httpclient.MethodPUT, baseURL+"/"+tenantID, bytes.NewReader(jsonBody))
470+
require.NoError(t, err)
471+
req.Header.Set("Content-Type", "application/json")
472+
req.Header.Set("Authorization", "Bearer "+suite.config.APIKey)
473+
474+
httpClient := &http.Client{}
475+
resp, err := httpClient.Do(req)
476+
require.NoError(t, err)
477+
defer resp.Body.Close()
478+
479+
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "Malformed JSON should return 400")
480+
}
481+
274482
func (suite *basicSuite) TestDestinationsAPI() {
275483
tenantID := idgen.String()
276484
sampleDestinationID := idgen.Destination()
@@ -796,6 +1004,37 @@ func (suite *basicSuite) TestDestinationsAPI() {
7961004
Validate: makeDestinationListValidator(2),
7971005
},
7981006
},
1007+
{
1008+
Name: "POST /:tenantID/destinations with metadata auto-conversion",
1009+
Request: suite.AuthRequest(httpclient.Request{
1010+
Method: httpclient.MethodPOST,
1011+
Path: "/" + tenantID + "/destinations",
1012+
Body: map[string]interface{}{
1013+
"type": "webhook",
1014+
"topics": "*",
1015+
"config": map[string]interface{}{
1016+
"url": "http://host.docker.internal:4444",
1017+
},
1018+
"metadata": map[string]interface{}{
1019+
"priority": 10,
1020+
"enabled": true,
1021+
"version": 1.5,
1022+
},
1023+
},
1024+
}),
1025+
Expected: APITestExpectation{
1026+
Match: &httpclient.Response{
1027+
StatusCode: http.StatusCreated,
1028+
Body: map[string]interface{}{
1029+
"metadata": map[string]interface{}{
1030+
"priority": "10",
1031+
"enabled": "true",
1032+
"version": "1.5",
1033+
},
1034+
},
1035+
},
1036+
},
1037+
},
7991038
}
8001039
suite.RunAPITests(suite.T(), tests)
8011040
}

docs/apis/openapi.yaml

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,26 @@ components:
4343
type: string
4444
description: List of subscribed topics across all destinations for this tenant.
4545
example: ["user.created", "user.deleted"]
46+
metadata:
47+
type: object
48+
additionalProperties:
49+
type: string
50+
nullable: true
51+
description: Arbitrary key-value pairs for storing contextual information about the tenant.
4652
created_at:
4753
type: string
4854
format: date-time
4955
description: ISO Date when the tenant was created.
5056
example: "2024-01-01T00:00:00Z"
57+
TenantUpsert:
58+
type: object
59+
properties:
60+
metadata:
61+
type: object
62+
additionalProperties:
63+
type: string
64+
nullable: true
65+
description: Optional metadata to store with the tenant.
5166
PortalRedirect:
5267
type: object
5368
properties:
@@ -1643,33 +1658,26 @@ paths:
16431658
summary: Create or Update Tenant
16441659
description: Idempotently creates or updates a tenant. Required before associating destinations.
16451660
operationId: upsertTenant
1661+
requestBody:
1662+
description: Optional tenant metadata
1663+
required: false
1664+
content:
1665+
application/json:
1666+
schema:
1667+
$ref: "#/components/schemas/TenantUpsert"
16461668
responses:
16471669
"200":
16481670
description: Tenant updated details.
16491671
content:
16501672
application/json:
16511673
schema:
16521674
$ref: "#/components/schemas/Tenant"
1653-
examples:
1654-
TenantExample:
1655-
value:
1656-
id: "tenant_123"
1657-
destinations_count: 5
1658-
topics: ["user.created", "user.deleted"]
1659-
created_at: "2024-01-01T00:00:00Z"
16601675
"201":
16611676
description: Tenant created details.
16621677
content:
16631678
application/json:
16641679
schema:
16651680
$ref: "#/components/schemas/Tenant"
1666-
examples:
1667-
TenantExample:
1668-
value:
1669-
id: "tenant_123"
1670-
destinations_count: 5
1671-
topics: ["user.created", "user.deleted"]
1672-
created_at: "2024-01-01T00:00:00Z"
16731681
# Add error responses
16741682
get:
16751683
tags: [Tenants]
@@ -1683,13 +1691,6 @@ paths:
16831691
application/json:
16841692
schema:
16851693
$ref: "#/components/schemas/Tenant"
1686-
examples:
1687-
TenantExample:
1688-
value:
1689-
id: "tenant_123"
1690-
destinations_count: 5
1691-
topics: ["user.created", "user.deleted"]
1692-
created_at: "2024-01-01T00:00:00Z"
16931694
"404":
16941695
description: Tenant not found.
16951696
# Add other error responses

internal/models/entity.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,23 @@ func (s *entityStoreImpl) UpsertTenant(ctx context.Context, tenant Tenant) error
150150
return err
151151
}
152152

153-
// Set tenant data
154-
return s.redisClient.HSet(ctx, key, tenant).Err()
153+
// Set tenant data (basic fields)
154+
if err := s.redisClient.HSet(ctx, key, tenant).Err(); err != nil {
155+
return err
156+
}
157+
158+
// Store metadata if present, otherwise delete field
159+
if tenant.Metadata != nil {
160+
if err := s.redisClient.HSet(ctx, key, "metadata", &tenant.Metadata).Err(); err != nil {
161+
return err
162+
}
163+
} else {
164+
if err := s.redisClient.HDel(ctx, key, "metadata").Err(); err != nil && err != redis.Nil {
165+
return err
166+
}
167+
}
168+
169+
return nil
155170
}
156171

157172
func (s *entityStoreImpl) DeleteTenant(ctx context.Context, tenantID string) error {

0 commit comments

Comments
 (0)