Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit41a66ed

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 parent99b2494 commit41a66ed

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+
deferresp.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
returnerr
151151
}
152152

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

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

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp