Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
5 changes: 3 additions & 2 deletions src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ private void ValidateRestMethods(Entity entity, string entityName)
/// <summary>
/// Helper method to validate that the rest path property for the entity is correctly configured.
/// The rest path should not be null/empty and should not contain any reserved characters.
/// Allows sub-directories (forward slashes) in the path.
/// </summary>
/// <param name="entityName">Name of the entity.</param>
/// <param name="pathForEntity">The rest path for the entity.</param>
Expand All @@ -672,10 +673,10 @@ private static void ValidateRestPathSettingsForEntity(string entityName, string
);
}

if (RuntimeConfigValidatorUtil.DoesUriComponentContainReservedChars(pathForEntity))
if (!RuntimeConfigValidatorUtil.TryValidateEntityRestPath(pathForEntity, out string? errorMessage))
{
throw new DataApiBuilderException(
message: $"The rest path: {pathForEntity} for entity: {entityName} contains one or more reserved characters.",
message: $"The rest path: {pathForEntity} for entity: {entityName} {errorMessage ?? "contains invalid characters."}",
statusCode: HttpStatusCode.ServiceUnavailable,
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError
);
Expand Down
66 changes: 66 additions & 0 deletions src/Core/Configurations/RuntimeConfigValidatorUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,72 @@ public static bool DoesUriComponentContainReservedChars(string uriComponent)
return _reservedUriCharsRgx.IsMatch(uriComponent);
}

/// <summary>
/// Method to validate an entity REST path allowing sub-directories (forward slashes).
/// Each segment of the path is validated for reserved characters.
/// </summary>
/// <param name="entityRestPath">The entity REST path to validate.</param>
/// <param name="errorMessage">Output parameter containing a specific error message if validation fails.</param>
/// <returns>true if the path is valid, false otherwise.</returns>
public static bool TryValidateEntityRestPath(string entityRestPath, out string? errorMessage)
{
errorMessage = null;

// Check for backslash usage - common mistake
if (entityRestPath.Contains('\\'))
{
errorMessage = "contains a backslash (\\). Use forward slash (/) for path separators.";
return false;
}

// Check for whitespace
if (entityRestPath.Any(char.IsWhiteSpace))
{
errorMessage = "contains whitespace which is not allowed in URL paths.";
return false;
}

// Split the path by '/' to validate each segment separately
string[] segments = entityRestPath.Split('/');

// Validate each segment doesn't contain reserved characters
foreach (string segment in segments)
{
if (string.IsNullOrEmpty(segment))
{
errorMessage = "contains empty path segments. Ensure there are no leading, consecutive, or trailing slashes.";
return false;
}

// Check for specific reserved characters and provide helpful messages
if (segment.Contains('?'))
{
errorMessage = "contains '?' which is reserved for query strings in URLs.";
return false;
}

if (segment.Contains('#'))
{
errorMessage = "contains '#' which is reserved for URL fragments.";
return false;
}

if (segment.Contains(':'))
{
errorMessage = "contains ':' which is reserved for port numbers in URLs.";
return false;
}

if (_reservedUriCharsRgx.IsMatch(segment))
{
errorMessage = "contains reserved characters that are not allowed in URL paths.";
return false;
}
}

return true;
}

/// <summary>
/// Method to validate if the TTL passed by the user is valid
/// </summary>
Expand Down
42 changes: 25 additions & 17 deletions src/Core/Services/RestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,13 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured
/// as the substring following the '/'.
/// For example, a request route should be of the form
/// {EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
/// or {SubDir}/.../{EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
///
/// Note: Uses shortest-prefix matching. When multiple entity paths could match,
/// the shortest matching path takes precedence. For example, if both "api" and
/// "api/books" are valid entity paths, a request to "/api/books/id/1" will match
/// "api" with primaryKeyRoute "books/id/1". Configure unique, non-overlapping
/// paths to avoid ambiguity.
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The shortest-prefix matching approach is unconventional compared to standard routing practices. Most routing frameworks (ASP.NET Core MVC, Express.js, etc.) use longest-prefix (most specific) matching to avoid ambiguity.

With the current implementation, if entities are configured with paths "cart" and "cart/item", a request to "/cart/item/id/123" will match "cart" (not "cart/item"), treating "item/id/123" as the primary key route.

While this is documented, it could surprise developers familiar with standard routing conventions and may lead to configuration errors. Consider whether longest-prefix matching would be more intuitive, or add validation to warn about overlapping entity paths during configuration validation.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 36b8dc3. Changed to longest-prefix (most-specific) matching to align with standard routing conventions. The loop now iterates from segments.Length down to 1.

/// </summary>
/// <param name="routeAfterPathBase">The request route (no '/' prefix) containing the entity path
/// (and optionally primary key).</param>
Expand All @@ -448,26 +455,27 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured

RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();

// Split routeAfterPath on the first occurrence of '/', if we get back 2 elements
// this means we have a non-empty primary key route which we save. Otherwise, save
// primary key route as empty string. Entity Path will always be the element at index 0.
// ie: {EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
// splits into [{EntityPath}] when there is an empty primary key route and into
// [{EntityPath}, {Primarykeyroute}] when there is a non-empty primary key route.
int maxNumberOfElementsFromSplit = 2;
string[] entityPathAndPKRoute = routeAfterPathBase.Split(new[] { '/' }, maxNumberOfElementsFromSplit);
string entityPath = entityPathAndPKRoute[0];
string primaryKeyRoute = entityPathAndPKRoute.Length == maxNumberOfElementsFromSplit ? entityPathAndPKRoute[1] : string.Empty;

