Skip to content

Commit fe529c7

Browse files
committed
Add Parameter Validation Feature
1 parent f498f28 commit fe529c7

20 files changed

Lines changed: 1445 additions & 1 deletion
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
namespace NpgsqlRest.Defaults;
2+
3+
internal static partial class DefaultCommentParser
4+
{
5+
/// <summary>
6+
/// Annotation: validate | validation
7+
/// Syntax: validate _param_name using rule_name[, rule_name2, ...]
8+
///
9+
/// Description: Add validation rule(s) to a parameter.
10+
/// The parameter name can be either the original PostgreSQL name (e.g., _email) or the converted name (e.g., email).
11+
/// The rule_name must match a key in ValidationOptions.Rules dictionary.
12+
/// Multiple rules can be specified as comma-separated values: validate _email using required, email
13+
/// Multiple validate annotations can also be added for the same parameter on separate lines.
14+
/// </summary>
15+
private static readonly string[] ValidateKey = [
16+
"validate",
17+
"validation",
18+
];
19+
20+
private static void HandleValidate(
21+
Routine routine,
22+
RoutineEndpoint endpoint,
23+
string[] words,
24+
int len,
25+
string description)
26+
{
27+
// Syntax: validate _param_name using rule_name[, rule_name2, ...]
28+
// Minimum: validate _param using rule = 4 words
29+
if (len < 4)
30+
{
31+
Logger?.ValidationInvalidSyntax(description, "Expected: validate _param_name using rule_name[, rule_name2, ...]");
32+
return;
33+
}
34+
35+
var paramName = words[1];
36+
37+
// Check for "using" keyword
38+
if (!string.Equals(words[2], "using", StringComparison.OrdinalIgnoreCase))
39+
{
40+
Logger?.ValidationInvalidSyntax(description, $"Expected 'using' keyword, got '{words[2]}'");
41+
return;
42+
}
43+
44+
// Verify parameter exists in the routine (check both original and converted names)
45+
if (!routine.OriginalParamsHash.Contains(paramName) && !routine.ParamsHash.Contains(paramName))
46+
{
47+
Logger?.ValidationParameterNotFound(description, paramName);
48+
return;
49+
}
50+
51+
// Find the actual parameter to get both names
52+
var param = routine.Parameters.FirstOrDefault(x =>
53+
string.Equals(x.ActualName, paramName, StringComparison.Ordinal) ||
54+
string.Equals(x.ConvertedName, paramName, StringComparison.Ordinal));
55+
56+
if (param is null)
57+
{
58+
Logger?.ValidationParameterNotFound(description, paramName);
59+
return;
60+
}
61+
62+
// Get the original parameter name for storage
63+
var originalName = param.ActualName;
64+
65+
// words[3..] contains the rule names - already split by SplitWords (which splits by both space and comma)
66+
// e.g., "validate _email using required, email" -> words = ["validate", "_email", "using", "required", "email"]
67+
// So words[3..] = ["required", "email"]
68+
foreach (var ruleName in words[3..])
69+
{
70+
// Look up the validation rule
71+
if (!Options.ValidationOptions.Rules.TryGetValue(ruleName, out var rule))
72+
{
73+
Logger?.ValidationRuleNotFound(description, ruleName);
74+
continue;
75+
}
76+
77+
// Store validation using the original parameter name as the key
78+
endpoint.ParameterValidations ??= new Dictionary<string, List<ValidationRule>>();
79+
if (!endpoint.ParameterValidations.TryGetValue(originalName, out var rules))
80+
{
81+
rules = new List<ValidationRule>();
82+
endpoint.ParameterValidations[originalName] = rules;
83+
}
84+
rules.Add(rule);
85+
86+
Logger?.ValidationRuleSet(description, paramName, ruleName);
87+
}
88+
}
89+
}

NpgsqlRest/Defaults/DefaultCommentParser.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,13 @@ internal static partial class DefaultCommentParser
352352
HandleErrorCodePolicy(routineEndpoint, words, description);
353353
}
354354

355+
// validate _param_name using rule_name
356+
// validation _param_name using rule_name
357+
else if (haveTag is true && len >= 4 && StrEqualsToArray(wordsLower[0], ValidateKey))
358+
{
359+
HandleValidate(routine, routineEndpoint, words, len, description);
360+
}
361+
355362
// proxy
356363
// proxy [ GET | POST | PUT | DELETE | PATCH ]
357364
// proxy host_url

