Skip to content

Commit 017aabb

Browse files
CopilotJerryNixon
andcommitted
Add REST Advanced Paths support with sub-directories
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
1 parent 40a7877 commit 017aabb

4 files changed

Lines changed: 110 additions & 29 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}",
679680
statusCode: HttpStatusCode.ServiceUnavailable,
680681
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError
681682
);

src/Core/Configurations/RuntimeConfigValidatorUtil.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,74 @@ 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.
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 backslash usage - common mistake
81+
if (entityRestPath.Contains('\\'))
82+
{
83+
errorMessage = "contains a backslash (\\). Use forward slash (/) for path separators.";
84+
return false;
85+
}
86+
87+
// Check for whitespace
88+
if (entityRestPath.Any(char.IsWhiteSpace))
89+
{
90+
errorMessage = "contains whitespace which is not allowed in URL paths.";
91+
return false;
92+
}
93+
94+
// Split the path by '/' to validate each segment separately
95+
string[] segments = entityRestPath.Split('/');
96+
97+
// Validate each segment doesn't contain reserved characters
98+
foreach (string segment in segments)
99+
{
100+
if (string.IsNullOrEmpty(segment))
101+
{
102+
errorMessage = "contains empty path segments. Ensure there are no leading, consecutive, or trailing slashes.";
103+
return false;
104+
}
105+
106+
// Check for specific reserved characters and provide helpful messages
107+
if (segment.Contains('?'))
108+
{
109+
errorMessage = "contains '?' which is reserved for query strings in URLs.";
110+
return false;
111+
}
112+
113+
if (segment.Contains('#'))
114+
{
115+
errorMessage = "contains '#' which is reserved for URL fragments.";
116+
return false;
117+
}
118+
119+
if (segment.Contains(':'))
120+
{
121+
errorMessage = "contains ':' which is reserved for port numbers in URLs.";
122+
return false;
123+
}
124+
125+
if (_reservedUriCharsRgx.IsMatch(segment))
126+
{
127+
errorMessage = "contains characters that are not allowed in URL paths. " +
128+
"Valid paths contain only alphanumeric characters, hyphens (-), and underscores (_), " +
129+
"with forward slashes (/) as path separators.";
130+
return false;
131+
}
132+
}
133+
134+
return true;
135+
}
136+
69137
/// <summary>
70138
/// Method to validate if the TTL passed by the user is valid
71139
/// </summary>

src/Core/Services/RestService.cs

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured
438438
/// as the substring following the '/'.
439439
/// For example, a request route should be of the form
440440
/// {EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
441+
/// or {SubDir}/.../{EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
441442
/// </summary>
442443
/// <param name="routeAfterPathBase">The request route (no '/' prefix) containing the entity path
443444
/// (and optionally primary key).</param>
@@ -448,26 +449,27 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured
448449

449450
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
450451

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))
452+
// Split routeAfterPath to extract segments
453+
string[] segments = routeAfterPathBase.Split('/');
454+
455+
// Try progressively longer paths until we find a match
456+
// Start with the first segment, then first two, etc.
457+
for (int i = 1; i <= segments.Length; i++)
463458
{
464-
throw new DataApiBuilderException(
465-
message: $"Invalid Entity path: {entityPath}.",
466-
statusCode: HttpStatusCode.NotFound,
467-
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
459+
string entityPath = string.Join("/", segments.Take(i));
460+
if (runtimeConfig.TryGetEntityNameFromPath(entityPath, out string? entityName))
461+
{
462+
// Found entity
463+
string primaryKeyRoute = i < segments.Length ? string.Join("/", segments.Skip(i)) : string.Empty;
464+
return (entityName!, primaryKeyRoute);
465+
}
468466
}
469467

470-
return (entityName!, primaryKeyRoute);
468+
// No entity found
469+
throw new DataApiBuilderException(
470+
message: $"Invalid Entity path: {segments[0]}.",
471+
statusCode: HttpStatusCode.NotFound,
472+
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
471473
}
472474

473475
/// <summary>

src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,21 +2066,31 @@ 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 characters that are not allowed in URL paths. Valid paths contain only alphanumeric characters, hyphens (-), and underscores (_), with forward slashes (/) as path separators.",
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 characters that are not allowed in URL paths. Valid paths contain only alphanumeric characters, hyphens (-), and underscores (_), with forward slashes (/) as path separators.",
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 characters that are not allowed in URL paths. Valid paths contain only alphanumeric characters, hyphens (-), and underscores (_), with forward slashes (/) as path separators.",
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.")]
20842094
public void ValidateRestPathForEntityInConfig(
20852095
bool exceptionExpected,
20862096
string entityName,

0 commit comments

Comments
 (0)