diff --git a/README.md b/README.md index 5ab4080..b58fdf8 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,18 @@ The API supports several types of POST interaction: GET https://localhost:5001//Bundle ``` which returns any message response that has not been retrieved yet -or -``` -GET https://localhost:5001//Bundle?_since=yyyy-MM-ddTHH:mm:ss.fffffff -``` -which returns any message created after the datetime provided in the _since parameter + +#### Optional Test Receive Responses GET Parameters + +There are 3 optional parameters that can be included in a GET request to this endpoint. They are `_since`, `certificateNumber`, and `deathYear` + +`_since` is to retrieve all response messages since a given timestamp. It is useful for seeing ALL message responses created in a specific time window. + +`deathYear` and `certificateNumber` can be used together or seperately to retrieve all message responses for the given set of business ids. It is useful for seeing the message response history for a particular record and verifying you successfully retrieved all messages during your testing. + +Providing any of these three search parameters may result in multiple pages of results. To make sure you retrieve all of the pages in the HTTP response, you will need to use pagination. See pagination section for details. + +#### NCHS API GET Request message types The API supports GET requests to retrieve responses from NCHS, including: @@ -80,8 +87,6 @@ The API supports GET requests to retrieve responses from NCHS, including: * Error messages describing problems with jurisdiction-submitted messages * Coding response messages coding jurisdiction-submitted data such as cause of death, race, and ethnicity -The API supports a `_since` parameter that will limit the messages returned to only message responses created since the provided timestamp. - Messages flow from NCHS back to jurisdictions by jurisdiction systems polling the API looking for new responses. This approach of pulling responses rather than NCHS pushing responses to jurisdictions allows return messages without requiring jurisdictions to set up a listening endpoint. diff --git a/messaging.tests/Integration/BundlesControllerTests.cs b/messaging.tests/Integration/BundlesControllerTests.cs index 533fb39..fcc6bdc 100644 --- a/messaging.tests/Integration/BundlesControllerTests.cs +++ b/messaging.tests/Integration/BundlesControllerTests.cs @@ -59,7 +59,7 @@ public async System.Threading.Tasks.Task NewSubmissionMessagePostCreatesNewAckno // This code does not have access to the background jobs, the best that can // be done is checking to see if the response is correct and if it is still // incorrect after the specified delay then assuming that something is wrong - for (int x = 0; x < 5; ++x) { + for (int x = 0; x < 10; ++x) { HttpResponseMessage oneAck = await _client.GetAsync("/MA/Bundle"); updatedBundle = await JsonResponseHelpers.ParseBundleAsync(oneAck); if (updatedBundle.Entry.Count > 0) { @@ -87,6 +87,131 @@ public async System.Threading.Tasks.Task NewSubmissionMessagePostCreatesNewAckno Assert.Equal(recordSubmission.MessageId, parsedMessage.AckedMessageId); } + [Fact] + public async System.Threading.Tasks.Task QueryByBusinessIdsCerficateNumberAndDeathYear() + { + // Clear any messages in the database for a clean test + DatabaseHelper.ResetDatabase(_context); + + // Create a new empty Death Record + DeathRecordSubmissionMessage recordSubmission = new(new DeathRecord()) + { + // Set missing required fields + MessageSource = "http://example.fhir.org", + CertNo = 1, + DeathYear = 2020 + }; + + // Submit that Death Record + HttpResponseMessage createSubmissionMessage = await JsonResponseHelpers.PostJsonAsync(_client, "/MA/Bundle", recordSubmission.ToJson()); + Assert.Equal(HttpStatusCode.NoContent, createSubmissionMessage.StatusCode); + + HttpResponseMessage getBundle = await _client.GetAsync("/MA/Bundle?certificateNumber=" + recordSubmission.CertNo + "&deathYear=" + recordSubmission.DeathYear); + Bundle updatedBundle = await JsonResponseHelpers.ParseBundleAsync(getBundle); + + Assert.Single(updatedBundle.Entry); + BaseMessage parsedMessage = BaseMessage.Parse((Bundle)updatedBundle.Entry[0].Resource); + Assert.Equal(recordSubmission.CertNo, parsedMessage.CertNo); + Assert.Equal(recordSubmission.DeathYear, parsedMessage.DeathYear); + } + + public async System.Threading.Tasks.Task QueryByBusinessIdsDeathYearPagination() + { + // Clear any messages in the database for a clean test + DatabaseHelper.ResetDatabase(_context); + + // Create a new empty Death Record + DeathRecordSubmissionMessage recordSubmission1 = new(new DeathRecord()) + { + // Set missing required fields + MessageSource = "http://example.fhir.org", + CertNo = 1, + DeathYear = 2020 + }; + + DeathRecordSubmissionMessage recordSubmission2 = new(new DeathRecord()) + { + // Set missing required fields + MessageSource = "http://example.fhir.org", + CertNo = 2, + DeathYear = 2020 + }; + + // Submit the Death Records + HttpResponseMessage createSubmissionMessage = await JsonResponseHelpers.PostJsonAsync(_client, "/MA/Bundle", recordSubmission1.ToJson()); + Assert.Equal(HttpStatusCode.NoContent, createSubmissionMessage.StatusCode); + createSubmissionMessage = await JsonResponseHelpers.PostJsonAsync(_client, "/MA/Bundle", recordSubmission2.ToJson()); + Assert.Equal(HttpStatusCode.NoContent, createSubmissionMessage.StatusCode); + + HttpResponseMessage getBundle = await _client.GetAsync("/MA/Bundle?deathYear=2020&_count=1"); + Bundle updatedBundlePage1 = await JsonResponseHelpers.ParseBundleAsync(getBundle); + Assert.Single(updatedBundlePage1.Entry); + BaseMessage parsedMessage = BaseMessage.Parse((Bundle)updatedBundlePage1.Entry[0].Resource); + Assert.Equal(recordSubmission1.CertNo, parsedMessage.CertNo); + Assert.Equal(recordSubmission1.DeathYear, parsedMessage.DeathYear); + + getBundle = await _client.GetAsync("/MA/Bundle?deathYear=2020&_count=1&page=2"); + Bundle updatedBundlePage2 = await JsonResponseHelpers.ParseBundleAsync(getBundle); + Assert.Single(updatedBundlePage1.Entry); + parsedMessage = BaseMessage.Parse((Bundle)updatedBundlePage2.Entry[0].Resource); + Assert.Equal(recordSubmission2.CertNo, parsedMessage.CertNo); + Assert.Equal(recordSubmission2.DeathYear, parsedMessage.DeathYear); + } + + public async System.Threading.Tasks.Task QueryByBusinessIdCerficateNumber() + { + // Clear any messages in the database for a clean test + DatabaseHelper.ResetDatabase(_context); + + // Create a new empty Death Record + DeathRecordSubmissionMessage recordSubmission = new(new DeathRecord()) + { + // Set missing required fields + MessageSource = "http://example.fhir.org", + CertNo = 1, + DeathYear = 2020 + }; + + // Submit that Death Record + HttpResponseMessage createSubmissionMessage = await JsonResponseHelpers.PostJsonAsync(_client, "/MA/Bundle", recordSubmission.ToJson()); + Assert.Equal(HttpStatusCode.NoContent, createSubmissionMessage.StatusCode); + + HttpResponseMessage getBundle = await _client.GetAsync("/MA/Bundle?certificateNumber=" + recordSubmission.CertNo.ToString().PadLeft(6, '0')); + Bundle updatedBundle = await JsonResponseHelpers.ParseBundleAsync(getBundle); + + Assert.Single(updatedBundle.Entry); + BaseMessage parsedMessage = BaseMessage.Parse((Bundle)updatedBundle.Entry[0].Resource); + Assert.Equal(recordSubmission.CertNo, parsedMessage.CertNo); + Assert.Equal(recordSubmission.DeathYear, parsedMessage.DeathYear); + } + + public async System.Threading.Tasks.Task QueryByBusinessIdsDeathYear() + { + // Clear any messages in the database for a clean test + DatabaseHelper.ResetDatabase(_context); + + // Create a new empty Death Record + DeathRecordSubmissionMessage recordSubmission = new(new DeathRecord()) + { + // Set missing required fields + MessageSource = "http://example.fhir.org", + CertNo = 1, + DeathYear = 2020 + }; + + // Submit that Death Record + HttpResponseMessage createSubmissionMessage = await JsonResponseHelpers.PostJsonAsync(_client, "/MA/Bundle", recordSubmission.ToJson()); + Assert.Equal(HttpStatusCode.NoContent, createSubmissionMessage.StatusCode); + + HttpResponseMessage getBundle = await _client.GetAsync("/MA/Bundle?&deathYear=" + recordSubmission.DeathYear); + Bundle updatedBundle = await JsonResponseHelpers.ParseBundleAsync(getBundle); + + Assert.Single(updatedBundle.Entry); + BaseMessage parsedMessage = BaseMessage.Parse((Bundle)updatedBundle.Entry[0].Resource); + Assert.Equal(recordSubmission.CertNo, parsedMessage.CertNo); + Assert.Equal(recordSubmission.DeathYear, parsedMessage.DeathYear); + } + [Fact] public async System.Threading.Tasks.Task UnparsableMessagesCauseAnError() { // Clear any messages in the database for a clean test diff --git a/messaging/Controllers/BundlesController.cs b/messaging/Controllers/BundlesController.cs index 79ab094..e3e6218 100644 --- a/messaging/Controllers/BundlesController.cs +++ b/messaging/Controllers/BundlesController.cs @@ -12,6 +12,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; namespace messaging.Controllers { @@ -37,6 +38,7 @@ public BundlesController(ILogger logger, ApplicationDbContext /// /// Retrieves outgoing messages for the jurisdiction + /// If the optional Certificate Number and Death year parameters are provided, retrieves all messages in history that match those given business ids. /// /// A Bundle of FHIR messages /// Content retrieved successfully @@ -46,7 +48,7 @@ public BundlesController(ILogger logger, ApplicationDbContext [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> GetOutgoingMessageItems(string jurisdictionId, int _count, DateTime _since = default(DateTime), int page = 1) + public async Task> GetOutgoingMessageItems(string jurisdictionId, int _count, string certificateNumber, string deathYear, DateTime _since = default(DateTime), int page = 1) { if (_count == 0) { @@ -70,26 +72,43 @@ public BundlesController(ILogger logger, ApplicationDbContext _logger.LogError("Rejecting request with invalid page number."); return BadRequest("page must not be negative"); } + bool additionalParamsProvided = !(_since == default(DateTime) && certificateNumber == null && deathYear == null); // Retrieving unread messages changes the result set (as they get marked read), so we don't REALLY support paging - if (_since == default(DateTime) && page > 1) + if (!additionalParamsProvided && page > 1) { _logger.LogError("Rejecting request with a page number but no _since parameter."); return BadRequest("Pagination does not support specifying a page without a _since parameter"); } - try + RouteValueDictionary searchParamValues = new() { - // Limit results to the jurisdiction's messages; note this just builds the query but doesn't execute until the result set is enumerated - IQueryable outgoingMessagesQuery = _context.OutgoingMessageItems.Where(message => (message.JurisdictionId == jurisdictionId)); + { "jurisdictionId", jurisdictionId }, + { "_count", _count } + }; + if (certificateNumber != null) { + // Pad left with leading zeros if not a 6-digit certificate number. + certificateNumber = certificateNumber.PadLeft(6, '0'); + searchParamValues.Add("certificateNumber", certificateNumber); + } + if (deathYear != null) { + searchParamValues.Add("deathYear", deathYear); + } + // Query for outgoing messages by jurisdiction ID. Filter by certificate number and death year if those parameters are provided. + IQueryable outgoingMessagesQuery = _context.OutgoingMessageItems.Where(message => message.JurisdictionId == jurisdictionId + && (certificateNumber == null || message.CertificateNumber.Equals(certificateNumber)) + && (deathYear == null || message.EventYear == int.Parse(deathYear))); + + try + { // Further scope the search to either unretrieved messages (or all since a specific time) // TODO only allow the since param in development - // if _since is the default value, then apply the retrieved at logic - if (_since == default(DateTime)) + // if _since is the default value, then apply the retrieved at logic unless certificate number or death year are provided + if (!additionalParamsProvided) { outgoingMessagesQuery = ExcludeRetrieved(outgoingMessagesQuery); } - else + if (_since != default(DateTime)) { outgoingMessagesQuery = outgoingMessagesQuery.Where(message => message.CreatedDate >= _since); } @@ -98,7 +117,7 @@ public BundlesController(ILogger logger, ApplicationDbContext // Convert to list to execute the query, capture the result for re-use int numToSkip = (page - 1) * _count; - IEnumerable outgoingMessages = outgoingMessagesQuery.OrderBy((message) => message.RetrievedAt).Skip(numToSkip).Take(_count); + IEnumerable outgoingMessages = outgoingMessagesQuery.OrderBy((message) => message.CreatedDate).Skip(numToSkip).Take(_count); // This uses the general FHIR parser and then sees if the json is a Bundle of BaseMessage Type // this will improve performance and prevent vague failures on the server, clients will be responsible for identifying incorrect messages @@ -113,28 +132,41 @@ public BundlesController(ILogger logger, ApplicationDbContext // For the usual use case (unread only), the "next" page is just a repeated request. // But when using since, we have to actually track pages string baseUrl = GetNextUri(); - if (_since == default(DateTime)) + if (!additionalParamsProvided) { // Only show the next link if there are additional messages beyond the current message set if (totalMessageCount > outgoingMessages.Count()) { - responseBundle.NextLink = new Uri(baseUrl + Url.Action("GetOutgoingMessageItems", new { jurisdictionId = jurisdictionId, _count = _count })); + responseBundle.NextLink = new Uri(baseUrl + Url.Action("GetOutgoingMessageItems", searchParamValues)); } } else { var sinceFmt = _since.ToString("yyyy-MM-ddTHH:mm:ss.fffffff"); - responseBundle.FirstLink = new Uri(baseUrl + Url.Action("GetOutgoingMessageItems", new { jurisdictionId = jurisdictionId, _since = sinceFmt, _count = _count, page = 1 })); + searchParamValues.Add("_since", sinceFmt); + searchParamValues.Remove("page"); + searchParamValues.Add("page", 1); + responseBundle.FirstLink = new Uri(baseUrl + Url.Action("GetOutgoingMessageItems", searchParamValues)); // take the total number of the original selected messages, round up, and divide by the count to get the total number of pages int lastPage = (outgoingMessagesQuery.Count() + (_count - 1)) / _count; - responseBundle.LastLink = new Uri(baseUrl + Url.Action("GetOutgoingMessageItems", new { jurisdictionId = jurisdictionId, _since = sinceFmt, _count = _count, page = lastPage })); + searchParamValues.Remove("page"); + searchParamValues.Add("page", lastPage); + responseBundle.LastLink = new Uri(baseUrl + Url.Action("GetOutgoingMessageItems", searchParamValues)); if (page < lastPage) { - responseBundle.NextLink = new Uri(baseUrl + Url.Action("GetOutgoingMessageItems", new { jurisdictionId = jurisdictionId, _since = sinceFmt, _count = _count, page = page + 1 })); + searchParamValues.Remove("page"); + searchParamValues.Add("page", page + 1); + responseBundle.NextLink = new Uri(baseUrl + Url.Action("GetOutgoingMessageItems", searchParamValues)); } } + var messages = await System.Threading.Tasks.Task.WhenAll(messageTasks); + DateTime retrievedTime = DateTime.UtcNow; + // update each outgoing message's RetrievedAt field + foreach(OutgoingMessageItem msgItem in outgoingMessages) { + MarkAsRetrieved(msgItem, retrievedTime); + } // Add messages to the bundle foreach (var message in messages) @@ -142,10 +174,6 @@ public BundlesController(ILogger logger, ApplicationDbContext responseBundle.AddResourceEntry((Bundle)message, "urn:uuid:" + message.MessageId); } - // update each outgoing message's RetrievedAt field - foreach(OutgoingMessageItem msgItem in outgoingMessages) { - MarkAsRetrieved(msgItem, retrievedTime); - } _context.SaveChanges(); return responseBundle; } diff --git a/messaging/NVSSAPI/input/fsh/NVSSAPI_CapStmt.fsh b/messaging/NVSSAPI/input/fsh/NVSSAPI_CapStmt.fsh index 633945d..b54c509 100644 --- a/messaging/NVSSAPI/input/fsh/NVSSAPI_CapStmt.fsh +++ b/messaging/NVSSAPI/input/fsh/NVSSAPI_CapStmt.fsh @@ -13,8 +13,17 @@ Usage: #definition * fhirVersion = #4.0.1 * format = #json * rest.mode = #server -* rest.resource[+].type = #Bundle -* rest.resource[=].interaction[0].code = #search-type -* rest.resource[=].searchParam[0].name = "_since" -* rest.resource[=].searchParam[=].type = #date -* rest.resource[=].interaction[+].code = #create +* rest.resource[+] + * type = #Bundle + * interaction[+].code = #search-type + * interaction[+].code = #create + * searchParam[+] + * name = "_since" + * type = #date + * searchParam[+] + * name = "certificateNumber" + * type = #string + * searchParam[+] + * name = "deathYear" + * type = #string + diff --git a/messaging/Services/ConvertToIJEBackgroundWork.cs b/messaging/Services/ConvertToIJEBackgroundWork.cs index 7adeb83..0476cc3 100644 --- a/messaging/Services/ConvertToIJEBackgroundWork.cs +++ b/messaging/Services/ConvertToIJEBackgroundWork.cs @@ -59,6 +59,9 @@ public async Task DoWork(Message message, CancellationToken cancellationToken) outgoingMessageItem.Message = errorMessage.ToJSON(); outgoingMessageItem.MessageId = errorMessage.MessageId; outgoingMessageItem.MessageType = errorMessage.GetType().Name; + outgoingMessageItem.CertificateNumber = errorMessage.CertNo.ToString().PadLeft(6, '0'); + outgoingMessageItem.EventYear = errorMessage.DeathYear; + outgoingMessageItem.EventType = "MOR"; this._context.OutgoingMessageItems.Add(outgoingMessageItem); } await this._context.SaveChangesAsync(); @@ -118,6 +121,9 @@ private void CreateAckMessage(BaseMessage message, IncomingMessageItem databaseM outgoingMessageItem.Message = ackMessage.ToJSON(); outgoingMessageItem.MessageId = ackMessage.MessageId; outgoingMessageItem.MessageType = ackMessage.GetType().Name; + outgoingMessageItem.CertificateNumber = ackMessage.CertNo.ToString().PadLeft(6, '0'); + outgoingMessageItem.EventYear = ackMessage.DeathYear; + outgoingMessageItem.EventType = "MOR"; this._context.OutgoingMessageItems.Add(outgoingMessageItem); this._context.SaveChanges(); }