by Horatiu Dan
1. Introduction
According to Richardson Maturity Model [Reference 1], a Level 3 REST architecture introduces the discoverability through hypermedia controls in addition to resources and HTTP verbs, thus making the communication between the involved actors more self-documenting.
Hypermedia enriches the interaction from various perspectives, decreasing the coupling between parties and also allowing them to evolve independently. Moreover, the data enclosed in the exchanged messages is enhanced with links, which makes the overall exchanged information more accurate. On the other hand, developers now need to pay more attention when thinking the design, as the representations have a greater impact.
HATEOAS (Hypermedia as the Engine of the Application State) is an architectural component that allows driving application state (resources’ representations) enhanced with hypermedia support.
Currently, the most common REST API implementations are those conforming to Level 2 [Reference 1] which most of the times fully solve the required business problems and thus are enough. A Level 3 one on the other hand, provides more insights as previously stated. Moreover, a REST API service provider can make a better promise [Reference 2] to its customers, if for certain business use cases additional pieces of information (hints) are provided. If leveraged, these might make the contract between the two go even smoother.
This post describes how such hints can be transmitted to clients via the optional but useful ‘Sunset’ and ‘Deprecation’ HTTP Headers.
2. Context
Just as any other software product, APIs have their own lifecycle (Reference 3). It is not uncommon for any target of a HTTP request, that is a resource, to no longer satisfy the requirements. While this may happen from various purposes, the concept is referred to as deprecation and means the resource is still operational although an alternative is available and recommended to be used instead. Usually, at some point, the former will reach its end of life (sunset) when it is decommissioned and finally removed.
No doubt, a process as the brief one described above may be accommodated via documentation and announcements towards the clients so that the contract is not broken and the communication is up and running correctly. This is imperative and obviously all service providers shall make sure they fulfill it. In addition, the ‘Deprecation’ and ‘Sunset’ HTTP headers may be used to help during this transition phase.
Moreover, apart from deprecation, the ‘Sunset’ header alone helps handling some other resource lifecycle aspects that include temporary state, migration or retention (Reference 4).
This post aims to exemplify several use cases where the two headers are used in a Level 3 REST API. In this direction, a service provider adds optional but useful hints for its clients, out of courtesy.
3. Project
As part of this post, a small project that leverages ‘Sunset’ and ‘Deprecation’ HTTP headers is implemented. The purpose is to come closer to the service clients and provide them with more pieces of information that makes the interaction more intuitive in case of following technical situations:
- Temporary Resources Handling [Reference 4]
- Resources Retention [Reference 4]
- Resources Deprecation [References 4 and 5]
The project simulates a code review application where the domain is represented by a single entity – Review – that may be in a certain status throughout its lifecycle – DRAFT, OPEN, CLOSED, CANCELLED. In addition to attributes as id, status and description, a review is described by the following date fields – dateCreated, dateOpened, dateClosed and dateCancelled.
A REST controller uses a service that further accesses the entities via a Spring Data JPA repository on top of an in-memory H2 database table.
The exposed operations are:
- Retrieve all Reviews – GET /reviews
- Retrieve one Review – GET /reviews/{id}
- Search Reviews – GET /reviews/search?filter=pattern
The exchanged messages include hypermedia and are HAL (Hypertext Application Language) formatted.
4. Use Cases to Analyze
As already mentioned above, apart from the normal implementation, we focus on the following three business use cases:
- A DRAFT review is a newly created one that has not been OPEN yet. If not opened within 2 days, it will be permanently deleted.
Technically, GET /review/{id} behaves differently before and after the moment designated by (dateCreated + 2 days):
- before – the DRAFT review is returned – 200 OK
- after – the DRAFT review is not returned anymore – 404 Not Found
- A recently created review (DRAFT) may be then either CLOSED or CANCELLED. If CANCELLED, it will be kept in the system for 1 year and then permanently deleted.
Again, technically, GET /review/{id} behaves differently before and after the moment designated by (dateCancelled + 1 year):
- before – the CANCELLED review is returned – 200 OK
- after – the CANCELLED review is not returned anymore – 404 Not Found
- At some point, for specific reasons, the product team
decides GET /reviews endpoint will be removed. This will happen
gradually, in two stages.
- first, by stating it is not the preferred way to retrieve all reviews and recommending favoring GET /reviews/search instead
- then by effectively decommissioning and removing it.
Technically, the two endpoints behave differently before and after the date of the first stage (let it be date1) and the date of the second stage (let it be date2) respectively:
- before date1 – both GET /reviews and GET /reviews/search are usable (200 OK)
- after date1, but before date2 – again, both GET /reviews and GET /reviews/search are usable (200 OK)
- after date2 – GET /reviews will not work anymore (404 Not Found), while GET /reviews/search will continue to be usable (200 OK)
Each of the three represents a technical use case as described in [Reference 4] and [Reference 5]
5. Solution Design
This section outlines how the previously mentioned use cases may be addressed not only via documentation, but also with additional “hints” that may help the clients.
- Temporary Resource Handling – the response of each GET /review/{id} in case of a DRAFT review will contain the ‘Sunset’ header.
Sunset: dateCreated + 2 days
If interpreted, the header informs the client the resource will not be available anymore after the ‘Sunset’ date.
- Resource Retention – the response of each GET /review/{id} in case of a CANCELLED review will contain the ‘Sunset’ header.
Sunset: dateCancelled + 1 year
If interpreted, the header informs the client the resource will not be available anymore after the ‘Sunset’ date.
- Resource Deprecation
- first stage – the response of GET /reviews will contain the ‘Deprecation’ and ‘Link’ headers.
Deprecation: true Link: http://reviews/search?filter=pattern; rel="alternate",https://technically-correct.eu/deprecation-policy; rel="deprecation"
If interpreted, the two headers inform the client the endpoint is considered deprecated, provide an alternative and also a way of finding more about this decision.
or
Deprecation: 01 Jan 2021 00:00:00 GMT Link: http://reviews/search?filter=pattern; rel="alternate",https://technically-correct.eu/deprecation-policy; rel="deprecation"
If interpreted, the two headers inform the client the endpoint is considered deprecated since 01 Jan 2021 00:00:00 GMT, provide an alternative and also a way of finding more about this decision.
- second stage – the response of GET /reviews will additionally contain the ‘Sunset’ header
Deprecation: true Link: http://reviews/search?filter=pattern; rel="alternate",https://technically-correct.eu/deprecation-policy; rel="deprecation" Sunset: 31 Dec 2021 23:59:59 GMT
If interpreted, the three headers inform the client the endpoint is considered deprecated, provide an alternative and also a way of finding more about this decision. Moreover, they say the endpoint is responsive until the ‘Sunset’ date and then decommissioned.
or
Deprecation: 01 Jan 2021 00:00:00 GMT Link: http://reviews/search?filter=pattern; rel="alternate",https://technically-correct.eu/deprecation-policy; rel="deprecation" Sunset: 31 Dec 2021 23:59:59 GMT
If interpreted, the three headers inform the client the endpoint is considered deprecated since 01 Jan 2021 00:00:00 GMT, provide an alternative and also a way of finding more about this decision. Moreover, they say the endpoint is responsive until the ‘Sunset’ date and then decommissioned.
6. Project Implementation
‘sunsetheader’ is the sample project, especially developed for the purpose of this post. It uses the following:
- Java 11
- Spring Boot 2.4.1
- Spring HATEOAS 1.2.2
- Apache Maven 3.6.3
Since it designates a code review application, the domain is represented by a Review entity. A minimum number of attributes are declared, the most significant ones being status and the dates that actually are milestones of a status change.
@Data @NoArgsConstructor @Entity public class Review { @Id @GeneratedValue private Long id; private String description; private Status status; private LocalDateTime dateCreated; private LocalDateTime dateOpened; private LocalDateTime dateClosed; private LocalDateTime dateCancelled; public Review(Status status, String description, LocalDateTime dateCreated) { this.description = description; this.status = status; this.dateCreated = dateCreated; } public enum Status { DRAFT, OPEN, CLOSED, CANCELLED; } }
Since this is a sample project, a few reviews are ingested (one for each status), right at start-up via a CommandLineRunner.
@Bean CommandLineRunner initData(ReviewRepository repository) { return args -> { Review draft = new Review(Status.DRAFT, "Draft review.", LocalDateTime.now()); Review open = new Review(Status.OPEN, "Open review.", LocalDateTime.now()); open.setDateOpened(open.getDateCreated().plusMinutes(5)); Review closed = new Review(Status.CLOSED, "Closed review.", LocalDateTime.now()); closed.setDateOpened(closed.getDateCreated().plusMinutes(10)); closed.setDateClosed(closed.getDateOpened().plusDays(3)); Review cancelled = new Review(Status.CANCELLED, "Cancelled review.", LocalDateTime.now()); cancelled.setDateOpened(cancelled.getDateCreated().plusMinutes(15)); cancelled.setDateCancelled(cancelled.getDateCreated().plusMonths(1)); List.of(draft, open, closed, cancelled) .forEach(review -> log.info("Load " + repository.save(review))); }; }
As specified in section 3, the implemented operations are exposed from the ReviewController.
@RestController public class ReviewController { private final ReviewService service; private final ReviewModelAssembler assembler; @Autowired public ReviewController(ReviewService service, ReviewModelAssembler assembler) { this.service = service; this.assembler = assembler; } @GetMapping("/reviews") public ResponseEntity<?> all(HttpServletResponse response) { final List<Review> reviews = service.findAll(); List<EntityModel<Review>> content = reviews.stream() .map(assembler::toModel) .collect(toList()); Link link = linkTo(methodOn(getClass()).all(response)).withSelfRel(); return ResponseEntity.ok() .body(of(content, link)); } @GetMapping("/reviews/{id}") public ResponseEntity<?> one(HttpServletResponse response, @PathVariable Long id) { try { Review review = service.findOne(id); return ResponseEntity.ok(assembler.toModel(review)); } catch (EntityNotFoundException e) { return ResponseEntity.notFound().build(); } } @GetMapping("/reviews/search") public ResponseEntity<?> search(@RequestParam(name = "filter", required = true) String filter) { final List<Review> reviews = service.search(filter); List<EntityModel<Review>> content = reviews.stream() .map(assembler::toModel) .collect(toList()); Link link = linkTo(methodOn(getClass()).search(filter)).withSelfRel(); return ResponseEntity.ok() .body(of(content, link)); } }
Since a Level 3 REST API is intended, a RepresentationModelAssembler is used to help in achieving it. One may also observe the status transitions a Review is permitted to go through.
@Component public class ReviewModelAssembler implements RepresentationModelAssembler<Review, EntityModel<Review>> { @Override public EntityModel<Review> toModel(Review entity) { EntityModel<Review> model = EntityModel.of(entity); model.add(linkTo(methodOn(ReviewController.class).one(null, entity.getId())).withSelfRel(), linkTo(methodOn(ReviewController.class).all(null)).withRel("reviews"), linkTo(methodOn(ReviewController.class).search("pattern")).withRel("search")); if (entity.getStatus() == Status.DRAFT) { model.add(linkTo(methodOn(ReviewController.class).open(entity.getId())).withRel("open")); } else if (entity.getStatus() == Status.OPEN) { model.add(linkTo(methodOn(ReviewController.class).close(entity.getId())).withRel("close")); model.add(linkTo(methodOn(ReviewController.class).cancel(entity.getId())).withRel("cancel")); } return model; } }
The implementation is straight-forward, nothing out of the ordinary. If interested in other particular implemented components (service or repository), one may check directly the project source code (see Resources).
7. Solution Implementation
Basically, sections 4 and 5 describe the goals of this post while this describes a way of actually implementing them.
a. Temporary Resource & Resource Retention
From a solution point of view, both temporary resource handling and resource retention use cases are implemented in the same manner, by leveraging the ‘Sunset’ header. The only difference is what its value represents, as described in section 5.
One may consider such hints are worth mentioning not only in the header, but also as part of the response body itself. For the sake of clarity, the focus in this post is solely on the headers.
Since a Review is a resource that in a certain status may be either temporary (DRAFT) or disposable at some point (CANCELLED), one may say it is ‘Sunsetable’. As such, the following interface is defined and a Review is made to be Sunsetable.
public interface Sunsetable { Optional<LocalDateTime> sunsetDate(); }
The interface defines a single method that provides a sunset date, if available.
public class Review implements Sunsetable { ... @Override public Optional<LocalDateTime> sunsetDate() { LocalDateTime result = null; if (status == Status.DRAFT) { result = dateCreated.plusDays(2); } else if (status == Status.CANCELLED) { result = dateCancelled.plusYears(1); } return Optional.ofNullable(result); } ... }
It may be easily observed that a DRAFT Review sunsets 2 days after its creation, while a CANCELLED one, 1 year after it has been cancelled.
So far, the optional and dynamic ‘sunsetDate’ attribute was attached to a Review. The next step is to make this hint visible to a client requesting a Review.
In order to keep the concern of retrieving a Review and the one that enriches the response with the designated ‘Sunset’ HTTP header decoupled, the implementation of GET /reviews/{id} operation is enhanced to publish an application event once the entity is successfully retrieved and ready to be sent.
@GetMapping("/reviews/{id}") public ResponseEntity<?> one(HttpServletResponse response, @PathVariable Long id) { try { Review review = service.findOne(id); eventPublisher.publishEvent(new SunsetEvent(this, response, review.sunsetDate())); return ResponseEntity.ok(assembler.toModel(review)); } catch (EntityNotFoundException e) { return ResponseEntity.notFound().build(); } }
That is all that needs to be changed at controller level (of course, apart from the injection the ApplicationEventPublisher dependency). The early mentioned application event, contains the ‘sunsetDate’, if available.
@Getter public class SunsetEvent extends ApplicationEvent { private static final long serialVersionUID = 1L; private final HttpServletResponse response; private final Optional<LocalDateTime> value; public SunsetEvent(Object source, HttpServletResponse response, Optional<LocalDateTime> value) { super(source); this.response = response; this.value = value; } }
The last step is to create an ApplicationListener that waits for such events. In case one is received, the ‘Sunset’ HTTP header value is set and visible to clients.
@Component class SunsetEventListener extends AbstractEventListener { @Override public void onApplicationEvent(SunsetEvent event) { Optional<LocalDateTime> date = event.getValue(); if (date.isPresent()) { event.getResponse().addHeader("Sunset", format(date.get())); } } }
Examples:
- Request a DRAFT review – GET http://localhost:8080/reviews/1
Response Header contains
Sunset: 21 Jan 2021 15:02:29 GMT
Response Body
{ "id": 1, "description": "Draft review.", "status": "DRAFT", "dateCreated": "2021-01-19T15:02:29.503462", "dateOpened": null, "dateClosed": null, "dateCancelled": null, "_links": { "self": { "href": "http://localhost:8080/reviews/1" }, "reviews": { "href": "http://localhost:8080/reviews" }, "search": { "href": "http://localhost:8080/reviews/search?filter=pattern" }, "open": { "href": "http://localhost:8080/reviews/1/open" } } }
The client is informed this Review is available until 21 Jan 2021 15:02:29 GMT (two days after it was created), which means this entity is in a temporary state.
- Request a CANCELLED review – GET http://localhost:8080/reviews/4
Response Header contains
Sunset: 19 Feb 2022 15:02:29 GMT
Response Body
{ "id": 4, "description": "Cancelled review.", "status": "CANCELLED", "dateCreated": "2021-01-19T15:02:29.504462", "dateOpened": "2021-01-19T15:17:29.504462", "dateClosed": null, "dateCancelled": "2021-02-19T15:02:29.504462", "_links": { "self": { "href": "http://localhost:8080/reviews/4" }, "reviews": { "href": "http://localhost:8080/reviews" }, "search": { "href": "http://localhost:8080/reviews/search?filter=pattern" } } }
The client is informed this Review is available until 19 Feb 2022 15:02:29 GMT (one year after it was cancelled), which means this entity is in a temporary state. Also, a hint on the retention policy of such entities is provided.
To conclude, whenever a Sunsetable entity is retrieved, a Sunset application event is published containing the sunset date, if available. Then, the corresponding ApplicationListener is the one that actually adds the ‘Sunset’ HTTP header to the response, if present.
b. Resource Deprecation
As already specified in section 4, the resource that designates the operation of retrieving all Reviews is considered deprecated. On the service side, this is marked through a custom annotation – @DeprecatedResource. See [Reference 6] for the Resource definition.
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DeprecatedResource { String since() default ""; String alternate() default ""; String policy() default ""; String sunset() default ""; }
since – the date since the resource is declared and considered
deprecated
alternate – the suggested alternative (either a new version of the
resource, or another resource)
policy – the deprecation policy
sunset – the date the resourced is decommissioned and becomes
unavailable
Throughout the former stage of the deprecation process (we have already mentioned that usually there are two), the service implementer annotates the method as follows and the intent is clear on this side.
@DeprecatedResource(since = "01 Jan 2021 00:00:00 GMT", alternate = "/reviews/search?filter=pattern", policy = "https://technically-correct.eu/deprecation-policy") @GetMapping("/reviews") public ResponseEntity<?> all(HttpServletResponse response) { final List<Review> reviews = service.findAll(); List<EntityModel<Review>> content = reviews.stream() .map(assembler::toModel) .collect(toList()); Link link = linkTo(methodOn(getClass()).all(response)).withSelfRel(); return ResponseEntity.ok() .body(of(content, link)); }
It should be noted that at this point, the ‘sunset’ value is not provided, as this information most likely is not known. Once available, the latter stage of the process starts and the method is annotated as below.
@DeprecatedResource(since = "01 Jan 2021 00:00:00 GMT", alternate = "/reviews/search?filter=pattern", policy = "https://technically-correct.eu/deprecation-policy", sunset = "31 Dec 2021 23:59:59 GMT")
The next step is to make this hint visible to a client calling this endpoint.
As previously, the concern of retrieving all Reviews and the one that enriches the response with the designated ‘Deprecation’, ‘Link’ and potentially ‘Sunset’ HTTP headers are kept decoupled. The implementation is enhanced so that @DeprecatedResource annotated endpoints are intercepted. An interceptor is defined to accommodate this.
@Component public class DeprecatedResourceInterceptor implements HandlerInterceptor { private final ApplicationEventPublisher eventPublisher; public DeprecatedResourceInterceptor(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; DeprecatedResource deprecated = handlerMethod.getMethod().getAnnotation(DeprecatedResource.class); if (deprecated == null) { return true; } var event = new DeprecatedResourceEvent(this, request, response, deprecated.since(), deprecated.alternate(), deprecated.policy(), deprecated.sunset()); eventPublisher.publishEvent(event); } return true; } }
In case a @DeprecatedResource annotated handler method is called, an ApplicationEvent containing pieces of information related to its deprecation is published.
@Getter public class DeprecatedResourceEvent extends ApplicationEvent { private static final long serialVersionUID = 1L; private final HttpServletRequest request; private final HttpServletResponse response; private final String since; private final String alternate; private final String policy; private final String sunset; public DeprecatedResourceEvent(Object source, HttpServletRequest request, HttpServletResponse response, String since, String alternate, String policy, String sunset) { super(source); this.request = request; this.response = response; this.since = since; this.alternate = alternate; this.policy = policy; this.sunset = sunset; } }
The last step is to create an ApplicationListener that receives such events and adds the HTTP headers accordingly in order to be visible to the clients.
@Component class DeprecatedResourceEventListener extends AbstractEventListener<DeprecatedResourceEvent> { private final ApplicationEventPublisher eventPublisher; @Autowired public DeprecatedResourceEventListener(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } @Override public void onApplicationEvent(DeprecatedResourceEvent event) { event.getResponse().addHeader("Deprecation", deprecation(event)); event.getResponse().addHeader(HttpHeaders.LINK, link(event)); eventPublisher.publishEvent(new SunsetEvent(this, event.getResponse(), parse(event.getSunset()))); } private String deprecation(DeprecatedResourceEvent event) { Optional<LocalDateTime> since = parse(event.getSince()); if (since.isPresent()) { return event.getSince(); } return String.valueOf(true); } private String link(DeprecatedResourceEvent event) { return formatLink(contextPath(event.getRequest()) + event.getAlternate(), "alternate") + "," + formatLink(event.getPolicy(), "deprecation"); } ... }
A few notes are needed.
The value of the ‘Deprecation’ is either the boolean true value
or a date, in case the ‘since’ attribute is provided and valid.
The value of the ‘Link’ contains two pieces of information – the
alternate that is to be favored and the deprecation policy.
Moreover, a SunsetEvent (exactly as described in the previous
subsection) is published.
Throughout the first stage, nothing more will appear in the headers’ section. In case the second phase of the process is reached, a ‘sunset’ value is provided at annotation level and consequently available when the SunsetEvent is published. Thus, a value for the ‘Sunset’ header is added in the response. See the implementation of the SunsetEventListener as presented above.
Examples:
- Request all reviews – GET http://localhost:8080/reviews, during the first stage of the deprecation process
Response Header contains
Deprecation: true Link: http://localhost8080/reviews/search?filter=pattern; rel="alternate",https://technically-correct.eu/deprecation-policy; rel="deprecation"
or
Deprecation: 01 Jan 2021 00:00:00 GMT Link: http://localhost8080/reviews/search?filter=pattern; rel="alternate",https://technically-correct.eu/deprecation-policy; rel="deprecation"
depending on the provisioning of the ‘since’ information.
The client is informed that GET /reviews operation is considered deprecated (since 01 Jan 2021 00:00:00 GMT), encouraged to use /reviews/search?filter=pattern instead and informed about the deprecation policy available at https://technically-correct.eu/deprecation-policy.
- Request all reviews – GET http://localhost:8080/reviews, during the second stage of the deprecation process
Response Header contains
Deprecation: true Link http://localhost8080/reviews/search?filter=pattern; rel="alternate",https://technically-correct.eu/deprecation-policy; rel="deprecation" Sunset: 31 Dec 2021 23:59:59 GMT
or
Deprecation: 01 Jan 2021 00:00:00 GMT Link http://localhost8080/reviews/search?filter=pattern; rel="alternate",https://technically-correct.eu/deprecation-policy; rel="deprecation" Sunset: 31 Dec 2021 23:59:59 GMT
depending on the provisioning of the ‘since’ information.
The client is informed that GET /reviews operation is considered deprecated (since 01 Jan 2021 00:00:00 GMT), encouraged to use /reviews/search?filter=pattern instead and informed about the deprecation policy available at https://technically-correct.eu/deprecation-policy. Moreover, it is communicated the operation is still available until 31 Dec 2021 23:59:59 GMT when it is decommissioned.
It is worth observing, that the value of the ‘Deprecation’ header is either Boolean or Date. This is not something I am very fond of, I would have preferred it either or. On the other hand though, if seen as a best effort, I think it is acceptable.
8. Conclusions
When implementing APIs (in particular REST APIs) the most important aspect is to keep the “promise” to the clients using it, that is to never break the contract. This is mandatory. In addition to correctness, at another level, come the nice-to-have features that a service provider might add so that the clients have an easier and more pleasant use of the service. In this post, two such features are presented – ‘Sunset’ and ‘Deprecation’ HTTP headers – courteous, respectful features that decorate the contract between a client and a server.
Apart from the theoretical aspects, an actual implementation was presented in order to make these concepts more clear.
References
- “Richardson Maturity Model”, by Martin Fowler, March 18th, 2010 – https://martinfowler.com/articles/richardsonMaturityModel.html
- “An API is a promise”, by Erik Wilde, December 16th, 2020 – https://apifriends.com/api-management/an-api-is-a-promise/
- “API Lifecycle Management: Deprecation and Sunsetting”, by Erik Wilde, November 16th, 2020 – https://apifriends.com/api-management/api-lifecycle-management-deprecation-and-sunsetting/
- RFC8594 – The Sunset HTTP Header Field – https://tools.ietf.org/html/rfc8594
- draft-dalal-deprecation-header-03 – The Deprecation HTTP Header Field – https://tools.ietf.org/html/draft-dalal-deprecation-header-03
- Section 2 of RFC7231 – Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content – https://tools.ietf.org/html/rfc7231#section-2
Resources
The fully functional sample project is available in GitHub – https://github.com/horatiucd/sunsetheader
The picture was taken in 2007, while crossing the Adriatic Sea, from Brindisi to Igoumenitsa.