Standardizing the return format from all endpoints is one of the best things you can do for your clients. This will ensure they always receive a specific object and only need to examine certain values based on the HTTP Status Code. It can significantly simplify the process for systems that call your endpoints, known as consumers, and make it easier for them to understand what they are receiving.
At YTGI, we believe that when you request data, you expect it to be delivered in a specific format. If the data returned is not in the expected format, the system that made the request must handle the unexpected format by implementing appropriate logic. The standard return format ensures the data can be processed correctly and efficiently.
Let’s say that a call to the customer endpoint with a customer number should return a customer object, i.e.:
{
"FirstName": "Jack",
"LastName": "Yasgar",
"CompanyName": "Yasgar Technology Group, Inc."
}
A developer will have code that expects this data and is prepared to push the values into an object in their software, allowing them to use or display it for a specific purpose. Now, suppose no customer is found with the passed customer number. What should the endpoint send back? An empty object may suffice, i.e.:
{
}
Many systems may send back a message like:
HTTP: 404
{
Message: Customer not found
}
I’m noting the “HTTP: 404” to show what HTTP Response Code is typically sent with a return payload. You won’t see that code in text format if you call the endpoint in a browser. You can see all the HTTP Response codes here if you’re interested.
What if an error could be generated if an invalid customer number format is sent? It could return something similar to the above or something like:
HTTP: 400
{
Error: Invalid Customer Number,
Message: The value in the customer number was not recognized
}
What if there is an error in the system retrieving the data:
HTTP: 500
{
Error: There was an error retrieving the customer,
Message: System exception; please try again later
}
Returning messages in this manner requires the caller to have extensive logic to determine the HTTP code returned and then deserialize the content sent back into different objects. It then needs to have unique code to choose the way to process the issue.
RFC 7807 and RFC 9457
The IETF standardized a recommended proposed standard for returning error messages to API consumers in July 2012 as RFC 7807 and again in April 2021 as RFC 9457. Please don’t assume that we recommend ignoring industry standards, as that is not our goal. However, there are a few things to consider.
- Working with dozens of vendors, I’ve only found that a small fraction adhere to this standard
- It still requires the consumer to have custom code for specific errors
Returning messages in this manner requires the caller to use considerable logic to determine the HTTP code returned and then deserialize the content sent back into distinct objects. It then needs to have special code to determine how to process the issue.
At YTGI, we design our microservice endpoints always to return the same object structure. The difference is that the “content” section of the object will contain an array of the objects your code expects. If there is an error, the object returned remains the same; the only difference is that you would need to look in a different section of the object to retrieve any details you may require to display or log for this call.
The base structure of this object is:
{
Metadata: {},
Content: [],
Message: {},
Links: [],
Validations: []
}
Metadata
The Metadata object will contain properties such as Page, PageSize, Count, TotalCount, Filter, SearchTerm, etc. This is all the data you need to make decisions when displaying your data to the user, whether a single record or a list of records.
Example JSON:
"Metadata": {
"Count": 0,
"Offset": 0,
"Limit": 0,
"Page": 0,
"Total": 0,
"SearchTerm": "",
"Filter": "",
"SortExpression": "",
"SortOrder": "",
"SortDescending": true,
"RefId": 0,
"RefUniqueId": "",
"ActiveOnly": false,
"FromDate": "",
"ToDate": ""
}
Legend:
Count | The count returned in this dataset |
Offset | The offset from the first record in the returned dataset |
Limit | The limit of objects that was set during the call (PageSize) |
Page | The page of this call, which is multiplied by the Limit for database calls |
Total | The total amount of records that could be returned with the SearchTerm and Filter if they are provide |
SearchTerm | A search term to limit the request, i.e. “John” |
Filter | A filter above the SearchTerm, i.e. “Company A” |
SortExpression | What was requested to sort the return with, i.e. “Id”, or if your API can handle more complex expressions |
SortOrder | Sort order returned as what was sent in the request, or a default |
SortDescending | Whether the sort should be descending or not as true or false |
RefId | The Reference Id for the return set, this is typically the parent Id. If this was a result of invoices, then the RefId would be the Customer Id |
RefUniqueId | The Reference Unique Id for the return set, this is typically the parent Unique Id. If this was a result of invoices, then the RefUniqueId would be the Customer UniqueId, typically as a GUID. |
ActiveOnly | If the request was for only active records (true) or all records (false). Many systems do not ever delete records, but mark them as inactive in one way or another for reporting purposes |
FromDate | Returns any FromDate sent in the request to limit the response by date |
ToDate | Returns any ToDate sent in the request to limit the response by date |
Content
The content object will contain an array of the object types you expect from this call. If the call returns no objects, i.e., not found or just no records that match your filters, then this collection will be empty. If the endpoint is only expecting one record, i.e. Users/{id}, you would still return it as one record in the array. YTGI standardizes on still returning this as a 200 if it is a paged result set that contains no records. It only returns as a 404 if it is a search by Id that is not found.
Example JSON:
"Content": [
{
"FirstName": "Jack",
"LastName": "Yasgar",
"CompanyName": "Yasgar Technology Group, Inc."
},
{
"FirstName": "Jill",
"LastName": "Yasgar",
"CompanyName": "Yasgar Technology Group, Inc."
}
]
Message
The purpose of the message object is to send back any messages related to the return values. The most exciting aspect of having this section is that you can send messages back even if there is content. Other APIs that only send messages in case of failure do not have this capability, except for using HTTP headers, which are not apparent to someone making the call in a test tool, Swagger, or a browser.
Example JSON:
"Message": {
"Id": 0,
"SQLLevel": 0,
"SQLState": 0,
"Source": "",
"Message": "",
"Description": "",
"Code": "",
"AdditionalInfo": "",
"LogLevel": ""
}
Legend:
Id | If this Message has an Id, such as one stored in a table of standard messages |
SQLLevel | The SQL Level return value |
SQLState | The SQL State value returned from the database call |
Source | The source of the message, i.e. function or stored procedure etc. |
Message | The short message returned for the call |
Description | A description of the issue. In many cases it will be a stack trace |
Code | If you have standard return codes for errors, use that code here |
AdditionalInfo | Any additional information that you want to provide for the response, does not have to be related to any error |
LogLevel | The log level of this response, YTGI uses these: “FATAL”, “ERROR”, “WARNING”, “INFO” |
Links
The links section allows you to send back HEATEOS links without butchering every return object type you have to add fields for this purpose.
Example JSON:
"Links": [
{
"HRef": "",
"URI": "",
"Rel": "",
"Method": "",
"Type": ""
}
]
Legend:
HRef | The link, relative or full URI to the location of related data |
URI | The link, full URI to the location of related data if data in HRef is relative |
Rel | The relationship of the data, i.e. next, previous etc. |
Method | The method needed to access the data, i.e. GET, POST |
Type | The description of the type of data, i.e. “stylesheet”, “application/json” etc. |
Validations
One of my pet peeves about APIs, especially POSTs, PUTs and PATCH, is when they return a 400 (Bad Request) without any detail about what is bad about the request. More often than not, a required field is left empty in the payload we’re testing with. It could also be that a field value is out of bounds, whether a code that is not legitimate or a field that is too long, etc. It would be nice to know what fields are causing the issue.
With this section of the standard return object, the user can interrogate the Validations collection to see exactly what the issue is that needs to be resolved.
Example JSON:
"Validations": [
{
"Id": "",
"Code": "",
"Message": "",
"MessageType": ""
}
]
Legend:
Id | Any standard Id associated with this validation message |
Code | Standard code used for the validation message, i.e. F01, W01 etc. |
Message | The message associated with the validation code, could be dynamic or a lookup from a table |
MessageType | The message type for this message. Rarely used, but could be used to kick off a particular process or response. |
When an object is POSTed to an endpoint, there should be some validations. .NET Core automatically validates that any field that is not marked nullable has a value. This is usually not a friendly way to reject a DTO, because it gives no details on what the problem is. I usually turn off this functionality in my Startup.cs like:
services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
// Stop APIController from automatically validating the modelstate, including children models
options.SuppressModelStateInvalidFilter = true;
});
This options setting will allow a model that doesn’t pass basic .NET required field validation to enter your controller method. At that point, it is up to us to validate the DTO as best we can and provide feedback in the Validation or Message section of the RestReturn object when sending a 400 Bad Request response.
For example, a consumer POSTs a customer object without a FirstName and LastName, which are required fields from a business perspective. We could return the HTTP Status of 400 Bad Request along with this object:
HTTP: 400
{
Metadata: {},
Content: [],
Message: {},
Links: [],
Validations: [
{
"Code": "F001",
"Message": "FirstName is required",
"MessageType": ""
},
{
"Code": "F002",
"Message": "LastName is required",
"MessageType": ""
}
]}
Summary
Your consumers will appreciate the ease of using your endpoints if you provide them with a way to write boilerplate code to interact with your endpoints. Providing them with a standard object return that is predictable, regardless of what happens, will make it much easier for them to code for every eventuality. It also makes code reviews and contract testing easier because everyone on the team knows what format is expected to be returned, rather than each endpoint being developed as a one-off.