Skip to content

Commit

Permalink
Merge pull request #112 from hmrc/API-7711
Browse files Browse the repository at this point in the history
API-7711 - API Publisher to accept no scopes at all
mi-akram authored Aug 22, 2024

Verified

This commit was signed with the committer’s verified signature. The key has expired.
DigitalBrains1 Peter Lebbing
2 parents 4d28b8f + ed8e5c4 commit d53743d
Showing 10 changed files with 269 additions and 47 deletions.
1 change: 0 additions & 1 deletion app/resources/api-definition-schema.json
Original file line number Diff line number Diff line change
@@ -292,7 +292,6 @@
}
},
"required": [
"scopes",
"api"
],
"additionalProperties": false
14 changes: 7 additions & 7 deletions app/uk/gov/hmrc/apipublisher/models/PublisherRequest.scala
Original file line number Diff line number Diff line change
@@ -54,8 +54,11 @@ object ApiVersionSource {
}
}

case class ApiAndScopes(api: JsObject, scopes: JsArray) {
private lazy val definedScopes: Seq[String] = (scopes \\ "key").map(_.as[String]).toSeq
case class ApiAndScopes(api: JsObject, scopes: Option[JsArray]) {

private lazy val definedScopes: Seq[String] =
if (scopes.nonEmpty) (scopes.get \\ "key").map(_.as[String]).toSeq
else Seq.empty

lazy val apiScopes: Seq[String] = (api \ "versions" \\ "scope").map(_.as[String]).toSeq

@@ -126,11 +129,8 @@ object ApiAndScopes {
val retrievedScopesKeys: Seq[String] = retrievedScopes.map(scope => scope.key)
val scopesRequiredByApi: Seq[String] = apiAndScopes.definedScopes ++ apiAndScopes.apiScopes
val missing = scopesRequiredByApi.filterNot(retrievedScopesKeys.contains)
if (missing.nonEmpty) {
ScopesNotDefined(s"Undefined scopes used in definition: ${missing.mkString("[", ", ", "]")}")
} else {
ScopesDefinedOk
}
if (scopesRequiredByApi.isEmpty || missing.isEmpty) ScopesDefinedOk
else ScopesNotDefined(s"Undefined scopes used in definition: ${missing.mkString("[", ", ", "]")}")
}
}

8 changes: 4 additions & 4 deletions app/uk/gov/hmrc/apipublisher/services/PublisherService.scala
Original file line number Diff line number Diff line change
@@ -47,7 +47,7 @@ class PublisherService @Inject() (

def publish(apiAndScopes: ApiAndScopes): Future[JsObject] = {
for {
_ <- apiScopeConnector.publishScopes(apiAndScopes.scopes)
_ <- if (apiAndScopes.scopes.nonEmpty) apiScopeConnector.publishScopes(apiAndScopes.scopes.get) else successful(())
api = apiDetailsWithServiceLocation(apiAndScopes)
_ <- apiDefinitionConnector.publishAPI(api)
_ <- publishFieldDefinitions(apiAndScopes.fieldDefinitions)
@@ -85,8 +85,8 @@ class PublisherService @Inject() (

def checkScopesForErrors(scopeServiceScopes: Seq[Scope], scopeSeq: Seq[Scope]): Future[Option[JsObject]] = {
for {
scopeErrors <- apiScopeConnector.validateScopes(apiAndScopes.scopes)
scopeChangedErrors <- successful(scopesRemainUnchanged(scopeServiceScopes, scopeSeq))
scopeErrors <- if (scopeSeq.nonEmpty) apiScopeConnector.validateScopes(apiAndScopes.scopes.get) else successful(None)
scopeChangedErrors <- if (scopeSeq.nonEmpty) successful(scopesRemainUnchanged(scopeServiceScopes, scopeSeq)) else successful(None)
apiErrors <- conditionalValidateApiDefinition(apiAndScopes, validateApiDefinition)
fieldDefnErrors <- apiSubscriptionFieldsConnector.validateFieldDefinitions(apiAndScopes.fieldDefinitions.flatMap(_.fieldDefinitions))
} yield {
@@ -116,7 +116,7 @@ class PublisherService @Inject() (
}
}

val scopeSeq: Seq[Scope] = apiAndScopes.scopes.as[Seq[Scope]]
val scopeSeq: Seq[Scope] = if (apiAndScopes.scopes.nonEmpty) apiAndScopes.scopes.get.as[Seq[Scope]] else Seq.empty
val scopesSearch: Set[String] = (scopeSeq.map(s => s.key).toList ++ apiAndScopes.apiScopes).toSet

apiScopeConnector.retrieveScopes(scopesSearch) flatMap { scopeServiceScopes =>
4 changes: 2 additions & 2 deletions docs/api-definition.md
Original file line number Diff line number Diff line change
@@ -4,8 +4,8 @@ Generated from [JSON schema](app/resources/api-definition-schema.json)
HMRC API definition. See [JSON definition]

| Name | Type | Required | Values | Description |
| --- | --- | --- | --- | --- |
| `scopes` | _object[]_ | Required | [scopes](#scopes) | This _object[]_ should be an empty list and all OAuth scopes used by an API should now be defined in [the api-scopes service JSON scopes file](https://github.com/hmrc/api-scope/blob/master/conf/scopes.json). If processing the contents of this _object[]_ would result in creating new or changing existing scopes then the API will not be published. See [this Confluence post](https://confluence.tools.tax.service.gov.uk/display/TEC/2021/09/07/Changes+to+scopes)
| --- | --- |----------| --- | --- |
| `scopes` | _object[]_ | Optional | [scopes](#scopes) | This _object[]_ should be an empty list and all OAuth scopes used by an API should now be defined in [the api-scopes service JSON scopes file](https://github.com/hmrc/api-scope/blob/master/conf/scopes.json). If processing the contents of this _object[]_ would result in creating new or changing existing scopes then the API will not be published. See [this Confluence post](https://confluence.tools.tax.service.gov.uk/display/TEC/2021/09/07/Changes+to+scopes)
| `api` | _object_ | Required | [api](#api) | Details of the API |
### `scopes`
Details of an OAuth scope
226 changes: 218 additions & 8 deletions it/test/uk/gov/hmrc/apipublisher/PublisherFeatureSpec.scala
Original file line number Diff line number Diff line change
@@ -42,28 +42,28 @@ class PublisherFeatureSpec extends BaseFeatureSpec {
Scenario("Publisher receive an API notification") {

Given("A microservice is running with an API Definition")
apiProducerMock.register(get(urlEqualTo("/api/definition")).willReturn(aResponse().withBody(definitionJson)))
apiProducerMock.register(get(urlEqualTo("/api/definition")).willReturn(aResponse().withBody(definitionJsonWithScopes)))
apiProducerMock.register(get(urlEqualTo("/api/conf/1.0/application.raml")).willReturn(aResponse().withBody(raml_1_0)))
apiProducerMock.register(get(urlEqualTo("/api/conf/2.0/application.raml")).willReturn(aResponse().withBody(raml_2_0)))
apiProducerMock.register(get(urlEqualTo("/api/conf/3.0/application.raml")).willReturn(aResponse().withBody(raml_3_0)))

And("The api definition is running")
And("api definition is running")
// TOOD - restore when api definition no longer rejects updated api
// apiDefinitionMock.register(post(urlEqualTo("/api-definition/validate")).willReturn(aResponse()))
apiDefinitionMock.register(post(urlEqualTo("/api-definition")).willReturn(aResponse()))

And("The api subscription fields is running")
And("api subscription fields is running")
apiSubscriptionFieldsMock.register(put(urlEqualTo(apiSubscriptionFieldsUrlVersion_1_0)).willReturn(aResponse()))
apiSubscriptionFieldsMock.register(put(urlEqualTo(apiSubscriptionFieldsUrlVersion_3_0)).willReturn(aResponse()))
apiSubscriptionFieldsMock.register(post(urlEqualTo("/validate")).willReturn(aResponse()))

And("The api scope is running")
And("api scope is running")
apiScopeMock.register(post(urlEqualTo("/scope")).willReturn(aResponse()))
apiScopeMock.register(post(urlEqualTo("/scope/validate")).willReturn(aResponse()))
apiScopeMock.register(get(urlEqualTo("/scope?keys=read:hello"))
.willReturn(aResponse().withStatus(200).withBody(scopes)))

When("The publisher is triggered")
When("publisher is triggered")
val publishResponse: HttpResponse[String] =
Http(s"$serverUrl/publish")
.header(CONTENT_TYPE, JSON)
@@ -98,7 +98,121 @@ class PublisherFeatureSpec extends BaseFeatureSpec {
.withHeader(CONTENT_TYPE, containing(JSON))
.withRequestBody(equalToJson(fieldDefinitions_3_0)))

And("The api-publisher responded with status 2xx")
And("api-publisher responded with status 2xx")
publishResponse.is2xx shouldBe true
}

Scenario("Publisher receives a call to publish an API with empty scopes in it's definition") {

Given("A microservice is running with an API Definition with empty scopes")
apiProducerMock.register(get(urlEqualTo("/api/definition")).willReturn(aResponse().withBody(definitionJsonWithEmptyScopes)))
apiProducerMock.register(get(urlEqualTo("/api/conf/1.0/application.raml")).willReturn(aResponse().withBody(raml_1_0)))
apiProducerMock.register(get(urlEqualTo("/api/conf/2.0/application.raml")).willReturn(aResponse().withBody(raml_2_0)))
apiProducerMock.register(get(urlEqualTo("/api/conf/3.0/application.raml")).willReturn(aResponse().withBody(raml_3_0)))

And("api definition is running")
// TOOD - restore when api definition no longer rejects updated api
apiDefinitionMock.register(post(urlEqualTo("/api-definition")).willReturn(aResponse()))

And("api subscription fields is running")
apiSubscriptionFieldsMock.register(put(urlEqualTo(apiSubscriptionFieldsUrlVersion_1_0)).willReturn(aResponse()))
apiSubscriptionFieldsMock.register(put(urlEqualTo(apiSubscriptionFieldsUrlVersion_3_0)).willReturn(aResponse()))
apiSubscriptionFieldsMock.register(post(urlEqualTo("/validate")).willReturn(aResponse()))

And("api scope is running")
apiScopeMock.register(post(urlEqualTo("/scope")).willReturn(aResponse()))
apiScopeMock.register(post(urlEqualTo("/scope/validate")).willReturn(aResponse()))
apiScopeMock.register(get(urlEqualTo("/scope?keys=read:hello"))
.willReturn(aResponse().withStatus(200).withBody(scopes)))

When("publisher is triggered")
val publishResponse: HttpResponse[String] =
Http(s"$serverUrl/publish")
.header(CONTENT_TYPE, JSON)
.header(AUTHORIZATION, encodedPublishingKey)
.postData(s"""{"serviceName":"test.example.com", "serviceUrl": "$apiProducerUrl", "metadata": { "third-party-api" : "true" } }""").asString

Then("The scope is validated")
apiScopeMock.verifyThat(postRequestedFor(urlEqualTo("/scope/validate"))
.withHeader(CONTENT_TYPE, containing(JSON)))

Then("The field definitions are validated")
apiSubscriptionFieldsMock.verifyThat(postRequestedFor(urlEqualTo("/validate"))
.withHeader(CONTENT_TYPE, containing(JSON)))

And("The scope is published to the API Scope microservice")
apiScopeMock.verifyThat(postRequestedFor(urlEqualTo("/scope"))
.withHeader(CONTENT_TYPE, containing(JSON))
.withRequestBody(equalToJson(scopes)))

Then("The definition is published to the API Definition microservice")
apiDefinitionMock.verifyThat(postRequestedFor(urlEqualTo("/api-definition"))
.withHeader(CONTENT_TYPE, containing(JSON)))

Then("The field definitions are published to the API Subscription Fields microservice")
apiSubscriptionFieldsMock.verifyThat(putRequestedFor(urlEqualTo(apiSubscriptionFieldsUrlVersion_1_0))
.withHeader(CONTENT_TYPE, containing(JSON))
.withRequestBody(equalToJson(fieldDefinitions_1_0)))

apiSubscriptionFieldsMock.verifyThat(0, putRequestedFor(urlEqualTo(apiSubscriptionFieldsUrlVersion_2_0)))

apiSubscriptionFieldsMock.verifyThat(putRequestedFor(urlEqualTo(apiSubscriptionFieldsUrlVersion_3_0))
.withHeader(CONTENT_TYPE, containing(JSON))
.withRequestBody(equalToJson(fieldDefinitions_3_0)))

And("api-publisher responded with status 2xx")
publishResponse.is2xx shouldBe true
}

Scenario("Publisher receives a call to publish an API with no scopes in it's definition") {

Given("A microservice is running with an API Definition without scopes")
apiProducerMock.register(get(urlEqualTo("/api/definition")).willReturn(aResponse().withBody(definitionJsonWithoutScopes)))
apiProducerMock.register(get(urlEqualTo("/api/conf/1.0/application.raml")).willReturn(aResponse().withBody(raml_1_0)))
apiProducerMock.register(get(urlEqualTo("/api/conf/2.0/application.raml")).willReturn(aResponse().withBody(raml_2_0)))
apiProducerMock.register(get(urlEqualTo("/api/conf/3.0/application.raml")).willReturn(aResponse().withBody(raml_3_0)))

And("api definition is running")
// TOOD - restore when api definition no longer rejects updated api
apiDefinitionMock.register(post(urlEqualTo("/api-definition")).willReturn(aResponse()))

And("api subscription fields is running")
apiSubscriptionFieldsMock.register(put(urlEqualTo(apiSubscriptionFieldsUrlVersion_1_0)).willReturn(aResponse()))
apiSubscriptionFieldsMock.register(put(urlEqualTo(apiSubscriptionFieldsUrlVersion_3_0)).willReturn(aResponse()))
apiSubscriptionFieldsMock.register(post(urlEqualTo("/validate")).willReturn(aResponse()))

And("api scope is running")
apiScopeMock.register(post(urlEqualTo("/scope")).willReturn(aResponse()))
apiScopeMock.register(get(urlEqualTo("/scope?keys=read:hello"))
.willReturn(aResponse().withStatus(200).withBody(scopes)))

When("publisher is triggered")
val publishResponse: HttpResponse[String] =
Http(s"$serverUrl/publish")
.header(CONTENT_TYPE, JSON)
.header(AUTHORIZATION, encodedPublishingKey)
.postData(s"""{"serviceName":"test.example.com", "serviceUrl": "$apiProducerUrl", "metadata": { "third-party-api" : "true" } }""").asString

Then("The field definitions are validated")
apiSubscriptionFieldsMock.verifyThat(postRequestedFor(urlEqualTo("/validate"))
.withHeader(CONTENT_TYPE, containing(JSON)))

Then("The definition is published to the API Definition microservice")
apiDefinitionMock.verifyThat(postRequestedFor(urlEqualTo("/api-definition"))
.withHeader(CONTENT_TYPE, containing(JSON)))

Then("The field definitions are published to the API Subscription Fields microservice")
apiSubscriptionFieldsMock.verifyThat(putRequestedFor(urlEqualTo(apiSubscriptionFieldsUrlVersion_1_0))
.withHeader(CONTENT_TYPE, containing(JSON))
.withRequestBody(equalToJson(fieldDefinitions_1_0)))

apiSubscriptionFieldsMock.verifyThat(0, putRequestedFor(urlEqualTo(apiSubscriptionFieldsUrlVersion_2_0)))

apiSubscriptionFieldsMock.verifyThat(putRequestedFor(urlEqualTo(apiSubscriptionFieldsUrlVersion_3_0))
.withHeader(CONTENT_TYPE, containing(JSON))
.withRequestBody(equalToJson(fieldDefinitions_3_0)))

And("api-publisher responded with status 2xx")
publishResponse.is2xx shouldBe true
}

@@ -127,7 +241,7 @@ class PublisherFeatureSpec extends BaseFeatureSpec {
)
}

Scenario("When fetch of definition.json file from micropservice fails with NOT_FOUND") {
Scenario("When fetch of definition.json file from microservice fails with NOT_FOUND") {

Given("A microservice is running with an invalid API Definition")
apiProducerMock.register(get(urlEqualTo("/api/definition")).willReturn(aResponse().withStatus(NOT_FOUND)))
@@ -192,7 +306,7 @@ class PublisherFeatureSpec extends BaseFeatureSpec {
|}
""".stripMargin

val definitionJson =
val definitionJsonWithScopes =
s"""
|{
| "scopes": [
@@ -246,6 +360,102 @@ class PublisherFeatureSpec extends BaseFeatureSpec {
|}
""".stripMargin

val definitionJsonWithEmptyScopes =
s"""
|{
| "scopes": [
| ],
| "api": {
| "name": "Test",
| "description": "Test API",
| "context": "$apiContext",
| "versions": [
| {
| "version": "1.0",
| "status": "PUBLISHED",
| "fieldDefinitions": [
| {
| "name": "callbackUrl",
| "description": "Callback URL",
| "hint": "Just a hint",
| "type": "URL"
| },
| {
| "name": "token",
| "description": "Secure Token",
| "hint": "Just a hint",
| "type": "SecureToken"
| }
| ]
| },
| {
| "version": "2.0",
| "status": "PUBLISHED"
| },
| {
| "version": "3.0",
| "status": "PUBLISHED",
| "fieldDefinitions": [
| {
| "name": "callbackUrlOnly",
| "description": "Only a callback URL",
| "hint": "Just a hint",
| "type": "URL"
| }
| ]
| }
| ]
| }
|}
""".stripMargin

val definitionJsonWithoutScopes =
s"""
|{
| "api": {
| "name": "Test",
| "description": "Test API",
| "context": "$apiContext",
| "versions": [
| {
| "version": "1.0",
| "status": "PUBLISHED",
| "fieldDefinitions": [
| {
| "name": "callbackUrl",
| "description": "Callback URL",
| "hint": "Just a hint",
| "type": "URL"
| },
| {
| "name": "token",
| "description": "Secure Token",
| "hint": "Just a hint",
| "type": "SecureToken"
| }
| ]
| },
| {
| "version": "2.0",
| "status": "PUBLISHED"
| },
| {
| "version": "3.0",
| "status": "PUBLISHED",
| "fieldDefinitions": [
| {
| "name": "callbackUrlOnly",
| "description": "Only a callback URL",
| "hint": "Just a hint",
| "type": "URL"
| }
| ]
| }
| ]
| }
|}
""".stripMargin

val fieldDefinitions_1_0 =
"""
|{
Original file line number Diff line number Diff line change
@@ -125,13 +125,13 @@ class MicroserviceConnectorSpec extends AsyncHmrcSpec with BeforeAndAfterAll wit
"Return the api definition" in new Setup {
stubFor(get(urlEqualTo("/api/definition")).willReturn(aResponse().withBody(apiAndScopeDefinition)))

await(connector.getAPIAndScopes(testService)).value shouldBe ApiAndScopes(api, scopes)
await(connector.getAPIAndScopes(testService)).value shouldBe ApiAndScopes(api, Some(scopes))
}

"Accept api definition for private API without whitelisted application IDs" in new Setup {
stubFor(get(urlEqualTo("/api/definition")).willReturn(aResponse().withBody(apiAndScopeDefinitionWithoutWhitelisting)))

await(connector.getAPIAndScopes(testService)).value shouldBe ApiAndScopes(apiWithoutWhitelistedAppIDs, scopes)
await(connector.getAPIAndScopes(testService)).value shouldBe ApiAndScopes(apiWithoutWhitelistedAppIDs, Some(scopes))
}

"Default categories to OTHER when API is not in categories map" in new Setup {
Original file line number Diff line number Diff line change
@@ -51,7 +51,7 @@ class PublisherControllerSpec extends AsyncHmrcSpec with GuiceOneAppPerSuite wit

private val api = Json.parse(getClass.getResourceAsStream("/input/api-with-endpoints-and-fields.json")).as[JsObject]
private val scopes = Json.parse(getClass.getResourceAsStream("/input/scopes.json")).as[JsArray]
private val apiAndScopes = ApiAndScopes(api, scopes)
private val apiAndScopes = ApiAndScopes(api, Some(scopes))

private val employeeServiceApproval = APIApproval("employee-paye", "http://employeepaye.example.com", "Employee PAYE", Some("Test Description"), Some(false))

Original file line number Diff line number Diff line change
@@ -68,7 +68,7 @@ class ApiAndScopesSpec extends AsyncHmrcSpec {
}

"Field definitions should be extracted from the JSON definition" in {
val apiAndScopes = ApiAndScopes(api = json("/input/api-with-endpoints-and-fields.json").as[JsObject], scopes = JsArray())
val apiAndScopes = ApiAndScopes(api = json("/input/api-with-endpoints-and-fields.json").as[JsObject], scopes = Some(JsArray()))
val apiContext = apiAndScopes.apiContext
val expectedApiWithoutFieldDefinitions = json("/input/api-with-endpoints.json").as[JsObject]
val expectedApiFieldDefinitions: Seq[ApiFieldDefinitions] = Seq(
Loading

0 comments on commit d53743d

Please sign in to comment.