Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API-7711 - API Publisher to accept no scopes at all #112

Merged
merged 3 commits into from
Aug 22, 2024
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
1 change: 0 additions & 1 deletion app/resources/api-definition-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,6 @@
}
},
"required": [
"scopes",
"api"
],
"additionalProperties": false
Expand Down
14 changes: 7 additions & 7 deletions app/uk/gov/hmrc/apipublisher/models/PublisherRequest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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("[", ", ", "]")}")
}
}

Expand Down
8 changes: 4 additions & 4 deletions app/uk/gov/hmrc/apipublisher/services/PublisherService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 =>
Expand Down
4 changes: 2 additions & 2 deletions docs/api-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
226 changes: 218 additions & 8 deletions it/test/uk/gov/hmrc/apipublisher/PublisherFeatureSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -192,7 +306,7 @@ class PublisherFeatureSpec extends BaseFeatureSpec {
|}
""".stripMargin

val definitionJson =
val definitionJsonWithScopes =
s"""
|{
| "scopes": [
Expand Down Expand Up @@ -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 =
"""
|{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading