diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs
index fd8f811c9e..1b1d5454f0 100644
--- a/src/Core/Configurations/RuntimeConfigValidator.cs
+++ b/src/Core/Configurations/RuntimeConfigValidator.cs
@@ -656,6 +656,7 @@ private void ValidateRestMethods(Entity entity, string entityName)
///
/// 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.
///
/// Name of the entity.
/// The rest path for the entity.
@@ -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
);
diff --git a/src/Core/Configurations/RuntimeConfigValidatorUtil.cs b/src/Core/Configurations/RuntimeConfigValidatorUtil.cs
index be742e586b..0849bce70f 100644
--- a/src/Core/Configurations/RuntimeConfigValidatorUtil.cs
+++ b/src/Core/Configurations/RuntimeConfigValidatorUtil.cs
@@ -66,6 +66,72 @@ public static bool DoesUriComponentContainReservedChars(string uriComponent)
return _reservedUriCharsRgx.IsMatch(uriComponent);
}
+ ///
+ /// Method to validate an entity REST path allowing sub-directories (forward slashes).
+ /// Each segment of the path is validated for reserved characters.
+ ///
+ /// The entity REST path to validate.
+ /// Output parameter containing a specific error message if validation fails.
+ /// true if the path is valid, false otherwise.
+ 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;
+ }
+
///
/// Method to validate if the TTL passed by the user is valid
///
diff --git a/src/Core/Services/RestService.cs b/src/Core/Services/RestService.cs
index 6a2308dd83..85222f8041 100644
--- a/src/Core/Services/RestService.cs
+++ b/src/Core/Services/RestService.cs
@@ -438,6 +438,12 @@ 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 longest-prefix matching (most-specific match wins). When multiple
+ /// entity paths could match, the longest matching path takes precedence. For example,
+ /// if both "cart" and "cart/item" are valid entity paths, a request to
+ /// "/cart/item/id/123" will match "cart/item" with primaryKeyRoute "id/123".
///
/// The request route (no '/' prefix) containing the entity path
/// (and optionally primary key).
@@ -448,26 +454,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 longest paths first (most-specific match wins)
+ // Start with all segments, then remove one at a time
+ for (int i = segments.Length; i >= 1; 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);
+ }
}
- 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);
}
///
diff --git a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
index 119e6637c6..49b909c886 100644
--- a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
+++ b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
@@ -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,
diff --git a/src/Service.Tests/UnitTests/RestServiceUnitTests.cs b/src/Service.Tests/UnitTests/RestServiceUnitTests.cs
index 1fa1a276ad..a3dfdd96c4 100644
--- a/src/Service.Tests/UnitTests/RestServiceUnitTests.cs
+++ b/src/Service.Tests/UnitTests/RestServiceUnitTests.cs
@@ -100,6 +100,73 @@ public void ErrorForInvalidRouteAndPathToParseTest(string route,
#endregion
+ #region Sub-directory Path Routing Tests
+
+ ///
+ /// Tests that sub-directory entity paths are correctly resolved.
+ ///
+ [DataTestMethod]
+ [DataRow("api/shopping-cart/item", "/api", "shopping-cart/item", "ShoppingCartItem", "")]
+ [DataRow("api/shopping-cart/item/id/123", "/api", "shopping-cart/item", "ShoppingCartItem", "id/123")]
+ [DataRow("api/invoice/item/categoryid/1/pieceid/2", "/api", "invoice/item", "InvoiceItem", "categoryid/1/pieceid/2")]
+ public void SubDirectoryPathRoutingTest(
+ string route,
+ string restPath,
+ string entityPath,
+ string expectedEntityName,
+ string expectedPrimaryKeyRoute)
+ {
+ InitializeTestWithEntityPath(restPath, entityPath, expectedEntityName);
+ string routeAfterPathBase = _restService.GetRouteAfterPathBase(route);
+ (string actualEntityName, string actualPrimaryKeyRoute) =
+ _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase);
+ Assert.AreEqual(expectedEntityName, actualEntityName);
+ Assert.AreEqual(expectedPrimaryKeyRoute, actualPrimaryKeyRoute);
+ }
+
+ ///
+ /// Tests longest-prefix matching: when both "cart" and "cart/item" are valid entity paths,
+ /// a request to "/cart/item/id/123" should match "cart/item" (longest match wins).
+ ///
+ [TestMethod]
+ public void LongestPrefixMatchingTest()
+ {
+ InitializeTestWithMultipleEntityPaths("/api", new Dictionary
+ {
+ { "cart", "CartEntity" },
+ { "cart/item", "CartItemEntity" }
+ });
+
+ string routeAfterPathBase = _restService.GetRouteAfterPathBase("api/cart/item/id/123");
+ (string actualEntityName, string actualPrimaryKeyRoute) =
+ _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase);
+
+ // Should match "cart/item" (longest), not "cart" (shortest)
+ Assert.AreEqual("CartItemEntity", actualEntityName);
+ Assert.AreEqual("id/123", actualPrimaryKeyRoute);
+ }
+
+ ///
+ /// Tests that when only shorter path exists, it matches correctly.
+ ///
+ [TestMethod]
+ public void SinglePathMatchingTest()
+ {
+ InitializeTestWithMultipleEntityPaths("/api", new Dictionary
+ {
+ { "cart", "CartEntity" }
+ });
+
+ string routeAfterPathBase = _restService.GetRouteAfterPathBase("api/cart/id/123");
+ (string actualEntityName, string actualPrimaryKeyRoute) =
+ _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase);
+
+ Assert.AreEqual("CartEntity", actualEntityName);
+ Assert.AreEqual("id/123", actualPrimaryKeyRoute);
+ }
+
+ #endregion
+
#region Helper Functions
///
@@ -207,6 +274,188 @@ public static void InitializeTest(string restRoutePrefix, string entityName)
/// The entity path.
/// Name of entity.
delegate void metaDataCallback(string entityPath, out string entity);
+
+ ///
+ /// Initializes test with a sub-directory entity path.
+ ///
+ /// REST path prefix (e.g., "/api").
+ /// Entity path with sub-directories (e.g., "shopping-cart/item").
+ /// Name of the entity.
+ public static void InitializeTestWithEntityPath(string restRoutePrefix, string entityPath, string entityName)
+ {
+ RuntimeConfig mockConfig = new(
+ Schema: "",
+ DataSource: new(DatabaseType.PostgreSQL, "", new()),
+ Runtime: new(
+ Rest: new(Path: restRoutePrefix),
+ GraphQL: new(),
+ Mcp: new(),
+ Host: new(null, null)
+ ),
+ Entities: new(new Dictionary())
+ );
+
+ MockFileSystem fileSystem = new();
+ fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson()));
+ FileSystemRuntimeConfigLoader loader = new(fileSystem);
+ RuntimeConfigProvider provider = new(loader);
+ MsSqlQueryBuilder queryBuilder = new();
+ Mock dbExceptionParser = new(provider);
+ Mock>> queryExecutorLogger = new();
+ Mock> queryEngineLogger = new();
+ Mock httpContextAccessor = new();
+ Mock metadataProviderFactory = new();
+ Mock queryManagerFactory = new();
+ Mock queryEngineFactory = new();
+
+ MsSqlQueryExecutor queryExecutor = new(
+ provider,
+ dbExceptionParser.Object,
+ queryExecutorLogger.Object,
+ httpContextAccessor.Object);
+
+ queryManagerFactory.Setup(x => x.GetQueryBuilder(It.IsAny())).Returns(queryBuilder);
+ queryManagerFactory.Setup(x => x.GetQueryExecutor(It.IsAny())).Returns(queryExecutor);
+
+ RuntimeConfig loadedConfig = provider.GetConfig();
+ loadedConfig.TryAddEntityPathNameToEntityName(entityPath, entityName);
+
+ Mock authorizationService = new();
+ DefaultHttpContext context = new();
+ httpContextAccessor.Setup(_ => _.HttpContext).Returns(context);
+ AuthorizationResolver authorizationResolver = new(provider, metadataProviderFactory.Object);
+ GQLFilterParser gQLFilterParser = new(provider, metadataProviderFactory.Object);
+
+ Mock cache = new();
+ DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor.Object);
+
+ SqlQueryEngine queryEngine = new(
+ queryManagerFactory.Object,
+ metadataProviderFactory.Object,
+ httpContextAccessor.Object,
+ authorizationResolver,
+ gQLFilterParser,
+ queryEngineLogger.Object,
+ provider,
+ cacheService);
+
+ queryEngineFactory.Setup(x => x.GetQueryEngine(It.IsAny())).Returns(queryEngine);
+
+ SqlMutationEngine mutationEngine =
+ new(
+ queryManagerFactory.Object,
+ metadataProviderFactory.Object,
+ queryEngineFactory.Object,
+ authorizationResolver,
+ gQLFilterParser,
+ httpContextAccessor.Object,
+ provider);
+
+ Mock mutationEngineFactory = new();
+ mutationEngineFactory.Setup(x => x.GetMutationEngine(It.IsAny())).Returns(mutationEngine);
+ RequestValidator requestValidator = new(metadataProviderFactory.Object, provider);
+
+ _restService = new RestService(
+ queryEngineFactory.Object,
+ mutationEngineFactory.Object,
+ metadataProviderFactory.Object,
+ httpContextAccessor.Object,
+ authorizationService.Object,
+ provider,
+ requestValidator);
+ }
+
+ ///
+ /// Initializes test with multiple entity paths for testing overlapping path scenarios.
+ ///
+ /// REST path prefix (e.g., "/api").
+ /// Dictionary mapping entity paths to entity names.
+ public static void InitializeTestWithMultipleEntityPaths(string restRoutePrefix, Dictionary entityPaths)
+ {
+ RuntimeConfig mockConfig = new(
+ Schema: "",
+ DataSource: new(DatabaseType.PostgreSQL, "", new()),
+ Runtime: new(
+ Rest: new(Path: restRoutePrefix),
+ GraphQL: new(),
+ Mcp: new(),
+ Host: new(null, null)
+ ),
+ Entities: new(new Dictionary())
+ );
+
+ MockFileSystem fileSystem = new();
+ fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson()));
+ FileSystemRuntimeConfigLoader loader = new(fileSystem);
+ RuntimeConfigProvider provider = new(loader);
+ MsSqlQueryBuilder queryBuilder = new();
+ Mock dbExceptionParser = new(provider);
+ Mock>> queryExecutorLogger = new();
+ Mock> queryEngineLogger = new();
+ Mock httpContextAccessor = new();
+ Mock metadataProviderFactory = new();
+ Mock queryManagerFactory = new();
+ Mock queryEngineFactory = new();
+
+ MsSqlQueryExecutor queryExecutor = new(
+ provider,
+ dbExceptionParser.Object,
+ queryExecutorLogger.Object,
+ httpContextAccessor.Object);
+
+ queryManagerFactory.Setup(x => x.GetQueryBuilder(It.IsAny())).Returns(queryBuilder);
+ queryManagerFactory.Setup(x => x.GetQueryExecutor(It.IsAny())).Returns(queryExecutor);
+
+ RuntimeConfig loadedConfig = provider.GetConfig();
+ foreach (KeyValuePair entityPath in entityPaths)
+ {
+ loadedConfig.TryAddEntityPathNameToEntityName(entityPath.Key, entityPath.Value);
+ }
+
+ Mock authorizationService = new();
+ DefaultHttpContext context = new();
+ httpContextAccessor.Setup(_ => _.HttpContext).Returns(context);
+ AuthorizationResolver authorizationResolver = new(provider, metadataProviderFactory.Object);
+ GQLFilterParser gQLFilterParser = new(provider, metadataProviderFactory.Object);
+
+ Mock cache = new();
+ DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor.Object);
+
+ SqlQueryEngine queryEngine = new(
+ queryManagerFactory.Object,
+ metadataProviderFactory.Object,
+ httpContextAccessor.Object,
+ authorizationResolver,
+ gQLFilterParser,
+ queryEngineLogger.Object,
+ provider,
+ cacheService);
+
+ queryEngineFactory.Setup(x => x.GetQueryEngine(It.IsAny())).Returns(queryEngine);
+
+ SqlMutationEngine mutationEngine =
+ new(
+ queryManagerFactory.Object,
+ metadataProviderFactory.Object,
+ queryEngineFactory.Object,
+ authorizationResolver,
+ gQLFilterParser,
+ httpContextAccessor.Object,
+ provider);
+
+ Mock mutationEngineFactory = new();
+ mutationEngineFactory.Setup(x => x.GetMutationEngine(It.IsAny())).Returns(mutationEngine);
+ RequestValidator requestValidator = new(metadataProviderFactory.Object, provider);
+
+ _restService = new RestService(
+ queryEngineFactory.Object,
+ mutationEngineFactory.Object,
+ metadataProviderFactory.Object,
+ httpContextAccessor.Object,
+ authorizationService.Object,
+ provider,
+ requestValidator);
+ }
#endregion
}
}