Skip to content

Commit 5469784

Browse files
CopilotJerryNixonRubenCerna2079Aniruddh25anushakolan
authored
REST Advanced Paths: Allow sub-directories in entity REST paths (#2999)
- [x] Explore and understand the codebase - [x] Understand how REST paths are validated and used - [x] Modify validation to allow forward slashes (/) in entity REST paths for sub-directories - [x] Update `GetEntityNameAndPrimaryKeyRouteFromRoute` to handle paths with sub-directories - [x] Provide clear error messages for invalid paths (backslash, invalid chars, etc.) - [x] Add unit tests for the new functionality - [x] Build and verify the changes - [x] Run dotnet format on modified files - [x] Run code review and address feedback - [x] Run CodeQL security check (no issues found) - [x] Address PR feedback: - Added null-safe fallback for error message in `ValidateRestPathSettingsForEntity` - Simplified generic error message to avoid overspecifying allowed characters - ~~Documented shortest-prefix matching~~ → **Fixed to use longest-prefix matching** - [x] **Fixed routing logic**: Changed from shortest-prefix to longest-prefix matching (most-specific match wins) - [x] **Added routing tests**: Tests for sub-directory paths, longest-prefix matching with overlapping paths - [x] **Updated XML documentation**: Clarified that entity paths may contain '/' and use longest-prefix matching - [x] **Fixed ':' error message**: Changed to neutral "reserved character" message - [x] **Refactored test helpers**: Removed duplicate code, all helpers now delegate to single core helper - [x] **Enhanced validation**: - Added 2048-character maximum path length limit - Added percent-encoding (%) validation - not allowed in config - Added path traversal pattern validation (`.` and `..` segments blocked) - Added tests for trailing slash, whitespace, percent-encoding, and path traversal patterns <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>⭐ [Enhancement]: REST Advanced Paths</issue_title> > <issue_description>## What is it? > > * The ability to customize REST paths with sub-directories, such as `/api/sub-directory/books`. > > This improvement addresses potential conflicts that arise when multiple data sources contain entities with the same desired REST path. For example, you might have an `Item` entity under both an `Invoice` and a `ShoppingCart`. With the current setup, you might end up with paths like `/shopping-cart` and `/shopping-cart-item`, when the desired paths would be `/shopping-cart/item` and `/invoice/item`. This issue becomes more pronounced as we introduce tenant support across multiple databases. > > ## Questions > > 1. How many sub-folders, just one? Yes. Just one. > 2. Are paths unique across config files? Yes, required. > > ## Related issues to close > > * #1085 > * #1566</issue_description> > > <agent_instructions>Change as little as possible, introduce as little as possible to accomplish this. Terse code FTW.</agent_instructions> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> - Fixes #2373 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: JerryNixon <[email protected]> Co-authored-by: RubenCerna2079 <[email protected]> Co-authored-by: Aniruddh Munde <[email protected]> Co-authored-by: Aniruddh25 <[email protected]> Co-authored-by: anushakolan <[email protected]>
1 parent 20df64c commit 5469784

5 files changed

Lines changed: 260 additions & 45 deletions

File tree

src/Core/Configurations/RuntimeConfigValidator.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ private void ValidateRestMethods(Entity entity, string entityName)
656656
/// <summary>
657657
/// Helper method to validate that the rest path property for the entity is correctly configured.
658658
/// The rest path should not be null/empty and should not contain any reserved characters.
659+
/// Allows sub-directories (forward slashes) in the path.
659660
/// </summary>
660661
/// <param name="entityName">Name of the entity.</param>
661662
/// <param name="pathForEntity">The rest path for the entity.</param>
@@ -672,10 +673,10 @@ private static void ValidateRestPathSettingsForEntity(string entityName, string
672673
);
673674
}
674675