if (!runtimeConfig.TryGetEntityNameFromPath(entityPath, out string? entityName))
// Split routeAfterPath to extract segments
string[] segments = routeAfterPathBase.Split('/');

// Try progressively longer paths until we find a match
// Start with the first segment, then first two, etc.
for (int i = 1; i <= segments.Length; i++)
{
throw new DataApiBuilderException(
message: $"Invalid Entity path: {entityPath}.",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
string entityPath = string.Join("/", segments.Take(i));
if (runtimeConfig.TryGetEntityNameFromPath(entityPath, out string? entityName))
{
// Found entity
string primaryKeyRoute = i < segments.Length ? string.Join("/", segments.Skip(i)) : string.Empty;
return (entityName!, primaryKeyRoute);
}
}
Comment on lines 457 to 471
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new routing logic for sub-directory paths lacks test coverage. While validation tests were added (lines 2084-2093), there are no tests verifying that the actual routing in GetEntityNameAndPrimaryKeyRouteFromRoute correctly handles sub-directory entity paths.

Consider adding tests to RestServiceUnitTests.cs that verify routing behavior with paths like:

  • "shopping-cart/item" as an entity path
  • "shopping-cart/item/id/123" as a request route
  • Multiple entities with overlapping prefixes to test the shortest-prefix matching behavior described in the documentation

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests in commit 36b8dc3:

  • SubDirectoryPathRoutingTest: Tests shopping-cart/item as entity path with various primary key routes
  • LongestPrefixMatchingTest: Tests overlapping paths (cart vs cart/item) - verifies longest match wins
  • SinglePathMatchingTest: Tests primary key extraction with sub-directory paths


return (entityName!, primaryKeyRoute);
// No entity found - show the full path for better debugging
throw new DataApiBuilderException(
message: $"Invalid Entity path: {routeAfterPathBase}.",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
}

/// <summary>
Expand Down
30 changes: 20 additions & 10 deletions src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2066,21 +2066,31 @@ public void ValidateRestMethodsForEntityInConfig(
[DataTestMethod]
[DataRow(true, "EntityA", "", true, "The rest path for entity: EntityA cannot be empty.",
DisplayName = "Empty rest path configured for an entity fails config validation.")]
[DataRow(true, "EntityA", "entity?RestPath", true, "The rest path: entity?RestPath for entity: EntityA contains one or more reserved characters.",
[DataRow(true, "EntityA", "entity?RestPath", true, "The rest path: entity?RestPath for entity: EntityA contains '?' which is reserved for query strings in URLs.",
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "EntityA", "entity#RestPath", true, "The rest path: entity#RestPath for entity: EntityA contains one or more reserved characters.",
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "EntityA", "entity[]RestPath", true, "The rest path: entity[]RestPath for entity: EntityA contains one or more reserved characters.",
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "EntityA", "entity+Rest*Path", true, "The rest path: entity+Rest*Path for entity: EntityA contains one or more reserved characters.",
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "Entity?A", null, true, "The rest path: Entity?A for entity: Entity?A contains one or more reserved characters.",
[DataRow(true, "EntityA", "entity#RestPath", true, "The rest path: entity#RestPath for entity: EntityA contains '#' which is reserved for URL fragments.",
DisplayName = "Rest path for an entity containing reserved character # fails config validation.")]
[DataRow(true, "EntityA", "entity[]RestPath", true, "The rest path: entity[]RestPath for entity: EntityA contains reserved characters that are not allowed in URL paths.",
DisplayName = "Rest path for an entity containing reserved character [] fails config validation.")]
[DataRow(true, "EntityA", "entity+Rest*Path", true, "The rest path: entity+Rest*Path for entity: EntityA contains reserved characters that are not allowed in URL paths.",
DisplayName = "Rest path for an entity containing reserved character +* fails config validation.")]
[DataRow(true, "Entity?A", null, true, "The rest path: Entity?A for entity: Entity?A contains '?' which is reserved for query strings in URLs.",
DisplayName = "Entity name for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "Entity&*[]A", null, true, "The rest path: Entity&*[]A for entity: Entity&*[]A contains one or more reserved characters.",
DisplayName = "Entity name containing reserved character ? fails config validation.")]
[DataRow(true, "Entity&*[]A", null, true, "The rest path: Entity&*[]A for entity: Entity&*[]A contains reserved characters that are not allowed in URL paths.",
DisplayName = "Entity name containing reserved character &*[] fails config validation.")]
[DataRow(false, "EntityA", "entityRestPath", true, DisplayName = "Rest path correctly configured as a non-empty string without any reserved characters.")]
[DataRow(false, "EntityA", "entityRest/?Path", false,
DisplayName = "Rest path for an entity containing reserved character but with rest disabled passes config validation.")]
[DataRow(false, "EntityA", "shopping-cart/item", true,
DisplayName = "Rest path with sub-directory passes config validation.")]
[DataRow(false, "EntityA", "api/v1/books", true,
DisplayName = "Rest path with multiple sub-directories passes config validation.")]
[DataRow(true, "EntityA", "entity\\path", true, "The rest path: entity\\path for entity: EntityA contains a backslash (\\). Use forward slash (/) for path separators.",
DisplayName = "Rest path with backslash fails config validation with helpful message.")]
[DataRow(false, "EntityA", "/entity/path", true,
DisplayName = "Rest path with leading slash is trimmed and passes config validation.")]
[DataRow(true, "EntityA", "entity//path", true, "The rest path: entity//path for entity: EntityA contains empty path segments. Ensure there are no leading, consecutive, or trailing slashes.",
DisplayName = "Rest path with consecutive slashes fails config validation.")]
public void ValidateRestPathForEntityInConfig(
bool exceptionExpected,
string entityName,
Expand Down