-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathRoutineEndpoint.cs
More file actions
457 lines (416 loc) · 20.9 KB
/
Copy pathRoutineEndpoint.cs
File metadata and controls
457 lines (416 loc) · 20.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
using System.Security.Claims;
using Microsoft.Extensions.Primitives;
using NpgsqlRest.Auth;
namespace NpgsqlRest;
public class RoutineEndpoint(
Routine routine,
string path,
Method method,
RequestParamType requestParamType,
bool requiresAuthorization,
string? responseContentType,
Dictionary<string, StringValues> responseHeaders,
RequestHeadersMode requestHeadersMode,
string requestHeadersParameterName,
string? bodyParameterName,
TextResponseNullHandling textResponseNullHandling,
QueryStringNullHandling queryStringNullHandling,
HashSet<string>? authorizeRoles = null,
bool login = false,
bool logout = false,
bool securitySensitive = false,
ulong? bufferRows = null,
bool raw = false,
string? rawValueSeparator = null,
string? rawNewLineSeparator = null,
bool rawColumnNames = false,
bool cached = false,
string[]? cachedParams = null,
TimeSpan? cacheExpiresIn = null,
string? connectionName = null,
bool upload = false,
string[]? uploadHandlers = null,
Dictionary<string, string>? customParameters = null,
bool userContext = false,
bool userParameters = false,
string? sseEventsPath = null,
SseEventsScope sseEventsScope = SseEventsScope.All,
HashSet<string>? sseEventsRoles = null,
bool encryptAllParameters = false,
HashSet<string>? encryptParameters = null,
bool decryptAllColumns = false,
HashSet<string>? decryptColumns = null)
{
private string? _bodyParameterName = bodyParameterName;
internal bool HasBodyParameter = !string.IsNullOrWhiteSpace(bodyParameterName);
internal Action<ILogger, string, string, Exception?>? LogCallback { get; set; }
internal bool HeadersNeedParsing { get; set; } = false;
internal bool CustomParamsNeedParsing { get; set; } = false;
public Routine Routine { get; } = routine;
public string Path { get; set; } = path;
public Method Method { get; set; } = method;
public RequestParamType RequestParamType { get; set; } = requestParamType;
public bool RequiresAuthorization { get; set; } = requiresAuthorization;
public string? ResponseContentType { get; set; } = responseContentType;
public Dictionary<string, StringValues> ResponseHeaders { get; set; } = responseHeaders;
public RequestHeadersMode RequestHeadersMode { get; set; } = requestHeadersMode;
public string RequestHeadersParameterName { get; set; } = requestHeadersParameterName;
public string? BodyParameterName
{
get => _bodyParameterName;
set
{
HasBodyParameter = !string.IsNullOrWhiteSpace(value);
_bodyParameterName = value;
}
}
public TextResponseNullHandling TextResponseNullHandling { get; set; } = textResponseNullHandling;
public QueryStringNullHandling QueryStringNullHandling { get; set; } = queryStringNullHandling;
public HashSet<string>? AuthorizeRoles { get; set; } = authorizeRoles;
public bool Login { get; set; } = login;
public bool Logout { get; set; } = logout;
public bool SecuritySensitive { get; set; } = securitySensitive;
public bool IsAuth => Login || Logout || SecuritySensitive;
public ulong? BufferRows { get; set; } = bufferRows;
public bool Raw { get; set; } = raw;
public string? RawValueSeparator { get; set; } = rawValueSeparator;
public string? RawNewLineSeparator { get; set; } = rawNewLineSeparator;
public bool RawColumnNames { get; set; } = rawColumnNames;
public string[][]? CommentWordLines { get; internal set; }
public bool Cached { get; set; } = cached;
public HashSet<string>? CachedParams { get; set; } = cachedParams?.ToHashSet();
public TimeSpan? CacheExpiresIn { get; set; } = cacheExpiresIn;
/// <summary>
/// Name of the cache profile selected for this endpoint via the <c>@cache_profile <name></c> annotation.
/// Resolved at startup against <see cref="CacheOptions.Profiles"/>; if a name is not found startup fails with
/// a single error listing every unresolved name and its offending endpoint.
/// </summary>
public string? CacheProfile { get; set; }
/// <summary>
/// Resolved cache backend for this endpoint (set during startup if <see cref="CacheProfile"/> is not null).
/// At runtime the endpoint uses this instance for read/write/invalidate; falls back to
/// <see cref="CacheOptions.DefaultRoutineCache"/> when null.
/// </summary>
internal IRoutineCache? ResolvedCache { get; set; }
/// <summary>
/// Cache key prefix (set to the resolved profile name) so two profiles sharing the same backend cannot collide.
/// Null for endpoints without a profile (root cache; existing key shape unchanged).
/// </summary>
internal string? CacheKeyPrefix { get; set; }
/// <summary>
/// Conditional rules inherited from <see cref="CacheProfile.When"/>. Evaluated in order at request time
/// against resolved parameter values; the first matching rule's action (bypass or TTL override) is applied.
/// </summary>
internal CacheWhenRule[]? CacheWhen { get; set; }
public string? ConnectionName { get; set; } = connectionName;
public bool Upload { get; set; } = upload;
public string[]? UploadHandlers { get; set; } = uploadHandlers;
public Dictionary<string, string>? CustomParameters { get; set; } = customParameters;
public bool UserContext { get; set; } = userContext;
public bool UseUserParameters { get; set; } = userParameters;
public PostgresNoticeLevels? SseEventNoticeLevel { get; set; } = null;
public string? SseEventsPath { get; set; } = sseEventsPath;
public SseEventsScope SseEventsScope { get; set; } = sseEventsScope;
public HashSet<string>? SseEventsRoles { get; set; } = sseEventsRoles;
/// <summary>
/// When true, <c>RAISE</c> statements in this routine's body whose severity matches the configured
/// SSE level are forwarded to the SSE broadcaster. Set by <c>@sse_publish</c> (publish-only, no URL
/// exposed) or as a side-effect of the <c>@sse</c> shorthand (publish + subscribe on the same path).
/// Independent of <see cref="SseEventsPath"/>: a routine can publish without exposing a subscribe URL,
/// and an <c>@sse_subscribe</c>-only routine exposes a URL without publishing from its own body.
/// </summary>
public bool SsePublishEnabled { get; set; } = false;
public Auth.EndpointBasicAuthOptions? BasicAuth { get; set; } = null;
public RetryStrategy? RetryStrategy { get; set; } = null;
public string? RateLimiterPolicy { get; set; } = null;
public string? ErrorCodePolicy { get; set; } = null;
public TimeSpan? CommandTimeout { get; set; } = null;
/// <summary>
/// When true, this endpoint is only accessible via internal self-referencing calls
/// (InternalRequestHandler). It is NOT registered as an HTTP route.
/// </summary>
public bool InternalOnly { get; set; } = false;
/// <summary>
/// Comment lines that were NOT recognized as built-in NpgsqlRest directives, in order, with
/// original case (trimmed). Null when the comment had no such lines. This is the extension point
/// for plugins (e.g. NpgsqlRest.Mcp): a plugin parses its own annotations out of these lines in
/// its endpoint-create handler, and treats the remainder as the human-readable description.
/// Core itself attaches no meaning to these lines.
/// </summary>
public string[]? UnhandledCommentLines { get; set; } = null;
private Dictionary<string, object?>? _items;
/// <summary>
/// Generic per-endpoint property bag for plugin-attached metadata, namespaced by key
/// (e.g. "mcp", "openapi"). Core attaches no meaning to its contents — it is the typed
/// extension point for plugins (parse from <see cref="UnhandledCommentLines"/> in an
/// endpoint-create handler, stash the result here). Populated at build time, read-only at
/// runtime. Lazily allocated, so endpoints with no plugin metadata cost nothing.
/// </summary>
public IDictionary<string, object?> Items => _items ??= new(StringComparer.Ordinal);
/// <summary>
/// Non-allocating read of an <see cref="Items"/> entry. Returns false (without allocating the
/// bag) when no items have been stored. Use this for reads on the hot/common path so endpoints
/// with no plugin metadata cost nothing.
/// </summary>
public bool TryGetItem(string key, out object? value)
{
if (_items is not null)
{
return _items.TryGetValue(key, out value);
}
value = null;
return false;
}
/// <summary>
/// When true, encrypt ALL text parameters using the default data protector.
/// </summary>
public bool EncryptAllParameters { get; set; } = encryptAllParameters;
/// <summary>
/// Set of parameter names (actual or converted) to encrypt using the default data protector.
/// </summary>
public HashSet<string>? EncryptParameters { get; set; } = encryptParameters;
/// <summary>
/// When true, decrypt ALL text result columns using the default data protector.
/// </summary>
public bool DecryptAllColumns { get; set; } = decryptAllColumns;
/// <summary>
/// Set of column names to decrypt using the default data protector.
/// </summary>
public HashSet<string>? DecryptColumns { get; set; } = decryptColumns;
/// <summary>
/// When true, this endpoint is a cache invalidation endpoint.
/// Instead of executing the routine, it removes the cached entry for the given parameters.
/// </summary>
public bool InvalidateCache { get; set; } = false;
/// <summary>
/// Dictionary of parameter names to SQL expressions that resolve their values server-side.
/// Key = actual parameter name (e.g., "_token"), Value = SQL expression template (e.g., "select api_token from tokens where user_id = {_user_id}").
/// Resolved parameters cannot be overridden by client input.
/// </summary>
public Dictionary<string, string>? ResolvedParameterExpressions { get; set; }
/// <summary>
/// List of parameter names that are extracted from the URL path.
/// For example, path "/products/{p_id}" would have PathParameters = ["p_id"].
/// These parameters are populated from ASP.NET Core RouteValues.
/// </summary>
public string[]? PathParameters { get; set; } = null;
/// <summary>
/// HashSet for O(1) case-insensitive lookup of path parameter names.
/// Lazily initialized when PathParameters is set and first accessed.
/// </summary>
internal HashSet<string>? PathParametersHashSet { get; private set; } = null;
/// <summary>
/// Returns true if this endpoint has any path parameters defined.
/// </summary>
public bool HasPathParameters => PathParameters is not null && PathParameters.Length > 0;
/// <summary>
/// Whether <paramref name="user"/> may invoke this endpoint, given its authorization annotations.
/// Login endpoints are always callable; otherwise an authenticated principal is required when the
/// endpoint requires authorization or restricts roles, and a matching role claim when roles are set.
/// This is the single source of truth shared by the request-time authorization check and the MCP
/// tools/list role filter.
/// </summary>
public bool IsCallableBy(ClaimsPrincipal? user, NpgsqlRestAuthenticationOptions auth)
{
if (Login)
{
return true;
}
if ((RequiresAuthorization || AuthorizeRoles is not null) && user?.Identity?.IsAuthenticated is not true)
{
return false;
}
return HasAuthorizeRoleMatch(user, auth);
}
/// <summary>
/// True when <see cref="AuthorizeRoles"/> is unset, or <paramref name="user"/> has a user-id / name /
/// role claim whose value is one of the authorized roles.
/// </summary>
public bool HasAuthorizeRoleMatch(ClaimsPrincipal? user, NpgsqlRestAuthenticationOptions auth)
{
if (AuthorizeRoles is null)
{
return true;
}
foreach (var claim in user?.Claims ?? [])
{
if ((string.Equals(claim.Type, auth.DefaultUserIdClaimType, StringComparison.Ordinal) ||
string.Equals(claim.Type, auth.DefaultNameClaimType, StringComparison.Ordinal) ||
string.Equals(claim.Type, auth.DefaultRoleClaimType, StringComparison.Ordinal))
&& AuthorizeRoles.Contains(claim.Value))
{
return true;
}
}
return false;
}
/// <summary>
/// Ensures the PathParametersHashSet is initialized for fast lookups.
/// Call this after setting PathParameters.
/// </summary>
internal void EnsurePathParametersHashSet()
{
if (PathParameters is not null && PathParametersHashSet is null)
{
PathParametersHashSet = new HashSet<string>(PathParameters, StringComparer.OrdinalIgnoreCase);
}
}
/// <summary>
/// Finds the matching path parameter name for a given parameter name (case-insensitive).
/// Returns null if no match is found.
/// </summary>
internal string? FindMatchingPathParameter(string convertedName, string? actualName)
{
if (PathParameters is null) return null;
// Use HashSet for O(1) contains check, then find exact match for the return value
EnsurePathParametersHashSet();
if (PathParametersHashSet!.Contains(convertedName))
{
// Find the exact string from the array to use as route key
foreach (var pathParam in PathParameters)
{
if (string.Equals(pathParam, convertedName, StringComparison.OrdinalIgnoreCase))
{
return pathParam;
}
}
}
if (actualName is not null && PathParametersHashSet.Contains(actualName))
{
foreach (var pathParam in PathParameters)
{
if (string.Equals(pathParam, actualName, StringComparison.OrdinalIgnoreCase))
{
return pathParam;
}
}
}
return null;
}
/// <summary>
/// When true, this endpoint acts as a reverse proxy.
/// Incoming requests are forwarded to ProxyHost + Path, and the response is returned to the client.
/// </summary>
public bool IsProxy { get; set; } = false;
/// <summary>
/// The proxy host URL for this endpoint (e.g., "https://api.example.com").
/// If null, uses ProxyOptions.Host from global configuration.
/// </summary>
public string? ProxyHost { get; set; } = null;
/// <summary>
/// Optional HTTP method override for the proxy request.
/// If null, uses the same method as the incoming request.
/// </summary>
public Method? ProxyMethod { get; set; } = null;
/// <summary>
/// Computed during endpoint creation: true if any routine parameter matches a proxy response field name.
/// When true, the routine will be invoked with proxy response data.
/// When false, the proxy response is returned directly without invoking the routine.
/// </summary>
internal bool HasProxyResponseParameters { get; set; } = false;
/// <summary>
/// Set of parameter names that receive proxy response data.
/// </summary>
internal HashSet<string>? ProxyResponseParameterNames { get; set; } = null;
/// <summary>
/// When true, this endpoint executes the PostgreSQL function first, then forwards
/// the function's result body as the request body to an upstream proxy service.
/// The upstream response is returned to the client.
/// </summary>
public bool IsProxyOut { get; set; } = false;
/// <summary>
/// The proxy host URL for proxy_out endpoints (e.g., "https://render-service.internal").
/// If null, uses ProxyOptions.Host from global configuration.
/// </summary>
public string? ProxyOutHost { get; set; } = null;
/// <summary>
/// HTTP method for the proxy_out request (e.g., POST, PUT).
/// </summary>
public Method? ProxyOutMethod { get; set; } = null;
/// <summary>
/// Dictionary of parameter validations. Key is the parameter name, value is the list of validation rules to apply.
/// Configured via comment annotations using "validate _param using rule_name" syntax.
/// </summary>
public Dictionary<string, List<ValidationRule>>? ParameterValidations { get; set; } = null;
/// <summary>
/// When true, only the first row is returned and the result is serialized as a JSON object
/// instead of a JSON array. If the query returns multiple rows, only the first row is used.
/// Configured via the "single" comment annotation.
/// </summary>
public bool ReturnSingleRecord { get; set; } = false;
/// <summary>
/// When true, composite type columns in the response are serialized as nested JSON objects.
/// For example, a column "req" of type "my_request(id int, name text)" becomes {"req": {"id": 1, "name": "test"}}
/// instead of the default flat structure {"id": 1, "name": "test"}.
/// </summary>
public bool? NestedJsonForCompositeTypes { get; set; } = null;
/// <summary>
/// When true, the endpoint is forced to behave as void — all statements are executed
/// but no result is returned. Returns 204 No Content.
/// Configured via the "void" comment annotation.
/// </summary>
public bool Void { get; set; } = false;
/// <summary>
/// True when this parameter is filled by the server for this endpoint rather than supplied by the
/// client, so a client-provided value would be ignored (overridden). Covers, unconditionally,
/// HTTP Custom Type fields, resolved-parameter expressions, and upload-metadata parameters; and,
/// only when <see cref="UseUserParameters"/> is enabled for this endpoint, IP-address and
/// user-claim parameters. Whether claim/IP parameters are automatic is therefore endpoint-specific.
/// </summary>
public bool IsAutomaticParameter(NpgsqlRestParameter parameter)
{
// HTTP Custom Type expanded field — filled by the outbound HTTP call before the routine runs.
if (parameter.TypeDescriptor.CustomType is not null
&& parameter.TypeDescriptor.CustomTypeName is not null
&& HttpClientType.HttpClientTypes.Definitions.ContainsKey(parameter.TypeDescriptor.CustomType))
{
return true;
}
// Resolved-parameter expression — computed server-side via SQL at request time.
if (ResolvedParameterExpressions is not null
&& (ResolvedParameterExpressions.ContainsKey(parameter.ActualName)
|| ResolvedParameterExpressions.ContainsKey(parameter.ConvertedName)))
{
return true;
}
// Upload metadata — set by the upload handler.
if (parameter.IsUploadMetadata)
{
return true;
}
// IP address / user claims — only automatic when this endpoint binds user parameters.
if (UseUserParameters && (parameter.IsIpAddress || parameter.IsFromUserClaims))
{
return true;
}
return false;
}
/// <summary>
/// True when a parameter should be omitted from generated client request shapes (TypeScript
/// modules, HTTP files, OpenAPI specs). A parameter is omitted when it is
/// <see cref="IsAutomaticParameter"/> (the client value would be overridden) AND optional — it has
/// a default or is a composite / HTTP Custom Type field. The optional guard ensures omission can
/// never make a required argument un-sendable; an automatic-but-required parameter is kept.
/// </summary>
public bool OmitParameterFromGeneratedRequest(NpgsqlRestParameter parameter) =>
IsAutomaticParameter(parameter)
&& (parameter.TypeDescriptor.HasDefault || parameter.TypeDescriptor.CustomType is not null);
/// <summary>
/// True when this parameter is the one designated by <c>@body_parameter_name</c> — it carries the
/// raw request body rather than a query/JSON field. The configured name is matched
/// case-insensitively against the parameter's converted (API) name, its actual (database) name, or
/// its expanded per-field name (for an HTTP Custom Type field expanded from a composite, e.g.
/// <c>responseBody</c> / <c>_response</c> / <c>_response_body</c>). Single source of truth for body-
/// parameter resolution across request handling and all code generators.
/// </summary>
public bool IsBodyParameter(NpgsqlRestParameter parameter)
{
if (!HasBodyParameter)
{
return false;
}
return string.Equals(BodyParameterName, parameter.ConvertedName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(BodyParameterName, parameter.ActualName, StringComparison.OrdinalIgnoreCase)
|| (parameter.ExpandedName is not null && string.Equals(BodyParameterName, parameter.ExpandedName, StringComparison.OrdinalIgnoreCase));
}
}