NpgsqlRest/Log.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,19 @@ public static partial class Log
250250

251251
[LoggerMessage(Level = LogLevel.Debug, Message = "{description} has set ERROR CODE POLICY NAME to {name} by the comment annotation.")]
252252
public static partial void ErrorCodePolicySet(this ILogger logger, string description, string name);
253+
254+
[LoggerMessage(Level = LogLevel.Debug, Message = "{description} has set VALIDATION RULE '{ruleName}' for parameter '{paramName}' by the comment annotation.")]
255+
public static partial void ValidationRuleSet(this ILogger logger, string description, string paramName, string ruleName);
256+
257+
[LoggerMessage(Level = LogLevel.Warning, Message = "{description} has failed to set VALIDATION RULE by the comment annotation. {error}")]
258+
public static partial void ValidationInvalidSyntax(this ILogger logger, string description, string error);
259+
260+
[LoggerMessage(Level = LogLevel.Warning, Message = "{description} has failed to set VALIDATION RULE by the comment annotation. Parameter '{paramName}' not found in routine parameters.")]
261+
public static partial void ValidationParameterNotFound(this ILogger logger, string description, string paramName);
262+
263+
[LoggerMessage(Level = LogLevel.Warning, Message = "{description} has failed to set VALIDATION RULE by the comment annotation. Rule '{ruleName}' not found in ValidationOptions.Rules.")]
264+
public static partial void ValidationRuleNotFound(this ILogger logger, string description, string ruleName);
265+
266+
[LoggerMessage(Level = LogLevel.Debug, Message = "{endpoint} validation failed for parameter '{paramName}': {message}")]
267+
public static partial void ValidationFailed(this ILogger logger, string endpoint, string paramName, string message);
253268
}

