Skip to content

Commit 6d63f2b

Browse files
authored
feat(tokenization): add individual key retrieval API by name (#116)
* chore(conductor): Add new track 'Add Individual Key Retrieval Endpoint for tokenization module (by name)' * feat(tokenization): Add GetByName to TokenizationKeyUseCase interface * conductor(plan): Mark task 'Add GetByName to TokenizationKeyUseCase interface' as complete * feat(tokenization): Implement GetByName in TokenizationKeyUseCase * conductor(plan): Mark task 'Implement GetByName in tokenizationKeyUseCase struct' as complete * conductor(checkpoint): Checkpoint end of Phase 1 * conductor(plan): Mark phase 'Phase 1: Domain and Use Case Layer' as complete * feat(tokenization): Add GetByNameHandler to TokenizationKeyHandler * conductor(plan): Mark task 'Add GetByNameHandler to TokenizationKeyHandler' as complete * feat(tokenization): Register GetByName route for tokenization keys * conductor(plan): Mark task 'Register the new route GET /v1/tokenization/keys/:name' as complete * conductor(checkpoint): Checkpoint end of Phase 2 * conductor(plan): Mark phase 'Phase 2: HTTP Layer' as complete * test(tokenization): Add GetByName integration test * conductor(plan): Mark task 'Update integration tests' as complete * docs(tokenization): Document GetByName endpoint for tokenization keys * conductor(plan): Mark task 'Update project documentation' as complete * docs(openapi): Add GetByName endpoint to OpenAPI specification * conductor(plan): Mark task 'Update OpenAPI specification' as complete * conductor(checkpoint): Checkpoint end of Phase 3 * conductor(plan): Mark phase 'Phase 3: Integration and Documentation' as complete * chore(conductor): Mark track 'Add Individual Key Retrieval Endpoint for tokenization module (by name)' as complete * chore(conductor): Archive track 'Add Individual Key Retrieval Endpoint for tokenization module (by name)' * feat(tokenization): add individual key retrieval API by name Implemented a new endpoint GET /v1/tokenization/keys/:name to retrieve metadata for a single tokenization key. This addition improves API parity with the transit module and facilitates direct key inspection. Key changes: - Added GetByName to TokenizationKeyUseCase and implemented it in tokenizationKeyUseCase and its metrics decorator. - Created GetByNameHandler in TokenizationKeyHandler and registered the corresponding route. - Added comprehensive unit tests for use case and HTTP handler layers. - Added an integration test case to verify the full flow across PostgreSQL and MySQL. - Updated docs/engines/tokenization.md and docs/openapi.yaml with the new endpoint details.
1 parent b5665a6 commit 6d63f2b

18 files changed

Lines changed: 664 additions & 95 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Track tokenization_key_retrieval_20260307 Context
2+
3+
- [Specification](./spec.md)
4+
- [Implementation Plan](./plan.md)
5+
- [Metadata](./metadata.json)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"track_id": "tokenization_key_retrieval_20260307",
3+
"type": "feature",
4+
"status": "new",
5+
"created_at": "2026-03-07T14:45:00Z",
6+
"updated_at": "2026-03-07T14:45:00Z",
7+
"description": "Add Individual Key Retrieval Endpoint for tokenization module (by name)"
8+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Implementation Plan: Tokenization Key Retrieval by Name
2+
3+
This plan outlines the steps to add a new endpoint `GET /v1/tokenization/keys/:name` to retrieve a single tokenization key by its name.
4+
5+
## Phase 1: Domain and Use Case Layer [checkpoint: 7385039]
6+
Add the `GetByName` functionality to the use case layer.
7+
8+
- [x] Task: Add `GetByName` to `TokenizationKeyUseCase` interface. 2fe4a7a
9+
- [x] Task: Implement `GetByName` in `tokenizationKeyUseCase` struct. 3ae4bf7
10+
- [x] Task: Conductor - User Manual Verification 'Phase 1: Domain and Use Case Layer' (Protocol in workflow.md) d906fd8
11+
12+
## Phase 2: HTTP Layer [checkpoint: 1f25be5]
13+
Expose the new functionality through a REST endpoint.
14+
15+
- [x] Task: Add `GetByNameHandler` to `TokenizationKeyHandler`. 7e55e0d
16+
- [x] Task: Register the new route `GET /v1/tokenization/keys/:name`. b8170b6
17+
- [x] Task: Conductor - User Manual Verification 'Phase 2: HTTP Layer' (Protocol in workflow.md) 5047107
18+
19+
## Phase 3: Integration and Documentation [checkpoint: 8ab39bd]
20+
Ensure end-to-end functionality and update documentation.
21+
22+
- [x] Task: Update integration tests in `test/integration/tokenization_flow_test.go`. d506864
23+
- [x] Task: Update project documentation `docs/engines/tokenization.md`. cca1f42
24+
- [x] Task: Update OpenAPI specification `docs/openapi.yaml`. 17dc1eb
25+
- [x] Task: Conductor - User Manual Verification 'Phase 3: Integration and Documentation' (Protocol in workflow.md) 046ad84
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Specification: Tokenization Key Retrieval by Name
2+
3+
## Overview
4+
Currently, the tokenization module supports listing keys and rotating keys, but it lacks a direct endpoint to retrieve a single key's metadata by its name. This track adds a new `GET` endpoint to the tokenization key API to allow efficient lookup of individual keys.
5+
6+
## Functional Requirements
7+
- **Endpoint:** `GET /v1/tokenization/keys/:name`
8+
- **Capability:** `tokenization:read`
9+
- **Input:** `name` (string) as a path parameter.
10+
- **Output:** Returns the latest version of the tokenization key metadata.
11+
- **Status Codes:**
12+
- `200 OK`: Key found. Returns key metadata (ID, Name, Version, FormatType, IsDeterministic, CreatedAt).
13+
- `404 Not Found`: Key with the given name does not exist or has been soft-deleted.
14+
- `401 Unauthorized`: Missing or invalid authentication token.
15+
- `403 Forbidden`: Authenticated client lacks `tokenization:read` capability.
16+
17+
## Non-Functional Requirements
18+
- **Consistency:** The response format must match the existing `TokenizationKeyResponse` DTO.
19+
- **Performance:** Direct lookup by name should be efficient (indexed in the database).
20+
21+
## Acceptance Criteria
22+
- [ ] A new method `GetByName` is added to `TokenizationKeyUseCase`.
23+
- [ ] A new handler `GetByNameHandler` is added to `TokenizationKeyHandler`.
24+
- [ ] The route `GET /v1/tokenization/keys/:name` is registered in the application.
25+
- [ ] The endpoint requires the `tokenization:read` capability.
26+
- [ ] Unit tests for the use case and handler are implemented.
27+
- [ ] Integration tests verify the end-to-end functionality (MySQL and PostgreSQL).
28+
- [ ] Updated integration tests in `test/integration/tokenization_flow_test.go`.
29+
- [ ] Updated documentation: `docs/engines/tokenization.md`
30+
- [ ] Updated OpenAPI documentation: `docs/openapi.yaml`
31+
32+
## Out of Scope
33+
- Retrieving specific versions of a key (only the latest version is returned).
34+
- Retrieving soft-deleted keys.
35+
- Modifying or deleting keys via this endpoint.

conductor/tracks.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@
33
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
44

55
---
6-

docs/engines/tokenization.md

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ All endpoints require `Authorization: Bearer <token>`.
2929
- **Body**: `name`, `format_type` (`uuid`, `numeric`, `luhn-preserving`, `alphanumeric`), `is_deterministic`, `algorithm`.
3030

3131
```bash
32-
curl -X POST http://localhost:8080/v1/tokenization/keys
33-
-H "Authorization: Bearer <token>"
34-
-H "Content-Type: application/json"
32+
curl -X POST http://localhost:8080/v1/tokenization/keys \
33+
-H "Authorization: Bearer <token>" \
34+
-H "Content-Type: application/json" \
3535
-d '{
3636
"name": "payment-cards",
3737
"format_type": "luhn-preserving",
@@ -52,9 +52,9 @@ curl -X POST http://localhost:8080/v1/tokenization/keys
5252
- **Body**: `plaintext` (base64), `metadata` (optional object), `ttl` (optional seconds).
5353

5454
```bash
55-
curl -X POST http://localhost:8080/v1/tokenization/keys/payment-cards/tokenize
56-
-H "Authorization: Bearer <token>"
57-
-H "Content-Type: application/json"
55+
curl -X POST http://localhost:8080/v1/tokenization/keys/payment-cards/tokenize \
56+
-H "Authorization: Bearer <token>" \
57+
-H "Content-Type: application/json" \
5858
-d '{
5959
"plaintext": "NDUzMjAxNTExMjgzMDM2Ng==",
6060
"metadata": { "last_four": "0366" }
@@ -125,7 +125,8 @@ Example response (`200 OK`):
125125
{
126126
"id": "0194f4c1-82de-7f9a-c2b3-9def1a7bc5d8",
127127
"name": "customer-ids",
128-
"algorithm": "uuid_v7",
128+
"format_type": "uuid",
129+
"algorithm": "aes-gcm",
129130
"is_deterministic": true,
130131
"version": 2,
131132
"created_at": "2026-02-27T20:10:00Z",
@@ -134,7 +135,8 @@ Example response (`200 OK`):
134135
{
135136
"id": "0194f4d3-a5bc-7e2f-d8a1-4bef2c9ad7e1",
136137
"name": "payment-tokens",
137-
"algorithm": "luhn_preserving",
138+
"format_type": "luhn-preserving",
139+
"algorithm": "chacha20-poly1305",
138140
"is_deterministic": false,
139141
"version": 1,
140142
"created_at": "2026-02-27T21:45:00Z",
@@ -147,9 +149,30 @@ Example response (`200 OK`):
147149

148150
**Note**: The `next_cursor` field is only present when there are more pages available.
149151

152+
#### Get Tokenization Key by Name
153+
154+
- **Endpoint**: `GET /v1/tokenization/keys/:name`
155+
- **Capability**: `read`
156+
- **Success**: `200 OK`
157+
158+
Example response (`200 OK`):
159+
160+
```json
161+
{
162+
"id": "0194f4c1-82de-7f9a-c2b3-9def1a7bc5d8",
163+
"name": "customer-ids",
164+
"format_type": "uuid",
165+
"algorithm": "aes-gcm",
166+
"is_deterministic": true,
167+
"version": 2,
168+
"created_at": "2026-02-27T20:10:00Z",
169+
"updated_at": "2026-02-28T10:30:00Z"
170+
}
171+
```
172+
150173
#### Delete Tokenization Key
151174

152-
- **Endpoint**: `DELETE /v1/tokenization/keys/:name`
175+
- **Endpoint**: `DELETE /v1/tokenization/keys/:id`
153176
- **Capability**: `delete`
154177
- **Success**: `204 No Content`
155178

docs/openapi.yaml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,37 @@ paths:
780780
$ref: "#/components/responses/ValidationError"
781781
"429":
782782
$ref: "#/components/responses/TooManyRequests"
783+
/v1/tokenization/keys/{name}:
784+
parameters:
785+
- name: name
786+
in: path
787+
required: true
788+
schema:
789+
type: string
790+
get:
791+
tags: [tokenization]
792+
summary: Get tokenization key details
793+
security:
794+
- bearerAuth: []
795+
responses:
796+
"200":
797+
description: Tokenization key details
798+
content:
799+
application/json:
800+
schema:
801+
$ref: "#/components/schemas/TokenizationKeyResponse"
802+
"401":
803+
$ref: "#/components/responses/Unauthorized"
804+
"403":
805+
$ref: "#/components/responses/Forbidden"
806+
"404":
807+
description: Tokenization key not found
808+
content:
809+
application/json:
810+
schema:
811+
$ref: "#/components/schemas/ErrorResponse"
812+
"429":
813+
$ref: "#/components/responses/TooManyRequests"
783814
/v1/tokenization/keys/{name}/rotate:
784815
post:
785816
tags: [tokenization]

internal/http/server.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,12 @@ func (s *Server) registerTokenizationRoutes(
379379
tokenizationKeyHandler.ListHandler,
380380
)
381381

382+
// Get individual tokenization key
383+
keys.GET("/:name",
384+
authHTTP.AuthorizationMiddleware(authDomain.ReadCapability, auditLogUseCase, s.logger),
385+
tokenizationKeyHandler.GetByNameHandler,
386+
)
387+
382388
// Create new tokenization key
383389
keys.POST("",
384390
authHTTP.AuthorizationMiddleware(authDomain.WriteCapability, auditLogUseCase, s.logger),

internal/tokenization/http/tokenization_key_handler.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,31 @@ func (h *TokenizationKeyHandler) DeleteHandler(c *gin.Context) {
163163
c.Data(http.StatusNoContent, "application/json", nil)
164164
}
165165

166+
// GetByNameHandler retrieves a single tokenization key by its name.
167+
// GET /v1/tokenization/keys/:name - Requires ReadCapability.
168+
// Returns 200 OK with key details.
169+
func (h *TokenizationKeyHandler) GetByNameHandler(c *gin.Context) {
170+
// Get key name from URL parameter
171+
keyName := c.Param("name")
172+
if keyName == "" {
173+
httputil.HandleBadRequestGin(c,
174+
fmt.Errorf("key name is required in URL path"),
175+
h.logger)
176+
return
177+
}
178+
179+
// Call use case
180+
key, err := h.keyUseCase.GetByName(c.Request.Context(), keyName)
181+
if err != nil {
182+
httputil.HandleErrorGin(c, err, h.logger)
183+
return
184+
}
185+
186+
// Map to response
187+
response := dto.MapTokenizationKeyToResponse(key)
188+
c.JSON(http.StatusOK, response)
189+
}
190+
166191
// ListHandler retrieves tokenization keys with cursor-based pagination support.
167192
// GET /v1/tokenization/keys?after_name=key-name&limit=50 - Requires ReadCapability.
168193
// Returns 200 OK with paginated tokenization key list ordered by name ascending.

internal/tokenization/http/tokenization_key_handler_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,91 @@ func TestTokenizationKeyHandler_ListHandler(t *testing.T) {
470470
assert.Equal(t, http.StatusOK, w.Code)
471471
})
472472
}
473+
474+
func TestTokenizationKeyHandler_GetByNameHandler(t *testing.T) {
475+
t.Run("Success_GetKeyByName", func(t *testing.T) {
476+
handler, mockUseCase := setupTestKeyHandler(t)
477+
478+
keyID := uuid.Must(uuid.NewV7())
479+
expectedKey := &tokenizationDomain.TokenizationKey{
480+
ID: keyID,
481+
Name: "test-key",
482+
Version: 1,
483+
FormatType: tokenizationDomain.FormatUUID,
484+
IsDeterministic: false,
485+
DekID: uuid.Must(uuid.NewV7()),
486+
CreatedAt: time.Now().UTC(),
487+
}
488+
489+
mockUseCase.EXPECT().
490+
GetByName(mock.Anything, "test-key").
491+
Return(expectedKey, nil).
492+
Once()
493+
494+
c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys/test-key", nil)
495+
c.Params = gin.Params{{Key: "name", Value: "test-key"}}
496+
497+
handler.GetByNameHandler(c)
498+
499+
assert.Equal(t, http.StatusOK, w.Code)
500+
501+
var response dto.TokenizationKeyResponse
502+
err := json.Unmarshal(w.Body.Bytes(), &response)
503+
assert.NoError(t, err)
504+
assert.Equal(t, keyID.String(), response.ID)
505+
assert.Equal(t, "test-key", response.Name)
506+
assert.Equal(t, uint(1), response.Version)
507+
assert.Equal(t, "uuid", response.FormatType)
508+
assert.False(t, response.IsDeterministic)
509+
})
510+
511+
t.Run("Error_MissingKeyNameInURL", func(t *testing.T) {
512+
handler, _ := setupTestKeyHandler(t)
513+
514+
c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys/", nil)
515+
c.Params = gin.Params{{Key: "name", Value: ""}}
516+
517+
handler.GetByNameHandler(c)
518+
519+
assert.Equal(t, http.StatusBadRequest, w.Code)
520+
521+
var response map[string]interface{}
522+
err := json.Unmarshal(w.Body.Bytes(), &response)
523+
assert.NoError(t, err)
524+
assert.Equal(t, "bad_request", response["error"])
525+
})
526+
527+
t.Run("Error_KeyNotFound", func(t *testing.T) {
528+
handler, mockUseCase := setupTestKeyHandler(t)
529+
530+
mockUseCase.EXPECT().
531+
GetByName(mock.Anything, "nonexistent-key").
532+
Return(nil, tokenizationDomain.ErrTokenizationKeyNotFound).
533+
Once()
534+
535+
c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys/nonexistent-key", nil)
536+
c.Params = gin.Params{{Key: "name", Value: "nonexistent-key"}}
537+
538+
handler.GetByNameHandler(c)
539+
540+
assert.Equal(t, http.StatusNotFound, w.Code)
541+
})
542+
543+
t.Run("Error_UseCaseError", func(t *testing.T) {
544+
handler, mockUseCase := setupTestKeyHandler(t)
545+
546+
dbError := errors.New("database error")
547+
548+
mockUseCase.EXPECT().
549+
GetByName(mock.Anything, "test-key").
550+
Return(nil, dbError).
551+
Once()
552+
553+
c, w := createTestContext(http.MethodGet, "/v1/tokenization/keys/test-key", nil)
554+
c.Params = gin.Params{{Key: "name", Value: "test-key"}}
555+
556+
handler.GetByNameHandler(c)
557+
558+
assert.Equal(t, http.StatusInternalServerError, w.Code)
559+
})
560+
}

0 commit comments

Comments
 (0)