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 responseHeaders, RequestHeadersMode requestHeadersMode, string requestHeadersParameterName, string? bodyParameterName, TextResponseNullHandling textResponseNullHandling, QueryStringNullHandling queryStringNullHandling, HashSet? 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? customParameters = null, bool userContext = false, bool userParameters = false, string? sseEventsPath = null, SseEventsScope sseEventsScope = SseEventsScope.All, HashSet? sseEventsRoles = null, bool encryptAllParameters = false, HashSet? encryptParameters = null, bool decryptAllColumns = false, HashSet? decryptColumns = null) { private string? _bodyParameterName = bodyParameterName; internal bool HasBodyParameter = !string.IsNullOrWhiteSpace(bodyParameterName); internal Action? 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 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? 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? CachedParams { get; set; } = cachedParams?.ToHashSet(); public TimeSpan? CacheExpiresIn { get; set; } = cacheExpiresIn; /// /// Name of the cache profile selected for this endpoint via the @cache_profile <name> annotation. /// Resolved at startup against ; if a name is not found startup fails with /// a single error listing every unresolved name and its offending endpoint. /// public string? CacheProfile { get; set; } /// /// Resolved cache backend for this endpoint (set during startup if is not null). /// At runtime the endpoint uses this instance for read/write/invalidate; falls back to /// when null. /// internal IRoutineCache? ResolvedCache { get; set; } /// /// 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). /// internal string? CacheKeyPrefix { get; set; } /// /// Conditional rules inherited from . Evaluated in order at request time /// against resolved parameter values; the first matching rule's action (bypass or TTL override) is applied. /// 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? 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? SseEventsRoles { get; set; } = sseEventsRoles; /// /// When true, RAISE statements in this routine's body whose severity matches the configured /// SSE level are forwarded to the SSE broadcaster. Set by @sse_publish (publish-only, no URL /// exposed) or as a side-effect of the @sse shorthand (publish + subscribe on the same path). /// Independent of : a routine can publish without exposing a subscribe URL, /// and an @sse_subscribe-only routine exposes a URL without publishing from its own body. /// 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; /// /// When true, this endpoint is only accessible via internal self-referencing calls /// (InternalRequestHandler). It is NOT registered as an HTTP route. /// public bool InternalOnly { get; set; } = false; /// /// 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. /// public string[]? UnhandledCommentLines { get; set; } = null; private Dictionary? _items; /// /// 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 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. /// public IDictionary Items => _items ??= new(StringComparer.Ordinal); /// /// Non-allocating read of an 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. /// public bool TryGetItem(string key, out object? value) { if (_items is not null) { return _items.TryGetValue(key, out value); } value = null; return false; } /// /// When true, encrypt ALL text parameters using the default data protector. /// public bool EncryptAllParameters { get; set; } = encryptAllParameters; /// /// Set of parameter names (actual or converted) to encrypt using the default data protector. /// public HashSet? EncryptParameters { get; set; } = encryptParameters; /// /// When true, decrypt ALL text result columns using the default data protector. /// public bool DecryptAllColumns { get; set; } = decryptAllColumns; /// /// Set of column names to decrypt using the default data protector. /// public HashSet? DecryptColumns { get; set; } = decryptColumns; /// /// When true, this endpoint is a cache invalidation endpoint. /// Instead of executing the routine, it removes the cached entry for the given parameters. /// public bool InvalidateCache { get; set; } = false; /// /// 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. /// public Dictionary? ResolvedParameterExpressions { get; set; } /// /// 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. /// public string[]? PathParameters { get; set; } = null; /// /// HashSet for O(1) case-insensitive lookup of path parameter names. /// Lazily initialized when PathParameters is set and first accessed. /// internal HashSet? PathParametersHashSet { get; private set; } = null; /// /// Returns true if this endpoint has any path parameters defined. /// public bool HasPathParameters => PathParameters is not null && PathParameters.Length > 0; /// /// Whether 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. /// 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); } /// /// True when is unset, or has a user-id / name / /// role claim whose value is one of the authorized roles. /// 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; } /// /// Ensures the PathParametersHashSet is initialized for fast lookups. /// Call this after setting PathParameters. /// internal void EnsurePathParametersHashSet() { if (PathParameters is not null && PathParametersHashSet is null) { PathParametersHashSet = new HashSet(PathParameters, StringComparer.OrdinalIgnoreCase); } } /// /// Finds the matching path parameter name for a given parameter name (case-insensitive). /// Returns null if no match is found. /// 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; } /// /// When true, this endpoint acts as a reverse proxy. /// Incoming requests are forwarded to ProxyHost + Path, and the response is returned to the client. /// public bool IsProxy { get; set; } = false; /// /// The proxy host URL for this endpoint (e.g., "https://api.example.com"). /// If null, uses ProxyOptions.Host from global configuration. /// public string? ProxyHost { get; set; } = null; /// /// Optional HTTP method override for the proxy request. /// If null, uses the same method as the incoming request. /// public Method? ProxyMethod { get; set; } = null; /// /// 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. /// internal bool HasProxyResponseParameters { get; set; } = false; /// /// Set of parameter names that receive proxy response data. /// internal HashSet? ProxyResponseParameterNames { get; set; } = null; /// /// 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. /// public bool IsProxyOut { get; set; } = false; /// /// The proxy host URL for proxy_out endpoints (e.g., "https://render-service.internal"). /// If null, uses ProxyOptions.Host from global configuration. /// public string? ProxyOutHost { get; set; } = null; /// /// HTTP method for the proxy_out request (e.g., POST, PUT). /// public Method? ProxyOutMethod { get; set; } = null; /// /// 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. /// public Dictionary>? ParameterValidations { get; set; } = null; /// /// 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. /// public bool ReturnSingleRecord { get; set; } = false; /// /// 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"}. /// public bool? NestedJsonForCompositeTypes { get; set; } = null; /// /// 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. /// public bool Void { get; set; } = false; }