675-
if (RuntimeConfigValidatorUtil.DoesUriComponentContainReservedChars(pathForEntity))
676+
if (!RuntimeConfigValidatorUtil.TryValidateEntityRestPath(pathForEntity, out string? errorMessage))
676677
{
677678
throw new DataApiBuilderException(
678-
message: $"The rest path: {pathForEntity} for entity: {entityName} contains one or more reserved characters.",
679+
message: $"The rest path: {pathForEntity} for entity: {entityName} {errorMessage ?? "contains invalid characters."}",
679680
statusCode: HttpStatusCode.ServiceUnavailable,
680681
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError
681682
);

src/Core/Configurations/RuntimeConfigValidatorUtil.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,94 @@ public static bool DoesUriComponentContainReservedChars(string uriComponent)
6666
return _reservedUriCharsRgx.IsMatch(uriComponent);
6767
}
6868

69+
/// <summary>
70+
/// Method to validate an entity REST path allowing sub-directories (forward slashes).
71+
/// Each segment of the path is validated for reserved characters and path traversal patterns.
72+
/// </summary>
73+
/// <param name="entityRestPath">The entity REST path to validate.</param>
74+
/// <param name="errorMessage">Output parameter containing a specific error message if validation fails.</param>
75+
/// <returns>true if the path is valid, false otherwise.</returns>
76+
public static bool TryValidateEntityRestPath(string entityRestPath, out string? errorMessage)
77+
{
78+
errorMessage = null;
79+
80+
// Check for maximum path length (reasonable limit for URL paths)
81+
const int MAX_PATH_LENGTH = 2048;
82+
if (entityRestPath.Length > MAX_PATH_LENGTH)
83+
{
84+
errorMessage = $"exceeds maximum allowed length of {MAX_PATH_LENGTH} characters.";
85+
return false;
86+
}
87+
88+
// Check for backslash usage - common mistake
89+
if (entityRestPath.Contains('\\'))
90+
{
91+
errorMessage = "contains a backslash (\\). Use forward slash (/) for path separators.";
92+
return false;
93+
}
94+
95+
// Check for percent-encoded characters (URL encoding not allowed in config)
96+
if (entityRestPath.Contains('%'))
97+
{
98+
errorMessage = "contains percent-encoding (%) which is not allowed. Use literal characters only.";
99+
return false;
100+
}
101+
102+
// Check for whitespace
103+
if (entityRestPath.Any(char.IsWhiteSpace))
104+
{
105+
errorMessage = "contains whitespace which is not allowed in URL paths.";
106+
return false;
107+
}
108+
109+
// Split the path by '/' to validate each segment separately
110+
string[] segments = entityRestPath.Split('/');
111+
112+
// Validate each segment doesn't contain reserved characters
113+
foreach (string segment in segments)
114+
{
115+
if (string.IsNullOrEmpty(segment))
116+
{
117+
errorMessage = "contains empty path segments. Ensure there are no leading, consecutive, or trailing slashes.";
118+
return false;
119+
}
120+
121+
// Check for path traversal patterns
122+
if (segment == "." || segment == "..")
123+
{
124+
errorMessage = "contains path traversal patterns ('.' or '..') which are not allowed.";
125+
return false;
126+
}
127+
128+
// Check for specific reserved characters and provide helpful messages
129+
if (segment.Contains('?'))
130+
{
131+
errorMessage = "contains '?' which is reserved for query strings in URLs.";
132+
return false;
133+
}
134+
135+
if (segment.Contains('#'))
136+
{
137+
errorMessage = "contains '#' which is reserved for URL fragments.";
138+
return false;
139+
}
140+
141+
if (segment.Contains(':'))
142+
{
143+
errorMessage = "contains ':' which is a reserved character and not allowed in URL paths.";
144+
return false;
145+
}
146+
147+
if (_reservedUriCharsRgx.IsMatch(segment))
148+
{
149+
errorMessage = "contains reserved characters that are not allowed in URL paths.";
150+
return false;
151+
}
152+
}
153+
154+
return true;
155+
}
156+
69157
/// <summary>
70158
/// Method to validate if the TTL passed by the user is valid
71159
/// </summary>

src/Core/Services/RestService.cs

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -433,11 +433,17 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured
433433

