Skip to content

Commit f4203c7

Browse files
authored
Merge pull request #151 from hmrc/MTDSA-21817
Added deprecated and sunset header
2 parents c02576b + bc036ff commit f4203c7

21 files changed

+825
-54
lines changed

app/api/controllers/RequestHandler.scala

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,16 @@ import api.models.errors.{ErrorWrapper, InternalError}
2222
import api.models.outcomes.ResponseWrapper
2323
import api.services.ServiceOutcome
2424
import cats.data.EitherT
25-
import cats.implicits._
25+
import cats.data.Validated.Valid
26+
import cats.implicits.*
27+
import config.AppConfig
28+
import config.Deprecation.Deprecated
2629
import play.api.http.Status
2730
import play.api.libs.json.{JsValue, Writes}
2831
import play.api.mvc.Result
2932
import play.api.mvc.Results.InternalServerError
33+
import routing.Version
34+
import utils.DateUtils.longDateTimestampGmt
3035
import utils.Logging
3136

3237
import scala.concurrent.{ExecutionContext, Future}
@@ -36,7 +41,8 @@ trait RequestHandler {
3641
def handleRequest()(implicit
3742
ctx: RequestContext,
3843
request: UserRequest[?],
39-
ec: ExecutionContext
44+
ec: ExecutionContext,
45+
appConfig: AppConfig
4046
): Future[Result]
4147

4248
}
@@ -61,7 +67,7 @@ object RequestHandler {
6167
auditHandler: Option[AuditHandler] = None
6268
) extends RequestHandler {
6369

64-
def handleRequest()(implicit ctx: RequestContext, request: UserRequest[?], ec: ExecutionContext): Future[Result] =
70+
def handleRequest()(implicit ctx: RequestContext, request: UserRequest[?], ec: ExecutionContext, appConfig: AppConfig): Future[Result] =
6571
Delegate.handleRequest()
6672

6773
def withErrorHandling(errorHandling: ErrorHandling): RequestHandlerBuilder[Input, Output] =
@@ -113,14 +119,26 @@ object RequestHandler {
113119
// Scoped as a private delegate so as to keep the logic completely separate from the configuration
114120
private object Delegate extends RequestHandler with Logging with RequestContextImplicits {
115121

116-
implicit class Response(result: Result) {
122+
implicit class Response(result: Result)(implicit appConfig: AppConfig, apiVersion: Version) {
123+
124+
private def withDeprecationHeaders: List[(String, String)] = {
125+
126+
appConfig.deprecationFor(apiVersion) match {
127+
case Valid(Deprecated(deprecatedOn, maybeSunsetDate)) =>
128+
List(
129+
"Deprecation" -> longDateTimestampGmt(deprecatedOn),
130+
"Link" -> appConfig.apiDocumentationUrl
131+
) ++ maybeSunsetDate.map(sunsetDate => "Sunset" -> longDateTimestampGmt(sunsetDate))
132+
case _ => Nil
133+
}
134+
}
117135

118136
def withApiHeaders(correlationId: String, responseHeaders: (String, String)*): Result = {
119137
val headers =
120138
responseHeaders ++
121139
List(
122140
"X-CorrelationId" -> correlationId
123-
)
141+
) ++ withDeprecationHeaders
124142

125143
result.copy(header = result.header.copy(headers = result.header.headers ++ headers))
126144
}
@@ -130,7 +148,8 @@ object RequestHandler {
130148
def handleRequest()(implicit
131149
ctx: RequestContext,
132150
request: UserRequest[?],
133-
ec: ExecutionContext
151+
ec: ExecutionContext,
152+
appConfig: AppConfig
134153
): Future[Result] = {
135154

136155
logger.info(
@@ -157,8 +176,11 @@ object RequestHandler {
157176
private def handleSuccess(parsedRequest: Input, serviceResponse: ResponseWrapper[Output])(implicit
158177
ctx: RequestContext,
159178
request: UserRequest[?],
160-
ec: ExecutionContext
179+
ec: ExecutionContext,
180+
appConfig: AppConfig
161181
): Result = {
182+
implicit val apiVersion: Version = Version(request)
183+
162184
logger.info(
163185
s"[${ctx.endpointLogContext.controllerName}][${ctx.endpointLogContext.endpointName}] - " +
164186
s"Success response received with CorrelationId: ${ctx.correlationId}")
@@ -174,8 +196,11 @@ object RequestHandler {
174196
private def handleFailure(errorWrapper: ErrorWrapper)(implicit
175197
ctx: RequestContext,
176198
request: UserRequest[?],
177-
ec: ExecutionContext
199+
ec: ExecutionContext,
200+
appConfig: AppConfig
178201
): Result = {
202+
implicit val apiVersion: Version = Version(request)
203+
179204
logger.warn(
180205
s"[${ctx.endpointLogContext.controllerName}][${ctx.endpointLogContext.endpointName}] - " +
181206
s"Error response received with CorrelationId: ${ctx.correlationId}")

app/config/AppConfig.scala

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,18 @@
1616

1717
package config
1818

19+
import cats.data.Validated
20+
import cats.implicits.catsSyntaxValidatedId
1921
import com.typesafe.config.Config
22+
import config.Deprecation.{Deprecated, NotDeprecated}
2023
import play.api.{ConfigLoader, Configuration}
2124
import routing.Version
2225
import uk.gov.hmrc.auth.core.ConfidenceLevel
2326
import uk.gov.hmrc.play.bootstrap.config.ServicesConfig
2427

28+
import java.time.LocalDateTime
29+
import java.time.format.{DateTimeFormatter, DateTimeFormatterBuilder}
30+
import java.time.temporal.ChronoField
2531
import javax.inject.{Inject, Singleton}
2632

2733
trait AppConfig {
@@ -72,6 +78,10 @@ trait AppConfig {
7278
def endpointsEnabled(version: Version): Boolean
7379
def endpointsEnabled(version: String): Boolean
7480

81+
def apiDocumentationUrl: String
82+
83+
def deprecationFor(version: Version): Validated[String, Deprecation]
84+
7585
def apiVersionReleasedInProduction(version: String): Boolean
7686

7787
def endpointReleasedInProduction(version: String, name: String): Boolean
@@ -84,6 +94,9 @@ trait AppConfig {
8494

8595
@Singleton
8696
class AppConfigImpl @Inject() (config: ServicesConfig, val configuration: Configuration) extends AppConfig {
97+
// API name
98+
val appName: String = config.getString("appName")
99+
87100
// MTD ID Lookup Config
88101
val mtdIdBaseUrl: String = config.baseUrl(serviceName = "mtd-id-lookup")
89102

@@ -118,8 +131,52 @@ class AppConfigImpl @Inject() (config: ServicesConfig, val configuration: Config
118131
def endpointsEnabled(version: Version): Boolean = config.getBoolean(s"api.${version.name}.endpoints.enabled")
119132
def endpointsEnabled(version: String): Boolean = config.getBoolean(s"api.$version.endpoints.enabled")
120133

134+
private val DATE_FORMATTER = new DateTimeFormatterBuilder()
135+
.append(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
136+
.parseDefaulting(ChronoField.HOUR_OF_DAY, 23)
137+
.parseDefaulting(ChronoField.MINUTE_OF_HOUR, 59)
138+
.parseDefaulting(ChronoField.SECOND_OF_MINUTE, 59)
139+
.toFormatter()
140+
141+
def deprecationFor(version: Version): Validated[String, Deprecation] = {
142+
val isApiDeprecated: Boolean = apiStatus(version) == "DEPRECATED"
143+
144+
val deprecatedOn: Option[LocalDateTime] =
145+
configuration
146+
.getOptional[String](s"api.$version.deprecatedOn")
147+
.map(value => LocalDateTime.parse(value, DATE_FORMATTER))
148+
149+
val sunsetDate: Option[LocalDateTime] =
150+
configuration
151+
.getOptional[String](s"api.$version.sunsetDate")
152+
.map(value => LocalDateTime.parse(value, DATE_FORMATTER))
153+
154+
val isSunsetEnabled: Boolean =
155+
configuration.getOptional[Boolean](s"api.$version.sunsetEnabled").getOrElse(true)
156+
157+
if (isApiDeprecated) {
158+
(deprecatedOn, sunsetDate, isSunsetEnabled) match {
159+
case (Some(dO), Some(sD), true) =>
160+
if (sD.isAfter(dO))
161+
Deprecated(dO, Some(sD)).valid
162+
else
163+
s"sunsetDate must be later than deprecatedOn date for a deprecated version $version".invalid
164+
case (Some(dO), None, true) => Deprecated(dO, Some(dO.plusMonths(6))).valid
165+
case (Some(dO), _, false) => Deprecated(dO, None).valid
166+
case _ => s"deprecatedOn date is required for a deprecated version $version".invalid
167+
}
168+
169+
} else NotDeprecated.valid
170+
171+
}
172+
121173
def apiVersionReleasedInProduction(version: String): Boolean = config.getBoolean(s"api.$version.endpoints.api-released-in-production")
122174

175+
def apiDocumentationUrl: String =
176+
configuration
177+
.getOptional[String]("api.documentation-url")
178+
.getOrElse(s"https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/$appName")
179+
123180
def safeEndpointsEnabled(version: String): Boolean =
124181
configuration
125182
.getOptional[Boolean](s"api.$version.endpoints.enabled")

app/config/Deprecation.scala

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2025 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package config
18+
19+
import java.time.LocalDateTime
20+
21+
sealed trait Deprecation
22+
23+
object Deprecation {
24+
case object NotDeprecated extends Deprecation
25+
26+
case class Deprecated(deprecatedOn: LocalDateTime, sunsetDate: Option[LocalDateTime]) extends Deprecation
27+
28+
}

app/definition/ApiDefinitionFactory.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package definition
1818

19+
import cats.data.Validated.Invalid
1920
import config.AppConfig
2021
import play.api.Logger
2122
import routing.{Version, Version1, Version2}
@@ -58,6 +59,7 @@ class ApiDefinitionFactory @Inject() (appConfig: AppConfig) {
5859
)
5960

6061
private[definition] def buildAPIStatus(version: Version): APIStatus = {
62+
checkDeprecationConfigFor(version)
6163
APIStatus.parser
6264
.lift(appConfig.apiStatus(version))
6365
.getOrElse {
@@ -66,4 +68,9 @@ class ApiDefinitionFactory @Inject() (appConfig: AppConfig) {
6668
}
6769
}
6870

71+
private def checkDeprecationConfigFor(version: Version): Unit = appConfig.deprecationFor(version) match {
72+
case Invalid(error) => throw new Exception(error)
73+
case _ => ()
74+
}
75+
6976
}

app/routing/Versions.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import play.api.mvc.RequestHeader
2222

2323
object Version {
2424

25+
def apply(request: RequestHeader): Version =
26+
Versions.getFromRequest(request).getOrElse(throw new Exception("Missing or unsupported version found in request accept header"))
27+
2528
object VersionWrites extends Writes[Version] {
2629

2730
def writes(version: Version): JsValue = version match {

app/utils/DateUtils.scala

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2025 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package utils
18+
19+
import java.time.format.DateTimeFormatter
20+
import java.time.{LocalDateTime, ZoneId}
21+
import java.util.Locale
22+
23+
object DateUtils {
24+
25+
def longDateTimestampGmt(dateTime: LocalDateTime): String = longDateTimeFormatGmt.format(dateTime)
26+
27+
private val longDateTimeFormatGmt: DateTimeFormatter = DateTimeFormatter
28+
.ofPattern("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)
29+
.withZone(ZoneId.of("GMT"))
30+
31+
}

test/api/controllers/ControllerBaseSpec.scala

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,22 @@
1717
package api.controllers
1818

1919
import api.controllers.ControllerTestRunner.validNino
20+
import api.mocks.MockIdGenerator
2021
import api.models.audit.{AuditError, AuditEvent, AuditResponse, GenericAuditDetail}
22+
import api.models.auth.UserDetails
2123
import api.models.errors.MtdError
2224
import api.services.{MockAuditService, MockEnrolmentsAuthService, MockMtdIdLookupService}
23-
import config.RealAppConfig
25+
import cats.implicits.catsSyntaxValidatedId
26+
import config.Deprecation.NotDeprecated
27+
import config.{AppConfig, MockAppConfig, RealAppConfig}
2428
import play.api.http.{HeaderNames, MimeTypes, Status}
2529
import play.api.libs.json.{JsValue, Json}
26-
import play.api.mvc.{AnyContentAsEmpty, ControllerComponents, Result}
30+
import play.api.mvc.{AnyContent, AnyContentAsEmpty, ControllerComponents, Result}
2731
import play.api.test.Helpers.stubControllerComponents
2832
import play.api.test.{FakeRequest, ResultExtractors}
33+
import routing.{Version, Version2}
2934
import support.UnitSpec
3035
import uk.gov.hmrc.http.HeaderCarrier
31-
import api.mocks.MockIdGenerator
3236

3337
import scala.concurrent.Future
3438

@@ -39,9 +43,13 @@ abstract class ControllerBaseSpec
3943
with HeaderNames
4044
with ResultExtractors
4145
with MockAuditService
42-
with ControllerSpecHateoasSupport {
46+
with ControllerSpecHateoasSupport
47+
with MockAppConfig {
48+
49+
implicit val apiVersion: Version = Version2
4350

44-
implicit lazy val fakeRequest: FakeRequest[AnyContentAsEmpty.type] = FakeRequest()
51+
implicit lazy val fakeRequest: FakeRequest[AnyContentAsEmpty.type] =
52+
FakeRequest().withHeaders(HeaderNames.ACCEPT -> s"application/vnd.hmrc.${apiVersion.name}+json")
4553

4654
lazy val cc: ControllerComponents = stubControllerComponents()
4755

@@ -52,6 +60,11 @@ abstract class ControllerBaseSpec
5260
def fakePostRequest[T](body: T): FakeRequest[T] = fakeRequest.withBody(body)
5361

5462
def fakePutRequest[T](body: T): FakeRequest[T] = fakeRequest.withBody(body)
63+
64+
private val userDetails = UserDetails("mtdId", "Individual", Some("agentReferenceNumber"))
65+
implicit val userRequest: UserRequest[AnyContent] = UserRequest[AnyContent](userDetails, fakeRequest)
66+
implicit val appConfig: AppConfig = mockAppConfig
67+
5568
}
5669

5770
trait ControllerTestRunner extends MockEnrolmentsAuthService with MockMtdIdLookupService with MockIdGenerator with RealAppConfig {
@@ -70,6 +83,7 @@ trait ControllerTestRunner extends MockEnrolmentsAuthService with MockMtdIdLooku
7083
MockedMtdIdLookupService.lookup(nino).returns(Future.successful(Right("test-mtd-id")))
7184
MockedEnrolmentsAuthService.authoriseUser()
7285
MockIdGenerator.generateCorrelationId.returns(correlationId)
86+
MockedAppConfig.deprecationFor(apiVersion).returns(NotDeprecated.valid).anyNumberOfTimes()
7387

7488
protected def runOkTest(expectedStatus: Int, maybeExpectedResponseBody: Option[JsValue] = None): Unit = {
7589
val result: Future[Result] = callController()

test/api/controllers/DocumentationControllerSpec.scala

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,14 @@
1717
package api.controllers
1818

1919
import com.typesafe.config.ConfigFactory
20+
import config.rewriters.*
2021
import config.rewriters.DocumentationRewriters.CheckAndRewrite
21-
import config.rewriters._
2222
import config.{MockAppConfig, RealAppConfig}
2323
import controllers.{AssetsConfiguration, DefaultAssetsMetadata, RewriteableAssets}
24-
import definition._
24+
import definition.*
2525
import play.api.http.{DefaultFileMimeTypes, DefaultHttpErrorHandler, FileMimeTypesConfiguration, HttpConfiguration}
2626
import play.api.mvc.Result
2727
import play.api.{Configuration, Environment}
28-
import routing.{Version, Versions}
2928
import uk.gov.hmrc.http.HeaderCarrier
3029

3130
import scala.concurrent.ExecutionContext.Implicits.global
@@ -35,11 +34,6 @@ class DocumentationControllerSpec extends ControllerBaseSpec with MockAppConfig
3534

3635
private val apiVersionName = s"$latestEnabledApiVersion.0"
3736

38-
protected val apiVersion: Version =
39-
Versions
40-
.getFrom(apiVersionName)
41-
.getOrElse(fail(s"Matching Version object not found for $apiVersionName"))
42-
4337
private val titleLineMatcher = """(.*title:.*)""".r
4438
private val titleMatcher = """^(\s*title:\s*".*?\s*\[test\sonly]).*$""".r
4539

0 commit comments

Comments
 (0)