Skip to content

Commit

Permalink
BDOG-2874 status endpoint and it tests
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanrowe committed Nov 10, 2023
1 parent e500210 commit 450c2c7
Show file tree
Hide file tree
Showing 14 changed files with 445 additions and 107 deletions.
108 changes: 83 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

This service enables sending messages into the HMRC Digital workspace on Slack.

The service exposes 2 endpoints:
The service provides 2 ways to send messages:

```
POST /notification # uses legacy incoming webhooks
POST /v2/notification # uses PlatOps Bot (recommended)
POST /notification # (sync) uses legacy incoming webhooks
POST /v2/notification # (async) uses a queue and PlatOps Bot (recommended)
GET /v2/:msgId/status # retrieve status of queued message
```

Both endpoints require a `channelLookup` in order to identify the correct channel for the message to be posted to.
Expand Down Expand Up @@ -147,15 +149,50 @@ curl -X POST -H 'Content-type: application/json' -H 'Authorization: Basic Zm9vOm
localhost:8866/slack-notifications/notification
```

## Response

Response will typically have 200 status code and the following details:

```
{
"successfullySentTo" : [
"channel1",
"channel2"
],
"errors" : [
{
"code" : "error_code_1",
"message" : "Details of a problem"
},
{
"code" : "error_code_2",
"message" : "Details of another problem"
}
],
"exclusions" : [
{
"code" : "exclusion_code",
"message" : "Details of why slack message was not sent"
}
]
}
# error/exclusion codes are stable, messages may change
```

## Setup and example usage of `POST /v2/notification`

This endpoint is asynchronous and utilises `work-item-repo` in order to queue messages for sending at a steady rate of 1 message per channel per second. This is to comply with Slack's [rate limit](https://api.slack.com/docs/rate-limits)

### Auth

This endpoint uses `internal-auth` for access control. If you want to use it then you will need to fork [internal-auth-config](https://github.com/hmrc/internal-auth-config) and raise a PR adding your service to the list of grantees for the `slack-notifications` resource type.

### Example request

Sends a Slack message to the channel specified using the [chat.postMessage](https://api.slack.com/methods/chat.postMessage) endpoint
Queues a Slack message to be sent to the channel specified using the [chat.postMessage](https://api.slack.com/methods/chat.postMessage) endpoint and returns a `msgId`

Here `text` is the text that will be displayed in the desktop notification, think of this like alt text for an image. It will not be displayed in the main body of the message when `blocks` are present, however it will be used as fallback when `blocks` fail to render.

Expand Down Expand Up @@ -191,39 +228,60 @@ body:

## Response

Both endpoints share the same response structure, including error codes.
If the message is queued successfully then you will receive a `202 Accepted` with the following body:

Response will typically have 200 status code and the following details:
```json
{
"msgId": "9013ba0f-68c9-49a1-b508-d7b16159c531"
}
```

This `msgId` can be used to call:

```
GET /v2/:msgId/status
```

There are two statuses, `complete` and `pending`:

```json
{
"msgId": "9013ba0f-68c9-49a1-b508-d7b16159c531",
"status": "pending"
}
```
```json
{
"msgId": "9013ba0f-68c9-49a1-b508-d7b16159c531",
"status": "complete",
"result": {
"successfullySentTo" : [
"channel1",
"channel2"
"channel1"
],
"errors" : [
{
"code" : "error_code_1",
"message" : "Details of a problem"
},
{
"code" : "error_code_2",
"message" : "Details of another problem"
}
],
"exclusions" : [
{
"code" : "exclusion_code",
"message" : "Details of why slack message was not sent"
}
]
"errors" : [],
"exclusions" : []
}
}
```

# error/exclusion codes are stable, messages may change
`result` shares the same structure as the response for `POST /notification`

If your message is unable to be queued because of an issue with the channel lookup or a downstream outage you will not receive a `msgId` and instead just get the `result` e.g.

```json
{
"successfullySentTo" : [],
"errors" : [
{
"code": "repository_not_found",
"message": "..."
}
],
"exclusions" : []
}
```


### Possible error codes are:

|Error Code | Meaning |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class SlackConnector @Inject()(
.withProxy
.execute[HttpResponse]

def postChatMessage(message: SlackMessage)(implicit hc: HeaderCarrier): Future[JsValue] = {
def postChatMessage(message: SlackMessage)(implicit hc: HeaderCarrier): Future[Either[UpstreamErrorResponse, JsValue]] = {
implicit val smF: Format[SlackMessage] = SlackMessage.format

httpClientV2
Expand All @@ -59,7 +59,7 @@ class SlackConnector @Inject()(
.setHeader(HeaderNames.Authorization -> s"Bearer $botToken")
.withBody(Json.toJson(message))
.withProxy
.execute[JsValue]
.execute[Either[UpstreamErrorResponse, JsValue]]
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ package uk.gov.hmrc.slacknotifications.controllers.v2
import play.api.Logging
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json._
import play.api.mvc.{Action, ControllerComponents}
import play.api.mvc.{Action, AnyContent, ControllerComponents, Result}
import uk.gov.hmrc.internalauth.client.{AuthenticatedRequest, BackendAuthComponents, IAAction, Predicate, Resource}
import uk.gov.hmrc.play.bootstrap.backend.controller.BackendController
import uk.gov.hmrc.slacknotifications.controllers.v2.NotificationController.SendNotificationRequest
import uk.gov.hmrc.slacknotifications.model.{ChannelLookup, NotificationResult, SendNotificationResponse}
import uk.gov.hmrc.slacknotifications.model.{ChannelLookup, NotificationResult, NotificationStatus, SendNotificationResponse}
import uk.gov.hmrc.slacknotifications.services.NotificationService

import java.util.UUID
import javax.inject.{Inject, Singleton}
import scala.concurrent.ExecutionContext

Expand All @@ -42,24 +43,32 @@ class NotificationController @Inject()(
Predicate.Permission(Resource.from("slack-notifications", "v2/notification"), IAAction("SEND_NOTIFICATION"))

def sendNotification(): Action[JsValue] =
auth.authorizedAction(predicate).async(parse.json) { implicit request: AuthenticatedRequest[JsValue, _] =>
auth.authorizedAction(predicate).async(parse.json) { implicit request =>
implicit val snrR: Reads[SendNotificationRequest] = SendNotificationRequest.reads
withJsonBody[SendNotificationRequest] { snr =>
notificationService.sendNotification(snr).value.map {
case Left(nr) =>
implicit val format: Format[NotificationResult] = NotificationResult.format
val asJson = Json.toJson(nr)
logger.info(s"Request: $snr resulted in a notification result: $asJson")
Ok(asJson)
InternalServerError(asJson)
case Right(response) =>
implicit val writes: Writes[SendNotificationResponse] = SendNotificationResponse.writes
val asJson = Json.toJson(response)
logger.info(s"Request: $snr was queued with msgId: ${response.msgId.toString}")
Ok(asJson)
Accepted(asJson)
}
}
}

def status(msgId: UUID): Action[AnyContent] =
auth.authorizedAction(predicate).async { implicit request =>
implicit val nsW: Writes[NotificationStatus] = NotificationStatus.writes
notificationService
.getMessageStatus(msgId)
.map(_.fold[Result](NotFound)(status => Ok(Json.toJson(status))))
}

}

object NotificationController {
Expand Down
14 changes: 11 additions & 3 deletions app/uk/gov/hmrc/slacknotifications/model/NotificationResult.scala
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ final case class NotificationResult(
exclusions : Seq[Exclusion] = Nil
) {
def addError(e: Error*): NotificationResult =
copy(errors = errors ++ e)
copy(errors = (errors ++ e).distinct)

def addSuccessfullySent(s: String*): NotificationResult =
copy(successfullySentTo = successfullySentTo ++ s)
copy(successfullySentTo = (successfullySentTo ++ s).distinct)

def addExclusion(e: Exclusion*): NotificationResult =
copy(exclusions = exclusions ++ e)
copy(exclusions = (exclusions ++ e).distinct)
}

object NotificationResult {
Expand All @@ -128,4 +128,12 @@ object NotificationResult {
~ (__ \ "errors" ).format[Seq[Error]]
~ (__ \ "exclusions" ).format[Seq[Exclusion]]
)(apply, unlift(unapply))

def concatResults(results: Seq[NotificationResult]): NotificationResult =
results.foldLeft(NotificationResult())((acc, current) =>
acc
.addSuccessfullySent(current.successfullySentTo: _*)
.addError(current.errors: _*)
.addExclusion(current.exclusions: _*)
)
}
37 changes: 37 additions & 0 deletions app/uk/gov/hmrc/slacknotifications/model/NotificationStatus.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2023 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package uk.gov.hmrc.slacknotifications.model

import play.api.libs.functional.syntax.{toFunctionalBuilderOps, unlift}
import play.api.libs.json._

import java.util.UUID

case class NotificationStatus(
msgId : UUID,
status: String,
result: Option[NotificationResult]
)

object NotificationStatus {
implicit val nrF: Format[NotificationResult] = NotificationResult.format
val writes: Writes[NotificationStatus] =
( (__ \ "msgId" ).write[UUID]
~ (__ \ "status").write[String]
~ (__ \ "result").writeNullable[NotificationResult]
)(unlift(unapply))
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import org.mongodb.scala.model.{Filters, IndexModel, IndexOptions, Indexes, Upda
import play.api.Configuration
import uk.gov.hmrc.mongo.MongoComponent
import uk.gov.hmrc.mongo.play.json.Codecs
import uk.gov.hmrc.mongo.play.json.formats.MongoUuidFormats
import uk.gov.hmrc.mongo.workitem.{ProcessingStatus, WorkItem, WorkItemFields, WorkItemRepository}
import uk.gov.hmrc.slacknotifications.model.{NotificationResult, QueuedSlackMessage}

Expand All @@ -46,7 +45,7 @@ class SlackMessageQueueRepository @Inject()(
IndexModel(Indexes.hashed("item.msgId"), IndexOptions().name("msgId-idx").background(true)),
IndexModel(Indexes.descending("updatedAt"), IndexOptions().name("ttl-idx").expireAfter(30, TimeUnit.DAYS))
),
extraCodecs = Seq(Codecs.playFormatCodec(MongoUuidFormats.uuidFormat))
extraCodecs = Seq(Codecs.playFormatCodec(NotificationResult.format))
){
override def now(): Instant =
Instant.now()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class LegacyNotificationService @Inject()(
case Left(fallbackChannel) => sendSlackMessage(fromNotification(notificationRequest, fallbackChannel, Some(Error.unableToFindTeamSlackChannelInUMP(teamName).message)), clientService, Some(teamName)).map(_.copy(errors = Seq(Error.unableToFindTeamSlackChannelInUMP(teamName))))
}
} yield acc :+ notificationRes
}.map(concatResults))
}.map(NotificationResult.concatResults))
resWithExclusions = notificationResult.addExclusion(excluded.map(Exclusion.notARealTeam): _*)
} yield resWithExclusions
}.merge
Expand All @@ -76,7 +76,7 @@ class LegacyNotificationService @Inject()(
case ChannelLookup.SlackChannel(slackChannels) =>
slackChannels.toList.foldLeftM(Seq.empty[NotificationResult]){(acc, slackChannel) =>
sendSlackMessage(fromNotification(notificationRequest, slackChannel), clientService).map(acc :+ _)
}.map(concatResults)
}.map(NotificationResult.concatResults)

case ChannelLookup.TeamsOfGithubUser(githubUsername) =>
if (slackNotificationConfig.notRealGithubUsers.contains(githubUsername))
Expand Down Expand Up @@ -111,7 +111,7 @@ class LegacyNotificationService @Inject()(
case Left(fallbackChannel) => sendSlackMessage(fromNotification(notificationRequest, fallbackChannel, Some(Error.unableToFindTeamSlackChannelInUMP(teamName).message)), service, Some(teamName)).map(_.copy(errors = Seq(Error.unableToFindTeamSlackChannelInUMP(teamName))))
}
} yield acc :+ notificationRes
}}.map(concatResults)
}}.map(NotificationResult.concatResults)
else {
logger.info(s"Failed to find teams for usertype: $userType, username: $username. " +
s"Sending slack notification to Platops admin channel instead")
Expand Down Expand Up @@ -141,14 +141,6 @@ class LegacyNotificationService @Inject()(
resWithExclusions = notificationResult.addExclusion(excluded.map(Exclusion.notARealTeam): _*)
} yield resWithExclusions

private def concatResults(results: Seq[NotificationResult]): NotificationResult =
results.foldLeft(NotificationResult())((acc, current) =>
acc
.addSuccessfullySent(current.successfullySentTo: _*)
.addError(current.errors: _*)
.addExclusion(current.exclusions: _*)
)

private def fromNotification(notificationRequest: NotificationRequest, slackChannel: String, errorMessage: Option[String] = None): LegacySlackMessage = {
import notificationRequest.messageDetails._
// username and user emoji are initially hard-coded to 'slack-notifications' and none,
Expand Down
Loading

0 comments on commit 450c2c7

Please sign in to comment.