diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln
index c8232065..30283eb6 100644
--- a/Datasync.Toolkit.sln
+++ b/Datasync.Toolkit.sln
@@ -68,6 +68,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.S
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB.Test", "tests\CommunityToolkit.Datasync.Server.MongoDB.Test\CommunityToolkit.Datasync.Server.MongoDB.Test.csproj", "{4FC45D20-0BA9-484B-9040-641687659AF6}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.OpenApi.Test", "tests\CommunityToolkit.Datasync.Server.OpenApi.Test\CommunityToolkit.Datasync.Server.OpenApi.Test.csproj", "{99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.OpenApi", "src\CommunityToolkit.Datasync.Server.OpenApi\CommunityToolkit.Datasync.Server.OpenApi.csproj", "{505421EC-2598-4114-B2EC-997959B89E74}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -166,6 +170,14 @@ Global
{4FC45D20-0BA9-484B-9040-641687659AF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4FC45D20-0BA9-484B-9040-641687659AF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4FC45D20-0BA9-484B-9040-641687659AF6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {505421EC-2598-4114-B2EC-997959B89E74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {505421EC-2598-4114-B2EC-997959B89E74}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {505421EC-2598-4114-B2EC-997959B89E74}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {505421EC-2598-4114-B2EC-997959B89E74}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -194,6 +206,8 @@ Global
{A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2}
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
+ {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
+ {505421EC-2598-4114-B2EC-997959B89E74} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f7599862..f9129f21 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Program.cs b/samples/datasync-server/src/Sample.Datasync.Server/Program.cs
index 71e5590f..e7e9af89 100644
--- a/samples/datasync-server/src/Sample.Datasync.Server/Program.cs
+++ b/samples/datasync-server/src/Sample.Datasync.Server/Program.cs
@@ -16,6 +16,7 @@
string? swaggerDriver = builder.Configuration["Swagger:Driver"];
bool nswagEnabled = swaggerDriver?.Equals("NSwag", StringComparison.InvariantCultureIgnoreCase) == true;
bool swashbuckleEnabled = swaggerDriver?.Equals("Swashbuckle", StringComparison.InvariantCultureIgnoreCase) == true;
+bool openApiEnabled = swaggerDriver?.Equals("NET9", StringComparison.InvariantCultureIgnoreCase) == true;
builder.Services.AddDbContext(options => options.UseSqlServer(connectionString));
builder.Services.AddDatasyncServices();
@@ -23,15 +24,18 @@
if (nswagEnabled)
{
- _ = builder.Services
- .AddOpenApiDocument(options => options.AddDatasyncProcessor());
+ _ = builder.Services.AddOpenApiDocument(options => options.AddDatasyncProcessor());
}
if (swashbuckleEnabled)
{
- _ = builder.Services
- .AddEndpointsApiExplorer()
- .AddSwaggerGen(options => options.AddDatasyncControllers());
+ _ = builder.Services.AddEndpointsApiExplorer();
+ _ = builder.Services.AddSwaggerGen(options => options.AddDatasyncControllers());
+}
+
+if (openApiEnabled)
+{
+ _ = builder.Services.AddOpenApi();
}
WebApplication app = builder.Build();
@@ -58,4 +62,9 @@
app.UseAuthorization();
app.MapControllers();
+if (openApiEnabled)
+{
+ _ = app.MapOpenApi(pattern: "swagger/{documentName}/swagger.json");
+}
+
app.Run();
diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Properties/launchSettings.json b/samples/datasync-server/src/Sample.Datasync.Server/Properties/launchSettings.json
index d11ca618..1be36e07 100644
--- a/samples/datasync-server/src/Sample.Datasync.Server/Properties/launchSettings.json
+++ b/samples/datasync-server/src/Sample.Datasync.Server/Properties/launchSettings.json
@@ -1,29 +1,12 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
- "iisSettings": {
- "windowsAuthentication": false,
- "anonymousAuthentication": true,
- "iisExpress": {
- "applicationUrl": "http://localhost:48768",
- "sslPort": 44300
- }
- },
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "launchUrl": "https://localhost:5001/tables/todoitem",
- "applicationUrl": "https://localhost:5001;http://localhost:5000",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- },
- "sqlDebugging": true
- },
- "IIS Express": {
- "commandName": "IISExpress",
- "launchBrowser": true,
- "launchUrl": "https://localhost:44300/tables/todoitem",
+ "launchUrl": "tables/todoitem",
+ "applicationUrl": "https://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj b/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj
index 17892697..8bbaecc0 100644
--- a/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj
+++ b/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj
@@ -1,21 +1,26 @@
- net8.0
+ net9.0
enable
enable
2fc55b72-4090-46ad-ae44-8b6a415339b8
-
-
-
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/samples/datasync-server/src/Sample.Datasync.Server/appsettings.Development.json b/samples/datasync-server/src/Sample.Datasync.Server/appsettings.Development.json
index 3d559e65..bc85dd30 100644
--- a/samples/datasync-server/src/Sample.Datasync.Server/appsettings.Development.json
+++ b/samples/datasync-server/src/Sample.Datasync.Server/appsettings.Development.json
@@ -6,6 +6,6 @@
}
},
"Swagger": {
- "Driver": "Swashbuckle"
+ "Driver": "net9"
}
}
diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/CommunityToolkit.Datasync.Server.OpenApi.csproj b/src/CommunityToolkit.Datasync.Server.OpenApi/CommunityToolkit.Datasync.Server.OpenApi.csproj
new file mode 100644
index 00000000..0ebe0db1
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Server.OpenApi/CommunityToolkit.Datasync.Server.OpenApi.csproj
@@ -0,0 +1,17 @@
+
+
+ Provides necessary capabilities for supporting the Datasync server library when using the official OpenApi generator.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs
new file mode 100644
index 00000000..f6d5ad66
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs
@@ -0,0 +1,227 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.Datasync.Server.Filters;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.OpenApi.Models;
+
+namespace CommunityToolkit.Datasync.Server.OpenApi;
+
+///
+/// The document transformer for the Datasync services.
+///
+public class DatasyncOperationTransformer : IOpenApiOperationTransformer
+{
+ ///
+ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ // Determine if this operation is in scope.
+ if (!IsDatasyncController(context))
+ {
+ return;
+ }
+
+ // Ensure the operation is set up for modification.
+ operation.Parameters ??= [];
+
+ string? actionName = context.Description.ActionDescriptor.RouteValues["action"];
+ if (actionName?.StartsWith("Create", StringComparison.InvariantCultureIgnoreCase) == true)
+ {
+ await TransformCreateAsync(operation, context, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ if (actionName?.StartsWith("Delete", StringComparison.InvariantCultureIgnoreCase) == true)
+ {
+ await TransformDeleteAsync(operation, context, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ if (actionName?.StartsWith("Query", StringComparison.InvariantCultureIgnoreCase) == true)
+ {
+ await TransformQueryAsync(operation, context, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ if (actionName?.StartsWith("Read", StringComparison.InvariantCultureIgnoreCase) == true)
+ {
+ await TransformReadAsync(operation, context, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ if (actionName?.StartsWith("Replace", StringComparison.InvariantCultureIgnoreCase) == true)
+ {
+ await TransformReplaceAsync(operation, context, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ return;
+ }
+
+ ///
+ /// Determines if a controller presented is a datasync controller.
+ ///
+ /// The transformer context.
+ /// true if the controller is a datasync controller; false otherwise.
+ internal static bool IsDatasyncController(OpenApiOperationTransformerContext context)
+ => context.Description.ActionDescriptor.FilterDescriptors.Any(fd => fd.Filter is DatasyncControllerAttribute);
+
+ ///
+ /// Retrieves the entity type for the controller.
+ ///
+ /// The transformer context.
+ /// The type of the entity being served.
+ internal static Type GetEntityType(OpenApiOperationTransformerContext context)
+ {
+ Type? baseType = (context.Description.ActionDescriptor as ControllerActionDescriptor)?.ControllerTypeInfo.AsType();
+ while (baseType is not null)
+ {
+ if (baseType.IsGenericType && baseType.GetGenericTypeDefinition() == typeof(TableController<>))
+ {
+ Type? entityType = baseType.GetGenericArguments().FirstOrDefault();
+ if (entityType is not null && typeof(ITableData).IsAssignableFrom(entityType))
+ {
+ return entityType;
+ }
+
+ throw new InvalidOperationException("Expecting the entity type to implement ITableData.");
+ }
+
+ baseType = baseType.BaseType;
+ }
+
+ throw new InvalidOperationException("Expecting the controller to be derived from TableController.");
+ }
+
+ ///
+ /// Transforms a create operation.
+ ///
+ /// The operation to transform.
+ /// The operation transformer context.
+ /// A cancellation token to observe.
+ /// A task that resolves when the operation is complete.
+ internal Task TransformCreateAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ Type entityType = GetEntityType(context);
+
+ operation.AddRequestBody(context.GetSchemaForType(entityType));
+
+ operation.Responses.AddEntityResponse(StatusCodes.Status201Created,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+ operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest);
+ operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+ operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Transforms a delete operation.
+ ///
+ /// The operation to transform.
+ /// The operation transformer context.
+ /// A cancellation token to observe.
+ /// A task that resolves when the operation is complete.
+ internal Task TransformDeleteAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ Type entityType = GetEntityType(context);
+
+ operation.Parameters.AddIfMatchHeader();
+ operation.Parameters.AddIfUnmodifiedSinceHeader();
+
+ operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest);
+ operation.Responses.AddStatusCode(StatusCodes.Status404NotFound);
+ operation.Responses.AddStatusCode(StatusCodes.Status410Gone);
+ operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+ operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Transforms a query operation.
+ ///
+ /// The operation to transform.
+ /// The operation transformer context.
+ /// A cancellation token to observe.
+ /// A task that resolves when the operation is complete.
+ internal Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ Type entityType = GetEntityType(context);
+ Type pagedEntityType = typeof(PagedResult<>).MakeGenericType(entityType);
+
+ operation.Parameters.AddBooleanQueryParameter("$count", "Whether to include the total count of items matching the query in the result");
+ operation.Parameters.AddStringQueryParameter("$filter", "The filter to apply to the query");
+ operation.Parameters.AddStringQueryParameter("$orderby", "The comma-separated list of ordering instructions to apply to the query");
+ operation.Parameters.AddStringQueryParameter("$select", "The comma-separated list of fields to return in the results");
+ operation.Parameters.AddIntQueryParameter("$skip", "The number of items to skip", 0);
+ operation.Parameters.AddIntQueryParameter("$top", "The number of items to return", 1);
+ operation.Parameters.AddIncludeDeletedQuery();
+
+ operation.Responses.AddEntityResponse(StatusCodes.Status200OK,
+ context.GetSchemaForType(pagedEntityType), includeConditionalHeaders: false);
+ operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Transforms a read operation.
+ ///
+ /// The operation to transform.
+ /// The operation transformer context.
+ /// A cancellation token to observe.
+ /// A task that resolves when the operation is complete.
+ internal Task TransformReadAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ Type entityType = GetEntityType(context);
+
+ operation.Parameters.AddIncludeDeletedQuery();
+ operation.Parameters.AddIfNoneMatchHeader();
+ operation.Parameters.AddIfModifiedSinceHeader();
+
+ operation.Responses.AddEntityResponse(StatusCodes.Status200OK,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+ operation.Responses.AddStatusCode(StatusCodes.Status304NotModified);
+ operation.Responses.AddStatusCode(StatusCodes.Status404NotFound);
+ operation.Responses.AddStatusCode(StatusCodes.Status410Gone);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Transforms a replace operation.
+ ///
+ /// The operation to transform.
+ /// The operation transformer context.
+ /// A cancellation token to observe.
+ /// A task that resolves when the operation is complete.
+ internal Task TransformReplaceAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ Type entityType = GetEntityType(context);
+
+ operation.AddRequestBody(context.GetSchemaForType(entityType));
+ operation.Parameters.AddIncludeDeletedQuery();
+ operation.Parameters.AddIfMatchHeader();
+ operation.Parameters.AddIfUnmodifiedSinceHeader();
+
+ operation.Responses.AddEntityResponse(StatusCodes.Status200OK,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+ operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest);
+ operation.Responses.AddStatusCode(StatusCodes.Status404NotFound);
+ operation.Responses.AddStatusCode(StatusCodes.Status410Gone);
+ operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+ operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed,
+ context.GetSchemaForType(entityType), includeConditionalHeaders: true);
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs
new file mode 100644
index 00000000..82ed11b0
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs
@@ -0,0 +1,243 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Models;
+using System.Net.Mime;
+
+namespace CommunityToolkit.Datasync.Server.OpenApi;
+
+///
+/// A set of internal extensions for code readability.
+///
+internal static class InternalExtensions
+{
+ ///
+ /// Adds a request body to the operation.
+ ///
+ /// The operation to modify.
+ /// The schema for the entity in the body.
+ internal static void AddRequestBody(this OpenApiOperation operation, OpenApiSchema bodySchema)
+ {
+ operation.RequestBody ??= new OpenApiRequestBody();
+ operation.RequestBody.Content.Add(MediaTypeNames.Application.Json, new OpenApiMediaType
+ {
+ Schema = bodySchema
+ });
+ operation.RequestBody.Description = "The entity to process.";
+ operation.RequestBody.Required = true;
+ }
+
+ ///
+ /// Adds a boolean query parameter to the operation parameters.
+ ///
+ /// The parameters collection.
+ /// The parameter name.
+ /// The parameter description.
+ internal static void AddBooleanQueryParameter(this IList parameters, string paramName, string description)
+ {
+ parameters.Add(new OpenApiParameter
+ {
+ Name = paramName,
+ In = ParameterLocation.Query,
+ Description = description,
+ Required = false,
+ Schema = new OpenApiSchema
+ {
+ Type = "string",
+ Enum = [new OpenApiString("true"), new OpenApiString("false")]
+ }
+ });
+ }
+
+ ///
+ /// Adds a date-time header to the parameter list.
+ ///
+ /// The parameters collection.
+ /// The parameter name.
+ /// The parameter description.
+ internal static void AddDateTimeHeader(this IList parameters, string headerName, string description)
+ {
+ parameters.Add(new OpenApiParameter
+ {
+ Name = headerName,
+ In = ParameterLocation.Header,
+ Description = description,
+ Required = false,
+ Schema = new OpenApiSchema { Type = "string", Format = "date-time" }
+ });
+ }
+
+ ///
+ /// Adds an entity response to the collection of responses.
+ ///
+ /// The responses collection.
+ /// The status code for the response.
+ /// The schema of the entity.
+ /// If true, include the headers for conditional access.
+ internal static void AddEntityResponse(this OpenApiResponses responses,
+ int statusCode, OpenApiSchema schema, bool includeConditionalHeaders = true)
+ {
+ OpenApiResponse response = new()
+ {
+ Description = ReasonPhrases.GetReasonPhrase(statusCode),
+ Content = new Dictionary
+ {
+ ["application/json"] = new OpenApiMediaType
+ {
+ Schema = schema
+ }
+ },
+ Headers = includeConditionalHeaders ? new Dictionary
+ {
+ ["ETag"] = new OpenApiHeader
+ {
+ Description = "The ETag value for the entity",
+ Schema = new OpenApiSchema { Type = "string" }
+ },
+ ["Last-Modified"] = new OpenApiHeader
+ {
+ Description = "The last modified timestamp for the entity",
+ Schema = new OpenApiSchema { Type = "string", Format = "date-time" }
+ }
+ } : null
+ };
+ responses.Upsert(statusCode.ToString(), response);
+ }
+
+ ///
+ /// Adds the __includeDeleted query parameter to the operation.
+ ///
+ /// The parameters list to modify.
+ internal static void AddIncludeDeletedQuery(this IList parameters)
+ => parameters.AddBooleanQueryParameter("__includeDeleted", "Include deleted items in the response ('true' or 'false').");
+
+ ///
+ /// Adds the If-Modified-Since header to the operation.
+ ///
+ /// The parameters list to modify.
+ internal static void AddIfModifiedSinceHeader(this IList parameters)
+ => parameters.AddDateTimeHeader("If-Modified-Since", "Timestamp to conditionally fetch the entity");
+
+ ///
+ /// Adds the If-Modified-Since header to the operation.
+ ///
+ /// The parameters list to modify.
+ internal static void AddIfUnmodifiedSinceHeader(this IList parameters)
+ => parameters.AddDateTimeHeader("If-Unmodified-Since", "Timestamp to conditionally fetch the entity");
+
+ ///
+ /// Adds the If-None-Match header to the operation.
+ ///
+ /// The parameters list to modify.
+ internal static void AddIfNoneMatchHeader(this IList parameters)
+ => parameters.AddStringHeader("If-None-Match", "ETag value to conditionally fetch the entity");
+
+ ///
+ /// Adds the If-Match header to the operation.
+ ///
+ /// The parameters list to modify.
+ internal static void AddIfMatchHeader(this IList parameters)
+ => parameters.AddStringHeader("If-Match", "ETag value to conditionally fetch the entity");
+
+ ///
+ /// Adds an int query parameter to the operation parameters.
+ ///
+ /// The parameters collection.
+ /// The parameter name.
+ /// The parameter description.
+ /// The minimum value for the parameter.
+ internal static void AddIntQueryParameter(this IList parameters, string paramName, string description, int minValue = 0)
+ {
+ parameters.Add(new OpenApiParameter
+ {
+ Name = paramName,
+ In = ParameterLocation.Query,
+ Description = description,
+ Required = false,
+ Schema = new OpenApiSchema { Type = "integer", Minimum = minValue }
+ });
+ }
+
+ ///
+ /// Adds a status code with no content to the responses collection.
+ ///
+ /// The responses collection.
+ /// The status code to add.
+ internal static void AddStatusCode(this OpenApiResponses responses, int statusCode)
+ => responses.Upsert(statusCode.ToString(), new OpenApiResponse { Description = ReasonPhrases.GetReasonPhrase(statusCode) });
+
+ ///
+ /// Adds a string header to the parameter list.
+ ///
+ /// The parameters collection.
+ /// The parameter name.
+ /// The parameter description.
+ internal static void AddStringHeader(this IList parameters, string headerName, string description)
+ {
+ parameters.Add(new OpenApiParameter
+ {
+ Name = headerName,
+ In = ParameterLocation.Header,
+ Description = description,
+ Required = false,
+ Schema = new OpenApiSchema { Type = "string" }
+ });
+ }
+
+ ///
+ /// Adds a string query parameter to the operation parameters.
+ ///
+ /// The parameters collection.
+ /// The parameter name.
+ /// The parameter description.
+ internal static void AddStringQueryParameter(this IList parameters, string paramName, string description)
+ {
+ parameters.Add(new OpenApiParameter
+ {
+ Name = paramName,
+ In = ParameterLocation.Query,
+ Description = description,
+ Required = false,
+ Schema = new OpenApiSchema { Type = "string" }
+ });
+ }
+
+ ///
+ /// Retrieves the schema for a given type. If the schema is not found, it is generated. If the schema
+ /// cannot be generated, an exception is thrown.
+ ///
+ /// The operation context.
+ /// The entity type.
+ /// The schema for the entity type.
+ /// Thrown if the schema cannot be found or generated.
+ internal static OpenApiSchema GetSchemaForType(this OpenApiOperationTransformerContext context, Type type)
+ {
+ // TODO: Add support for retrieving schemas.
+ return new OpenApiSchema { Type = "object" };
+ }
+
+ ///
+ /// Converts the property name to JSON casing.
+ ///
+ /// The JsonSerializerOptions object being used.
+ /// The name to convert.
+ /// The converted name.
+ internal static string ToJsonCasing(this IDatasyncServiceOptions options, string name)
+ => options.JsonSerializerOptions.PropertyNamingPolicy?.ConvertName(name) ?? name;
+
+ ///
+ /// Upserts a response into the responses collection, overwriting the old version if it is there.
+ ///
+ /// The responses collection.
+ /// The key - generally the status code.
+ /// The response object.
+ internal static void Upsert(this OpenApiResponses responses, string key, OpenApiResponse response)
+ {
+ _ = responses.Remove(key);
+ responses.Add(key, response);
+ }
+}
diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/OpenApiExtensions.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/OpenApiExtensions.cs
new file mode 100644
index 00000000..1a5691e0
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Server.OpenApi/OpenApiExtensions.cs
@@ -0,0 +1,26 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.AspNetCore.OpenApi;
+
+namespace CommunityToolkit.Datasync.Server.OpenApi;
+
+///
+/// A set of extension methods to make it easier to configure the OpenAPI options for the Datasync services.
+///
+public static class OpenApiExtensions
+{
+ ///
+ /// Adds the appropriate transformers to the OpenAPI options to support the Datasync services.
+ ///
+ /// The to modify.
+ /// The modified for chaining.
+ public static OpenApiOptions AddDatasyncTransformers(this OpenApiOptions options)
+ {
+ _ = options
+ .AddOperationTransformer();
+
+ return options;
+ }
+}
diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs
index f15368aa..5fc8dc1d 100644
--- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs
+++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs
@@ -2,23 +2,21 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
+using CommunityToolkit.Datasync.Server.OData;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Extensions;
+using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Query.Expressions;
using Microsoft.AspNetCore.OData.Query.Validator;
-using Microsoft.AspNetCore.OData.Query.Wrapper;
-using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Routing.Controllers;
+using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-using Microsoft.OData.UriParser;
+using Microsoft.Extensions.Primitives;
using Microsoft.OData;
+using Microsoft.OData.UriParser;
using System.Diagnostics.CodeAnalysis;
-using CommunityToolkit.Datasync.Server.OData;
-using Microsoft.AspNetCore.Http.Extensions;
-using Microsoft.AspNetCore.WebUtilities;
-using Microsoft.Extensions.Primitives;
using System.Globalization;
namespace CommunityToolkit.Datasync.Server;
diff --git a/src/CommunityToolkit.Datasync.Server/Models/PagedResult.cs b/src/CommunityToolkit.Datasync.Server/Models/PagedResult.cs
index 1929fc2f..27d28c6e 100644
--- a/src/CommunityToolkit.Datasync.Server/Models/PagedResult.cs
+++ b/src/CommunityToolkit.Datasync.Server/Models/PagedResult.cs
@@ -26,3 +26,29 @@ public class PagedResult(IEnumerable