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
36 changes: 13 additions & 23 deletions articles/ETag_HOWTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ That's it. No changes to return types or service layer required.

1. **First Request**: Client requests `/cohortdefinition/123`
- Server generates response JSON
- Filter computes SHA-256 hash of response body
- Response includes `ETag: "a1b2c3..."` header
- Filter computes MD5 hash of response body
- Response includes weak ETag header: `ETag: W/"0a1b2c3..."`
- Client receives full response (200 OK)

2. **Subsequent Requests**: Client requests same URL
- Browser automatically sends `If-None-Match: "a1b2c3..."` header
- Browser automatically sends `If-None-Match: W/"0a1b2c3..."` header
- Filter computes ETag of current response
- If ETags match → returns `304 Not Modified` (no body)
- If ETags differ → returns full response with new ETag (200 OK)
Expand All @@ -49,37 +49,26 @@ public @interface UseEtag {
}
```

### `EtagUtil` Utility Class

Location: `org.ohdsi.webapi.util.EtagUtil`

Provides ETag generation and comparison:

- `generateEtag(byte[] content)` - Computes SHA-256 hash, returns quoted string per RFC 7232 (e.g., `"a1b2c3..."`)
- `matches(String ifNoneMatch, String etag)` - Compares `If-None-Match` header to generated ETag, handles multiple values and `*` wildcard

### `EtagFilter` Servlet Filter

Location: `org.ohdsi.webapi.util.EtagFilter`

A servlet `Filter` that:
Extends Spring's `ShallowEtagHeaderFilter` to add selective ETag processing based on the `@UseEtag` annotation:

1. Looks up the handler method via `RequestMappingHandlerMapping`
2. Checks for `@UseEtag` annotation
3. Wraps response with `ContentCachingResponseWrapper` to capture the body
4. After response is written, computes ETag from cached bytes
5. Compares with `If-None-Match` header
6. Returns 304 or full response with appropriate headers
1. Overrides `shouldNotFilter()` to skip requests without `@UseEtag` annotation
2. Sets Cache-Control and CORS headers before delegating to parent
3. Leverages Spring's built-in ETag generation (MD5) and 304 handling
4. Configured to generate weak ETags (`W/"..."`) per RFC 7232

**Key Design Decision**: Uses a Filter (not `ResponseBodyAdvice`) to avoid double-serialization. `ResponseBodyAdvice` receives the Java object before JSON serialization, so computing an ETag there would require serializing to JSON twice. The Filter intercepts after Spring has already serialized the response.
**Key Design Decision**: Extends `ShallowEtagHeaderFilter` rather than implementing from scratch. This leverages Spring's tested implementation for response caching, ETag generation, async dispatch handling, and `If-None-Match` comparison.

## HTTP Headers

For `@UseEtag` endpoints, the filter sets these response headers:

| Header | Value | Purpose |
|--------|-------|---------|
| `ETag` | `"<sha256-hash>"` | Unique identifier for response content |
| `ETag` | `W/"0<md5-hash>"` | Weak ETag - unique identifier for response content |
| `Cache-Control` | `private, max-age=0, must-revalidate` | Allows browser caching but forces revalidation |
| `Access-Control-Expose-Headers` | `ETag` | Exposes ETag to JavaScript in CORS contexts |

Expand Down Expand Up @@ -184,7 +173,8 @@ Invoke-WebRequest: ParameterBindingException

## Security Considerations

- ETags use SHA-256 hashing, which is cryptographically strong
- ETags use MD5 hashing (sufficient for cache validation; collision resistance is not required)
- Weak ETags (`W/"..."`) indicate semantic equivalence rather than byte-for-byte identity
- `Cache-Control: private` ensures responses are not stored in shared caches (proxies)
- The `Vary: Origin` header (set by Spring CORS) ensures CORS responses are cached per-origin

Expand All @@ -193,4 +183,4 @@ Invoke-WebRequest: ParameterBindingException
- [RFC 7232 - HTTP Conditional Requests](https://tools.ietf.org/html/rfc7232)
- [MDN - ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)
- [MDN - If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
- [Spring ContentCachingResponseWrapper](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/util/ContentCachingResponseWrapper.html)
- [Spring ShallowEtagHeaderFilter](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/ShallowEtagHeaderFilter.html)
34 changes: 0 additions & 34 deletions src/main/java/org/ohdsi/webapi/activity/Activity.java

This file was deleted.

64 changes: 0 additions & 64 deletions src/main/java/org/ohdsi/webapi/activity/Tracker.java

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public CohortCharacterizationDTO create(@RequestBody final CohortCharacterizatio
* @return The cohort characterization definition of the newly created copy
*/
@PostMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("(isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)) and isPermitted('create:cohort-characterization')")
@PreAuthorize("(isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)) and isPermitted('create:cohort-characterization')")
public CohortCharacterizationDTO copy(@PathVariable("id") final Long id) {
CohortCharacterizationDTO dto = getDesign(id);
dto.setName(service.getNameForCopy(dto.getName()));
Expand Down Expand Up @@ -162,7 +162,7 @@ public Page<CohortCharacterizationDTO> listDesign(@Pagination Pageable pageable)
* @return name, createdDate, tags, etc for a single cohort characterization.
*/
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted('read:cohort-characterization','write:cohort-characterization') or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
public CcShortDTO get(@PathVariable("id") final Long id) {
return convertCcToShortDto(service.findById(id));
}
Expand All @@ -174,7 +174,7 @@ public CcShortDTO get(@PathVariable("id") final Long id) {
* @return JSON containing the cohort characterization specification
*/
@GetMapping(value = "/{id}/design", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted('read:cohort-characterization','write:cohort-characterization') or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
public CohortCharacterizationDTO getDesign(@PathVariable("id") final Long id) {
CohortCharacterizationEntity cc = service.findByIdWithLinkedEntities(id);
ExceptionUtils.throwNotFoundExceptionIfNull(cc, String.format("There is no cohort characterization with id = %d.", id));
Expand Down Expand Up @@ -248,7 +248,7 @@ public CohortCharacterizationDTO doImport(@RequestBody final CcExportDTO dto) {
* @return JSON containing the cohort characterization definition
*/
@GetMapping(value = "/{id}/export", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted('read:cohort-characterization','write:cohort-characterization') or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
public String export(@PathVariable("id") final Long id) {
return service.serializeCc(id);
}
Expand All @@ -259,7 +259,7 @@ public String export(@PathVariable("id") final Long id) {
* @return A zip file containing three csv files (mappedConcepts, includedConcepts, conceptSetExpression)
*/
@GetMapping(value = "/{id}/export/conceptset", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted('read:cohort-characterization') or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted('read:cohort-characterization','write:cohort-characterization') or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
public ResponseEntity<StreamingResponseBody> exportConceptSets(@PathVariable("id") final Long id) {

CohortCharacterizationEntity cc = service.findById(id);
Expand Down Expand Up @@ -287,7 +287,7 @@ public CheckResult runDiagnostics(@RequestBody CohortCharacterizationDTO charact
* @return A json object with information about the generation job included the status and execution id.
*/
@PostMapping(value = "/{id}/generation/{sourceKey}", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("(isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted(anyOf('write:cohort-characterization','read:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)) and (isPermitted('write:source') or hasSourceAccess(#sourceKey, WRITE))")
@PreAuthorize("(isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('write:cohort-characterization','read:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)) and (isPermitted('write:source') or hasSourceAccess(#sourceKey, WRITE))")
public JobExecutionResource generate(@PathVariable("id") final Long id, @PathVariable("sourceKey") final String sourceKey) {
CohortCharacterizationEntity cc = service.findByIdWithLinkedEntities(id);
ExceptionUtils.throwNotFoundExceptionIfNull(cc, String.format("There is no cohort characterization with id = %d.", id));
Expand All @@ -305,7 +305,7 @@ public JobExecutionResource generate(@PathVariable("id") final Long id, @PathVar
* @return Status code
*/
@DeleteMapping(value = "/{id}/generation/{sourceKey}")
@PreAuthorize("(isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted(anyOf('write:cohort-characterization','read:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)) and (isPermitted('write:source') or hasSourceAccess(#sourceKey, WRITE))")
@PreAuthorize("(isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('write:cohort-characterization','read:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)) and (isPermitted('write:source') or hasSourceAccess(#sourceKey, WRITE))")
public ResponseEntity<Void> cancelGeneration(@PathVariable("id") final Long id, @PathVariable("sourceKey") final String sourceKey) {
service.cancelGeneration(id, sourceKey);
return ResponseEntity.ok().build();
Expand All @@ -317,7 +317,7 @@ public ResponseEntity<Void> cancelGeneration(@PathVariable("id") final Long id,
* @return An array of all generations that includes the generation id, sourceKey, start and end times
*/
@GetMapping(value = "/{id}/generation", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
public List<CommonGenerationDTO> getGenerationList(@PathVariable("id") final Long id) {

Map<String, Source> sourcesMap = sourceService.getSourcesMap(SourceMapKey.BY_SOURCE_KEY);
Expand Down Expand Up @@ -525,7 +525,7 @@ public void unassignPermissionProtectedTag(@PathVariable("id") final long id, @P
* @return
*/
@GetMapping(value = "/{id}/version", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
public List<VersionDTO> getVersions(@PathVariable("id") final long id) {
return service.getVersions(id);
}
Expand All @@ -538,7 +538,7 @@ public List<VersionDTO> getVersions(@PathVariable("id") final long id) {
* @return
*/
@GetMapping(value = "/{id}/version/{version}", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
public CcVersionFullDTO getVersion(@PathVariable("id") final long id, @PathVariable("version") final int version) {
return service.getVersion(id, version);
}
Expand Down
Loading
Loading