Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions IF_MATCH_DOCUMENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# If-Match Header Support for Update Operations

This document describes the If-Match header support that has been added to all update endpoints in the AEPC bookstore example.

## Overview

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.

## Features

### Supported Operations

The If-Match header is supported for all update operations:
- `UpdateBook`
- `UpdatePublisher`
- `UpdateStore`
- `UpdateItem`

### ETag Generation

ETags are generated by:
1. Serializing the protobuf message using `proto.Marshal`
2. Computing an MD5 hash of the serialized data
3. Encoding the hash as a hexadecimal string
4. Wrapping in quotes (e.g., `"a1b2c3d4..."`)

### Header Processing

The grpc-gateway is configured to forward the `If-Match` HTTP header to gRPC metadata:
- HTTP header: `If-Match: "etag-value"`
- gRPC metadata: `grpcgateway-if-match: "etag-value"`

## Usage

### HTTP API

```bash
# Get a resource to obtain its current state
GET /publishers/1/books/1

# Update with If-Match header
PATCH /publishers/1/books/1
If-Match: "current-etag-value"
Content-Type: application/json

{
"book": {
"price": 30,
"published": true,
"edition": 2
}
}
```

### Response Codes

- `200 OK`: Update successful with valid If-Match header
- `412 Precondition Failed`: If-Match header value doesn't match current resource ETag
- `404 Not Found`: Resource doesn't exist
- No If-Match header: Update proceeds normally (backwards compatible)

### gRPC API

The If-Match header is automatically extracted from gRPC metadata by the service methods. No additional client configuration is required when using grpc-gateway.

## Implementation Details

### Core Components

1. **ETag Generation** (`types.go`):
- `GenerateETag(msg proto.Message)`: Creates ETag from protobuf message
- `ValidateETag(provided, current string)`: Compares ETags with quote handling

2. **Header Extraction** (`service.go`):
- `extractIfMatchHeader(ctx context.Context)`: Extracts If-Match from gRPC metadata

3. **Gateway Configuration** (`gateway.go`):
- Custom header matcher forwards `If-Match` header to `grpcgateway-if-match` metadata

4. **Update Methods**: All update methods now:
- Extract If-Match header from context
- Fetch current resource if header is present
- Generate ETag for current resource
- Validate provided ETag against current ETag
- Reject with `FailedPrecondition` if validation fails
- Proceed with normal update logic if validation passes

### Error Handling

- **Missing Resource**: Returns `NotFound` when trying to validate ETag for non-existent resource
- **ETag Mismatch**: Returns `FailedPrecondition` when If-Match header doesn't match current ETag
- **ETag Generation Failure**: Returns `Internal` if ETag generation fails
- **No If-Match Header**: Proceeds normally for backwards compatibility

## Testing

### Unit Tests

The implementation includes comprehensive unit tests:
- `TestUpdateBookWithIfMatchHeader`: Tests successful and failed ETag validation
- `TestUpdatePublisherWithIfMatchHeader`: Tests publisher-specific ETag handling
- `TestETagGeneration`: Tests ETag generation and validation logic

### Test Coverage

Tests verify:
- ✅ Updates succeed with correct If-Match header
- ✅ Updates fail with incorrect If-Match header (412 status)
- ✅ Updates succeed without If-Match header (backwards compatibility)
- ✅ Updates fail for non-existent resources (404 status)
- ✅ ETag generation produces consistent results for identical content
- ✅ ETag generation produces different results for different content
- ✅ ETag validation handles quoted and unquoted ETags correctly

### Integration Testing

An integration test script is provided (`test_if_match_integration.sh`) that demonstrates:
- End-to-end HTTP API functionality
- Proper error codes for failed preconditions
- Complete workflow from resource creation to ETag-validated updates

## Security Considerations

- ETags are deterministic based on resource content
- ETags do not expose sensitive information (they are content hashes)
- No additional authentication is required beyond existing API security
- ETag validation happens before database operations, preventing unnecessary writes

## Performance Notes

- ETag generation requires serializing and hashing the resource
- Validation requires fetching the current resource before updating
- Impact is minimal for typical update operations
- No additional database operations beyond the standard Get/Update pattern
- ETags are computed on-demand and not stored in the database

## Backwards Compatibility

The If-Match header support is fully backwards compatible:
- Existing clients without If-Match headers continue to work unchanged
- No changes to existing API contracts or response formats
- No new required fields in resources themselves
- All functionality is implemented via HTTP headers and gRPC sidechannel metadata
18 changes: 18 additions & 0 deletions example/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ func Run(grpcServerEndpoint string) {
UseProtoNames: true,
},
}),
// Configure header forwarding for If-Match header
runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
switch key {
case "If-Match":
return "grpcgateway-if-match", true
default:
return runtime.DefaultHeaderMatcher(key)
}
}),
// Configure outgoing header forwarding for ETag header
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
switch key {
case "etag":
return "ETag", true
default:
return runtime.DefaultHeaderMatcher(key)
}
}),
)
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
err := bpb.RegisterBookstoreHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts)
Expand Down
Loading