NpgsqlRest/NpgsqlRestEndpoint.cs

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1301,7 +1301,16 @@ await Options.ValidateParametersAsync(new ParameterValidationValues(
13011301
}
13021302

13031303
// parameters are ready !!!
1304-
1304+
1305+
// Validate parameters before any further processing (authorization, proxy, etc.)
1306+
if (endpoint.ParameterValidations is not null)
1307+
{
1308+
if (!await ValidateParametersAsync(command.Parameters, endpoint, context))
1309+
{
1310+
return;
1311+
}
1312+
}
1313+
13051314
// authorization check
13061315
if (endpoint.Login is false)
13071316
{
@@ -2363,4 +2372,168 @@ private async ValueTask ReturnErrorAsync(
23632372
await context.Response.WriteAsync(message);
23642373
await context.Response.CompleteAsync();
23652374
}
2375+
2376+
private async ValueTask<bool> ValidateParametersAsync(
2377+
NpgsqlParameterCollection parameters,
2378+
RoutineEndpoint endpoint,
2379+
HttpContext context)
2380+
{
2381+
if (endpoint.ParameterValidations is null)
2382+
{
2383+
return true;
2384+
}
2385+
2386+
foreach (var kvp in endpoint.ParameterValidations)
2387+
{
2388+
var originalParamName = kvp.Key;
2389+
var rules = kvp.Value;
2390+
2391+
// Find the parameter by original name
2392+
NpgsqlRestParameter? parameter = null;
2393+
for (var i = 0; i < parameters.Count; i++)
2394+
{
2395+
var p = parameters[i] as NpgsqlRestParameter;
2396+
if (p is not null && string.Equals(p.ActualName, originalParamName, StringComparison.Ordinal))
2397+
{
2398+
parameter = p;
2399+
break;
2400+
}
2401+
}
2402+
2403+
if (parameter is null)
2404+
{
2405+
continue;
2406+
}
2407+
2408+
// Get the value to validate - use OriginalStringValue for consistency
2409+
var valueToValidate = parameter.OriginalStringValue;
2410+
var convertedParamName = parameter.ConvertedName;
2411+
2412+
// Find rule names for error messages
2413+
foreach (var rule in rules)
2414+
{
2415+
// Find the rule name by looking it up in ValidationOptions
2416+
var ruleName = FindRuleName(rule);
2417+
2418+
var (isValid, message) = ValidateParameterValue(
2419+
valueToValidate,
2420+
parameter.Value,
2421+
rule,
2422+
originalParamName,
2423+
convertedParamName,
2424+
ruleName);
2425+
2426+
if (!isValid)
2427+
{
2428+
var urlInfo = string.Concat(endpoint.Method.ToString(), " ", endpoint.Path);
2429+
Logger?.ValidationFailed(urlInfo, originalParamName, message);
2430+
2431+
context.Response.StatusCode = rule.StatusCode;
2432+
context.Response.ContentType = Text.Plain;
2433+
await context.Response.WriteAsync(message);
2434+
await context.Response.CompleteAsync();
2435+
return false;
2436+
}
2437+
}
2438+
}
2439+
2440+
return true;
2441+
}
2442+
2443+
private static string FindRuleName(ValidationRule rule)
2444+
{
2445+
foreach (var kvp in Options.ValidationOptions.Rules)
2446+
{
2447+
if (ReferenceEquals(kvp.Value, rule))
2448+
{
2449+
return kvp.Key;
2450+
}
2451+
}
2452+
return string.Empty;
2453+
}
2454+
2455+
private static (bool IsValid, string Message) ValidateParameterValue(
2456+
string? originalStringValue,
2457+
object? value,
2458+
ValidationRule rule,
2459+
string originalParamName,
2460+
string convertedParamName,
2461+
string ruleName)
2462+
{
2463+
// Message format: {0} = original param name, {1} = converted param name, {2} = rule name
2464+
var message = string.Format(rule.Message, originalParamName, convertedParamName, ruleName);
2465+
2466+
switch (rule.Type)
2467+
{
2468+
case ValidationType.NotNull:
2469+
// Check if value is null or DBNull
2470+
if (value is null || value == DBNull.Value)
2471+
{
2472+
return (false, message);
2473+
}
2474+
break;
2475+
2476+
case ValidationType.NotEmpty:
2477+
// Check if value is an empty string (null values pass - use NotNull for null check)
2478+
if (originalStringValue is not null && originalStringValue.Length == 0)
2479+
{
2480+
return (false, message);
2481+
}
2482+
break;
2483+
2484+
case ValidationType.Required:
2485+
// Combines NotNull and NotEmpty - value cannot be null or empty string
2486+
if (value is null || value == DBNull.Value)
2487+
{
2488+
return (false, message);
2489+
}
2490+
if (originalStringValue is not null && originalStringValue.Length == 0)
2491+
{
2492+
return (false, message);
2493+
}
2494+
break;
2495+
2496+
case ValidationType.Regex:
2497+
if (rule.Pattern is null)
2498+
{
2499+
return (true, string.Empty);
2500+
}
2501+
// Null/empty values don't match regex (use NotEmpty rule first if required)
2502+
if (string.IsNullOrEmpty(originalStringValue))
2503+
{
2504+
return (false, message);
2505+
}
2506+
if (!System.Text.RegularExpressions.Regex.IsMatch(originalStringValue, rule.Pattern))
2507+
{
2508+
return (false, message);
2509+
}
2510+
break;
2511+
2512+
case ValidationType.MinLength:
2513+
if (rule.MinLength is null)
2514+
{
2515+
return (true, string.Empty);
2516+
}
2517+
var strValue = originalStringValue ?? string.Empty;
2518+
if (strValue.Length < rule.MinLength.Value)
2519+
{
2520+
return (false, message);
2521+
}
2522+
break;
2523+
2524+
case ValidationType.MaxLength:
2525+
if (rule.MaxLength is null)
2526+
{
2527+
return (true, string.Empty);
2528+
}
2529+
var strVal = originalStringValue ?? string.Empty;
2530+
if (strVal.Length > rule.MaxLength.Value)
2531+
{
2532+
return (false, message);
2533+
}
2534+
break;
2535+
}
2536+
2537+
return (true, string.Empty);
2538+
}
23662539
}

NpgsqlRest/Options/NpgsqlRestOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,4 +352,10 @@ public NpgsqlRestOptions(NpgsqlDataSource dataSource)
352352
/// When enabled, endpoints marked with 'proxy' annotation will forward requests to another URL.
353353
/// </summary>
354354
public ProxyOptions ProxyOptions { get; set; } = new();
355+
356+
/// <summary>
357+
/// Options for parameter validation rules.
358+
/// Define named validation rules that can be referenced in comment annotations using "validate _param using rule_name" syntax.
359+
/// </summary>
360+
public ValidationOptions ValidationOptions { get; set; } = new();
355361
}

0 commit comments

Comments
 (0)