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 } }