Skip to content

Commit 20ed3b7

Browse files
committed
feat(if-match): add if-match header support
adding if-match support as a proof of concept that it is possible for grpc APIs to support this behavior, allowing removal of other methods such as etags.
1 parent 04d62f4 commit 20ed3b7

File tree

7 files changed

+639
-5
lines changed

7 files changed

+639
-5
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
main
2-
terraform-provider-bookstore
2+
terraform-provider-bookstore
3+
bin

IF_MATCH_DOCUMENTATION.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# If-Match Header Support for Update Operations
2+
3+
This document describes the If-Match header support that has been added to all update endpoints in the AEPC bookstore example.
4+
5+
## Overview
6+
7+
The If-Match header provides optimistic concurrency control for update operations. When provided, the server validates that the current resource matches the expected ETag before performing the update. If the ETags don't match, the update is rejected with a `412 Precondition Failed` status.
8+
9+
## Features
10+
11+
### Supported Operations
12+
13+
The If-Match header is supported for all update operations:
14+
- `UpdateBook`
15+
- `UpdatePublisher`
16+
- `UpdateStore`
17+
- `UpdateItem`
18+
19+
### ETag Generation
20+
21+
ETags are generated by:
22+
1. Serializing the protobuf message using `proto.Marshal`
23+
2. Computing an MD5 hash of the serialized data
24+
3. Encoding the hash as a hexadecimal string
25+
4. Wrapping in quotes (e.g., `"a1b2c3d4..."`)
26+
27+
### Header Processing
28+
29+
The grpc-gateway is configured to forward the `If-Match` HTTP header to gRPC metadata:
30+
- HTTP header: `If-Match: "etag-value"`
31+
- gRPC metadata: `grpcgateway-if-match: "etag-value"`
32+
33+
## Usage
34+
35+
### HTTP API
36+
37+
```bash
38+
# Get a resource to obtain its current state
39+
GET /publishers/1/books/1
40+
41+
# Update with If-Match header
42+
PATCH /publishers/1/books/1
43+
If-Match: "current-etag-value"
44+
Content-Type: application/json
45+
46+
{
47+
"book": {
48+
"price": 30,
49+
"published": true,
50+
"edition": 2
51+
}
52+
}
53+
```
54+
55+
### Response Codes
56+
57+
- `200 OK`: Update successful with valid If-Match header
58+
- `412 Precondition Failed`: If-Match header value doesn't match current resource ETag
59+
- `404 Not Found`: Resource doesn't exist
60+
- No If-Match header: Update proceeds normally (backwards compatible)
61+
62+
### gRPC API
63+
64+
The If-Match header is automatically extracted from gRPC metadata by the service methods. No additional client configuration is required when using grpc-gateway.
65+
66+
## Implementation Details
67+
68+
### Core Components
69+
70+
1. **ETag Generation** (`types.go`):
71+
- `GenerateETag(msg proto.Message)`: Creates ETag from protobuf message
72+
- `ValidateETag(provided, current string)`: Compares ETags with quote handling
73+
74+
2. **Header Extraction** (`service.go`):
75+
- `extractIfMatchHeader(ctx context.Context)`: Extracts If-Match from gRPC metadata
76+
77+
3. **Gateway Configuration** (`gateway.go`):
78+
- Custom header matcher forwards `If-Match` header to `grpcgateway-if-match` metadata
79+
80+
4. **Update Methods**: All update methods now:
81+
- Extract If-Match header from context
82+
- Fetch current resource if header is present
83+
- Generate ETag for current resource
84+
- Validate provided ETag against current ETag
85+
- Reject with `FailedPrecondition` if validation fails
86+
- Proceed with normal update logic if validation passes
87+
88+
### Error Handling
89+
90+
- **Missing Resource**: Returns `NotFound` when trying to validate ETag for non-existent resource
91+
- **ETag Mismatch**: Returns `FailedPrecondition` when If-Match header doesn't match current ETag
92+
- **ETag Generation Failure**: Returns `Internal` if ETag generation fails
93+
- **No If-Match Header**: Proceeds normally for backwards compatibility
94+
95+
## Testing
96+
97+
### Unit Tests
98+
99+
The implementation includes comprehensive unit tests:
100+
- `TestUpdateBookWithIfMatchHeader`: Tests successful and failed ETag validation
101+
- `TestUpdatePublisherWithIfMatchHeader`: Tests publisher-specific ETag handling
102+
- `TestETagGeneration`: Tests ETag generation and validation logic
103+
104+
### Test Coverage
105+
106+
Tests verify:
107+
- ✅ Updates succeed with correct If-Match header
108+
- ✅ Updates fail with incorrect If-Match header (412 status)
109+
- ✅ Updates succeed without If-Match header (backwards compatibility)
110+
- ✅ Updates fail for non-existent resources (404 status)
111+
- ✅ ETag generation produces consistent results for identical content
112+
- ✅ ETag generation produces different results for different content
113+
- ✅ ETag validation handles quoted and unquoted ETags correctly
114+
115+
### Integration Testing
116+
117+
An integration test script is provided (`test_if_match_integration.sh`) that demonstrates:
118+
- End-to-end HTTP API functionality
119+
- Proper error codes for failed preconditions
120+
- Complete workflow from resource creation to ETag-validated updates
121+
122+
## Security Considerations
123+
124+
- ETags are deterministic based on resource content
125+
- ETags do not expose sensitive information (they are content hashes)
126+
- No additional authentication is required beyond existing API security
127+
- ETag validation happens before database operations, preventing unnecessary writes
128+
129+
## Performance Notes
130+
131+
- ETag generation requires serializing and hashing the resource
132+
- Validation requires fetching the current resource before updating
133+
- Impact is minimal for typical update operations
134+
- No additional database operations beyond the standard Get/Update pattern
135+
- ETags are computed on-demand and not stored in the database
136+
137+
## Backwards Compatibility
138+
139+
The If-Match header support is fully backwards compatible:
140+
- Existing clients without If-Match headers continue to work unchanged
141+
- No changes to existing API contracts or response formats
142+
- No new required fields in resources themselves
143+
- All functionality is implemented via HTTP headers and gRPC sidechannel metadata

