Skip to content

Commit 8848f56

Browse files
jordaneclaude
andcommitted
feat(LFXV2-2472): expose committee→project lookup via NATS request-reply
Add lfx.committee-api.get_project request-reply subject so consumers (e.g. mailing-list-service) can resolve a committee UID to its owning project UID without routing through the v1-sync-helper. - pkg/constants/subjects.go: add CommitteeGetProjectSubject constant - pkg/api/committee.go: new public contract package with typed request/response structs (GetCommitteeProjectRequest/Response) - port/message_handler.go: add HandleCommitteeGetProject to interface - service/message_handler.go: implement handler — JSON unmarshal, UUID validation, GetBase lookup, structured not-found reply envelope - committee_handler.go / providers.go: wire subject into router and NATS queue subscription map - message_handler_test.go: table-driven tests for success, not-found, malformed JSON, and invalid UUID cases - docs/nats-request-reply.md: document all request-reply subjects - nats-messaging.md: add get_project to inbound RPC subject inventory - README.md: reference new docs/nats-request-reply.md Issue: LFXV2-2472 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jordan Evans <jevans@linuxfoundation.org>
1 parent 5a84a55 commit 8848f56

10 files changed

Lines changed: 343 additions & 0 deletions

File tree

.claude/skills/committee-service-dev/references/nats-messaging.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Repo-local inventory of NATS subjects, queue groups, KV buckets, Object Stores,
1818
```go
1919
"lfx.committee-api.get_name" // get committee name by UID
2020
"lfx.committee-api.list_members" // list committee members
21+
"lfx.committee-api.get_project" // resolve committee UID to owning project UID (pkg/api: GetCommitteeProjectRequest/Response)
2122
```
2223

2324
### Inbound event subjects (consumed from other services)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ The LFX v2 Committee Service is a RESTful API service that manages committees an
5656
- [Invite & Application Flows](docs/invite-application-flows.md) — membership modes, invite/application lifecycle, state transitions, and edge cases
5757
- [Indexer Contract](docs/indexer-contract.md) — authoritative reference for all messages sent to the indexer service
5858
- [FGA Contract](docs/fga-contract.md) — authoritative reference for all messages sent to the fga-sync service
59+
- [NATS Request-Reply Subjects](docs/nats-request-reply.md) — synchronous request/reply subjects served by this service for inter-service queries
5960
- [Committee CLI](cmd/committee-cli/README.md) — operational tool for running data repair and sync tasks against the service
6061

6162
## Releases