434434
/// <summary>
435435
/// Tries to get the Entity name and primary key route from the provided string
436-
/// returns the entity name via a lookup using the string which includes
437-
/// characters up until the first '/', and then resolves the primary key
438-
/// as the substring following the '/'.
436+
/// by matching against configured entity paths (which may include '/' for sub-directories)
437+
/// using longest-prefix matching, then treating the remaining suffix as the primary key route.
438+
///
439439
/// For example, a request route should be of the form
440440
/// {EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
441+
/// where {EntityPath} may be a single segment like "books" or multi-segment like "shopping-cart/item".
442+
///
443+
/// Uses longest-prefix matching (most-specific match wins). When multiple
444+
/// entity paths could match, the longest matching path takes precedence. For example,
445+
/// if both "cart" and "cart/item" are valid entity paths, a request to
446+
/// "cart/item/id/123" will match "cart/item" with primaryKeyRoute "id/123".
441447
/// </summary>
442448
/// <param name="routeAfterPathBase">The request route (no '/' prefix) containing the entity path
443449
/// (and optionally primary key).</param>
@@ -448,26 +454,27 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured
448454

449455
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
450456

451-
// Split routeAfterPath on the first occurrence of '/', if we get back 2 elements
452-
// this means we have a non-empty primary key route which we save. Otherwise, save
453-
// primary key route as empty string. Entity Path will always be the element at index 0.
454-
// ie: {EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
455-
// splits into [{EntityPath}] when there is an empty primary key route and into
456-
// [{EntityPath}, {Primarykeyroute}] when there is a non-empty primary key route.
457-
int maxNumberOfElementsFromSplit = 2;
458-
string[] entityPathAndPKRoute = routeAfterPathBase.Split(new[] { '/' }, maxNumberOfElementsFromSplit);
459-
string entityPath = entityPathAndPKRoute[0];
460-
string primaryKeyRoute = entityPathAndPKRoute.Length == maxNumberOfElementsFromSplit ? entityPathAndPKRoute[1] : string.Empty;
461-
462-
if (!runtimeConfig.TryGetEntityNameFromPath(entityPath, out string? entityName))
457+
// Split routeAfterPath to extract segments
458+
string[] segments = routeAfterPathBase.Split('/');
459+
460+
// Try longest paths first (most-specific match wins)
461+
// Start with all segments, then remove one at a time
462+
for (int i = segments.Length; i >= 1; i--)
463463
{
464-
throw new DataApiBuilderException(
465-
message: $"Invalid Entity path: {entityPath}.",
466-
statusCode: HttpStatusCode.NotFound,
467-
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
464+
string entityPath = string.Join("/", segments.Take(i));
465+
if (runtimeConfig.TryGetEntityNameFromPath(entityPath, out string? entityName))
466+
{
467+
// Found entity
468+
string primaryKeyRoute = i < segments.Length ? string.Join("/", segments.Skip(i)) : string.Empty;
469+
return (entityName!, primaryKeyRoute);
470+
}
468471
}
469472

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

473480
/// <summary>

src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,21 +2066,41 @@ public void ValidateRestMethodsForEntityInConfig(
20662066
[DataTestMethod]
20672067
[DataRow(true, "EntityA", "", true, "The rest path for entity: EntityA cannot be empty.",
20682068
DisplayName = "Empty rest path configured for an entity fails config validation.")]
2069-
[DataRow(true, "EntityA", "entity?RestPath", true, "The rest path: entity?RestPath for entity: EntityA contains one or more reserved characters.",
2069+
[DataRow(true, "EntityA", "entity?RestPath", true, "The rest path: entity?RestPath for entity: EntityA contains '?' which is reserved for query strings in URLs.",
20702070
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
2071-
[DataRow(true, "EntityA", "entity#RestPath", true, "The rest path: entity#RestPath for entity: EntityA contains one or more reserved characters.",
2072-
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
2073-
[DataRow(true, "EntityA", "entity[]RestPath", true, "The rest path: entity[]RestPath for entity: EntityA contains one or more reserved characters.",
2074-
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
2075-
[DataRow(true, "EntityA", "entity+Rest*Path", true, "The rest path: entity+Rest*Path for entity: EntityA contains one or more reserved characters.",
2076-
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
2077-
[DataRow(true, "Entity?A", null, true, "The rest path: Entity?A for entity: Entity?A contains one or more reserved characters.",
2071+
[DataRow(true, "EntityA", "entity#RestPath", true, "The rest path: entity#RestPath for entity: EntityA contains '#' which is reserved for URL fragments.",
2072+
DisplayName = "Rest path for an entity containing reserved character # fails config validation.")]
2073+
[DataRow(true, "EntityA", "entity[]RestPath", true, "The rest path: entity[]RestPath for entity: EntityA contains reserved characters that are not allowed in URL paths.",
2074+
DisplayName = "Rest path for an entity containing reserved character [] fails config validation.")]
2075+
[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.",
2076+
DisplayName = "Rest path for an entity containing reserved character +* fails config validation.")]
2077+
[DataRow(true, "Entity?A", null, true, "The rest path: Entity?A for entity: Entity?A contains '?' which is reserved for query strings in URLs.",
20782078
DisplayName = "Entity name for an entity containing reserved character ? fails config validation.")]
2079-
[DataRow(true, "Entity&*[]A", null, true, "The rest path: Entity&*[]A for entity: Entity&*[]A contains one or more reserved characters.",
2080-
DisplayName = "Entity name containing reserved character ? fails config validation.")]
2079+
[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.",
2080+
DisplayName = "Entity name containing reserved character &*[] fails config validation.")]
20812081
[DataRow(false, "EntityA", "entityRestPath", true, DisplayName = "Rest path correctly configured as a non-empty string without any reserved characters.")]
20822082
[DataRow(false, "EntityA", "entityRest/?Path", false,
20832083
DisplayName = "Rest path for an entity containing reserved character but with rest disabled passes config validation.")]
2084+
[DataRow(false, "EntityA", "shopping-cart/item", true,
2085+
DisplayName = "Rest path with sub-directory passes config validation.")]
2086+
[DataRow(false, "EntityA", "api/v1/books", true,
2087+
DisplayName = "Rest path with multiple sub-directories passes config validation.")]
2088+
[DataRow(true, "EntityA", "entity\\path", true, "The rest path: entity\\path for entity: EntityA contains a backslash (\\). Use forward slash (/) for path separators.",
2089+
DisplayName = "Rest path with backslash fails config validation with helpful message.")]
2090+
[DataRow(false, "EntityA", "/entity/path", true,
2091+
DisplayName = "Rest path with leading slash is trimmed and passes config validation.")]
2092+
[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.",
2093+
DisplayName = "Rest path with consecutive slashes fails config validation.")]
2094+
[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.",
2095+
DisplayName = "Rest path with trailing slash fails config validation.")]
2096+
[DataRow(true, "EntityA", "entity /path", true, "The rest path: entity /path for entity: EntityA contains whitespace which is not allowed in URL paths.",
2097+
DisplayName = "Rest path with whitespace fails config validation with helpful message.")]
2098+
[DataRow(true, "EntityA", "entity%3Frest", true, "The rest path: entity%3Frest for entity: EntityA contains percent-encoding (%) which is not allowed. Use literal characters only.",
2099+
DisplayName = "Rest path with percent-encoded characters fails config validation.")]
2100+
[DataRow(true, "EntityA", "entity/../path", true, "The rest path: entity/../path for entity: EntityA contains path traversal patterns ('.' or '..') which are not allowed.",
2101+
DisplayName = "Rest path with dot-dot segments fails config validation.")]
2102+
[DataRow(true, "EntityA", "entity/./path", true, "The rest path: entity/./path for entity: EntityA contains path traversal patterns ('.' or '..') which are not allowed.",
2103+
DisplayName = "Rest path with dot segments fails config validation.")]
20842104
public void ValidateRestPathForEntityInConfig(
20852105
bool exceptionExpected,
20862106
string entityName,

0 commit comments

Comments
 (0)