example/gateway/gateway.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ func Run(grpcServerEndpoint string) {
3333
UseProtoNames: true,
3434
},
3535
}),
36+
// Configure header forwarding for If-Match header
37+
runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
38+
switch key {
39+
case "If-Match":
40+
return "grpcgateway-if-match", true
41+
default:
42+
return runtime.DefaultHeaderMatcher(key)
43+
}
44+
}),
3645
)
3746
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
3847
err := bpb.RegisterBookstoreHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts)

example/service/service.go

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"google.golang.org/grpc"
1515
"google.golang.org/grpc/codes"
16+
"google.golang.org/grpc/metadata"
1617
"google.golang.org/grpc/status"
1718

1819
_ "buf.build/gen/go/aep/api/protocolbuffers/go/aep/api"
@@ -63,6 +64,26 @@ func (s *operationStore) getOperation(id string) (*operationStatus, bool) {
6364
return op, exists
6465
}
6566

67+
// extractIfMatchHeader extracts the If-Match header from gRPC metadata
68+
func extractIfMatchHeader(ctx context.Context) string {
69+
md, ok := metadata.FromIncomingContext(ctx)
70+
if !ok {
71+
return ""
72+
}
73+
74+
// Check for If-Match header (grpc-gateway converts HTTP headers to lowercase with grpcgateway- prefix)
75+
if values := md.Get("grpcgateway-if-match"); len(values) > 0 {
76+
return values[0]
77+
}
78+
79+
// Also check for standard if-match in case it comes through differently
80+
if values := md.Get("if-match"); len(values) > 0 {
81+
return values[0]
82+
}
83+
84+
return ""
85+
}
86+
6687
type BookstoreServer struct {
6788
bpb.UnimplementedBookstoreServer
6889
lrpb.UnimplementedOperationsServer
@@ -134,7 +155,30 @@ func (s BookstoreServer) ApplyBook(_ context.Context, r *bpb.ApplyBookRequest) (
134155
return book.Book, nil
135156
}
136157

137-
func (s BookstoreServer) UpdateBook(_ context.Context, r *bpb.UpdateBookRequest) (*bpb.Book, error) {
158+
func (s BookstoreServer) UpdateBook(ctx context.Context, r *bpb.UpdateBookRequest) (*bpb.Book, error) {
159+
// Extract If-Match header from context
160+
ifMatchHeader := extractIfMatchHeader(ctx)
161+
162+
// If If-Match header is provided, validate it against current resource
163+
if ifMatchHeader != "" {
164+
// First, get the current resource to generate its ETag
165+
currentBook, err := s.GetBook(ctx, &bpb.GetBookRequest{Path: r.Path})
166+
if err != nil {
167+
return nil, err // This will return NotFound if the resource doesn't exist
168+
}
169+
170+
// Generate ETag for current resource
171+
currentETag, err := GenerateETag(currentBook)
172+
if err != nil {
173+
return nil, status.Errorf(codes.Internal, "failed to generate ETag: %v", err)
174+
}
175+
176+
// Validate the provided ETag
177+
if !ValidateETag(ifMatchHeader, currentETag) {
178+
return nil, status.Errorf(codes.FailedPrecondition, "If-Match header value does not match current resource ETag")
179+
}
180+
}
181+
138182
book, err := NewSerializableBook(proto.Clone(r.Book).(*bpb.Book))
139183
if err != nil {
140184
return nil, status.Errorf(codes.Internal, "failed to create book: %v", err)
@@ -350,7 +394,30 @@ func (s BookstoreServer) ApplyPublisher(_ context.Context, r *bpb.ApplyPublisher
350394
return publisher, nil
351395
}
352396

353-
func (s BookstoreServer) UpdatePublisher(_ context.Context, r *bpb.UpdatePublisherRequest) (*bpb.Publisher, error) {
397+
func (s BookstoreServer) UpdatePublisher(ctx context.Context, r *bpb.UpdatePublisherRequest) (*bpb.Publisher, error) {
398+
// Extract If-Match header from context
399+
ifMatchHeader := extractIfMatchHeader(ctx)
400+
401+
// If If-Match header is provided, validate it against current resource
402+
if ifMatchHeader != "" {
403+
// First, get the current resource to generate its ETag
404+
currentPublisher, err := s.GetPublisher(ctx, &bpb.GetPublisherRequest{Path: r.Path})
405+
if err != nil {
406+
return nil, err // This will return NotFound if the resource doesn't exist
407+
}
408+
409+
// Generate ETag for current resource
410+
currentETag, err := GenerateETag(currentPublisher)
411+
if err != nil {
412+
return nil, status.Errorf(codes.Internal, "failed to generate ETag: %v", err)
413+
}
414+
415+
// Validate the provided ETag
416+
if !ValidateETag(ifMatchHeader, currentETag) {
417+
return nil, status.Errorf(codes.FailedPrecondition, "If-Match header value does not match current resource ETag")
418+
}
419+
}
420+
354421
publisher := proto.Clone(r.Publisher).(*bpb.Publisher)
355422
publisher.Path = r.Path
356423

@@ -486,7 +553,30 @@ func (s BookstoreServer) GetStore(_ context.Context, r *bpb.GetStoreRequest) (*b
486553
return store, nil
487554
}
488555

489-
func (s BookstoreServer) UpdateStore(_ context.Context, r *bpb.UpdateStoreRequest) (*bpb.Store, error) {
556+
func (s BookstoreServer) UpdateStore(ctx context.Context, r *bpb.UpdateStoreRequest) (*bpb.Store, error) {
557+
// Extract If-Match header from context
558+
ifMatchHeader := extractIfMatchHeader(ctx)
559+
560+
// If If-Match header is provided, validate it against current resource
561+
if ifMatchHeader != "" {
562+
// First, get the current resource to generate its ETag
563+
currentStore, err := s.GetStore(ctx, &bpb.GetStoreRequest{Path: r.Path})
564+
if err != nil {
565+
return nil, err // This will return NotFound if the resource doesn't exist
566+
}
567+
568+
// Generate ETag for current resource
569+
currentETag, err := GenerateETag(currentStore)
570+
if err != nil {
571+
return nil, status.Errorf(codes.Internal, "failed to generate ETag: %v", err)
572+
}
573+
574+
// Validate the provided ETag
575+
if !ValidateETag(ifMatchHeader, currentETag) {
576+
return nil, status.Errorf(codes.FailedPrecondition, "If-Match header value does not match current resource ETag")
577+
}
578+
}
579+
490580
store := proto.Clone(r.Store).(*bpb.Store)
491581
store.Path = r.Path
492582

@@ -571,7 +661,30 @@ func (s BookstoreServer) GetItem(_ context.Context, r *bpb.GetItemRequest) (*bpb
571661
return item, nil
572662
}
573663

574-
func (s BookstoreServer) UpdateItem(_ context.Context, r *bpb.UpdateItemRequest) (*bpb.Item, error) {
664+
func (s BookstoreServer) UpdateItem(ctx context.Context, r *bpb.UpdateItemRequest) (*bpb.Item, error) {
665+
// Extract If-Match header from context
666+
ifMatchHeader := extractIfMatchHeader(ctx)
667+
668+
// If If-Match header is provided, validate it against current resource
669+
if ifMatchHeader != "" {
670+
// First, get the current resource to generate its ETag
671+
currentItem, err := s.GetItem(ctx, &bpb.GetItemRequest{Path: r.Path})
672+
if err != nil {
673+
return nil, err // This will return NotFound if the resource doesn't exist
674+
}
675+
676+
// Generate ETag for current resource
677+
currentETag, err := GenerateETag(currentItem)
678+
if err != nil {
679+
return nil, status.Errorf(codes.Internal, "failed to generate ETag: %v", err)
680+
}
681+
682+
// Validate the provided ETag
683+
if !ValidateETag(ifMatchHeader, currentETag) {
684+
return nil, status.Errorf(codes.FailedPrecondition, "If-Match header value does not match current resource ETag")
685+
}
686+
}
687+
575688
item := proto.Clone(r.Item).(*bpb.Item)
576689
item.Path = r.Path
577690

0 commit comments

Comments
 (0)