The goal of this application is to solve the problem of needing to write boilerplate code when creating a microservice. Code necessary to be written should be as simple as possible while still allowing flexibilty for complex use cases. In addition to this, unlike a project template, microservices created from this application should be able to pull in versioned enhancements and fixes as desired.
- Start off by forking this repository. Check out versions and branching strategy to decide what branch to start from.
- Clone the new repo locally.
- Create a new branch.
- Open the solution in an IDE.
- Add any models if desired or use existing example models.
- Ensure data store is running.
- Add connection information to appsettings.
- Start the application.
- Use Postman or similar application to start calling the CRUD routes. (See example Postman requests.)
Models are POCOs located in the Models folder. These map directly to a collection/table in the data store.
Examples in the following documentation use the User and Address models that come defaultly in this project. These are used soley for examples and may be removed. Do not remove IExternalEntity or ExternalEntity.
The Table data annotation is optional. It may be used to specify the name of the collection/table that the model will be stored in. Otherwise, the name that will be used as the collection/table will default to the pluralized version of the class name.
The following example will specify that the name of the collection/table to store the model in should be "users".
[Table("users")]
public class User : ExternalEntity
Standard and custom data annotation validators may be added to properties in the model. These will automatically be used to validate the model without adding any additional code to the Validator.
Standard System.Text.Json attributes, like JsonPropertyNameAttribute, can be added to the properties in the model to customize the JSON serialization and deserialization.
The PreventCrud attribute is optional. This is used to prevent some or all CRUD operations on a model. See details here.
The PreventQuery attribute is optional. This is used to prevent some or all Query operators on a model's property. See details here.
This class and IExternalEntity interface should not be removed from the application. Although not necessary, it is highly suggested to inherit from for models that map directly to a collection/table. Example: User maps to the Users
collection while Address is stored within a document in that collection. The purpose of this class is to give each document/row a unique "random" identifier so that it may be safely referenced by external applications. Sequential identifiers are not as safe to use as they can be easily manipulated and without the proper checks, allow access to other data. They do make for better clustered indexes, so they should continue to be used within the data store.
This application uses a RESTful naming convention for the routes. In the examples below, replace {typeName}
with the pluralized name of the model the action will be on. For example, when acting on User, replace {typeName}
with "users".
Add the JSON describing the model to be created in the request body.
Replace {id:guid}
with the Id
of the model to be retrieved.
Replace {?prop1=val1...&propN=valN}
with query parameter filtering. By default, at least one query parameter is required. To allow returning all, the validator check for this will need to be removed. All documents/rows that match the filter will be retrieved.
Add the JSON query filtering to the body of the request. All documents/rows that match the filter will be retrieved.
This returns the number
of documents/rows that the query filtering filtered. The forseen utility of this route is for pagination.
Replace {id:guid}
with the Id
of the model to be updated. The document/row that this Id
matches will be replaced by the JSON object in the body of the request.
Replace {id:guid}
with the Id
of the model to be updated. The document/row that this Id
matches will have only the fields/columns updated that are in the JSON object in the body of the request.
Replace {?prop1=val1...&propN=valN}
with query parameter filtering. By default, at least one query parameter is required. To allow updating all, the validator check for this will need to be removed. All documents/rows that match the filter will have only the fields/columns updated that are in the JSON object in the body of the request.
Note: Unable to do query filtering and partial update as both require JSON in the body of the request.
Replace {id:guid}
with the Id
of the model to be deleted.
Replace {?prop1=val1...&propN=valN}
with query parameter filtering. By default, at least one query parameter is required. To allow deleting all, the validator check for this will need to be removed. All documents/rows that match the filter will be deleted.
Add the JSON query filtering to the body of the request. All documents/rows that match the filter will be deleted.
Properties of the model may be added as a query parameter to filter the documents/rows acted on in the data store. The operator is limited to equality for filtering. The underscore delimiter parent_child
may be used to refer to child properties.
The following example will filter on Users with age
equal to 42 and city
equal to "Tampa".
api/users?age=42&address_city=Tampa
Queries can be added to the body of a request in JSON format. This will then be used to filter the documents/rows acted on in the data store. The dot delimiter parent.child
may be used to refer to child properties.
Fields/columns that will be returned from the data store. If this and Excludes are null, all fields/columns are returned.
The following example will only return the age
, name
, and city
for all Users retrieved.
{
"includes": ["age", "name", "address.city"]
}
Example returned JSON:
[
{
"name": "Bill Johnson",
"address": {
"city": "Pittsburgh"
},
"age": 25
},
{
"name": "John Billson",
"address": {
"city": "Dallas"
},
"age": 31
},
{
"name": "Johnny Bill",
"address": {
"city": "Tampa"
},
"age": 42
}
]
Fields/columns that will not be returned from the data store. If this and Includes are null, all fields/columns are returned.
The following example will return all properties except hairColor
, age
, formerAddresses
, and state
for all Users retrieved.
{
"excludes": ["hairColor", "age", "formerAddresses", "address.state"]
}
Example returned JSON:
[
{
"id": "6cd6f392-8271-49bb-8564-e584ddf48890",
"name": "Bill Johnson",
"address": {
"street": "44 Maple Street",
"city": "Pittsburgh"
},
"favoriteThings": ["Steelers", "Pirates", "Penguins"]
},
{
"id": "c7b1ebaf-4ac1-4fe0-b066-1282e072585a",
"name": "John Billson",
"address": {
"street": "101 Elm Street",
"city": "Dallas"
},
"favoriteThings": ["Cowboys", "Stars", "Mavericks"]
},
{
"id": "f4064c6b-e41a-4c34-a0b2-9e7a233b8310",
"name": "Johnny Bill",
"address": {
"street": "75 Oak Street",
"city": "Tampa"
},
"favoriteThings": ["Buccaneers", "Lightning"]
}
]
Constrains what documents/rows are filtered on in the data store.
JSON Type | Name | Description |
---|---|---|
String? |
Field | Name of the field/column side being evaluated. Should be null if GroupedConditions is populated. |
String? |
ComparisonOperator | The operator used in the evaluation. Should be null if GroupedConditions is populated. |
String? |
Value | Value that the ComparisonOperator will compare the Field against in the evaluation. Should be null if Values or GroupedConditions is populated. |
Array[String]? |
Values | Values that the ComparisonOperator will compare the Field against in the evaluation. Should be null if Value or GroupedConditions is populated. |
Array[GroupedCondition]? |
GroupedConditions | Groups of conditions used for complex logic to constrain what documents/rows are filtered on in the data store. For more details, see the GroupedConditions section. |
The following example will filter on Users with an age less than 30.
{
"where": {
"field": "age",
"comparisonOperator": "<",
"value": "30"
}
}
Groups of conditions used for complex logic to constrain what documents/rows are filtered on in the data store.
Note: Top level Grouped Conditions default to an AND LogicalOperator.
JSON Type | Name | Description |
---|---|---|
String? |
LogicalOperator | The operator applied between each condition in Conditions . |
Array[Condition] |
Conditions | All conditions have the same LogicalOperator applied between each condition. |
The following example will filter on Users with city
equal to "Dallas" or an age
equal to 25.
{
"where": {
"groupedConditions": [{
"logicalOperator": "||",
"conditions": [{
"field": "address.city",
"comparisonOperator": "==",
"value": "Dallas"
},
{
"field": "age",
"comparisonOperator": "==",
"value": "25"
}]
}]
}
}
The aliases are put in a Condition's ComparisonOperator
. Aliases are not case sensitive. Some operators have multiple aliases for the same operator. These may be mixed and matched to fit any style.
Name | Aliases | Description |
---|---|---|
Equality | == Equals EQ |
|
Inequality | != NotEquals NE |
|
GreaterThan | > GreaterThan GT |
|
GreaterThanOrEquals | >= GreaterThanOrEquals GTE |
|
LessThan | < LessThan LT |
|
LessThanOrEquals | <= LessThanOrEquals LTE |
|
In | IN |
If any value in Field matches any value in Values . |
NotIn | NotIn NIN |
If all values in Field do not match any value in Values . |
All | All |
If all values in Values match any value in Field . |
Contains | Contains |
For use with Field properties of type String . If value in Field contains the value in Value . There may be hits to performance when using this operator. All queries may be prevented from using this operator by setting PreventAllQueryContains to true in the appsettings.json. Instead of preventing all, individual properties may be prevented from being being queried on using this operator by decorating it with the PreventQuery(Operator.Contains). |
StartsWith | StartsWith |
For use with Field properties of type String . If value in Field starts with the value in Value . There may be hits to performance when using this operator. All queries may be prevented from using this operator by setting PreventAllQueryStartsWith to true in the appsettings.json. Instead of preventing all, individual properties may be prevented from being being queried on using this operator by decorating it with the PreventQuery(Operator.StartsWith). |
EndsWith | EndsWith |
For use with Field properties of type String . If value in Field ends with the value in Value . There may be hits to performance when using this operator. All queries may be prevented from using this operator by setting PreventAllQueryEndsWith to true in the appsettings.json. Instead of preventing all, individual properties may be prevented from being being queried on using this operator by decorating it with the PreventQuery(Operator.EndsWith). |
The aliases are put in a GroupedCondition's LogicalOperator
. This LogicalOperator
is applied between each condition in Conditions
. Aliases are not case sensitive. Some operators have multiple aliases for the same operator. These may be mixed at matched to fit any style.
Name | Aliases |
---|---|
And | && AND |
Or | || OR |
In what order the documents/rows will be returned from the data store.
JSON Type | Name | Description |
---|---|---|
String? |
Field | Name of the field/column being sorted. |
Boolean? |
IsDescending | If the Field will be in descending order.Default: false |
The following example will return all Users ordered first by their city
ascending, then age
descending, then by name
ascending.
{
"orderby": [
{
"field": "address.city"
},
{
"field": "age",
"isDescending": true
},
{
"field": "name"
}
]
}
Sets the max number of documents/rows that will be returned from the data store.
The following example limits the max number of Users returned to 2.
{
"limit": 2
}
Sets how many documents/rows to skip over.
The following example skips over the first 3 Users that would have been returned and returns the rest.
{
"skip": 3
}
The following example will only return name
, age
, and favoriteThings
of Users with a name
that ends with "Johnson" or favoriteThings
that are in ["Steelers", "Lightning"] and a city
equal to "Pittsburgh" and age
less than or equal to 42. The result will be ordered by name
in ascending order, then age
in descending order. The first two that would have returned are skipped over. The max number of Users returned is ten.
{
"includes": ["name", "age", "favoriteThings"],
"where": {
"groupedConditions": [
{
"logicalOperator": "&&",
"conditions": [
{
"groupedConditions": [
{
"logicalOperator": "||",
"conditions": [
{
"field": "name",
"comparisonOperator": "ENDSWITH",
"value": "Johnson"
},
{
"field": "favoriteThings",
"comparisonOperator": "IN",
"values": ["Steelers", "Lightning"]
}
]
},
{
"logicalOperator": "&&",
"conditions": [
{
"field": "address.city",
"comparisonOperator": "==",
"value": "Pittsburgh"
},
{
"field": "age",
"comparisonOperator": "<=",
"value": "42"
}
]
}
]
}
]
}
]
},
"orderby": [
{
"field": "name"
},
{
"field": "age",
"isDescending": true
}
],
"limit": 10,
"skip": 2
}
To help get a better understanding, the following is an equivalent C# logical statement of the where condition in the JSON above.
if (
(user.Name.EndsWith("Johnson", StringComparison.OrdinalIgnoreCase)
|| user.FavoriteThings.Any(favoriteThing => new List<string> { "Steelers", "Lightning" }.Any(x => x == favoriteThing)))
&&
(user.Address.City == "Pittsburgh"
&& user.Age <= 42)
)
These methods may be used to prevent a CRUD operation and optionally return a message stating why the operation was invalid.
Signature | Description |
---|---|
Task<ValidationResult> ValidateCreateAsync(Object model) |
Validates the model when creating. By default, data annotations on the model are validated. |
Task<ValidationResult> ValidateReadAsync(Object model, IDictionary<String, String>? queryParams) |
Validates the model when reading with query parameter filtering. By default, all query parameters are ensured to be properties of the model. |
Task<ValidationResult> ValidateUpdateAsync(Object model, Guid id) |
Validates the model when replacement updating with an Id. By default, data annotations on the model are validated. |
Task<ValidationResult> ValidatePartialUpdateAsync(Object model, Guid id, IReadOnlyCollection<String>? propertiesToBeUpdated) |
Validates the model when partially updating with an Id. By default, all properties to be updated are ensured to be properties of the model and data annotations on the model are validated. |
Task<ValidationResult> ValidatePartialUpdateAsync(Object model, IDictionary<String, String>? queryParams, IReadOnlyCollection<String>? propertiesToBeUpdated) |
Validates the model when partially updating with query parameter filtering. By default, all query parameters are ensured to be properties of the model, all properties to be updated are ensured to be properties of the model, and data annotations on the model are validated. |
Task<ValidationResult> ValidateDeleteAsync(Object model, IDictionary<String, String>? queryParams) |
Validates the model when deleting with query parameter filtering. By default, all query parameters are ensured to be properties of the model. |
ValidationResult ValidateQuery(Object model, Query query) |
Validates the model when using body query filtering. |
Each signature above may be overloaded by replacing the Object model
parameter with a specific model type. There are many examples using the User model to override the validating method in the Validator class. These may be removed as they are solely there as examples.
The following example overrides the Task<ValidationResult> ValidateCreateAsync(Object model)
validating method and also calls the Object model
version of the method to reuse the logic.
public async Task<ValidationResult> ValidateCreateAsync(User user)
{
if (user is null)
return new ValidationResult(false, $"{nameof(User)} cannot be null.");
var objectValidationResult = await ValidateCreateAsync((object)user);
if (!objectValidationResult.IsValid)
return objectValidationResult;
return new ValidationResult(true);
}
Preprocessing is optional. These methods may be used to do any sort of preprocessing actions. See details here.
Postprocessing is optional. These methods may be used to do any sort of postprocessing actions. See details here.
CRUD operations on models has been simplified. But at what cost? The following metrics were obtained by running the exact same Postman requests against this application versus running them against an application that does the same operations, but without the dynamic model capabilities, called CrudMetrics.
The following is the average of each request which was run with 100 iterations and no indexes on the collections.
Request | CrudMetrics (baseline) | Crud (dynamic models) |
---|---|---|
CreateUser | 4 ms | 4 ms |
ReadUser_Id | 3 ms | 3 ms |
ReadUser_Name | 3 ms | 3 ms |
UpdateUser_Id | 3 ms | 3 ms |
PartialUpdateUser_Id | 3 ms | 4 ms |
PartialUpdateUser_Name | 3 ms | 3 ms |
DeleteUser_Id | 3 ms | 3 ms |
DeleteUser_Name | 3 ms | 3 ms |
The following are files and folders that may be altered when using this code to create a microservice. All other files and folders should only be modified by contributors.
- Models - See details here.
- Validator - See details here.
- PreprocessingService - See details here.
- PostprocessingService - See details here.
Pattern: #.#.# - breaking-change.new-features.maintenance
Incrementing version zeroes-out version numbers to the right.
Example: Current version is 1.0.3, new features are added, new version is 1.1.0.
If a new version is released and these updates would be useful in a forked application:
- At minimum, read all release notes for each breaking change since the last fetch. (Example: Last forked from v1.0.3. Desired updated version is v4.2.6. At least read release notes of v2.0.0, v3.0.0, and v4.0.0 as code changes may be required.)
- Fetch the desired v#.#.# branch from this repository into the forked repository.
- Create a new branch.
- Merge existing forked application code and v#.#.# branch.
- Fix any merge conflicts.
- Test.
Name | Description |
---|---|
v#.#.# | Standard branches to create a forked application from. |
v#.#.#-beta | Used when the next version requires burn in testing. This may be forked from to test out new features, but should not be used in a production environment. |
Number | Available Preservers | Framework | Notes |
---|---|---|---|
2.0.0 | MongoDB | .NET 8 | See details here. |
1.0.1 | MongoDB | .NET 7 | See details here. |
1.0.0 | MongoDB | .NET 7 | See details here. |
Please see detailed documentation on how to contribute to this project here.