cmd/committee-api/service/committee_handler.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func (mhs *MessageHandlerService) HandleMessage(ctx context.Context, msg port.Tr
2929
handlers := map[string]func(ctx context.Context, msg port.TransportMessenger) ([]byte, error){
3030
constants.CommitteeGetNameSubject: mhs.handleCommitteeGetName,
3131
constants.CommitteeListMembersSubject: mhs.handleCommitteeListMembers,
32+
constants.CommitteeGetProjectSubject: mhs.handleCommitteeGetProject,
3233
constants.MailingListCommitteeChangedSubject: mhs.handleMailingListChanged,
3334
constants.CommitteeUpdatedSubject: mhs.handleCommitteeUpdated,
3435
constants.CommitteeMemberCreatedSubject: mhs.handleCommitteeMemberCreated,
@@ -110,6 +111,10 @@ func (mhs *MessageHandlerService) handleCommitteeLinkCreated(ctx context.Context
110111
return mhs.messageHandler.HandleCommitteeLinkCreated(ctx, msg)
111112
}
112113

114+
func (mhs *MessageHandlerService) handleCommitteeGetProject(ctx context.Context, msg port.TransportMessenger) ([]byte, error) {
115+
return mhs.messageHandler.HandleCommitteeGetProject(ctx, msg)
116+
}
117+
113118
func (mhs *MessageHandlerService) respondWithError(ctx context.Context, msg port.TransportMessenger, errorMsg string) {
114119
errResponse := []byte(fmt.Sprintf(`{"error":"%s"}`, errorMsg))
115120
if err := msg.Respond(errResponse); err != nil {

cmd/committee-api/service/providers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,7 @@ func QueueSubscriptions(ctx context.Context, committeeReader port.CommitteeReade
874874
subjects := map[string]func(context.Context, port.TransportMessenger){
875875
constants.CommitteeGetNameSubject: messageHandlerService.HandleMessage,
876876
constants.CommitteeListMembersSubject: messageHandlerService.HandleMessage,
877+
constants.CommitteeGetProjectSubject: messageHandlerService.HandleMessage,
877878
constants.MailingListCommitteeChangedSubject: messageHandlerService.HandleMessage,
878879
constants.CommitteeUpdatedSubject: messageHandlerService.HandleMessage,
879880
constants.CommitteeMemberCreatedSubject: messageHandlerService.HandleMessage,

docs/nats-request-reply.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# NATS Request-Reply Subjects
2+
3+
This document describes the NATS request-reply subjects served by the committee service. These are synchronous, point-to-point subjects (core NATS request/reply with a queue group) used by other services to query committee state. All subjects share the `lfx.committee-api.queue` queue group.
4+
5+
For event subjects emitted by this service (fire-and-forget publishes) see [Indexer Contract](indexer-contract.md) and [FGA Contract](fga-contract.md).
6+
7+
---
8+
9+
## `lfx.committee-api.get_project`
10+
11+
Resolves a v2 committee UID to the UID of the project that owns it.
12+
13+
**Subject constant:** `pkg/constants``CommitteeGetProjectSubject`.
14+
**Request/response types:** `pkg/api``GetCommitteeProjectRequest`, `GetCommitteeProjectResponse`.
15+
Consumers should import both packages to use the typed structs and constant rather than hard-coding strings.
16+
17+
### Request
18+
19+
```json
20+
{ "committee_uid": "<v2 UUID>" }
21+
```
22+
23+
| Field | Type | Required | Description |
24+
|-------|------|----------|-------------|
25+
| `committee_uid` | string (UUID v4) | yes | The v2 UID of the committee to look up. |
26+
27+
### Response (success)
28+
29+
```json
30+
{ "project_uid": "<v2 UUID>" }
31+
```
32+
33+
| Field | Type | Description |
34+
|-------|------|-------------|
35+
| `project_uid` | string (UUID v4) | The v2 UID of the owning project. |
36+
37+
### Response (not found)
38+
39+
```json
40+
{ "error": "not found" }
41+
```
42+
43+
Returned when no committee exists for the supplied UID. The NATS reply is still sent (no timeout); only the `error` field is set.
44+
45+
### Response (request error)
46+
47+
```json
48+
{ "error": "<message>" }
49+
```
50+
51+
Returned for malformed JSON or an invalid (non-UUID) `committee_uid`. The `error` field describes the failure.
52+
53+
### Example
54+
55+
```go
56+
import committeeapi "github.com/linuxfoundation/lfx-v2-committee-service/pkg/api"
57+
58+
reqBytes, _ := json.Marshal(committeeapi.GetCommitteeProjectRequest{CommitteeUID: committeeUID})
59+
msg, err := nc.Request(committeeapi.GetCommitteeProjectSubject, reqBytes, 5*time.Second)
60+
if err != nil {
61+
// NATS timeout or connection error
62+
}
63+
64+
var resp committeeapi.GetCommitteeProjectResponse
65+
if err := json.Unmarshal(msg.Data, &resp); err != nil {
66+
// malformed reply
67+
}
68+
if resp.Error != "" {
69+
// "not found" or request validation error
70+
}
71+
// resp.ProjectUID is the owning project's v2 UID
72+
```
73+
74+
---
75+
76+
## `lfx.committee-api.get_name`
77+
78+
Returns the display name of a committee.
79+
80+
> **Wire format:** plain-text, not JSON. Request payload is the raw committee UID string; success reply is the raw name string; failure reply is `{"error":"<message>"}`.
81+
82+
**Defined in:** `pkg/constants``CommitteeGetNameSubject`.
83+
84+
### Request
85+
86+
```
87+
<committee_uid>
88+
```
89+
90+
Plain UTF-8 UUID string (no JSON wrapper).
91+
92+
### Response (success)
93+
94+
```
95+
<committee name>
96+
```
97+
98+
Plain UTF-8 string.
99+
100+
### Response (failure)
101+
102+
```json
103+
{ "error": "<message>" }
104+
```
105+
106+
---
107+
108+
## `lfx.committee-api.list_members`
109+
110+
Returns all members of a committee as a JSON array.
111+
112+
> **Wire format:** plain-text UID in, JSON array out. Request payload is the raw committee UID string.
113+
114+
**Defined in:** `pkg/constants``CommitteeListMembersSubject`.
115+
116+
### Request
117+
118+
```
119+
<committee_uid>
120+
```
121+
122+
Plain UTF-8 UUID string (no JSON wrapper).
123+
124+
### Response (success)
125+
126+
JSON array of committee member objects:
127+
128+
```json
129+
[
130+
{
131+
"uid": "...",
132+
"committee_uid": "...",
133+
"username": "...",
134+
"role": "...",
135+
...
136+
}
137+
]
138+
```
139+
140+
### Response (failure)
141+
142+
```json
143+
{ "error": "<message>" }
144+
```

internal/domain/port/message_handler.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import "context"
1010
type CommitteeAttributeHandler interface {
1111
// HandleCommitteeGetAttribute handles committee get attribute messages
1212
HandleCommitteeGetAttribute(ctx context.Context, msg TransportMessenger, attribute string) ([]byte, error)
13+
// HandleCommitteeGetProject resolves a committee UID to its owning project UID.
14+
// Request payload: JSON-encoded GetCommitteeProjectRequest (pkg/api).
15+
// Success reply: JSON-encoded GetCommitteeProjectResponse with ProjectUID set.
16+
// Not-found reply: JSON-encoded GetCommitteeProjectResponse with Error set to "not found".
17+
HandleCommitteeGetProject(ctx context.Context, msg TransportMessenger) ([]byte, error)
1318
}
1419

1520
// CommitteeMemberHandler handles member-related messages: responding to external

internal/service/message_handler.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/linuxfoundation/lfx-v2-committee-service/internal/domain/model"
1818
"github.com/linuxfoundation/lfx-v2-committee-service/internal/domain/port"
1919
emailsvc "github.com/linuxfoundation/lfx-v2-committee-service/internal/service/email"
20+
committeeapi "github.com/linuxfoundation/lfx-v2-committee-service/pkg/api"
2021
"github.com/linuxfoundation/lfx-v2-committee-service/pkg/constants"
2122
"github.com/linuxfoundation/lfx-v2-committee-service/pkg/errors"
2223
"github.com/linuxfoundation/lfx-v2-committee-service/pkg/fields"
@@ -209,6 +210,48 @@ func (m *messageHandlerOrchestrator) HandleCommitteeGetAttribute(ctx context.Con
209210
return []byte(strValue), nil
210211
}
211212

213+
// HandleCommitteeGetProject resolves a committee UID to its owning project UID.
214+
// The request payload must be a JSON-encoded GetCommitteeProjectRequest.
215+
// On success it returns a JSON-encoded GetCommitteeProjectResponse with ProjectUID set.
216+
// When the committee does not exist it returns a JSON-encoded GetCommitteeProjectResponse
217+
// with Error set to "not found" (successful reply, not a Go error) so the NATS router
218+
// sends the structured payload back to the caller rather than the generic error envelope.
219+
func (m *messageHandlerOrchestrator) HandleCommitteeGetProject(ctx context.Context, msg port.TransportMessenger) ([]byte, error) {
220+
var req committeeapi.GetCommitteeProjectRequest
221+
if err := json.Unmarshal(msg.Data(), &req); err != nil {
222+
slog.ErrorContext(ctx, "failed to unmarshal get_project request", "error", err)
223+
return nil, errors.NewValidation("invalid get_project request payload", err)
224+
}
225+
226+
slog.DebugContext(ctx, "committee get project request", "committee_uid", req.CommitteeUID)
227+
228+
if _, err := uuid.Parse(req.CommitteeUID); err != nil {
229+
slog.ErrorContext(ctx, "invalid committee UID in get_project request", "error", err, "committee_uid", req.CommitteeUID)
230+
return nil, errors.NewValidation("invalid committee UID", err)
231+
}
232+
233+
committee, _, err := m.committeeReader.GetBase(ctx, req.CommitteeUID)
234+
if err != nil {
235+
var nf errors.NotFound
236+
if stderrors.As(err, &nf) {
237+
slog.DebugContext(ctx, "committee not found for get_project request", "committee_uid", req.CommitteeUID)
238+
return json.Marshal(committeeapi.GetCommitteeProjectResponse{Error: "not found"})
239+
}
240+
slog.ErrorContext(ctx, "failed to get committee base for get_project request",
241+
"error", err,
242+
"committee_uid", req.CommitteeUID,
243+
)
244+
return nil, err
245+
}
246+
247+
slog.DebugContext(ctx, "committee get project response",
248+
"committee_uid", req.CommitteeUID,
249+
"project_uid", committee.ProjectUID,
250+
)
251+
252+
return json.Marshal(committeeapi.GetCommitteeProjectResponse{ProjectUID: committee.ProjectUID})
253+
}
254+
212255
// HandleCommitteeListMembers handles the retrieval of all members for a committee
213256
func (m *messageHandlerOrchestrator) HandleCommitteeListMembers(ctx context.Context, msg port.TransportMessenger) ([]byte, error) {
214257

internal/service/message_handler_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/linuxfoundation/lfx-v2-committee-service/internal/domain/model"
1919
"github.com/linuxfoundation/lfx-v2-committee-service/internal/domain/port"
2020
"github.com/linuxfoundation/lfx-v2-committee-service/internal/infrastructure/mock"
21+
committeeapi "github.com/linuxfoundation/lfx-v2-committee-service/pkg/api"
2122
"github.com/linuxfoundation/lfx-v2-committee-service/pkg/constants"
2223
errs "github.com/linuxfoundation/lfx-v2-committee-service/pkg/errors"
2324
emailapi "github.com/linuxfoundation/lfx-v2-email-service/pkg/api"
@@ -2515,3 +2516,114 @@ func TestHandleInviteAccepted(t *testing.T) {
25152516
})
25162517
}
25172518
}
2519+
2520+
func TestMessageHandlerOrchestratorHandleCommitteeGetProject(t *testing.T) {
2521+
ctx := context.Background()
2522+
2523+
testCommitteeUID := uuid.New().String()
2524+
testProjectUID := "test-project-uid"
2525+
testCommittee := &model.Committee{
2526+
CommitteeBase: model.CommitteeBase{
2527+
UID: testCommitteeUID,
2528+
ProjectUID: testProjectUID,
2529+
Name: "Test Committee",
2530+
},
2531+
}
2532+
2533+
tests := []struct {
2534+
name string
2535+
setupMock func(mockRepo *mock.MockRepository)
2536+
messageData []byte
2537+
expectedError bool
2538+
errorType interface{}
2539+
validateResponse func(*testing.T, []byte)
2540+
}{
2541+
{
2542+
name: "success - returns project UID",
2543+
setupMock: func(mockRepo *mock.MockRepository) {
2544+
mockRepo.ClearAll()
2545+
mockRepo.AddCommittee(testCommittee)
2546+
},
2547+
messageData: mustMarshalGetProjectJSON(t, committeeapi.GetCommitteeProjectRequest{CommitteeUID: testCommitteeUID}),
2548+
expectedError: false,
2549+
validateResponse: func(t *testing.T, response []byte) {
2550+
var resp committeeapi.GetCommitteeProjectResponse
2551+
require.NoError(t, json.Unmarshal(response, &resp))
2552+
assert.Equal(t, testProjectUID, resp.ProjectUID)
2553+
assert.Empty(t, resp.Error)
2554+
},
2555+
},
2556+
{
2557+
name: "not found - returns error envelope",
2558+
setupMock: func(mockRepo *mock.MockRepository) {
2559+
mockRepo.ClearAll()
2560+
},
2561+
messageData: mustMarshalGetProjectJSON(t, committeeapi.GetCommitteeProjectRequest{CommitteeUID: uuid.New().String()}),
2562+
expectedError: false,
2563+
validateResponse: func(t *testing.T, response []byte) {
2564+
var resp committeeapi.GetCommitteeProjectResponse
2565+
require.NoError(t, json.Unmarshal(response, &resp))
2566+
assert.Equal(t, "not found", resp.Error)
2567+
assert.Empty(t, resp.ProjectUID)
2568+
},
2569+
},
2570+
{
2571+
name: "malformed JSON payload - returns validation error",
2572+
setupMock: func(mockRepo *mock.MockRepository) {},
2573+
messageData: []byte(`not-json`),
2574+
expectedError: true,
2575+
errorType: errs.Validation{},
2576+
validateResponse: func(t *testing.T, response []byte) {
2577+
assert.Nil(t, response)
2578+
},
2579+
},
2580+
{
2581+
name: "invalid UUID in payload - returns validation error",
2582+
setupMock: func(mockRepo *mock.MockRepository) {},
2583+
messageData: mustMarshalGetProjectJSON(t, committeeapi.GetCommitteeProjectRequest{CommitteeUID: "not-a-uuid"}),
2584+
expectedError: true,
2585+
errorType: errs.Validation{},
2586+
validateResponse: func(t *testing.T, response []byte) {
2587+
assert.Nil(t, response)
2588+
},
2589+
},
2590+
}
2591+
2592+
for _, tt := range tests {
2593+
t.Run(tt.name, func(t *testing.T) {
2594+
mockRepo := mock.NewMockRepository()
2595+
tt.setupMock(mockRepo)
2596+
2597+
handler := NewMessageHandlerOrchestrator(
2598+
WithCommitteeReaderForMessageHandler(
2599+
NewCommitteeReaderOrchestrator(
2600+
WithCommitteeReader(mockRepo),
2601+
),
2602+
),
2603+
)
2604+
2605+
mockMsg := newMockTransportMessenger(constants.CommitteeGetProjectSubject, tt.messageData)
2606+
2607+
response, err := handler.HandleCommitteeGetProject(ctx, mockMsg)
2608+
2609+
if tt.expectedError {
2610+
require.Error(t, err)
2611+
if tt.errorType != nil {
2612+
assert.IsType(t, tt.errorType, err)
2613+
}
2614+
} else {
2615+
require.NoError(t, err)
2616+
}
2617+
2618+
tt.validateResponse(t, response)
2619+
})
2620+
}
2621+
}
2622+
2623+
// mustMarshalGetProjectJSON marshals v to JSON and fails the test on error.
2624+
func mustMarshalGetProjectJSON(t *testing.T, v interface{}) []byte {
2625+
t.Helper()
2626+
b, err := json.Marshal(v)
2627+
require.NoError(t, err)
2628+
return b
2629+
}

0 commit comments

Comments
 (0)