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

Revert "Revert "Query by business ids"" #82

Closed
Closed
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
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,25 @@ The API supports several types of POST interaction:
GET https://localhost:5001/<jurisdiction-id>/Bundle
```
which returns any message response that has not been retrieved yet
or
```
GET https://localhost:5001/<jurisdiction-id>/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:

* Acknowledgment messages acknowledging jurisdiction-submitted submission, update, and void messages
* 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.
Expand Down
127 changes: 126 additions & 1 deletion messaging.tests/Integration/BundlesControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<AcknowledgementMessage>((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<AcknowledgementMessage>((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<AcknowledgementMessage>((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<AcknowledgementMessage>((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<AcknowledgementMessage>((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
Expand Down
64 changes: 46 additions & 18 deletions messaging/Controllers/BundlesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace messaging.Controllers
{
Expand All @@ -37,6 +38,7 @@ public BundlesController(ILogger<BundlesController> logger, ApplicationDbContext

/// <summary>
/// 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.
/// </summary>
/// <returns>A Bundle of FHIR messages</returns>
/// <response code="200">Content retrieved successfully</response>
Expand All @@ -46,7 +48,7 @@ public BundlesController(ILogger<BundlesController> logger, ApplicationDbContext
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<Bundle>> GetOutgoingMessageItems(string jurisdictionId, int _count, DateTime _since = default(DateTime), int page = 1)
public async Task<ActionResult<Bundle>> GetOutgoingMessageItems(string jurisdictionId, int _count, string certificateNumber, string deathYear, DateTime _since = default(DateTime), int page = 1)
{
if (_count == 0)
{
Expand All @@ -70,26 +72,43 @@ public BundlesController(ILogger<BundlesController> 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<OutgoingMessageItem> 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<OutgoingMessageItem> 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);
}
Expand All @@ -98,7 +117,7 @@ public BundlesController(ILogger<BundlesController> logger, ApplicationDbContext

// Convert to list to execute the query, capture the result for re-use
int numToSkip = (page - 1) * _count;
IEnumerable<OutgoingMessageItem> outgoingMessages = outgoingMessagesQuery.OrderBy((message) => message.RetrievedAt).Skip(numToSkip).Take(_count);
IEnumerable<OutgoingMessageItem> 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
Expand All @@ -113,39 +132,48 @@ public BundlesController(ILogger<BundlesController> 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)
{
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;
}
Expand Down
19 changes: 14 additions & 5 deletions messaging/NVSSAPI/input/fsh/NVSSAPI_CapStmt.fsh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Loading