diff --git a/.agents/skills/backend-architecture/SKILL.md b/.agents/skills/backend-architecture/SKILL.md index 62159e6790..fbc066a74c 100644 --- a/.agents/skills/backend-architecture/SKILL.md +++ b/.agents/skills/backend-architecture/SKILL.md @@ -23,7 +23,7 @@ aspire run ```text Exceptionless.Core → Domain logic, services, repositories, validation Exceptionless.Insulation → Infrastructure implementations (Redis, GeoIP, Mail, HealthChecks) -Exceptionless.Web → ASP.NET Core host, controllers, WebSocket hubs +Exceptionless.Web → ASP.NET Core host, Minimal API endpoints, Mediator handlers, WebSocket hubs Exceptionless.Job → Background job workers ``` @@ -120,49 +120,135 @@ public static class AuthorizationRoles public const string GlobalAdmin = "global"; } -// Usage -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class OrganizationController : RepositoryApiController<...> { } +// Minimal API endpoint groups (NEW pattern) +var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.UserPolicy) // Group default + .WithTags("Tokens"); -[Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] -public class AdminController : ExceptionlessApiController { } +// Override on specific endpoints +group.MapGet("tokens/me", ...).RequireAuthorization(AuthorizationRoles.ClientPolicy); +group.MapPost("auth/login", ...).AllowAnonymous(); ``` ## Controller Patterns -Most controllers extend `RepositoryApiController`. Auth/special-case controllers extend `ExceptionlessApiController` directly. +> **DEPRECATED**: Controllers are being migrated to Minimal API endpoints + Mediator handlers (see below). +> Do NOT add new controllers. Use the Endpoint + Handler pattern instead. + +Legacy controllers extend `RepositoryApiController`. Auth/special-case controllers extend `ExceptionlessApiController` directly. + +## Minimal API + Mediator Architecture (NEW) + +All new API work uses Minimal API endpoints with Foundatio.Mediator for command/query dispatch. + +### Structure + +```text +src/Exceptionless.Web/Api/ +├── Endpoints/ ← Thin HTTP adapters (routing, auth, response mapping) +├── Messages/ ← Command/query records (mediator messages) +├── Handlers/ ← Use-case logic (transport-agnostic, return Result) +├── Middleware/ ← Mediator pipeline middleware (validation, logging) +├── Filters/ ← Endpoint filters (HTTP-specific cross-cutting) +├── Results/ ← Result→IResult mapping, pagination, response types +├── Infrastructure/ ← Shared utilities (validation, pagination, links) +└── OpenApi/ ← OpenAPI conventions and transformers +``` + +### Endpoint Pattern + +```csharp +public static class TokenEndpoints +{ + public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .WithTags("Tokens"); + + group.MapGet("tokens/{id}", async (string id, IMediator mediator) + => (await mediator.InvokeAsync>(new GetTokenById(id))).ToHttpResult()) + .WithName("GetTokenById") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound); + + return endpoints; + } +} +``` + +### Handler Pattern (CRITICAL: Transport-Agnostic) + +Handlers MUST return `Result` or `Result` — NEVER `IResult` or HTTP types. ```csharp -[Route(API_PREFIX + "/organizations")] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class OrganizationController : RepositoryApiController +public class TokenHandler(ITokenRepository repository, ...) : HandlerBase { - [HttpGet] - public async Task>> GetAllAsync(string? mode = null) + public async Task> Handle(GetTokenById message) { - var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray()); - return Ok(await MapCollectionAsync(organizations, true)); + var model = await repository.GetByIdAsync(message.Id); + if (model is null) + return Result.NotFound("Token not found."); + return mapper.MapToViewToken(model); } } ``` +### Result→HTTP Status Code Mapping + +| Result Type | HTTP Status | Notes | +|---|---|---| +| `Result` success | 200 OK | Default success | +| `Result.Created(val, loc)` | 201 Created | With Location header | +| `Result.NotFound(msg)` | 404 | Message in `title` field | +| `Result.Forbidden(msg)` | 403 | Message in `title` field | +| `Result.BadRequest(msg)` | 400 | Message in `title` field | +| `Result.Invalid(ValidationError)` | 422 | Errors in `errors` dict | +| `Result.Invalid("plan_limit", msg)` | 426 | Upgrade Required | +| `Result.Invalid("not_implemented", msg)` | 501 | Not Implemented | +| `Result.Invalid("rate_limit", msg)` | 429 | Too Many Requests | +| `WorkInProgressResult` | 202 Accepted | Bulk operations | +| `ModelActionResults` (has failures) | 400 | Per-ID failure details | +| `PagedResult` | 200 + Link headers | Auto-pagination | +| `NotModifiedResponse` | 304 | No body | + +### Key Rules + +- Handlers MUST NOT import `Microsoft.AspNetCore.Http` +- Handlers CAN accept `HttpContext` as a method parameter (auto-resolved by mediator) for auth +- Pagination link URLs MUST be built in the endpoint/mapper layer +- `ProblemDetails` shape MUST be preserved: `instance`, `reference-id`, `errors`, `lower_underscore` keys +- Messages go in `title` field (NOT `detail`) — matches original controller behavior +- Use `ApiValidation.ValidateAsync(model, serviceProvider)` at endpoint level (returns 422 by default) +- Keep v1 legacy route aliases in the same endpoint file as canonical v2 routes + ## ProblemDetails and Error Handling -Return helpers from `ExceptionlessApiController`: `Ok()`, `Created()`, `NoContent()`, `Unauthorized()`, `Forbidden()`, `NotFound()`, `ValidationProblem(ModelState)`. +### Endpoint-level validation (Minimal API) -Exceptions auto-convert via `ExceptionToProblemDetailsHandler`: `MiniValidatorException`/`ValidationException` → 422, others → 500. +Use `ApiValidation.ValidateAsync(model, serviceProvider)` — returns `ValidationProblemDetails` at 422 for DataAnnotation failures. For MVC-model-binding-compatible 400 responses, pass explicit status code or add endpoint-level checks. + +### Handler-level errors + +Return `Result.NotFound()`, `Result.Forbidden()`, `Result.BadRequest()`, or `Result.Invalid(ValidationError)`. The `ResultExtensions.ToHttpResult()` method converts these to proper `IResult` with ProblemDetails shape. + +### Exception handling + +Exceptions auto-convert via `ExceptionToProblemDetailsHandler`: `MiniValidatorException`/`ValidationException` → 422, `UnauthorizedAccessException` → 401, `VersionConflictDocumentException` → 409, others → 500. ## OpenAPI Baseline After any API change (new endpoint, changed status codes, modified request/response models), **always regenerate the OpenAPI baseline**: -```powershell -# Requires the API to be running (`aspire run` or the AppHost) -Invoke-WebRequest -Uri "https://api-ex.dev.localhost:7111/docs/v2/openapi.json" -OutFile "tests/Exceptionless.Tests/Controllers/Data/openapi.json" +```bash +# Requires the API to be running (aspire run --project src/Exceptionless.AppHost) +curl -s http://localhost:7110/docs/v2/openapi.json | jq . > tests/Exceptionless.Tests/Controllers/Data/openapi.json ``` -Then include the updated `openapi.json` in the same commit as the API change (or amend). The `OpenApiControllerTests.GetOpenApiJson_Default_ReturnsExpectedBaseline` test will fail if the baseline is stale. -If local TLS tooling fails, use the Aspire-described HTTP endpoint: `http://api-ex.dev.localhost:7110/docs/v2/openapi.json`. +Then include the updated `openapi.json` in the same commit as the API change. The `OpenApiSnapshotTests.GetOpenApiJson_Default_MatchesSnapshot` test will fail if the baseline is stale. +If local TLS tooling is preferred, use the Aspire HTTPS endpoint: `https://api-ex.dev.localhost:7111/docs/v2/openapi.json`. + +The endpoint manifest test (`EndpointManifestTests`) verifies all registered routes haven't changed — update `tests/Exceptionless.Tests/Controllers/Data/endpoint-manifest.txt` when adding/removing routes. ## WebSocket Hubs (NOT SignalR) diff --git a/.gitignore b/.gitignore index 1ab442acbd..adb0db57a5 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ debug-storybook.log .devcontainer/devcontainer-lock.json *.lscache +audit-output/ diff --git a/docs/serialization-architecture.md b/docs/serialization-architecture.md index c835859a9d..666aaf7c55 100644 --- a/docs/serialization-architecture.md +++ b/docs/serialization-architecture.md @@ -7,7 +7,7 @@ This document describes the complete serialization architecture after the Newton 1. [Serializer Configuration](#serializer-configuration) 2. [Serialization Paths](#serialization-paths) 3. [Data Flow: Event Lifecycle](#data-flow-event-lifecycle) -4. [GetValue\ Dictionary Extraction](#getvaluet-dictionary-extraction) +4. [GetValue\ Dictionary Extraction](#getvaluet-dictionary-extraction) 5. [ObjectToInferredTypesConverter](#objecttoinferredtypesconverter) 6. [Event Upgrade Pipeline](#event-upgrade-pipeline) 7. [Model Annotations & Naming](#model-annotations--naming) @@ -66,11 +66,11 @@ Every Foundatio infrastructure component (queues, cache, message bus) resolves ` ### Path 1: API Responses (ASP.NET Core) -**Config:** `Startup.cs` → `.AddJsonOptions(o => o.JsonSerializerOptions.ConfigureExceptionlessDefaults())` +**Config:** `Program.cs` → `.ConfigureHttpJsonOptions(o => o.SerializerOptions.ConfigureExceptionlessApiDefaults())` -- Separate `JsonSerializerOptions` instance from DI, but identically configured -- Additional converter: `DeltaJsonConverterFactory` for PATCH operations -- Also configured for Minimal APIs: `.ConfigureHttpJsonOptions(...)` +- Separate `JsonSerializerOptions` instance from DI, with the same naming/converter baseline +- API responses use `ConfigureExceptionlessApiDefaults()` so response bodies preserve empty collections where the public contract expects them +- PATCH operations use `Microsoft.AspNetCore.JsonPatch.SystemTextJson` JSON Patch documents ### Path 2: Elasticsearch Documents diff --git a/openspec/changes/minimal-api-mediator-openapi-migration/acceptance.md b/openspec/changes/minimal-api-mediator-openapi-migration/acceptance.md new file mode 100644 index 0000000000..bd986519ad --- /dev/null +++ b/openspec/changes/minimal-api-mediator-openapi-migration/acceptance.md @@ -0,0 +1,69 @@ +# Acceptance Criteria: Minimal API + Mediator + OpenAPI Migration + +## Route Preservation + +- **SHALL** preserve all existing v2 routes with identical HTTP methods, paths, and parameter binding. +- **SHALL** preserve existing v1 compatibility aliases with identical behavior. +- **SHALL** produce identical response status codes for all success and error cases. +- **SHALL** preserve response body shapes (JSON property names, nesting, types). +- **SHALL** preserve response headers (pagination, configuration version, rate-limit headers). +- **SHALL** preserve query parameter behavior (filtering, sorting, paging, time ranges). + +## Authentication and Authorization + +- **SHALL** preserve auth/authorization behavior for all endpoints. +- **SHALL** preserve `ApiKeyAuthenticationHandler` behavior (API key via header, query string, and bearer token). +- **SHALL** preserve role-based policies (UserPolicy, GlobalAdminPolicy) on all endpoints. +- **SHALL** preserve anonymous access on endpoints currently marked `[AllowAnonymous]`. + +## Middleware + +- **SHALL** preserve `ThrottlingMiddleware` behavior (rate limiting, response codes, headers). +- **SHALL** preserve `OverageMiddleware` behavior (plan enforcement). +- **SHALL NOT** replace existing middleware implementations. +- **SHALL NOT** change middleware pipeline ordering for existing middleware. + +## Validation and Error Handling + +- **SHALL** preserve ProblemDetails shape: `instance` field, `reference-id` extension, `errors` map. +- **SHALL** preserve `lower_underscore` error keys in validation error responses. +- **SHALL** produce 422 for validation failures with errors map. +- **SHALL** produce 401 for unauthenticated requests. +- **SHALL** produce 403 for unauthorized requests. +- **SHALL** produce 404 for not-found resources. + +## Patching + +- **SHALL** preserve `Delta` patch behavior (partial update semantics, unchanged fields not modified). +- **SHALL NOT** introduce JSON Patch in this change. + +## Event Ingestion + +- **SHALL** preserve raw event ingestion behavior (multipart, compressed, raw body). +- **SHALL** preserve event submission via API key authentication. +- **SHALL** preserve batch event submission. + +## Mediator Pattern + +- **SHALL NOT** use generated mediator endpoints (MapMediatorEndpoints) for existing public API routes. +- **SHALL** use Foundatio.Mediator for command/query dispatch from endpoint lambdas. +- **SHALL** register all handlers via DI auto-discovery. + +## OpenAPI + +- **SHALL** preserve `/docs/v2/openapi.json` serving Scalar docs. +- **SHALL** generate build-time OpenAPI artifact during `dotnet build`. +- **SHALL** add route manifest snapshot tests that fail on route addition/removal/change. +- **SHALL** add OpenAPI snapshot tests that fail on schema drift. + +## Architecture + +- **SHALL** place all new endpoint code under `src/Exceptionless.Web/Api/`. +- **SHALL** keep v1 legacy aliases in the same endpoint file as the canonical v2 route. +- **SHALL** remove `AddControllers()` and `MapControllers()` after all controllers are migrated. +- **SHALL** delete `Controllers/` folder after all controllers are migrated. + +## Testing + +- **SHALL** pass all existing integration tests without modification (unless test infrastructure needs updating for host changes). +- **SHALL** update `tests/http/*.http` files if endpoint paths or parameters change (they should not). diff --git a/openspec/changes/minimal-api-mediator-openapi-migration/design.md b/openspec/changes/minimal-api-mediator-openapi-migration/design.md new file mode 100644 index 0000000000..2f3f2389b8 --- /dev/null +++ b/openspec/changes/minimal-api-mediator-openapi-migration/design.md @@ -0,0 +1,234 @@ +# Design: Minimal API + Mediator + OpenAPI Migration + +## Architecture Overview + +``` +HTTP Request + → ASP.NET Minimal API endpoint (route + auth + filters) + → Foundatio.Mediator dispatch (message → handler) + → Handler (reuses Core repositories/services) + → IResult (typed result with headers/status) + → Response +``` + +## File Layout + +All new code lives under `src/Exceptionless.Web/Api/`: + +``` +Api/ + ApiEndpoints.cs # Extension method: app.MapApiEndpoints() + ApiEndpointGroups.cs # Shared group configuration (prefix, auth, filters) + Endpoints/ # One file per feature area + Messages/ # Request/response message records + Handlers/ # Mediator handlers (one per feature area) + Middleware/ # ValidationMiddleware, LoggingMiddleware + Filters/ # Endpoint filters (ConfigurationResponse, ApiResponseHeaders) + Results/ # Custom IResult types and mapping helpers + Infrastructure/ # Shared utilities (pagination, time range, etc.) + OpenApi/ # OpenAPI customization and conventions +``` + +## Endpoint Registration Pattern + +### ApiEndpoints.cs + +Single extension method called from `Program.cs`: + +```csharp +public static class ApiEndpoints +{ + public static WebApplication MapApiEndpoints(this WebApplication app) + { + app.MapStatusEndpoints(); + app.MapUtilityEndpoints(); + app.MapTokenEndpoints(); + // ... all feature endpoint groups + return app; + } +} +``` + +### ApiEndpointGroups.cs + +Shared group builder configuration: + +```csharp +public static class ApiEndpointGroups +{ + public static RouteGroupBuilder MapApiGroup(this IEndpointRouteBuilder routes, string prefix) + { + return routes.MapGroup($"api/v2/{prefix}") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .WithOpenApi(); + } +} +``` + +### Individual Endpoint Files + +Each `*Endpoints.cs` file: +1. Creates a route group with appropriate prefix and auth. +2. Maps all routes for that feature (GET, POST, PUT, PATCH, DELETE). +3. Includes v1 legacy aliases in the same file as the canonical v2 route. +4. Delegates to Foundatio.Mediator for business logic dispatch. + +```csharp +public static class StatusEndpoints +{ + public static WebApplication MapStatusEndpoints(this WebApplication app) + { + var group = app.MapApiGroup(""); + + group.MapGet("about", async (IMediator mediator) => + { + var result = await mediator.SendAsync(new GetAboutQuery()); + return Results.Ok(result); + }).AllowAnonymous(); + + // ... other status routes + return app; + } +} +``` + +## Mediator Dispatch Pattern + +### Messages + +Records in `Messages/*.cs` representing commands and queries: + +```csharp +// Messages/StatusMessages.cs +public record GetAboutQuery : ICommand; +public record GetQueueStatsQuery : ICommand; +public record PostReleaseNotificationCommand(string Message, bool Critical) : ICommand; +``` + +### Handlers + +Classes in `Handlers/*.cs` that implement `ICommandHandler`: + +```csharp +// Handlers/StatusHandler.cs +public class StatusHandler : + ICommandHandler, + ICommandHandler +{ + // Inject existing Core services/repositories + private readonly AppOptions _appOptions; + private readonly IQueue _eventQueue; + // ... +} +``` + +### Handler Reuse of Existing Logic + +Handlers do NOT duplicate repository/service logic. They: +1. Accept the message. +2. Call existing `Core` repositories (`IEventRepository`, `IStackRepository`, etc.) and services. +3. Map results to response DTOs or return domain models directly. +4. Return the result (handler does not create HTTP responses). + +The endpoint lambda is responsible for mapping handler results to HTTP semantics (status codes, headers, pagination links). + +## Validation Strategy + +### Automatic Validation (DataAnnotation) + +ASP.NET Core Minimal API validates `[AsParameters]` and `[FromBody]` DTOs automatically when `AddEndpointsApiExplorer()` and validation filters are configured. This covers simple required/range/string-length constraints. + +### MiniValidation (Complex Cases) + +For validation that cannot be expressed with DataAnnotations (cross-field, conditional, post-patch): + +```csharp +var (isValid, errors) = MiniValidator.TryValidate(model); +if (!isValid) + return Results.ValidationProblem(errors); +``` + +Used for: +- Delta patch validation (validate merged model after applying delta). +- Complex cross-field rules. +- Conditional validation based on AppOptions/feature flags. + +### Delta Preservation + +- `Delta` remains the patch mechanism. +- No JSON Patch introduced. +- After applying delta to the entity, MiniValidation validates the merged result. + +## ProblemDetails Centralization + +Configure `AddProblemDetails()` with a customizer that ensures: + +- `instance` field set to request path. +- `extensions["reference-id"]` set to trace ID. +- `errors` map uses `lower_underscore` keys. +- Validation errors produce 422 with errors map. +- Not-found produces 404. +- Auth failures produce 401/403. + +This is configured once in DI and applies to all endpoints. + +## OpenAPI Generation + +### Runtime + +- `Microsoft.AspNetCore.OpenApi` generates `/docs/v2/openapi.json` at runtime. +- Scalar UI served at `/docs` (or existing Scalar path). +- Operation IDs derived from endpoint metadata. + +### Build-Time + +- `Microsoft.Extensions.ApiDescription.Server` generates `openapi.json` during `dotnet build`. +- Artifact committed or CI-compared for drift detection. +- Snapshot test compares build-time artifact against known-good baseline. + +### Route Manifest Tests + +- Test enumerates all registered endpoints at startup. +- Compares `{method} {path}` list against a checked-in manifest file. +- Any route addition/removal/change fails the test until manifest is updated. + +## Migration Strategy + +### Incremental, Controller-by-Controller + +1. **Baseline**: Capture OpenAPI snapshot and route manifest from existing controllers. +2. **Infrastructure**: Build Api/ folder, registration, filters, results, infrastructure utilities. +3. **Per-controller migration** (ordered by complexity, simplest first): + - Create endpoint file + messages + handler. + - Wire up in ApiEndpoints.cs. + - Run integration tests against new endpoints. + - Verify OpenAPI snapshot unchanged. + - Remove old controller. +4. **Final cleanup**: Remove `AddControllers()` / `MapControllers()`, delete `Controllers/` folder. + +### Coexistence During Migration + +During migration, both controllers and new endpoints exist. Route conflicts are avoided by: +- Only activating the new endpoint after the controller is deleted. +- OR: Using a feature flag / conditional registration (prefer delete-and-replace approach). + +## Rollback Approach + +- Each controller migration is a separate PR. +- Reverting a PR restores the controller and removes the Minimal API endpoint. +- OpenAPI snapshot and route manifest tests confirm the revert is clean. +- No database/index migrations are involved; rollback is purely code. + +## Security Considerations + +- Auth policies are applied identically via `.RequireAuthorization()`. +- `ApiKeyAuthenticationHandler` remains unchanged in the pipeline. +- Endpoint filters replicate any per-action auth checks from controller action methods. +- No new attack surface introduced. + +## Performance Considerations + +- Minimal APIs have slightly lower overhead than MVC controllers (no model binding pipeline, no action filters reflection). +- Mediator dispatch adds negligible overhead (in-process, no serialization). +- No performance regression expected. diff --git a/openspec/changes/minimal-api-mediator-openapi-migration/proposal.md b/openspec/changes/minimal-api-mediator-openapi-migration/proposal.md new file mode 100644 index 0000000000..31ad26225f --- /dev/null +++ b/openspec/changes/minimal-api-mediator-openapi-migration/proposal.md @@ -0,0 +1,67 @@ +# Proposal: Minimal API + Mediator + OpenAPI Migration + +## Summary + +Migrate all ASP.NET Core MVC controllers in `src/Exceptionless.Web/Controllers/` to Minimal API endpoints backed by Foundatio.Mediator for command/query dispatch, with runtime and build-time OpenAPI generation. + +## Why OpenSpec Is Justified + +This change affects: + +- **Public API behavior** — Every existing route is being re-implemented in a new hosting model. +- **SDK/client compatibility** — Route paths, response shapes, status codes, headers, and auth must remain identical. +- **Middleware ordering** — ThrottlingMiddleware, OverageMiddleware, and endpoint filters must maintain current behavior. +- **OpenAPI contract** — New generation mechanism replaces the existing Swagger setup. +- **Cross-cutting concerns** — Validation, ProblemDetails, pagination, and Delta patching all interact with the new endpoint model. + +The scope is large (14 controllers), the compatibility surface is wide, and regression risk without explicit acceptance criteria is high. + +## Classification + +- **Primary**: Refactor (controller → Minimal API) +- **Secondary**: Infrastructure (Mediator pattern, OpenAPI generation, build-time artifact) + +## Affected Areas + +| Area | Impact | +|------|--------| +| Backend/API | All public endpoints migrated | +| Tests | New snapshot tests, existing integration tests must pass | +| SDK/client compatibility | Must be zero-breaking-change | +| Docker/deployment | No container changes; build-time OpenAPI artifact added | +| Docs | Scalar docs preserved at /docs/v2/openapi.json | + +## Compatibility Risks + +| Risk | Mitigation | +|------|-----------| +| Route regression | Route manifest snapshot tests detect any path/method drift | +| Auth bypass | Existing auth policies applied identically; integration tests verify | +| Response shape change | OpenAPI snapshot tests detect schema drift | +| Middleware ordering | Pipeline order preserved; no middleware replaced | +| Validation gap | Automatic validation + MiniValidation covers all current cases | +| Header loss | Endpoint filters replicate current action filters | + +## Rollback Plan + +1. The migration is incremental (one controller at a time). Each migrated endpoint coexists with the original controller during development. +2. If a regression is detected post-merge, revert the PR that removed the specific controller. The Minimal API endpoint and the controller cannot both be active for the same route, so reverting the controller removal restores prior behavior. +3. OpenAPI snapshot tests and route manifest tests provide immediate CI signal if rollback introduces drift. + +## Controllers to Migrate + +| Controller | Routes | Priority | +|-----------|--------|----------| +| StatusController | /api/v2/about, queue-stats, notifications/* | Early (simple) | +| UtilityController | /api/v2/search/validate, /api/v2/timezones | Early (simple) | +| TokenController | CRUD for API tokens | Mid | +| SavedViewController | CRUD for saved views | Mid | +| ProjectController | CRUD + config, notifications, integrations | Mid | +| OrganizationController | CRUD + invoices, plans, suspend | Mid | +| StackController | CRUD + mark fixed/critical/snoozed | Mid | +| UserController | CRUD + email verification | Mid | +| WebHookController | CRUD for webhooks | Mid | +| StripeController | Webhook receiver | Mid | +| AuthController | Login, signup, OAuth, forgot-password | Late (complex auth) | +| AdminController | System admin operations | Late | +| EventController | Ingestion, query, count, sessions | Last (highest complexity) | diff --git a/openspec/changes/minimal-api-mediator-openapi-migration/risks.md b/openspec/changes/minimal-api-mediator-openapi-migration/risks.md new file mode 100644 index 0000000000..c4cf4f0904 --- /dev/null +++ b/openspec/changes/minimal-api-mediator-openapi-migration/risks.md @@ -0,0 +1,91 @@ +# Risk Register: Minimal API + Mediator + OpenAPI Migration + +## Risk 1: Route Regression + +| Field | Value | +|-------|-------| +| Likelihood | Medium | +| Impact | High | +| Description | A route path, HTTP method, or parameter binding is accidentally changed during migration, breaking SDK/client compatibility. | +| Mitigation | Route manifest snapshot tests detect any path/method change. OpenAPI snapshot tests detect parameter/response drift. Both run in CI. | +| Detection | CI fails on snapshot mismatch. | + +## Risk 2: Auth Bypass + +| Field | Value | +|-------|-------| +| Likelihood | Low | +| Impact | Critical | +| Description | An endpoint is migrated without the correct authorization policy, allowing unauthenticated/unauthorized access. | +| Mitigation | Auth policies applied at group level via `ApiEndpointGroups.cs`. Per-endpoint overrides (AllowAnonymous, GlobalAdmin) explicitly mapped. Existing auth integration tests cover all protected endpoints. | +| Detection | Existing integration tests fail. Manual review of endpoint registration. | + +## Risk 3: Validation Gaps + +| Field | Value | +|-------|-------| +| Likelihood | Medium | +| Impact | Medium | +| Description | Minimal API automatic validation does not trigger for a DTO, or MiniValidation is missed for a Delta patch, allowing invalid data. | +| Mitigation | Validation tests verify error shapes. Each endpoint migration task includes validation verification. MiniValidation helper is centralized and reusable. | +| Detection | Validation tests fail. Manual review during PR. | + +## Risk 4: OpenAPI Drift + +| Field | Value | +|-------|-------| +| Likelihood | Medium | +| Impact | Medium | +| Description | The generated OpenAPI document differs from the baseline (different operation IDs, missing parameters, changed schemas) breaking documentation or code generators. | +| Mitigation | OpenAPI snapshot test compares against baseline. Build-time artifact generation ensures reproducibility. | +| Detection | Snapshot test fails in CI. | + +## Risk 5: Middleware Ordering + +| Field | Value | +|-------|-------| +| Likelihood | Low | +| Impact | High | +| Description | Pipeline ordering changes cause throttling/overage middleware to not execute, or execute in wrong order relative to auth. | +| Mitigation | Middleware registration order preserved in Program.cs. No middleware implementations changed. Integration tests exercise full pipeline. | +| Detection | Rate limiting / overage tests fail. Manual pipeline audit in Task 19. | + +## Risk 6: Breaking Changes in Response Headers + +| Field | Value | +|-------|-------| +| Likelihood | Medium | +| Impact | Medium | +| Description | Custom response headers (pagination links, configuration version, rate-limit) are lost when moving from action filters to endpoint filters. | +| Mitigation | Endpoint filters (`ApiResponseHeadersEndpointFilter`, `ConfigurationResponseEndpointFilter`) replicate existing action filter behavior. Integration tests verify headers. | +| Detection | Tests checking response headers fail. | + +## Risk 7: Rollback Complexity + +| Field | Value | +|-------|-------| +| Likelihood | Low | +| Impact | Medium | +| Description | If a late-stage migration (e.g., EventController) causes issues, rolling back requires re-adding the controller while the Api infrastructure is already in place. | +| Mitigation | Each controller migration is a separate, independently revertible PR. The Api infrastructure (Task 2-4) is additive and does not conflict with controllers. Reverting a controller migration PR restores the controller without affecting other migrated endpoints. | +| Detection | Git revert + CI green confirms clean rollback. | + +## Risk 8: Raw Event Ingestion Regression + +| Field | Value | +|-------|-------| +| Likelihood | Medium | +| Impact | High | +| Description | Event ingestion (multipart, compressed, raw body) has complex model binding that may not translate directly to Minimal API parameter binding. | +| Mitigation | EventEndpoints is migrated last (Task 17) after all simpler endpoints validate the pattern. Dedicated event ingestion tests verify all content types. Manual smoke test with real SDK submission. | +| Detection | Event ingestion integration tests fail. Manual smoke test in Task 19. | + +## Risk 9: Foundatio.Mediator Version Compatibility + +| Field | Value | +|-------|-------| +| Likelihood | Low | +| Impact | Low | +| Description | Foundatio.Mediator API may change or have undocumented behavior that affects handler dispatch. | +| Mitigation | Exceptionless already depends on Foundatio packages. Mediator registration smoke test (Task 3) validates DI and dispatch work before any endpoint migration begins. | +| Detection | Smoke test fails in Task 3. | diff --git a/openspec/changes/minimal-api-mediator-openapi-migration/tasks.md b/openspec/changes/minimal-api-mediator-openapi-migration/tasks.md new file mode 100644 index 0000000000..028853baad --- /dev/null +++ b/openspec/changes/minimal-api-mediator-openapi-migration/tasks.md @@ -0,0 +1,349 @@ +# Tasks: Minimal API + Mediator + OpenAPI Migration + +## Task 1: Contract/OpenAPI Baseline Tests + +**Goal**: Establish snapshot baselines before any migration work begins. + +**Work**: +- Add a test that starts the web host and captures the full OpenAPI document as a snapshot. +- Add a test that enumerates all registered routes (method + path) and captures as a route manifest snapshot. +- Check in baseline snapshot files. + +**Verification**: +```bash +dotnet test --filter "FullyQualifiedName~OpenApiSnapshotTests" +dotnet test --filter "FullyQualifiedName~RouteManifestTests" +``` + +--- + +## Task 2: Api Infrastructure + +**Goal**: Create the folder structure and shared utilities that all endpoints depend on. + +**Work**: +- Create `src/Exceptionless.Web/Api/` folder structure. +- Implement `ApiEndpoints.cs` (empty, calls no feature endpoints yet). +- Implement `ApiEndpointGroups.cs` (shared group builder with prefix, auth, OpenAPI). +- Implement `Results/ApiResults.cs`, `Results/OkWithHeadersResult.cs`, `Results/CollectionResult.cs`. +- Implement `Infrastructure/Pagination.cs`, `Infrastructure/TimeRangeParser.cs`, `Infrastructure/CurrentUserAccessor.cs`. +- Implement `Filters/ApiResponseHeadersEndpointFilter.cs`, `Filters/ConfigurationResponseEndpointFilter.cs`. +- Implement `Infrastructure/ApiProblemDetails.cs` (ProblemDetails customization). +- Implement `Infrastructure/ApiValidation.cs` (MiniValidation helper). + +**Verification**: +```bash +dotnet build src/Exceptionless.Web/Exceptionless.Web.csproj +``` + +--- + +## Task 3: Mediator Registration + +**Goal**: Configure Foundatio.Mediator DI so handlers are auto-discovered. + +**Work**: +- Add Foundatio.Mediator registration in DI (Bootstrapper or Program.cs). +- Register handler assemblies for auto-discovery. +- Add a smoke test that resolves IMediator from DI and dispatches a no-op message. + +**Verification**: +```bash +dotnet build src/Exceptionless.Web/Exceptionless.Web.csproj +dotnet test --filter "FullyQualifiedName~MediatorRegistrationTests" +``` + +--- + +## Task 4: Validation and ProblemDetails Integration + +**Goal**: Wire up automatic validation for Minimal API DTOs and ProblemDetails customization. + +**Work**: +- Configure `AddProblemDetails()` with instance, reference-id, lower_underscore error keys. +- Add `Middleware/ValidationMiddleware.cs` (endpoint filter for automatic DTO validation). +- Verify MiniValidation helper works with Delta. +- Add tests for validation error response shape. + +**Verification**: +```bash +dotnet test --filter "FullyQualifiedName~ValidationProblemDetailsTests" +``` + +--- + +## Task 5: StatusEndpoints + +**Goal**: Migrate `StatusController` to Minimal API. + +**Work**: +- Create `Endpoints/StatusEndpoints.cs` with all routes from StatusController. +- Create `Messages/StatusMessages.cs` (GetAbout, GetQueueStats, PostReleaseNotification, Get/Post/DeleteSystemNotification). +- Create `Handlers/StatusHandler.cs`. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/StatusController.cs`. + +**Verification**: +```bash +dotnet test +# Manual: curl http://localhost:7110/api/v2/about +``` + +--- + +## Task 6: UtilityEndpoints + +**Goal**: Migrate `UtilityController` to Minimal API. + +**Work**: +- Create `Endpoints/UtilityEndpoints.cs`. +- Create messages and handler if needed (may be thin enough to inline). +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/UtilityController.cs`. + +**Verification**: +```bash +dotnet test +dotnet test --filter "FullyQualifiedName~RouteManifestTests" +``` + +--- + +## Task 7: TokenEndpoints + +**Goal**: Migrate `TokenController` to Minimal API. + +**Work**: +- Create `Endpoints/TokenEndpoints.cs`, `Messages/TokenMessages.cs`, `Handlers/TokenHandler.cs`. +- Include v1 aliases if any exist. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/TokenController.cs`. + +**Verification**: +```bash +dotnet test +dotnet test --filter "FullyQualifiedName~OpenApiSnapshotTests" +``` + +--- + +## Task 8: SavedViewEndpoints + +**Goal**: Migrate `SavedViewController` to Minimal API. + +**Work**: +- Create `Endpoints/SavedViewEndpoints.cs`, `Messages/SavedViewMessages.cs`, `Handlers/SavedViewHandler.cs`. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/SavedViewController.cs`. + +**Verification**: +```bash +dotnet test +``` + +--- + +## Task 9: ProjectEndpoints + +**Goal**: Migrate `ProjectController` to Minimal API. + +**Work**: +- Create `Endpoints/ProjectEndpoints.cs`, `Messages/ProjectMessages.cs`, `Handlers/ProjectHandler.cs`. +- Include config endpoint, notification settings, integration endpoints. +- Include v1 aliases. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/ProjectController.cs`. + +**Verification**: +```bash +dotnet test +dotnet test --filter "FullyQualifiedName~RouteManifestTests" +dotnet test --filter "FullyQualifiedName~OpenApiSnapshotTests" +``` + +--- + +## Task 10: OrganizationEndpoints + +**Goal**: Migrate `OrganizationController` to Minimal API. + +**Work**: +- Create `Endpoints/OrganizationEndpoints.cs`, `Messages/OrganizationMessages.cs`, `Handlers/OrganizationHandler.cs`. +- Include invoice, plan, suspend, billing endpoints. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/OrganizationController.cs`. + +**Verification**: +```bash +dotnet test +``` + +--- + +## Task 11: StackEndpoints + +**Goal**: Migrate `StackController` to Minimal API. + +**Work**: +- Create `Endpoints/StackEndpoints.cs`, `Messages/StackMessages.cs`, `Handlers/StackHandler.cs`. +- Include mark-fixed, mark-critical, snooze, promote endpoints. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/StackController.cs`. + +**Verification**: +```bash +dotnet test +``` + +--- + +## Task 12: UserEndpoints + +**Goal**: Migrate `UserController` to Minimal API. + +**Work**: +- Create `Endpoints/UserEndpoints.cs`, `Messages/UserMessages.cs`, `Handlers/UserHandler.cs`. +- Include email verification, admin email endpoints. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/UserController.cs`. + +**Verification**: +```bash +dotnet test +``` + +--- + +## Task 13: WebHookEndpoints + +**Goal**: Migrate `WebHookController` to Minimal API. + +**Work**: +- Create `Endpoints/WebHookEndpoints.cs`, `Messages/WebHookMessages.cs`, `Handlers/WebHookHandler.cs`. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/WebHookController.cs`. + +**Verification**: +```bash +dotnet test +``` + +--- + +## Task 14: StripeEndpoints + +**Goal**: Migrate `StripeController` to Minimal API. + +**Work**: +- Create `Endpoints/StripeEndpoints.cs`. +- Stripe webhook handler may not need mediator (direct processing). +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/StripeController.cs`. + +**Verification**: +```bash +dotnet test +``` + +--- + +## Task 15: AuthEndpoints + +**Goal**: Migrate `AuthController` to Minimal API. + +**Work**: +- Create `Endpoints/AuthEndpoints.cs`, `Messages/AuthMessages.cs`, `Handlers/AuthHandler.cs`. +- Include login, signup, OAuth callbacks, forgot-password, change-password. +- Preserve all auth/authorization behavior exactly. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/AuthController.cs`. + +**Verification**: +```bash +dotnet test +# Manual: verify login flow at http://localhost:7110 +``` + +--- + +## Task 16: AdminEndpoints + +**Goal**: Migrate `AdminController` to Minimal API. + +**Work**: +- Create `Endpoints/AdminEndpoints.cs`, `Messages/AdminMessages.cs`, `Handlers/AdminHandler.cs`. +- Preserve GlobalAdmin policy. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/AdminController.cs`. + +**Verification**: +```bash +dotnet test +``` + +--- + +## Task 17: EventEndpoints + +**Goal**: Migrate `EventController` to Minimal API (most complex). + +**Work**: +- Create `Endpoints/EventEndpoints.cs`, `Messages/EventMessages.cs`, `Handlers/EventHandler.cs`. +- Preserve raw event ingestion (multipart, compressed, raw body). +- Preserve query/count/session endpoints. +- Preserve v1 aliases. +- Wire into `ApiEndpoints.cs`. +- Remove `Controllers/EventController.cs`. + +**Verification**: +```bash +dotnet test +dotnet test --filter "FullyQualifiedName~EventIngestion" +dotnet test --filter "FullyQualifiedName~RouteManifestTests" +dotnet test --filter "FullyQualifiedName~OpenApiSnapshotTests" +``` + +--- + +## Task 18: Remove Controllers Infrastructure + +**Goal**: Remove MVC controller infrastructure. + +**Work**: +- Remove `AddControllers()` from DI registration. +- Remove `MapControllers()` from endpoint mapping. +- Delete `Controllers/` folder and `Controllers/Base/` folder. +- Remove any MVC-specific action filters that are fully replaced by endpoint filters. +- Verify build succeeds without MVC controller support. + +**Verification**: +```bash +dotnet build +dotnet test +dotnet test --filter "FullyQualifiedName~RouteManifestTests" +dotnet test --filter "FullyQualifiedName~OpenApiSnapshotTests" +``` + +--- + +## Task 19: Final OpenAPI/Route/Middleware Audit + +**Goal**: Final verification that the migration is complete and correct. + +**Work**: +- Update route manifest snapshot (should match pre-migration baseline). +- Update OpenAPI snapshot (should match pre-migration baseline modulo non-breaking metadata). +- Verify middleware pipeline order matches pre-migration. +- Verify `tests/http/*.http` files work against new endpoints. +- Run full test suite. +- Manual smoke test of login, event submission, and dashboard at http://localhost:7110. + +**Verification**: +```bash +dotnet build +dotnet test +# Manual smoke test: +# curl http://localhost:7110/api/v2/about +# Login as admin@exceptionless.test / tester +# Submit test event and verify it appears +``` diff --git a/openspec/specs/api-architecture.md b/openspec/specs/api-architecture.md new file mode 100644 index 0000000000..603c91063e --- /dev/null +++ b/openspec/specs/api-architecture.md @@ -0,0 +1,72 @@ +# Spec: API Architecture (Minimal API + Mediator) + +## Overview + +Defines the architecture for Exceptionless's Minimal API endpoint layer using Foundatio.Mediator for command/query dispatch. + +## Requirements + +### Endpoint Registration + +- **ADDED**: The system SHALL register all API endpoints via a single `app.MapApiEndpoints()` extension method in `src/Exceptionless.Web/Api/ApiEndpoints.cs`. +- **ADDED**: Each feature area SHALL have its own endpoint registration method (e.g., `MapStatusEndpoints()`, `MapEventEndpoints()`). +- **ADDED**: Endpoint groups SHALL apply shared configuration (route prefix, auth policy, filters) via `ApiEndpointGroups.cs`. +- **ADDED**: All API endpoints SHALL be routed under the `api/v2/` prefix. +- **ADDED**: V1 legacy aliases SHALL be defined in the same endpoint file as their canonical v2 route. + +### Mediator Dispatch + +- **ADDED**: Endpoint lambdas SHALL dispatch commands/queries to Foundatio.Mediator via `IMediator.SendAsync()`. +- **ADDED**: The system SHALL NOT use `MapMediatorEndpoints()` or any auto-generated endpoint mapping for existing public API routes. +- **ADDED**: Each feature area SHALL define message records in `Messages/*.cs`. +- **ADDED**: Each feature area SHALL define handler classes in `Handlers/*.cs`. +- **ADDED**: Handlers SHALL implement `ICommandHandler` from Foundatio.Mediator. + +### Handler Patterns + +- **ADDED**: Handlers SHALL reuse existing Core repositories and services (e.g., `IEventRepository`, `IStackRepository`, `IOrganizationRepository`). +- **ADDED**: Handlers SHALL NOT create HTTP responses (IResult, status codes). They return domain objects or DTOs. +- **ADDED**: Endpoint lambdas SHALL be responsible for mapping handler results to HTTP status codes, headers, and response bodies. + +### Dependency Injection + +- **ADDED**: Foundatio.Mediator SHALL be registered in DI during application startup. +- **ADDED**: All handlers SHALL be auto-discovered and registered via assembly scanning. +- **ADDED**: Handlers SHALL use constructor injection for dependencies. + +### File Organization + +- **ADDED**: All new API code SHALL reside under `src/Exceptionless.Web/Api/`. +- **ADDED**: The folder structure SHALL include: `Endpoints/`, `Messages/`, `Handlers/`, `Middleware/`, `Filters/`, `Results/`, `Infrastructure/`, `OpenApi/`. + +## Scenarios + +### Scenario: Endpoint dispatches to mediator + +``` +Given a registered Minimal API endpoint for GET /api/v2/about +When an HTTP GET request arrives at /api/v2/about +Then the endpoint lambda resolves IMediator from DI +And sends a GetAboutQuery message +And the StatusHandler handles the message +And returns an AboutResponse +And the endpoint returns HTTP 200 with the response serialized as JSON +``` + +### Scenario: Handler reuses existing repository + +``` +Given a GetProjectByIdQuery message with a project ID +When the ProjectHandler receives the message +Then it calls IProjectRepository.GetByIdAsync() from Exceptionless.Core +And returns the Project entity +``` + +### Scenario: No auto-generated mediator endpoints + +``` +Given the application starts +When endpoint routing is configured +Then no routes are registered via MapMediatorEndpoints() +And all public API routes are explicitly mapped in *Endpoints.cs files +``` diff --git a/openspec/specs/api-contract.md b/openspec/specs/api-contract.md new file mode 100644 index 0000000000..86cad5bedb --- /dev/null +++ b/openspec/specs/api-contract.md @@ -0,0 +1,88 @@ +# Spec: API Contract Preservation + +## Overview + +Defines the contract preservation requirements during the Minimal API migration. All existing public API behavior must remain unchanged. + +## Requirements + +### Route Preservation + +- **MODIFIED**: The system SHALL preserve all existing v2 API routes with identical HTTP methods and paths. +- **MODIFIED**: The system SHALL preserve all existing v1 compatibility aliases with identical behavior. +- **MODIFIED**: The system SHALL preserve route parameter names and types (e.g., `{id}`, `{organizationId}`). +- **MODIFIED**: The system SHALL preserve query parameter names, types, and default values. + +### Response Shapes + +- **MODIFIED**: The system SHALL preserve JSON response body property names (camelCase). +- **MODIFIED**: The system SHALL preserve JSON response body nesting structure. +- **MODIFIED**: The system SHALL preserve JSON response body property types (string, number, boolean, array, object). +- **MODIFIED**: The system SHALL preserve null vs. absent field behavior in responses. + +### Status Codes + +- **MODIFIED**: The system SHALL return identical HTTP status codes for all success cases (200, 201, 202, 204). +- **MODIFIED**: The system SHALL return identical HTTP status codes for all error cases (400, 401, 403, 404, 409, 422, 429). + +### Response Headers + +- **MODIFIED**: The system SHALL preserve pagination headers (`X-Result-Count`, `Link`). +- **MODIFIED**: The system SHALL preserve configuration version headers. +- **MODIFIED**: The system SHALL preserve rate-limit headers. +- **MODIFIED**: The system SHALL preserve CORS headers. + +### Pagination and Filtering + +- **MODIFIED**: The system SHALL preserve pagination behavior (page, limit, before/after cursor). +- **MODIFIED**: The system SHALL preserve filtering behavior (query string filters, date ranges). +- **MODIFIED**: The system SHALL preserve sorting behavior (sort parameter). +- **MODIFIED**: The system SHALL preserve time range parsing (start, end, offset parameters). + +### Backwards Compatibility + +- **MODIFIED**: The system SHALL NOT remove, rename, or change the type of any existing route parameter. +- **MODIFIED**: The system SHALL NOT remove, rename, or change the type of any existing response field. +- **MODIFIED**: The system SHALL NOT change authentication requirements for any existing endpoint. +- **MODIFIED**: The system SHALL NOT change content-type requirements for any existing endpoint. + +## Scenarios + +### Scenario: v2 route preserved + +``` +Given an existing v2 route GET /api/v2/projects/{id} +When the migration is complete +Then GET /api/v2/projects/{id} returns the same response shape and status code +And accepts the same query parameters +And returns the same headers +``` + +### Scenario: v1 alias preserved + +``` +Given an existing v1 alias GET /api/v1/project/{id} +When the migration is complete +Then GET /api/v1/project/{id} still resolves to the same handler logic +And returns the same response as the v2 equivalent +``` + +### Scenario: Pagination headers preserved + +``` +Given a collection endpoint GET /api/v2/projects with results exceeding page size +When the client requests the first page +Then the response includes X-Result-Count header +And the response includes Link header with next page URL +And the header format is identical to pre-migration behavior +``` + +### Scenario: SDK compatibility + +``` +Given an Exceptionless SDK client configured with an API key +When the client submits events via POST /api/v2/events +Then the request succeeds with the same status code as pre-migration +And the client receives the same response headers +And the event is processed identically +``` diff --git a/openspec/specs/api-middleware.md b/openspec/specs/api-middleware.md new file mode 100644 index 0000000000..9a9c540c33 --- /dev/null +++ b/openspec/specs/api-middleware.md @@ -0,0 +1,84 @@ +# Spec: API Middleware and Filters + +## Overview + +Defines middleware and endpoint filter behavior for the Minimal API layer. + +## Requirements + +### Existing Middleware Preservation + +- **MODIFIED**: The system SHALL preserve `ThrottlingMiddleware` behavior unchanged (rate limiting logic, response codes, headers). +- **MODIFIED**: The system SHALL preserve `OverageMiddleware` behavior unchanged (plan overage enforcement). +- **MODIFIED**: The system SHALL NOT replace, remove, or modify existing middleware implementations. +- **MODIFIED**: The system SHALL preserve middleware pipeline ordering (ThrottlingMiddleware and OverageMiddleware execute in the same relative position). + +### New Middleware + +- **ADDED**: `ValidationMiddleware` SHALL validate bound DTOs and short-circuit with ProblemDetails on failure. +- **ADDED**: `LoggingMiddleware` SHALL log request/response metadata for observability. + +### Endpoint Filters + +- **ADDED**: `ConfigurationResponseEndpointFilter` SHALL add configuration version headers to responses (replicating existing action filter behavior). +- **ADDED**: `ApiResponseHeadersEndpointFilter` SHALL add standard API response headers (replicating existing action filter behavior). +- **ADDED**: Endpoint filters SHALL be applied at the group level via `ApiEndpointGroups.cs`. + +### Pipeline Ordering + +- **MODIFIED**: The middleware pipeline SHALL execute in this order: + 1. Exception handling / ProblemDetails + 2. Authentication + 3. ThrottlingMiddleware + 4. OverageMiddleware + 5. Authorization + 6. Endpoint routing + endpoint filters +- **MODIFIED**: Endpoint filters SHALL execute in registration order within the endpoint pipeline. + +## Scenarios + +### Scenario: ThrottlingMiddleware unchanged + +``` +Given a client exceeding the rate limit +When a request is made to any API endpoint +Then ThrottlingMiddleware returns HTTP 429 +And the response includes rate-limit headers +And this behavior is identical to pre-migration +``` + +### Scenario: OverageMiddleware unchanged + +``` +Given an organization that has exceeded its plan limits +When a request is made to submit an event +Then OverageMiddleware returns the appropriate overage response +And this behavior is identical to pre-migration +``` + +### Scenario: ConfigurationResponseEndpointFilter adds headers + +``` +Given a successful API response from any endpoint in the group +When the response is being written +Then ConfigurationResponseEndpointFilter adds the configuration version header +And the header value matches the current configuration version +``` + +### Scenario: Validation filter short-circuits + +``` +Given a request with an invalid DTO body +When the request reaches the endpoint filter pipeline +Then ValidationMiddleware short-circuits before the endpoint lambda executes +And returns HTTP 422 ProblemDetails +``` + +### Scenario: Middleware order preserved + +``` +Given an unauthenticated request to a rate-limited endpoint +When the request enters the pipeline +Then ThrottlingMiddleware evaluates the request before authentication rejects it +And the pipeline order is: exception handling → auth → throttling → overage → authorization → routing +``` diff --git a/openspec/specs/api-openapi.md b/openspec/specs/api-openapi.md new file mode 100644 index 0000000000..098cdf662b --- /dev/null +++ b/openspec/specs/api-openapi.md @@ -0,0 +1,92 @@ +# Spec: API OpenAPI Generation + +## Overview + +Defines OpenAPI document generation requirements for runtime and build-time, including snapshot testing and route manifests. + +## Requirements + +### Runtime OpenAPI Generation + +- **MODIFIED**: The system SHALL serve an OpenAPI 3.x document at `/docs/v2/openapi.json` at runtime. +- **MODIFIED**: The system SHALL serve Scalar API documentation UI (at existing Scalar path). +- **ADDED**: The system SHALL use `Microsoft.AspNetCore.OpenApi` for runtime document generation. +- **ADDED**: All Minimal API endpoints SHALL include OpenAPI metadata (summary, description, response types, parameters). +- **ADDED**: Operation IDs SHALL be derived from endpoint method metadata. + +### Build-Time OpenAPI Generation + +- **ADDED**: The system SHALL generate an OpenAPI document as a build artifact during `dotnet build`. +- **ADDED**: The build-time artifact SHALL be generated via `Microsoft.Extensions.ApiDescription.Server`. +- **ADDED**: The build-time artifact SHALL be deterministic (same source = same output). + +### Route Manifest Tests + +- **ADDED**: The system SHALL include a test that enumerates all registered endpoints (HTTP method + path). +- **ADDED**: The route manifest test SHALL compare against a checked-in baseline file. +- **ADDED**: The route manifest test SHALL fail if any route is added, removed, or changed without updating the baseline. +- **ADDED**: The route manifest format SHALL be one line per route: `{METHOD} {path}` sorted alphabetically. + +### OpenAPI Snapshot Tests + +- **ADDED**: The system SHALL include a test that compares the generated OpenAPI document against a checked-in baseline. +- **ADDED**: The OpenAPI snapshot test SHALL fail if the document schema changes without updating the baseline. +- **ADDED**: The snapshot comparison SHALL ignore non-semantic differences (whitespace, key ordering). + +## Scenarios + +### Scenario: Runtime OpenAPI document served + +``` +Given the application is running +When a GET request is made to /docs/v2/openapi.json +Then the response is HTTP 200 +And the Content-Type is application/json +And the body is a valid OpenAPI 3.x document +And all registered endpoints are present in the document +``` + +### Scenario: Scalar docs accessible + +``` +Given the application is running +When a browser navigates to the Scalar docs URL +Then the Scalar UI loads successfully +And displays documentation for all API endpoints +``` + +### Scenario: Build-time artifact generated + +``` +Given the source code has not changed +When `dotnet build src/Exceptionless.Web/Exceptionless.Web.csproj` is run +Then an openapi.json file is generated in the build output +And running the build again produces an identical file +``` + +### Scenario: Route manifest detects new route + +``` +Given a baseline route manifest with N routes +When a developer adds a new endpoint without updating the manifest +Then the route manifest test fails +And the failure message indicates which route was added +``` + +### Scenario: Route manifest detects removed route + +``` +Given a baseline route manifest with route "GET /api/v2/projects" +When that endpoint is removed without updating the manifest +Then the route manifest test fails +And the failure message indicates which route was removed +``` + +### Scenario: OpenAPI snapshot detects schema change + +``` +Given a baseline OpenAPI snapshot +When a response schema is changed (e.g., field renamed) +Then the OpenAPI snapshot test fails +And the failure shows the diff between baseline and current +``` diff --git a/openspec/specs/api-patching.md b/openspec/specs/api-patching.md new file mode 100644 index 0000000000..08237e6f32 --- /dev/null +++ b/openspec/specs/api-patching.md @@ -0,0 +1,75 @@ +# Spec: API Patching (Delta) + +## Overview + +Defines patching behavior preservation during the Minimal API migration. Delta remains the sole patching mechanism. + +## Requirements + +### Delta Preservation + +- **MODIFIED**: The system SHALL preserve `Delta` as the patch mechanism for all PATCH endpoints. +- **MODIFIED**: The system SHALL apply only fields present in the request body to the target entity. +- **MODIFIED**: The system SHALL NOT modify fields absent from the request body. +- **MODIFIED**: The system SHALL validate the merged entity (after delta application) using MiniValidation. +- **MODIFIED**: The system SHALL return HTTP 422 if the merged entity fails validation. + +### JSON Patch Exclusion + +- **MODIFIED**: The system SHALL NOT introduce JSON Patch (RFC 6902) in this migration. +- **MODIFIED**: The system SHALL NOT accept `application/json-patch+json` content type on any endpoint. +- **MODIFIED**: PATCH endpoints SHALL continue to accept `application/json` with partial field sets. + +### Partial Update Semantics + +- **MODIFIED**: When a field is present in the patch body with a value, that value SHALL replace the existing value. +- **MODIFIED**: When a field is present in the patch body with null, that field SHALL be set to null (if nullable). +- **MODIFIED**: When a field is absent from the patch body, the existing value SHALL be preserved unchanged. + +## Scenarios + +### Scenario: Partial update preserves unmodified fields + +``` +Given a project entity with Name="Original", DeleteBotDataEnabled=true, CustomContent="hello" +When a PATCH /api/v2/projects/{id} request sends {"name": "Updated"} +Then the project Name becomes "Updated" +And DeleteBotDataEnabled remains true +And CustomContent remains "hello" +``` + +### Scenario: Null value clears nullable field + +``` +Given a project entity with Description="Some description" +When a PATCH request sends {"description": null} +Then the project Description becomes null +``` + +### Scenario: Delta validation rejects invalid merge + +``` +Given a project entity with Name="Valid" +When a PATCH request sends {"name": ""} +Then the delta is applied (Name becomes "") +And MiniValidation rejects the merged entity (Name is required) +And the response is HTTP 422 with ProblemDetails +And the original entity is NOT modified in storage +``` + +### Scenario: JSON Patch not accepted + +``` +Given any PATCH endpoint +When a request is sent with Content-Type: application/json-patch+json +Then the response is HTTP 415 Unsupported Media Type +``` + +### Scenario: Delta binding in Minimal API + +``` +Given a PATCH endpoint registered in Minimal API +When the endpoint receives a JSON body with partial fields +Then Delta correctly identifies which fields are present +And only those fields are applied to the entity +``` diff --git a/openspec/specs/api-problem-details.md b/openspec/specs/api-problem-details.md new file mode 100644 index 0000000000..f9ee1987a5 --- /dev/null +++ b/openspec/specs/api-problem-details.md @@ -0,0 +1,78 @@ +# Spec: API ProblemDetails + +## Overview + +Defines the ProblemDetails error response format for all API error responses. + +## Requirements + +### ProblemDetails Shape + +- **MODIFIED**: All error responses SHALL use the RFC 9457 ProblemDetails format. +- **MODIFIED**: ProblemDetails responses SHALL include the `instance` field set to the request path. +- **MODIFIED**: ProblemDetails responses SHALL include a `reference-id` extension field set to the request trace ID. +- **MODIFIED**: ProblemDetails responses SHALL include the `errors` map for validation failures. +- **MODIFIED**: The `errors` map SHALL use `lower_underscore` field name keys. +- **MODIFIED**: ProblemDetails responses SHALL include `type`, `title`, and `status` fields. + +### Status Code Mapping + +- **MODIFIED**: Validation failures SHALL produce HTTP 422 with ProblemDetails. +- **MODIFIED**: Authentication failures SHALL produce HTTP 401 with ProblemDetails. +- **MODIFIED**: Authorization failures SHALL produce HTTP 403 with ProblemDetails. +- **MODIFIED**: Not-found errors SHALL produce HTTP 404 with ProblemDetails. +- **MODIFIED**: Conflict errors SHALL produce HTTP 409 with ProblemDetails. +- **MODIFIED**: Rate-limit errors SHALL produce HTTP 429 with ProblemDetails. + +### Centralization + +- **ADDED**: ProblemDetails customization SHALL be configured once via `AddProblemDetails()` in DI. +- **ADDED**: All endpoints SHALL use the centralized ProblemDetails configuration without per-endpoint customization. +- **ADDED**: Exception handling middleware SHALL produce ProblemDetails for unhandled exceptions (500). + +## Scenarios + +### Scenario: Validation error ProblemDetails + +``` +Given a request that fails validation on fields "name" and "url" +When the validation middleware produces an error response +Then the response status is 422 +And the Content-Type is application/problem+json +And the body contains: + - type: a URI identifying the error type + - title: "Validation Failed" or similar + - status: 422 + - instance: the request path (e.g., "/api/v2/projects") + - reference-id: the request trace ID + - errors: {"name": ["Name is required"], "url": ["URL is not valid"]} +``` + +### Scenario: Not-found ProblemDetails + +``` +Given a request for GET /api/v2/projects/{id} with a non-existent ID +When the handler returns null/not-found +Then the response status is 404 +And the body is ProblemDetails with instance set to the request path +And reference-id is present +``` + +### Scenario: Unhandled exception ProblemDetails + +``` +Given a request that triggers an unhandled exception in a handler +When the exception propagates to the middleware +Then the response status is 500 +And the body is ProblemDetails +And sensitive exception details are NOT exposed in production +And reference-id is present for correlation +``` + +### Scenario: Error keys are lower_underscore + +``` +Given a model with properties "OrganizationId" and "ProjectName" that fail validation +When the ProblemDetails errors map is constructed +Then keys are "organization_id" and "project_name" +``` diff --git a/openspec/specs/api-validation.md b/openspec/specs/api-validation.md new file mode 100644 index 0000000000..c03f5f7eba --- /dev/null +++ b/openspec/specs/api-validation.md @@ -0,0 +1,72 @@ +# Spec: API Validation + +## Overview + +Defines validation behavior for the Minimal API endpoint layer, covering automatic DataAnnotation validation, MiniValidation for complex cases, and Delta patch validation. + +## Requirements + +### Automatic DataAnnotation Validation + +- **ADDED**: The system SHALL automatically validate `[FromBody]` DTOs using DataAnnotation attributes before the endpoint lambda executes. +- **ADDED**: The system SHALL return HTTP 422 with a ProblemDetails body when automatic validation fails. +- **ADDED**: The system SHALL include all validation errors in the `errors` map of the ProblemDetails response. + +### MiniValidation for Complex Cases + +- **ADDED**: The system SHALL use MiniValidation for validation that cannot be expressed with DataAnnotations (cross-field, conditional). +- **ADDED**: The system SHALL use MiniValidation to validate the merged entity after applying Delta patches. +- **ADDED**: MiniValidation failures SHALL produce HTTP 422 with ProblemDetails body. + +### Validation Error Shape + +- **MODIFIED**: Validation error responses SHALL use `lower_underscore` keys in the errors map (e.g., `organization_id`, not `OrganizationId`). +- **MODIFIED**: Validation error responses SHALL be ProblemDetails with `type`, `title`, `status`, `instance`, and `errors` fields. +- **MODIFIED**: The `errors` map SHALL be a dictionary of field name → array of error messages. + +### Delta Patch Validation + +- **MODIFIED**: The system SHALL preserve Delta partial update semantics. +- **MODIFIED**: When a PATCH request is received, only fields present in the request body SHALL be applied to the entity. +- **MODIFIED**: After applying the delta, the merged entity SHALL be validated using MiniValidation. +- **MODIFIED**: The system SHALL NOT introduce JSON Patch as an alternative patching mechanism. + +## Scenarios + +### Scenario: Automatic validation rejects invalid DTO + +``` +Given a POST /api/v2/tokens endpoint expecting a body with [Required] Name field +When a request is sent with an empty Name +Then the response is HTTP 422 +And the body is ProblemDetails with errors map containing "name" key +And the error message indicates the field is required +``` + +### Scenario: MiniValidation validates merged patch + +``` +Given a PATCH /api/v2/projects/{id} endpoint with Delta +When a request patches the Name field to an empty string +Then the system applies the delta to the existing project +And validates the merged project with MiniValidation +And returns HTTP 422 because Name is required +``` + +### Scenario: Delta preserves unmodified fields + +``` +Given a project with Name="MyProject" and DeleteBotDataEnabled=true +When a PATCH request sends only {"name": "NewName"} +Then only the Name field is updated to "NewName" +And DeleteBotDataEnabled remains true +``` + +### Scenario: Validation errors use lower_underscore keys + +``` +Given a POST endpoint with validation errors on OrganizationId and ProjectName +When validation fails +Then the errors map contains keys "organization_id" and "project_name" +And NOT "OrganizationId" or "ProjectName" +``` diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 5d2191ab4f..e4bc68253d 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -22,7 +22,88 @@ public static async Task Main(string[] args) { try { - await CreateHostBuilder(args).Build().RunAsync(); + var jobOptions = new JobRunnerOptions(args); + + Console.Title = $"Exceptionless {jobOptions.JobName} Job"; + string environment = Environment.GetEnvironmentVariable("EX_AppMode") ?? "Production"; + + var builder = WebApplication.CreateBuilder(args); + builder.Host.UseEnvironment(environment); + builder.Configuration.Sources.Clear(); + builder.Configuration + .AddYamlFile("appsettings.yml", optional: true, reloadOnChange: true) + .AddYamlFile($"appsettings.{environment}.yml", optional: true, reloadOnChange: true) + .AddCustomEnvironmentVariables() + .AddCommandLine(args); + + var configuration = (IConfigurationRoot)builder.Configuration; + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateBootstrapLogger() + .ForContext(); + + var options = AppOptions.ReadFromConfiguration(configuration); + // only poll the queue metrics if this process is going to run the stack event count job + options.QueueOptions.MetricsPollingEnabled = jobOptions.StackEventCount; + + var apmConfig = new ApmConfig(configuration, $"job-{jobOptions.JobName.ToLowerUnderscoredWords('-')}", options.InformationalVersion, options.CacheOptions.Provider == "redis"); + + Log.Information("Bootstrapping Exceptionless {JobName} job(s) in {AppMode} mode ({InformationalVersion}) on {MachineName} with options {@Options}", jobOptions.JobName ?? "All", environment, options.InformationalVersion, Environment.MachineName, options); + + builder.Logging.ClearProviders(); + builder.Host + .UseSerilog((ctx, sp, c) => + { + c.ReadFrom.Configuration(ctx.Configuration); + c.ReadFrom.Services(sp); + c.Enrich.WithMachineName(); + + if (!String.IsNullOrEmpty(options.ExceptionlessApiKey)) + c.WriteTo.Sink(new ExceptionlessSink(), LogEventLevel.Information); + }, writeToProviders: true) + .AddApm(apmConfig); + + AddJobs(builder.Services, jobOptions); + builder.Services.AddAppOptions(options); + Bootstrapper.RegisterServices(builder.Services, options); + Insulation.Bootstrapper.RegisterServices(builder.Services, options, true); + + var app = builder.Build(); + + app.UseSerilogRequestLogging(o => + { + o.MessageTemplate = "TraceId={TraceId} HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; + o.GetLevel = (context, duration, ex) => + { + if (ex is not null || context.Response.StatusCode > 499) + return LogEventLevel.Error; + + return duration < 1000 && context.Response.StatusCode < 400 ? LogEventLevel.Debug : LogEventLevel.Information; + }; + }); + + Core.Bootstrapper.LogConfiguration(app.Services, options, app.Services.GetRequiredService>()); + + if (!String.IsNullOrEmpty(options.ExceptionlessApiKey) && !String.IsNullOrEmpty(options.ExceptionlessServerUrl)) + app.UseExceptionless(ExceptionlessClient.Default); + + app.UseHealthChecks("/health", new HealthCheckOptions + { + Predicate = _ => false + }); + + app.UseHealthChecks("/ready", new HealthCheckOptions + { + Predicate = hcr => hcr.Tags.Contains("Critical") + }); + + app.UseWaitForStartupActionsBeforeServingRequests(); + app.MapFallback(async context => + { + await context.Response.WriteAsync($"Running Job: {jobOptions.JobName}"); + }); + + await app.RunAsync(); return 0; } catch (Exception ex) @@ -40,98 +121,6 @@ public static async Task Main(string[] args) } } - public static IHostBuilder CreateHostBuilder(string[] args) - { - var jobOptions = new JobRunnerOptions(args); - - Console.Title = $"Exceptionless {jobOptions.JobName} Job"; - string environment = Environment.GetEnvironmentVariable("EX_AppMode") ?? "Production"; - var config = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddYamlFile("appsettings.yml", optional: true, reloadOnChange: true) - .AddYamlFile($"appsettings.{environment}.yml", optional: true, reloadOnChange: true) - .AddCustomEnvironmentVariables() - .AddCommandLine(args) - .Build(); - - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(config) - .CreateBootstrapLogger() - .ForContext(); - - var options = AppOptions.ReadFromConfiguration(config); - // only poll the queue metrics if this process is going to run the stack event count job - options.QueueOptions.MetricsPollingEnabled = jobOptions.StackEventCount; - - var apmConfig = new ApmConfig(config, $"job-{jobOptions.JobName.ToLowerUnderscoredWords('-')}", options.InformationalVersion, options.CacheOptions.Provider == "redis"); - - Log.Information("Bootstrapping Exceptionless {JobName} job(s) in {AppMode} mode ({InformationalVersion}) on {MachineName} with options {@Options}", jobOptions.JobName ?? "All", environment, options.InformationalVersion, Environment.MachineName, options); - - var builder = Host.CreateDefaultBuilder() - .UseEnvironment(environment) - .ConfigureLogging(b => b.ClearProviders()) // clears .net providers since we are telling serilog to write to providers we only want it to be the otel provider - .UseSerilog((ctx, sp, c) => - { - c.ReadFrom.Configuration(ctx.Configuration); - c.ReadFrom.Services(sp); - c.Enrich.WithMachineName(); - - if (!String.IsNullOrEmpty(options.ExceptionlessApiKey)) - c.WriteTo.Sink(new ExceptionlessSink(), LogEventLevel.Information); - }, writeToProviders: true) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder - .UseConfiguration(config) - .Configure(app => - { - app.UseSerilogRequestLogging(o => - { - o.MessageTemplate = "TraceId={TraceId} HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; - o.GetLevel = new Func((context, duration, ex) => - { - if (ex is not null || context.Response.StatusCode > 499) - return LogEventLevel.Error; - - return duration < 1000 && context.Response.StatusCode < 400 ? LogEventLevel.Debug : LogEventLevel.Information; - }); - }); - - Bootstrapper.LogConfiguration(app.ApplicationServices, options, app.ApplicationServices.GetRequiredService>()); - - if (!String.IsNullOrEmpty(options.ExceptionlessApiKey) && !String.IsNullOrEmpty(options.ExceptionlessServerUrl)) - app.UseExceptionless(ExceptionlessClient.Default); - - app.UseHealthChecks("/health", new HealthCheckOptions - { - Predicate = _ => false - }); - - app.UseHealthChecks("/ready", new HealthCheckOptions - { - Predicate = hcr => hcr.Tags.Contains("Critical") - }); - - app.UseWaitForStartupActionsBeforeServingRequests(); - app.Run(async context => - { - await context.Response.WriteAsync($"Running Job: {jobOptions.JobName}"); - }); - }); - }) - .ConfigureServices((ctx, services) => - { - AddJobs(services, jobOptions); - services.AddAppOptions(options); - - Bootstrapper.RegisterServices(services, options); - Insulation.Bootstrapper.RegisterServices(services, options, true); - }) - .AddApm(apmConfig); - - return builder; - } - private static void AddJobs(IServiceCollection services, JobRunnerOptions options) { services.AddJobLifetimeService(); diff --git a/src/Exceptionless.Web/Api/ApiEndpoints.cs b/src/Exceptionless.Web/Api/ApiEndpoints.cs new file mode 100644 index 0000000000..1a7e5b36d1 --- /dev/null +++ b/src/Exceptionless.Web/Api/ApiEndpoints.cs @@ -0,0 +1,27 @@ +using Exceptionless.Web.Api.Endpoints; +using Foundatio.Mediator; + +namespace Exceptionless.Web.Api; + +public static class ApiEndpoints +{ + public static WebApplication MapApiEndpoints(this WebApplication app) + { + app.MapStatusEndpoints(); + app.MapUtilityEndpoints(); + app.MapAuthEndpoints(); + app.MapTokenEndpoints(); + app.MapWebHookEndpoints(); + app.MapStripeEndpoints(); + app.MapSavedViewEndpoints(); + app.MapUserEndpoints(); + app.MapProjectEndpoints(); + app.MapOrganizationEndpoints(); + app.MapStackEndpoints(); + app.MapAdminEndpoints(); + app.MapEventEndpoints(); + app.MapMediatorEndpoints(); + + return app; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs new file mode 100644 index 0000000000..4862fd28fd --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -0,0 +1,29 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Foundatio.Mediator; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class AdminEndpoints +{ + public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2/admin") + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .ExcludeFromDescription(); + + group.MapGet("echo", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new GetAdminEcho(httpContext))).ToHttpResult(resultMapper)); + + group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string organizationId, string planId) + => (await mediator.InvokeAsync>(new AdminChangePlan(organizationId, planId, httpContext))).ToHttpResult(resultMapper)); + + group.MapPost("set-bonus", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string organizationId, int bonusEvents, DateTime? expires = null) + => (await mediator.InvokeAsync(new AdminSetBonus(organizationId, bonusEvents, expires, httpContext))).ToHttpResult(resultMapper)); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000000..49459d7078 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,290 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Models; +using Foundatio.Mediator; +using Microsoft.AspNetCore.Mvc; +using AuthMessages = Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Utility.OpenApi; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class AuthEndpoints +{ + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2/auth") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .WithTags("Auth"); + + group.MapPost("login", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, HttpContext httpContext, [FromBody] Login model) => + { + var validation = await ApiValidation.ValidateAsync(model, serviceProvider, StatusCodes.Status422UnprocessableEntity); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new AuthMessages.LoginMessage(model, httpContext))).ToHttpResult(resultMapper); + }) + .AllowAnonymous() + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Login") + .WithDescription(""" + Log in with your email address and password to generate a token scoped with your users roles. + + ```{ "email": "noreply@exceptionless.io", "password": "exceptionless" }``` + + This token can then be used to access the api. You can use this token in the header (bearer authentication) + or append it onto the query string: ?access_token=MY_TOKEN + + Please note that you can also use this token on the documentation site by placing it in the + headers api_key input box. + """) + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "User Authentication Token", + ["401"] = "Login failed", + ["422"] = "Validation error", + } + }); + + group.MapGet("intercom", async (IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext) + => (await mediator.InvokeAsync>(new AuthMessages.GetIntercomToken(httpContext))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Get the current user's Intercom messenger token.") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "Intercom messenger token", + ["401"] = "User not logged in", + ["422"] = "Intercom is not enabled.", + } + }); + + group.MapGet("logout", async (IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext) + => (await mediator.InvokeAsync(new AuthMessages.LogoutMessage(httpContext))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status403Forbidden) + .WithSummary("Logout the current user and remove the current access token") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "User successfully logged-out", + ["401"] = "User not logged in", + ["403"] = "Current action is not supported with user access token", + } + }); + + group.MapPost("signup", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, HttpContext httpContext, [FromBody] Signup model) => + { + var validation = await ApiValidation.ValidateAsync(model, serviceProvider, StatusCodes.Status422UnprocessableEntity); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new AuthMessages.SignupMessage(model, httpContext))).ToHttpResult(resultMapper); + }) + .AllowAnonymous() + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Sign up") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "User Authentication Token", + ["401"] = "Sign-up failed", + ["403"] = "Account Creation is currently disabled", + ["422"] = "Validation error", + } + }); + + group.MapPost("github", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, HttpContext httpContext, [FromBody] ExternalAuthInfo value) => + { + var validation = await ApiValidation.ValidateAsync(value, serviceProvider, StatusCodes.Status422UnprocessableEntity); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new AuthMessages.GitHubLogin(value, httpContext))).ToHttpResult(resultMapper); + }) + .AllowAnonymous() + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Sign in with GitHub") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "User Authentication Token", + ["403"] = "Account Creation is currently disabled", + ["422"] = "Validation error", + } + }); + + group.MapPost("google", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, HttpContext httpContext, [FromBody] ExternalAuthInfo value) => + { + var validation = await ApiValidation.ValidateAsync(value, serviceProvider, StatusCodes.Status422UnprocessableEntity); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new AuthMessages.GoogleLogin(value, httpContext))).ToHttpResult(resultMapper); + }) + .AllowAnonymous() + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Sign in with Google") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "User Authentication Token", + ["403"] = "Account Creation is currently disabled", + ["422"] = "Validation error", + } + }); + + group.MapPost("facebook", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, HttpContext httpContext, [FromBody] ExternalAuthInfo value) => + { + var validation = await ApiValidation.ValidateAsync(value, serviceProvider, StatusCodes.Status422UnprocessableEntity); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new AuthMessages.FacebookLogin(value, httpContext))).ToHttpResult(resultMapper); + }) + .AllowAnonymous() + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Sign in with Facebook") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "User Authentication Token", + ["403"] = "Account Creation is currently disabled", + ["422"] = "Validation error", + } + }); + + group.MapPost("live", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, HttpContext httpContext, [FromBody] ExternalAuthInfo value) => + { + var validation = await ApiValidation.ValidateAsync(value, serviceProvider, StatusCodes.Status422UnprocessableEntity); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new AuthMessages.LiveLogin(value, httpContext))).ToHttpResult(resultMapper); + }) + .AllowAnonymous() + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Sign in with Microsoft") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "User Authentication Token", + ["403"] = "Account Creation is currently disabled", + ["422"] = "Validation error", + } + }); + + group.MapPost("unlink/{providerName:minlength(1)}", async (string providerName, IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext, [FromBody] ValueFromBody providerUserId) + => (await mediator.InvokeAsync>(new AuthMessages.RemoveExternalLogin(providerName, providerUserId, httpContext))).ToHttpResult(resultMapper)) + .Accepts>("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Removes an external login provider from the account") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The provider user id.", + ParameterDescriptions = new() { + ["providerName"] = "The provider name.", + }, + ResponseDescriptions = new() { + ["200"] = "User Authentication Token", + ["400"] = "Invalid provider name.", + } + }); + + group.MapPost("change-password", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, HttpContext httpContext, [FromBody] ChangePasswordModel model) => + { + var validation = await ApiValidation.ValidateAsync(model, serviceProvider, StatusCodes.Status422UnprocessableEntity); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new AuthMessages.ChangePassword(model, httpContext))).ToHttpResult(resultMapper); + }) + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Change password") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "User Authentication Token", + ["422"] = "Validation error", + } + }); + + group.MapGet("check-email-address/{email:minlength(1)}", async (string email, IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext) + => (await mediator.InvokeAsync(new AuthMessages.CheckEmailAddress(email, httpContext))).ToHttpResult(resultMapper)) + .AllowAnonymous() + .ExcludeFromDescription(); + + group.MapGet("forgot-password/{email:minlength(1)}", async (string email, IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext) + => (await mediator.InvokeAsync(new AuthMessages.ForgotPassword(email, httpContext))).ToHttpResult(resultMapper)) + .AllowAnonymous() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Forgot password") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["email"] = "The email address.", + }, + ResponseDescriptions = new() { + ["200"] = "Forgot password email was sent.", + ["400"] = "Invalid email address.", + } + }); + + group.MapPost("reset-password", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, HttpContext httpContext, [FromBody] ResetPasswordModel model) => + { + var validation = await ApiValidation.ValidateAsync(model, serviceProvider, StatusCodes.Status422UnprocessableEntity); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync(new AuthMessages.ResetPassword(model, httpContext))).ToHttpResult(resultMapper); + }) + .AllowAnonymous() + .Accepts("application/json", "application/*+json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Reset password") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "Password reset email was sent.", + ["422"] = "Invalid reset password model.", + } + }); + + group.MapPost("cancel-reset-password/{token:minlength(1)}", async (string token, IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext) + => (await mediator.InvokeAsync(new AuthMessages.CancelResetPassword(token, httpContext))).ToHttpResult(resultMapper)) + .AllowAnonymous() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Cancel reset password") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["token"] = "The password reset token.", + }, + ResponseDescriptions = new() { + ["200"] = "Password reset email was cancelled.", + ["400"] = "Invalid password reset token.", + } + }); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs new file mode 100644 index 0000000000..c1ca2d23c3 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs @@ -0,0 +1,893 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Foundatio.Repositories.Models; +using Exceptionless.Web.Api.Results; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Exceptionless.Web.Utility.OpenApi; +using System.Text.Json; +using IResult = Microsoft.AspNetCore.Http.IResult; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class EventEndpoints +{ + public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("Event"); + + // Count + group.MapGet("events/count", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? aggregations = null, string? time = null, string? offset = null, string? mode = null) + => (await mediator.InvokeAsync>(new GetEventCount(filter, aggregations, time, offset, mode, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Count") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["filter"] = "A filter that controls what data is returned from the server.", + ["aggregations"] = "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + } + }); + + group.MapGet("organizations/{organizationId:objectid}/events/count", async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? aggregations = null, string? time = null, string? offset = null, string? mode = null) + => (await mediator.InvokeAsync>(new GetEventCountByOrganization(organizationId, filter, aggregations, time, offset, mode, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Count by organization") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["aggregations"] = "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + } + }); + + group.MapGet("projects/{projectId:objectid}/events/count", async (string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? aggregations = null, string? time = null, string? offset = null, string? mode = null) + => (await mediator.InvokeAsync>(new GetEventCountByProject(projectId, filter, aggregations, time, offset, mode, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Count by project") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["aggregations"] = "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If mode is set to stack_new, then additional filters will be added.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + } + }); + + // Get by id + group.MapGet("events/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? time = null, string? offset = null) + => (await mediator.InvokeAsync>(new GetEventById(id, time, offset, httpContext))).ToHttpResult(resultMapper)) + .WithName("GetPersistentEventById") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get by id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the event.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + }, + ResponseDescriptions = new() { + ["404"] = "The event occurrence could not be found.", + ["426"] = "Unable to view event occurrence due to plan limits.", + } + }); + + // Get all + group.MapGet("events", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetAllEvents(filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get all") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // Get by organization + group.MapGet("organizations/{organizationId:objectid}/events", async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetEventsByOrganization(organizationId, filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get by organization") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The organization could not be found.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // Get by project + group.MapGet("projects/{projectId:objectid}/events", async (string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetEventsByProject(projectId, filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get by project") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The project could not be found.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // Get by stack + group.MapGet("stacks/{stackId:objectid}/events", async (string stackId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetEventsByStack(stackId, filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get by stack") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["stackId"] = "The identifier of the stack.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The stack could not be found.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // Get by reference id + group.MapGet("events/by-ref/{referenceId:identifier}", async (string referenceId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetEventsByReferenceId(referenceId, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get by reference id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["referenceId"] = "An identifier used that references an event instance.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // Get by reference id + project + group.MapGet("projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}", async (string referenceId, string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetEventsByReferenceIdAndProject(referenceId, projectId, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get by reference id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["referenceId"] = "An identifier used that references an event instance.", + ["projectId"] = "The identifier of the project.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The project could not be found.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // Sessions by session id + group.MapGet("events/sessions/{sessionId:identifier}", async (string sessionId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetEventsBySessionId(sessionId, filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get a list of all sessions or events by a session id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["sessionId"] = "An identifier that represents a session of events.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // Sessions by session id + project + group.MapGet("projects/{projectId:objectid}/events/sessions/{sessionId:identifier}", async (string sessionId, string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetEventsBySessionIdAndProject(sessionId, projectId, filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get a list of by a session id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["sessionId"] = "An identifier that represents a session of events.", + ["projectId"] = "The identifier of the project.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The project could not be found.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // All sessions + group.MapGet("events/sessions", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetSessions(filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Get a list of all sessions") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + } + }); + + // Sessions by organization + group.MapGet("organizations/{organizationId:objectid}/events/sessions", async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetSessionsByOrganization(organizationId, filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get a list of all sessions") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The project could not be found.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // Sessions by project + group.MapGet("projects/{projectId:objectid}/events/sessions", async (string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) + => (await mediator.InvokeAsync>>(new GetSessionsByProject(projectId, filter, sort, time, offset, mode, page, limit, before, after, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get a list of all sessions") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["before"] = "The before parameter is a cursor used for pagination and defines your place in the list of results.", + ["after"] = "The after parameter is a cursor used for pagination and defines your place in the list of results.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The project could not be found.", + ["426"] = "Unable to view event occurrences for the suspended organization.", + } + }); + + // User description + group.MapPost("events/by-ref/{referenceId:identifier}/user-description", async (string referenceId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UserDescription description, string? projectId = null) + => (await mediator.InvokeAsync(new SetEventUserDescription(referenceId, description, projectId, httpContext))).ToHttpResult(resultMapper)) + .AddEndpointFilter() + .Accepts("application/json") + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Set user description") + .WithDescription("You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The user description.", + ParameterDescriptions = new() { + ["referenceId"] = "An identifier used that references an event instance.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "Description must be specified.", + ["404"] = "The event occurrence with the specified reference id could not be found.", + } + }); + + group.MapPost("projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}/user-description", async (string referenceId, string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UserDescription description) + => (await mediator.InvokeAsync(new SetEventUserDescription(referenceId, description, projectId, httpContext))).ToHttpResult(resultMapper)) + .AddEndpointFilter() + .Accepts("application/json") + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Set user description") + .WithDescription("You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The user description.", + ParameterDescriptions = new() { + ["referenceId"] = "An identifier used that references an event instance.", + ["projectId"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "Description must be specified.", + ["404"] = "The event occurrence with the specified reference id could not be found.", + } + }); + + // Legacy patch (v1) — accepts partial JSON objects from old clients and converts to JSON Patch + endpoints.MapPatch("api/v1/error/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonElement body) => + { + var options = httpContext.RequestServices.GetRequiredService>().Value.SerializerOptions; + var patch = JsonPatchValidation.FromPartialObject(body, options); + if (patch is null) + return Microsoft.AspNetCore.Http.Results.Problem("Invalid request body. Expected a JSON object.", statusCode: StatusCodes.Status400BadRequest); + return (await mediator.InvokeAsync(new LegacyPatchEvent(id, patch, httpContext))).ToHttpResult(resultMapper); + }) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("Event") + .WithMetadata(new ObsoleteAttribute("Use PATCH /api/v2/events")); + + // Heartbeat + group.MapGet("events/session/heartbeat", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? id = null, bool close = false) + => (await mediator.InvokeAsync(new RecordEventHeartbeat(id, close, httpContext))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Submit heartbeat") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The session id or user id.", + ["close"] = "If true, the session will be closed.", + }, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + // Submit via GET - v1 legacy + endpoints.MapGet("api/v1/events/submit", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new SubmitEventByGet(null, 1, null, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("Event") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ObsoleteAttribute("Use GET /api/v2/events/submit")) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.SubmitGetAdditionalParameters, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + endpoints.MapGet("api/v1/events/submit/{type:minlength(1)}", async (string type, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new SubmitEventByGet(null, 1, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("Event") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ObsoleteAttribute("Use GET /api/v2/events/submit")) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.SubmitGetAdditionalParameters, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + endpoints.MapGet("api/v1/projects/{projectId:objectid}/events/submit", async (string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new SubmitEventByGet(projectId, 1, null, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("Event") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ObsoleteAttribute("Use GET /api/v2/events/submit")) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.SubmitGetAdditionalParameters, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + endpoints.MapGet("api/v1/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", async (string projectId, string type, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new SubmitEventByGet(projectId, 1, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("Event") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ObsoleteAttribute("Use GET /api/v2/events/submit")) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.SubmitGetAdditionalParameters, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + // Submit via GET - v2 + group.MapGet("events/submit", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? type = null) + => (await mediator.InvokeAsync(new SubmitEventByGet(null, 2, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper)) + .AddEndpointFilter() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Submit event by GET") + .WithDescription(""" + You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event. + + Feature usage named build with a duration of 10: + ```/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10``` + + Log with message, geo and extended data + ```/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true``` + """) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.SubmitGetAdditionalParameters, + ParameterDescriptions = new() { + ["type"] = "The event type (ie. error, log message, feature usage).", + ["source"] = "The event source (ie. machine name, log name, feature name).", + ["message"] = "The event message.", + ["reference"] = "An optional identifier to be used for referencing this event instance at a later time.", + ["date"] = "The date that the event occurred on.", + ["count"] = "The number of duplicated events.", + ["value"] = "The value of the event if any.", + ["geo"] = "The geo coordinates where the event happened.", + ["tags"] = "A list of tags used to categorize this event (comma separated).", + ["identity"] = "The user's identity that the event happened to.", + ["identityname"] = "The user's friendly name that the event happened to.", + ["userAgent"] = "The user agent that submitted the event.", + ["parameters"] = "Query string parameters that control what properties are set on the event", + }, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + group.MapGet("events/submit/{type:minlength(1)}", async (string type, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new SubmitEventByGet(null, 2, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper)) + .AddEndpointFilter() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Submit event type by GET") + .WithDescription(""" + You can submit an event using an HTTP GET and query string parameters. + + Feature usage event named build with a value of 10: + ```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10``` + + Log event with message, geo and extended data + ```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true``` + """) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.SubmitGetAdditionalParameters, + ParameterDescriptions = new() { + ["type"] = "The event type (ie. error, log message, feature usage).", + ["source"] = "The event source (ie. machine name, log name, feature name).", + ["message"] = "The event message.", + ["reference"] = "An optional identifier to be used for referencing this event instance at a later time.", + ["date"] = "The date that the event occurred on.", + ["count"] = "The number of duplicated events.", + ["value"] = "The value of the event if any.", + ["geo"] = "The geo coordinates where the event happened.", + ["tags"] = "A list of tags used to categorize this event (comma separated).", + ["identity"] = "The user's identity that the event happened to.", + ["identityname"] = "The user's friendly name that the event happened to.", + ["userAgent"] = "The user agent that submitted the event.", + ["parameters"] = "Query string parameters that control what properties are set on the event", + }, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + group.MapGet("projects/{projectId:objectid}/events/submit", async (string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? type = null) + => (await mediator.InvokeAsync(new SubmitEventByGet(projectId, 2, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper)) + .AddEndpointFilter() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Submit event type by GET for a specific project") + .WithDescription("You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```") + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.SubmitGetAdditionalParameters, + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["source"] = "The event source (ie. machine name, log name, feature name).", + ["message"] = "The event message.", + ["reference"] = "An optional identifier to be used for referencing this event instance at a later time.", + ["date"] = "The date that the event occurred on.", + ["count"] = "The number of duplicated events.", + ["value"] = "The value of the event if any.", + ["geo"] = "The geo coordinates where the event happened.", + ["tags"] = "A list of tags used to categorize this event (comma separated).", + ["identity"] = "The user's identity that the event happened to.", + ["identityname"] = "The user's friendly name that the event happened to.", + ["userAgent"] = "The user agent that submitted the event.", + ["parameters"] = "Query String parameters that control what properties are set on the event", + }, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + group.MapGet("projects/{projectId:objectid}/events/submit/{type:minlength(1)}", async (string projectId, string type, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new SubmitEventByGet(projectId, 2, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper)) + .AddEndpointFilter() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Submit event type by GET for a specific project") + .WithDescription("You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```") + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.SubmitGetAdditionalParameters, + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["type"] = "The event type (ie. error, log message, feature usage).", + ["source"] = "The event source (ie. machine name, log name, feature name).", + ["message"] = "The event message.", + ["reference"] = "An optional identifier to be used for referencing this event instance at a later time.", + ["date"] = "The date that the event occurred on.", + ["count"] = "The number of duplicated events.", + ["value"] = "The value of the event if any.", + ["geo"] = "The geo coordinates where the event happened.", + ["tags"] = "A list of tags used to categorize this event (comma separated).", + ["identity"] = "The user's identity that the event happened to.", + ["identityname"] = "The user's friendly name that the event happened to.", + ["userAgent"] = "The user agent that submitted the event.", + ["parameters"] = "Query String parameters that control what properties are set on the event", + }, + ResponseDescriptions = new() { + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + // Submit via POST - v1 legacy + endpoints.MapPost("api/v1/error", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => await SubmitEventByPostAsync(null, 1, httpContext, mediator, resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .Accepts("application/json", "text/plain") + .WithTags("Event") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ObsoleteAttribute("Use POST /api/v2/events")) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.PostUserAgentParameter, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + endpoints.MapPost("api/v1/events", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => await SubmitEventByPostAsync(null, 1, httpContext, mediator, resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .Accepts("application/json", "text/plain") + .WithTags("Event") + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ObsoleteAttribute("Use POST /api/v2/events")) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.PostUserAgentParameter, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + endpoints.MapPost("api/v1/projects/{projectId:objectid}/events", async (string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => await SubmitEventByPostAsync(projectId, 1, httpContext, mediator, resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .Accepts("application/json", "text/plain") + .WithTags("Event") + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ObsoleteAttribute("Use POST /api/v2/events")) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.PostUserAgentParameter, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + // Submit via POST - v2 + group.MapPost("events", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => await SubmitEventByPostAsync(null, 2, httpContext, mediator, resultMapper)) + .AddEndpointFilter() + .Accepts("application/json", "text/plain") + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Submit event by POST") + .WithDescription(""" + You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON object into the events data collection. + + You can also post a multi-line string. We automatically split strings by the \n character and create a new log event for every line. + + Simple event: + ```{ "message": "Exceptionless is amazing!" }``` + + Simple log event with user identity: + ```{ "type": "log", "message": "Exceptionless is amazing!", "date":"2030-01-01T12:00:00.0000000-05:00", "@user":{ "identity":"123456789", "name": "Test User" } }``` + + Simple error: + ```{ "type": "error", "date":"2030-01-01T12:00:00.0000000-05:00", "@simple_error": { "message": "Simple Exception", "type": "System.Exception", "stack_trace": " at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77" } }``` + """) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.PostUserAgentParameter, + ParameterDescriptions = new() { + ["userAgent"] = "The user agent that submitted the event.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + group.MapPost("projects/{projectId:objectid}/events", async (string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => await SubmitEventByPostAsync(projectId, 2, httpContext, mediator, resultMapper)) + .AddEndpointFilter() + .Accepts("application/json", "text/plain") + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Submit event by POST for a specific project") + .WithDescription(""" + You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON object into the events data collection. + + You can also post a multi-line string. We automatically split strings by the \n character and create a new log event for every line. + + Simple event: + ```{ "message": "Exceptionless is amazing!" }``` + + Simple log event with user identity: + ```{ "type": "log", "message": "Exceptionless is amazing!", "date":"2030-01-01T12:00:00.0000000-05:00", "@user":{ "identity":"123456789", "name": "Test User" } }``` + + Simple error: + ```{ "type": "error", "date":"2030-01-01T12:00:00.0000000-05:00", "@simple_error": { "message": "Simple Exception", "type": "System.Exception", "stack_trace": " at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77" } }``` + """) + .WithMetadata(new EndpointDocumentation { + AdditionalParameters = EventEndpointHelpers.PostUserAgentParameter, + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["userAgent"] = "The user agent that submitted the event.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "No project id specified and no default project was found.", + ["404"] = "No project was found.", + } + }); + + // Delete + group.MapDelete("events/{ids:objectids}", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new DeleteEvents(ids, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Remove") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of event identifiers.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "One or more validation errors occurred.", + ["404"] = "One or more event occurrences were not found.", + ["500"] = "An error occurred while deleting one or more event occurrences.", + } + }); + + return endpoints; + } + + private static async Task SubmitEventByPostAsync(string? projectId, int apiVersion, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + { + if (httpContext.Request.ContentLength is <= 0) + return Microsoft.AspNetCore.Http.Results.StatusCode(StatusCodes.Status202Accepted); + + return (await mediator.InvokeAsync(new SubmitEventByPost(projectId, apiVersion, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper); + } +} + +internal static class EventEndpointHelpers +{ + /// + /// Additional parameters for all event submit GET endpoints. + /// These are read from HttpContext/query string rather than method parameters. + /// + public static readonly List SubmitGetAdditionalParameters = + [ + new("source", "query", Description: "The event source (ie. machine name, log name, feature name)."), + new("message", "query", Description: "The event message."), + new("reference", "query", Description: "An optional identifier to be used for referencing this event instance at a later time."), + new("date", "query", Description: "The date that the event occurred on."), + new("count", "query", Description: "The number of duplicated events.", Type: "integer", Format: "int32"), + new("value", "query", Description: "The value of the event if any.", Type: "number", Format: "double"), + new("geo", "query", Description: "The geo coordinates where the event happened."), + new("tags", "query", Description: "A list of tags used to categorize this event (comma separated)."), + new("identity", "query", Description: "The user's identity that the event happened to."), + new("identityname", "query", Description: "The user's friendly name that the event happened to."), + new("userAgent", "header", Description: "The user agent that submitted the event."), + new("parameters", "query", Description: "Query string parameters that control what properties are set on the event", Type: "array"), + ]; + + /// + /// Additional parameters for POST event endpoints (just userAgent header). + /// + public static readonly List PostUserAgentParameter = + [ + new("userAgent", "header", Description: "The user agent that submitted the event."), + ]; +} diff --git a/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs new file mode 100644 index 0000000000..2ffa943ca1 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs @@ -0,0 +1,447 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Billing; +using Exceptionless.Core.Repositories; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Storage; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OrganizationMessages = Exceptionless.Web.Api.Messages; +using Invoice = Exceptionless.Web.Models.Invoice; +using Exceptionless.Web.Utility.OpenApi; +using HttpIResult = Microsoft.AspNetCore.Http.IResult; +using HttpResults = Microsoft.AspNetCore.Http.Results; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class OrganizationEndpoints +{ + public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .WithTags("Organization"); + + group.MapGet("organizations", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? mode = null) + => (await mediator.InvokeAsync>>(new OrganizationMessages.GetOrganizations(filter, mode, httpContext))).ToHttpResult(resultMapper)) + .Produces>() + .WithSummary("Get all") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["filter"] = "A filter that controls what data is returned from the server.", + ["mode"] = "If no mode is set then a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned.", + } + }); + + group.MapGet("admin/organizations", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? criteria = null, bool? paid = null, bool? suspended = null, string? mode = null, int page = 1, int limit = 10, OrganizationSortBy sort = OrganizationSortBy.Newest) + => (await mediator.InvokeAsync>>(new OrganizationMessages.GetAdminOrganizations(criteria, paid, suspended, mode, page, limit, sort, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces>() + .ExcludeFromDescription(); + + group.MapGet("admin/organizations/stats", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new OrganizationMessages.GetOrganizationPlanStats(httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces() + .ExcludeFromDescription(); + + group.MapGet("organizations/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? mode = null) + => (await mediator.InvokeAsync>(new OrganizationMessages.GetOrganizationById(id, mode, httpContext))).ToHttpResult(resultMapper)) + .WithName("GetOrganizationById") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["mode"] = "If no mode is set then the a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization could not be found.", + } + }); + + group.MapPost("organizations", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, [FromBody] NewOrganization organization) => + { + var validation = await ApiValidation.ValidateAsync(organization, serviceProvider); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new OrganizationMessages.CreateOrganization(organization, httpContext))).ToHttpResult(resultMapper); + }) + .Accepts("application/json") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Create") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The organization.", + ResponseDescriptions = new() { + ["201"] = "Created", + ["400"] = "An error occurred while creating the organization.", + ["409"] = "The organization already exists.", + } + }); + + group.MapPatch("organizations/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new OrganizationMessages.UpdateOrganizationMessage(id, patchDocument, httpContext))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the organization.", + ["404"] = "The organization could not be found.", + } + }); + + group.MapPut("organizations/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new OrganizationMessages.UpdateOrganizationMessage(id, patchDocument, httpContext))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the organization.", + ["404"] = "The organization could not be found.", + } + }); + + group.MapPost("organizations/{id:objectid}/icon", UploadIconAsync) + .Accepts("multipart/form-data") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithMetadata( + new MultipartFileUploadAttribute(), + new RequestSizeLimitAttribute(ProfileImageStorage.MaxRequestBodySize), + new RequestFormLimitsAttribute { MultipartBodyLengthLimit = ProfileImageStorage.MaxRequestBodySize }) + .WithSummary("Upload icon") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["file"] = "The organization icon image file.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization could not be found.", + ["422"] = "The image file is invalid.", + } + }) + .DisableAntiforgery(); + + group.MapDelete("organizations/{id:objectid}/icon", DeleteIconAsync) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove icon") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization could not be found.", + } + }); + + group.MapGet("organizations/{id:objectid}/icon/{fileName}", GetIconAsync) + .AllowAnonymous() + .WithName("GetOrganizationIcon") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ResponseCacheAttribute { Duration = 31536000, Location = ResponseCacheLocation.Any }) + .WithSummary("Get icon") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["fileName"] = "The icon file name.", + }, + ResponseDescriptions = new() { + ["404"] = "The icon could not be found.", + } + }); + + group.MapDelete("organizations/{ids:objectids}", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new OrganizationMessages.DeleteOrganizations(ids.FromDelimitedString(), httpContext))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Remove") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of organization identifiers.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "One or more validation errors occurred.", + ["404"] = "One or more organizations were not found.", + ["500"] = "An error occurred while deleting one or more organizations.", + } + }); + + group.MapGet("organizations/invoice/{id:minlength(10)}", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new OrganizationMessages.GetInvoice(id, httpContext))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get invoice") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the invoice.", + }, + ResponseDescriptions = new() { + ["404"] = "The invoice was not found.", + } + }); + + group.MapGet("organizations/{id:objectid}/invoices", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? before = null, string? after = null, int limit = 12) + => (await mediator.InvokeAsync>>(new OrganizationMessages.GetInvoices(id, before, after, limit, httpContext))).ToHttpResult(resultMapper)) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get invoices") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["before"] = "A cursor for use in pagination. before is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with obj_bar, your subsequent call can include before=obj_bar in order to fetch the previous page of the list.", + ["after"] = "A cursor for use in pagination. after is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with obj_foo, your subsequent call can include after=obj_foo in order to fetch the next page of the list.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization was not found.", + } + }); + + group.MapGet("organizations/{id:objectid}/plans", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>>(new OrganizationMessages.GetPlans(id, httpContext))).ToHttpResult(resultMapper)) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get plans") + .WithDescription("Gets available plans for a specific organization.") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization was not found.", + } + }); + + group.MapPost("organizations/{id:objectid}/change-plan", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ChangePlanRequest? model = null, + [FromQuery] string? planId = null, + [FromQuery] string? stripeToken = null, + [FromQuery] string? last4 = null, + [FromQuery] string? couponId = null) + => (await mediator.InvokeAsync>(new OrganizationMessages.ChangeOrganizationPlan(id, model, planId, stripeToken, last4, couponId, httpContext))).ToHttpResult(resultMapper)) + .Accepts("application/json") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Change plan") + .WithDescription("Upgrades or downgrades the organization's plan. Accepts parameters via JSON body (preferred) or query string (legacy).") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The plan change request (JSON body).", + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["planId"] = "Legacy query parameter: the plan identifier.", + ["stripeToken"] = "Legacy query parameter: the Stripe token.", + ["last4"] = "Legacy query parameter: last four digits of the card.", + ["couponId"] = "Legacy query parameter: the coupon identifier.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization was not found.", + } + }); + + group.MapPost("organizations/{id:objectid}/users/{email:minlength(1)}", async (string id, string email, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new OrganizationMessages.AddOrganizationUser(id, email, httpContext))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Add user") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["email"] = "The email address of the user you wish to add to your organization.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization was not found.", + ["426"] = "Please upgrade your plan to add an additional user.", + } + }); + + group.MapDelete("organizations/{id:objectid}/users/{email:minlength(1)}", async (string id, string email, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new OrganizationMessages.RemoveOrganizationUser(id, email, httpContext))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove user") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["email"] = "The email address of the user you wish to remove from your organization.", + }, + ResponseDescriptions = new() { + ["400"] = "The error occurred while removing the user from your organization", + ["404"] = "The organization was not found.", + } + }); + + group.MapPost("organizations/{id:objectid}/suspend", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, SuspensionCode? code = null, string? notes = null) + => (await mediator.InvokeAsync(new OrganizationMessages.SuspendOrganization(id, code ?? SuspensionCode.Billing, notes, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapDelete("organizations/{id:objectid}/suspend", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new OrganizationMessages.UnsuspendOrganization(id, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapPost("organizations/{id:objectid}/data/{key:minlength(1)}", async (string id, string key, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] ValueFromBody value) + => (await mediator.InvokeAsync(new OrganizationMessages.SetOrganizationData(id, key, value, httpContext))).ToHttpResult(resultMapper)) + .Accepts>("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Add custom data") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "Any string value.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["key"] = "The key name of the data object.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization was not found.", + } + }); + + group.MapDelete("organizations/{id:objectid}/data/{key:minlength(1)}", async (string id, string key, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new OrganizationMessages.DeleteOrganizationData(id, key, httpContext))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove custom data") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the organization.", + ["key"] = "The key name of the data object.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization was not found.", + } + }); + + group.MapPost("organizations/{id:objectid}/features/{feature:minlength(1)}", async (string id, string feature, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new OrganizationMessages.SetOrganizationFeature(id, feature, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapDelete("organizations/{id:objectid}/features/{feature:minlength(1)}", async (string id, string feature, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new OrganizationMessages.RemoveOrganizationFeature(id, feature, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapGet("organizations/check-name", async (string name, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new OrganizationMessages.CheckOrganizationName(name, httpContext))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status204NoContent) + .WithSummary("Check for unique name") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["name"] = "The organization name to check.", + }, + ResponseDescriptions = new() { + ["201"] = "The organization name is available.", + ["204"] = "The organization name is not available.", + } + }); + + return endpoints; + } + + private static async Task UploadIconAsync(string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromServices] IFileStorage fileStorage, [FromForm] IFormFile? file, CancellationToken cancellationToken) + { + var modelState = new ModelStateDictionary(); + var image = await ProfileImageStorage.SaveAsync(fileStorage, file, "organizations", id, modelState, cancellationToken); + if (image is null) + return ValidationProblem(modelState); + + try + { + var result = await mediator.InvokeAsync>>(new OrganizationMessages.SetOrganizationIcon(id, image.FileName, httpContext)); + if (!result.IsSuccess) + { + await ProfileImageStorage.TryDeleteAsync(fileStorage, image.FileName, "organizations", id, CancellationToken.None); + return result.ToHttpResult(resultMapper); + } + + var update = result.ValueOrDefault!; + await ProfileImageStorage.DeleteAsync(fileStorage, update.PreviousFileName, "organizations", id, cancellationToken); + return HttpResults.Ok(update.View); + } + catch + { + await ProfileImageStorage.TryDeleteAsync(fileStorage, image.FileName, "organizations", id, CancellationToken.None); + throw; + } + } + + private static async Task DeleteIconAsync(string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromServices] IFileStorage fileStorage, CancellationToken cancellationToken) + { + var result = await mediator.InvokeAsync>>(new OrganizationMessages.DeleteOrganizationIcon(id, httpContext)); + if (!result.IsSuccess) + return result.ToHttpResult(resultMapper); + + var update = result.ValueOrDefault!; + await ProfileImageStorage.DeleteAsync(fileStorage, update.PreviousFileName, "organizations", id, cancellationToken); + return HttpResults.Ok(update.View); + } + + private static async Task GetIconAsync(string id, string fileName, [FromServices] IFileStorage fileStorage, CancellationToken cancellationToken) + { + if (!ProfileImageStorage.TryGetContentType(fileName, out string contentType)) + return HttpResults.NotFound(); + + var stream = await ProfileImageStorage.GetFileStreamAsync(fileStorage, fileName, "organizations", id, cancellationToken); + return stream is null ? HttpResults.NotFound() : HttpResults.File(stream, contentType); + } + + private static HttpIResult ValidationProblem(ModelStateDictionary modelState) + { + var errors = modelState + .Where(kvp => kvp.Value?.Errors.Count > 0) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()); + + return HttpResults.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs new file mode 100644 index 0000000000..9d72e9ac6e --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs @@ -0,0 +1,595 @@ +using System.Text.Json; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Extensions; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Models; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; +using ProjectMessages = Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Utility.OpenApi; +using HttpJsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; +using HttpIResult = Microsoft.AspNetCore.Http.IResult; +using HttpResults = Microsoft.AspNetCore.Http.Results; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class ProjectEndpoints +{ + public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("Project"); + + group.MapGet("projects", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, int page = 1, int limit = 10, string? mode = null) + => (await mediator.InvokeAsync>>(new ProjectMessages.GetProjects(filter, sort, page, limit, mode, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces>() + .WithSummary("Get all") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["mode"] = "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", + } + }); + + group.MapGet("organizations/{organizationId:objectid}/projects", async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, int page = 1, int limit = 10, string? mode = null) + => (await mediator.InvokeAsync>>(new ProjectMessages.GetProjectsByOrganization(organizationId, filter, sort, page, limit, mode, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get all") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + ["mode"] = "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization could not be found.", + } + }); + + group.MapGet("projects/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? mode = null) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectById(id, mode, httpContext))).ToHttpResult(resultMapper)) + .WithName("GetProjectById") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["mode"] = "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapPost("projects", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, [FromBody] NewProject project) => + { + var validation = await ApiValidation.ValidateAsync(project, serviceProvider); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new ProjectMessages.CreateProject(project, httpContext))).ToHttpResult(resultMapper); + }) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Create") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The project.", + ResponseDescriptions = new() { + ["201"] = "Created", + ["400"] = "An error occurred while creating the project.", + ["409"] = "The project already exists.", + } + }); + + group.MapPatch("projects/{id:objectid}", UpdateProjectAsync) + .AcceptAnyJsonContentType() + .WithDisplayName("HTTP: PATCH api/v2/projects/{id:objectid}") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update") + .WithMetadata(new JsonPatchRequestBodyAttribute()) + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the project.", + ["404"] = "The project could not be found.", + } + }); + + group.MapPut("projects/{id:objectid}", UpdateProjectAsync) + .AcceptAnyJsonContentType() + .WithDisplayName("HTTP: PUT api/v2/projects/{id:objectid}") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update") + .WithMetadata(new JsonPatchRequestBodyAttribute()) + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the project.", + ["404"] = "The project could not be found.", + } + }); + + group.MapDelete("projects/{ids:objectids}", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new ProjectMessages.DeleteProjects(ids.FromDelimitedString(), httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Remove") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of project identifiers.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "One or more validation errors occurred.", + ["404"] = "One or more projects were not found.", + ["500"] = "An error occurred while deleting one or more projects.", + } + }); + + endpoints.MapGet("api/v1/project/config", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, int? v = null) + => (await mediator.InvokeAsync>(new ProjectMessages.GetLegacyProjectConfig(v, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .WithTags("Project") + .Produces() + .Produces(StatusCodes.Status304NotModified) + .ProducesProblem(StatusCodes.Status404NotFound); + + group.MapGet("projects/config", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, int? v = null) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectConfig(null, v, httpContext))).ToHttpResult(resultMapper)) + .Produces() + .Produces(StatusCodes.Status304NotModified) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get configuration settings") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["v"] = "The client configuration version.", + }, + ResponseDescriptions = new() { + ["304"] = "The client configuration version is the current version.", + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("projects/{id:objectid}/config", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, int? v = null) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectConfig(id, v, httpContext))).ToHttpResult(resultMapper)) + .Produces() + .Produces(StatusCodes.Status304NotModified) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get configuration settings") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["v"] = "The client configuration version.", + }, + ResponseDescriptions = new() { + ["304"] = "The client configuration version is the current version.", + ["404"] = "The project could not be found.", + } + }); + + group.MapPost("projects/{id:objectid}/config", async (string id, string key, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] ValueFromBody value) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectConfig(id, key, value, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts>("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Add configuration value") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The configuration value.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["key"] = "The key name of the configuration object.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid configuration value.", + ["404"] = "The project could not be found.", + } + }); + + group.MapDelete("projects/{id:objectid}/config", async (string id, string key, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new ProjectMessages.DeleteProjectConfig(id, key, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove configuration value") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["key"] = "The key name of the configuration object.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid key value.", + ["404"] = "The project could not be found.", + } + }); + + group.MapPost("projects/{id:objectid}/sample-data", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new ProjectMessages.GenerateProjectSampleData(id, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Generate sample project data") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("projects/{id:objectid}/reset-data", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new ProjectMessages.ResetProjectData(id, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Reset project data") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["404"] = "The project could not be found.", + } + }); + + group.MapPost("projects/{id:objectid}/reset-data", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new ProjectMessages.ResetProjectData(id, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Reset project data") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("projects/{id:objectid}/notifications", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>>(new ProjectMessages.GetProjectNotificationSettings(id, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapGet("users/{userId:objectid}/projects/{id:objectid}/notifications", async (string id, string userId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectUserNotificationSettings(id, userId, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get user notification settings") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["userId"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("projects/{id:objectid}/{integration:minlength(1)}/notifications", async (string id, string integration, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectIntegrationNotificationSettings(id, integration, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapPut("users/{userId:objectid}/projects/{id:objectid}/notifications", async (string id, string userId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NotificationSettings? settings = null) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectUserNotificationSettings(id, userId, settings, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Set user notification settings") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The notification settings.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["userId"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapPost("users/{userId:objectid}/projects/{id:objectid}/notifications", async (string id, string userId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NotificationSettings? settings = null) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectUserNotificationSettings(id, userId, settings, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Set user notification settings") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The notification settings.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["userId"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapPut("projects/{id:objectid}/{integration:minlength(1)}/notifications", async (string id, string integration, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NotificationSettings? settings = null) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectIntegrationNotificationSettings(id, integration, settings, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Set an integrations notification settings") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The notification settings.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["integration"] = "The identifier of the integration.", + }, + ResponseDescriptions = new() { + ["404"] = "The project or integration could not be found.", + ["426"] = "Please upgrade your plan to enable integrations.", + } + }); + + group.MapPost("projects/{id:objectid}/{integration:minlength(1)}/notifications", async (string id, string integration, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NotificationSettings? settings = null) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectIntegrationNotificationSettings(id, integration, settings, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Set an integrations notification settings") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The notification settings.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["integration"] = "The identifier of the integration.", + }, + ResponseDescriptions = new() { + ["404"] = "The project or integration could not be found.", + ["426"] = "Please upgrade your plan to enable integrations.", + } + }); + + group.MapDelete("users/{userId:objectid}/projects/{id:objectid}/notifications", async (string id, string userId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new ProjectMessages.DeleteProjectNotificationSettings(id, userId, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove user notification settings") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["userId"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapPut("projects/{id:objectid}/promotedtabs", async (string id, string name, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new ProjectMessages.PromoteProjectTab(id, name, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Promote tab") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["name"] = "The tab name.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid tab name.", + ["404"] = "The project could not be found.", + } + }); + + group.MapPost("projects/{id:objectid}/promotedtabs", async (string id, string name, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new ProjectMessages.PromoteProjectTab(id, name, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Promote tab") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["name"] = "The tab name.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid tab name.", + ["404"] = "The project could not be found.", + } + }); + + group.MapDelete("projects/{id:objectid}/promotedtabs", async (string id, string name, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new ProjectMessages.DemoteProjectTab(id, name, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Demote tab") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["name"] = "The tab name.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid tab name.", + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("projects/check-name", async (string name, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? organizationId = null) + => (await mediator.InvokeAsync(new ProjectMessages.CheckProjectName(name, organizationId, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status204NoContent) + .WithSummary("Check for unique name") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["name"] = "The project name to check.", + }, + ResponseDescriptions = new() { + ["201"] = "The project name is available.", + ["204"] = "The project name is not available.", + } + }); + + group.MapGet("organizations/{organizationId:objectid}/projects/check-name", async (string organizationId, string name, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new ProjectMessages.CheckProjectName(name, organizationId, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status204NoContent) + .WithSummary("Check for unique name") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["name"] = "The project name to check.", + ["organizationId"] = "If set the check name will be scoped to a specific organization.", + }, + ResponseDescriptions = new() { + ["201"] = "The project name is available.", + ["204"] = "The project name is not available.", + } + }); + + group.MapPost("projects/{id:objectid}/data", async (string id, string key, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] ValueFromBody value) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectData(id, key, value, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts>("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Add custom data") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "Any string value.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["key"] = "The key name of the data object.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid key or value.", + ["404"] = "The project could not be found.", + } + }); + + group.MapDelete("projects/{id:objectid}/data", async (string id, string key, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new ProjectMessages.DeleteProjectData(id, key, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove custom data") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["key"] = "The key name of the data object.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid key or value.", + ["404"] = "The project could not be found.", + } + }); + + group.MapPost("projects/{id:objectid}/slack", async (string id, string code, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new ProjectMessages.AddProjectSlack(id, code, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status304NotModified) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapDelete("projects/{id:objectid}/slack", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new ProjectMessages.RemoveProjectSlack(id, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + return endpoints; + } + + private static async Task UpdateProjectAsync( + string id, + HttpContext httpContext, + IMediator mediator, + IMediatorResultMapper resultMapper, + IOptions jsonOptions, + [FromBody] JsonElement body) + { + var patchDocument = CreatePatchDocument(body, jsonOptions.Value.SerializerOptions); + if (patchDocument is null) + { + return HttpResults.ValidationProblem(new Dictionary + { + ["patch"] = ["Invalid patch document."] + }); + } + + return (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectMessage(id, patchDocument, httpContext))).ToHttpResult(resultMapper); + } + + private static JsonPatchDocument? CreatePatchDocument(JsonElement body, JsonSerializerOptions options) + { + if (body.ValueKind is JsonValueKind.Array) + return body.Deserialize>(options); + + return JsonPatchValidation.FromPartialObject(body, options); + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs new file mode 100644 index 0000000000..2b5880e414 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs @@ -0,0 +1,246 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Seed; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Models; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Mvc; +using SavedViewMessages = Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Utility.OpenApi; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class SavedViewEndpoints +{ + public static IEndpointRouteBuilder MapSavedViewEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .WithTags("SavedView"); + + group.MapGet("organizations/{organizationId:objectid}/saved-views", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper, int page = 1, int limit = 25) + => (await mediator.InvokeAsync>>(new SavedViewMessages.GetSavedViewsByOrganization(organizationId, page, limit))).ToHttpResult(resultMapper)) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by organization") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization could not be found.", + } + }); + + group.MapGet("organizations/{organizationId:objectid}/saved-views/{viewType}", async (string organizationId, string viewType, IMediator mediator, IMediatorResultMapper resultMapper, int page = 1, int limit = 25) + => (await mediator.InvokeAsync>>(new SavedViewMessages.GetSavedViewsByView(organizationId, viewType, page, limit))).ToHttpResult(resultMapper)) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by organization and view") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["viewType"] = "The dashboard view type (events, issues, stream).", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization could not be found.", + } + }); + + group.MapGet("saved-views/{id:objectid}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new SavedViewMessages.GetSavedViewById(id))).ToHttpResult(resultMapper)) + .WithName("GetSavedViewById") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the saved view.", + }, + ResponseDescriptions = new() { + ["404"] = "The saved view could not be found.", + } + }); + + group.MapPost("organizations/{organizationId:objectid}/saved-views", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, + [FromBody] NewSavedView savedView) => + { + var validation = await ApiValidation.ValidateAsync(savedView, serviceProvider); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new SavedViewMessages.CreateSavedView(organizationId, savedView))).ToHttpResult(resultMapper); + }) + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Create") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The saved view.", + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["201"] = "Created", + ["400"] = "An error occurred while creating the saved view.", + ["409"] = "The saved view already exists.", + } + }); + + group.MapPost("organizations/{organizationId:objectid}/saved-views/predefined", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>>(new SavedViewMessages.CreatePredefinedSavedViews(organizationId))).ToHttpResult(resultMapper)) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Create or update predefined saved views") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["200"] = "The predefined saved views were created or updated.", + ["404"] = "The organization could not be found.", + } + }); + + group.MapGet("saved-views/predefined", async (IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>>(new SavedViewMessages.GetPredefinedSavedViews())).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces>() + .WithSummary("Get global predefined saved views as seed JSON") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["200"] = "The current predefined saved views.", + } + }); + + group.MapGet("organizations/{organizationId:objectid}/saved-views/export", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>>(new SavedViewMessages.ExportOrganizationSavedViews(organizationId))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get an organization's saved views exported as predefined definitions") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization to export from.", + }, + ResponseDescriptions = new() { + ["200"] = "The organization's saved views as predefined definitions.", + ["404"] = "The organization could not be found.", + } + }); + + group.MapPut("saved-views/predefined", async (IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] IReadOnlyCollection definitions) + => (await mediator.InvokeAsync>>(new SavedViewMessages.ReplacePredefinedSavedViews(definitions))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces>() + .WithSummary("Replace all predefined saved views with the provided definitions") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The full set of predefined saved view definitions.", + ResponseDescriptions = new() { + ["200"] = "The predefined saved views were replaced.", + } + }); + + group.MapPost("saved-views/{id:objectid}/predefined", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new SavedViewMessages.PromoteToPredefinedSavedView(id))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Save a saved view as a global predefined saved view") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the saved view to promote.", + }, + ResponseDescriptions = new() { + ["200"] = "The predefined saved view was created or updated.", + ["404"] = "The saved view could not be found.", + } + }); + + group.MapDelete("saved-views/{id:objectid}/predefined", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new SavedViewMessages.DeletePredefinedSavedView(id))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Delete a global predefined saved view") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the saved view whose predefined saved view should be deleted.", + }, + ResponseDescriptions = new() { + ["204"] = "The predefined saved view was deleted.", + ["404"] = "The saved view could not be found.", + } + }); + + group.MapPatch("saved-views/{id:objectid}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new SavedViewMessages.UpdateSavedViewMessage(id, patchDocument))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the saved view.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the saved view.", + ["404"] = "The saved view could not be found.", + } + }); + + group.MapPut("saved-views/{id:objectid}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new SavedViewMessages.UpdateSavedViewMessage(id, patchDocument))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the saved view.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the saved view.", + ["404"] = "The saved view could not be found.", + } + }); + + group.MapDelete("saved-views/{ids:objectids}", async (string ids, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new SavedViewMessages.DeleteSavedViews(ids.FromDelimitedString()))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Remove") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of saved view identifiers.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "One or more validation errors occurred.", + ["404"] = "One or more saved views were not found.", + ["500"] = "An error occurred while deleting one or more saved views.", + } + }); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs new file mode 100644 index 0000000000..bf6e801f4a --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs @@ -0,0 +1,311 @@ +using System.Text.Json; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Models; +using Foundatio.Mediator; +using Microsoft.AspNetCore.Mvc; +using Exceptionless.Web.Utility.OpenApi; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class StackEndpoints +{ + public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("Stack"); + + // GET by id + group.MapGet("stacks/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? offset = null) + => (await mediator.InvokeAsync>(new GetStackById(id, offset, httpContext))).ToHttpResult(resultMapper)) + .WithName("GetStackById") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the stack.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the `time` filter. This is used for time zone support.", + }, + ResponseDescriptions = new() { + ["404"] = "The stack could not be found.", + } + }); + + // Mark fixed + group.MapPost("stacks/{ids:objectids}/mark-fixed", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? version = null) + => (await mediator.InvokeAsync(new MarkStacksFixed(ids, version, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Mark fixed") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of stack identifiers.", + ["version"] = "A version number that the stack was fixed in.", + }, + ResponseDescriptions = new() { + ["200"] = "The stacks were marked as fixed.", + ["404"] = "One or more stacks could not be found.", + } + }); + + // Mark fixed - Zapier legacy v1 + endpoints.MapPost("api/v1/stack/markfixed", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new MarkStacksFixedByZapier(data, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + // Mark fixed - Zapier v2 (no id in route) + group.MapPost("stacks/mark-fixed", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new MarkStacksFixedByZapier(data, httpContext))).ToHttpResult(resultMapper)) + .ExcludeFromDescription(); + + // Snooze + group.MapPost("stacks/{ids:objectids}/mark-snoozed", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, DateTime snoozeUntilUtc) + => (await mediator.InvokeAsync(new SnoozeStacks(ids, snoozeUntilUtc, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Mark the selected stacks as snoozed") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of stack identifiers.", + ["snoozeUntilUtc"] = "A time that the stack should be snoozed until.", + }, + ResponseDescriptions = new() { + ["200"] = "The stacks were snoozed.", + ["404"] = "One or more stacks could not be found.", + } + }); + + // Add link + group.MapPost("stacks/{id:objectid}/add-link", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] ValueFromBody url) + => (await mediator.InvokeAsync(new AddStackLink(id, url, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts>("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Add reference link") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The reference link.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the stack.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid reference link.", + ["404"] = "The stack could not be found.", + } + }); + + // Add link - Zapier legacy v1 + endpoints.MapPost("api/v1/stack/addlink", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new AddStackLinkByZapier(data, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + // Add link - Zapier v2 (no id in route) + group.MapPost("stacks/add-link", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new AddStackLinkByZapier(data, httpContext))).ToHttpResult(resultMapper)) + .ExcludeFromDescription(); + + // Remove link + group.MapPost("stacks/{id:objectid}/remove-link", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] ValueFromBody url) + => (await mediator.InvokeAsync(new RemoveStackLink(id, url, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts>("application/json") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove reference link") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The reference link.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the stack.", + }, + ResponseDescriptions = new() { + ["204"] = "The reference link was removed.", + ["400"] = "Invalid reference link.", + ["404"] = "The stack could not be found.", + } + }); + + // Mark critical + group.MapPost("stacks/{ids:objectids}/mark-critical", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new MarkStacksCritical(ids, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Mark future occurrences as critical") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of stack identifiers.", + }, + ResponseDescriptions = new() { + ["404"] = "One or more stacks could not be found.", + } + }); + + // Mark not critical + group.MapDelete("stacks/{ids:objectids}/mark-critical", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new MarkStacksNotCritical(ids, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Mark future occurrences as not critical") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of stack identifiers.", + }, + ResponseDescriptions = new() { + ["204"] = "The stacks were marked as not critical.", + ["404"] = "One or more stacks could not be found.", + } + }); + + // Change status + group.MapPost("stacks/{ids:objectids}/change-status", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, StackStatus status) + => (await mediator.InvokeAsync(new ChangeStacksStatus(ids, status, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Change stack status") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of stack identifiers.", + ["status"] = "The status that the stack should be changed to.", + }, + ResponseDescriptions = new() { + ["404"] = "One or more stacks could not be found.", + } + }); + + // Promote + group.MapPost("stacks/{id:objectid}/promote", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new PromoteStack(id, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .Produces(StatusCodes.Status501NotImplemented) + .WithSummary("Promote to external service") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the stack.", + }, + ResponseDescriptions = new() { + ["404"] = "The stack could not be found.", + ["426"] = "Promote to External is a premium feature used to promote an error stack to an external system.", + ["501"] = "No promoted web hooks are configured for this project.", + } + }); + + // Delete + group.MapDelete("stacks/{ids:objectids}", async (string ids, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new DeleteStacks(ids, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Remove") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of stack identifiers.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "One or more validation errors occurred.", + ["404"] = "One or more stacks were not found.", + ["500"] = "An error occurred while deleting one or more stacks.", + } + }); + + // Get all + group.MapGet("stacks", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new GetAllStacks(filter, sort, time, offset, mode, page, limit, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Get all") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + } + }); + + // Get by organization + group.MapGet("organizations/{organizationId:objectid}/stacks", async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new GetStacksByOrganization(organizationId, filter, sort, time, offset, mode, page, limit, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get by organization") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The organization could not be found.", + ["426"] = "Unable to view stack occurrences for the suspended organization.", + } + }); + + // Get by project + group.MapGet("projects/{projectId:objectid}/stacks", async (string projectId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new GetStacksByProject(projectId, filter, sort, time, offset, mode, page, limit, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Get by project") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["filter"] = "A filter that controls what data is returned from the server.", + ["sort"] = "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + ["time"] = "The time filter that limits the data being returned to a specific date range.", + ["offset"] = "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + ["mode"] = "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["400"] = "Invalid filter.", + ["404"] = "The organization could not be found.", + ["426"] = "Unable to view stack occurrences for the suspended organization.", + } + }); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs new file mode 100644 index 0000000000..6ee570de2f --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs @@ -0,0 +1,53 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Models; +using Foundatio.Mediator; +using Microsoft.AspNetCore.Mvc; +using HttpResults = Microsoft.AspNetCore.Http.Results; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class StatusEndpoints +{ + public static IEndpointRouteBuilder MapStatusEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .ExcludeFromDescription(); + + group.MapPost("notifications/release", async (IMediator mediator, [FromBody] ValueFromBody message, bool critical = false) => + { + var result = await mediator.InvokeAsync(new PostReleaseNotification(message.Value, critical)); + return HttpResults.Ok(result); + }) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy); + + group.MapGet("notifications/system", async (IMediator mediator) => + { + var result = await mediator.InvokeAsync(new GetSystemNotification()); + return result.Date == DateTime.MinValue ? HttpResults.Ok() : HttpResults.Ok(result); + }); + + group.MapPost("notifications/system", async (IMediator mediator, [FromBody] SetSystemNotificationRequest request, bool publish = true) => + { + if (String.IsNullOrWhiteSpace(request.Message)) + return HttpResults.NotFound(); + + var result = await mediator.InvokeAsync(new PostSystemNotification(request.Message, request.Level, request.Target, publish)); + return HttpResults.Ok(result); + }) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy); + + group.MapDelete("notifications/system", async (IMediator mediator, bool publish = true) => + { + await mediator.InvokeAsync(new RemoveSystemNotification(publish)); + return HttpResults.Ok(); + }) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/StripeEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/StripeEndpoints.cs new file mode 100644 index 0000000000..be45356ae2 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/StripeEndpoints.cs @@ -0,0 +1,25 @@ +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Foundatio.Mediator; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class StripeEndpoints +{ + public static IEndpointRouteBuilder MapStripeEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost("api/v2/stripe", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) => + { + using var reader = new StreamReader(httpContext.Request.Body, leaveOpen: true); + string json = await reader.ReadToEndAsync(); + string? signature = httpContext.Request.Headers["Stripe-Signature"]; + return (await mediator.InvokeAsync(new HandleStripeWebhook(json, signature))).ToHttpResult(resultMapper); + }) + .AddEndpointFilter() + .AllowAnonymous() + .ExcludeFromDescription(); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/TokenEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/TokenEndpoints.cs new file mode 100644 index 0000000000..847c9482ef --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/TokenEndpoints.cs @@ -0,0 +1,230 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Models; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using TokenMessages = Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Utility.OpenApi; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class TokenEndpoints +{ + public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .WithTags("Token"); + + group.MapGet("organizations/{organizationId:objectid}/tokens", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new TokenMessages.GetTokensByOrganization(organizationId, page, limit))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by organization") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization could not be found.", + } + }); + + group.MapGet("projects/{projectId:objectid}/tokens", async (string projectId, IMediator mediator, IMediatorResultMapper resultMapper, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new TokenMessages.GetTokensByProject(projectId, page, limit))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by project") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("projects/{projectId:objectid}/tokens/default", async (string projectId, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new TokenMessages.GetDefaultToken(projectId))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get a projects default token") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("tokens/{id:token}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new TokenMessages.GetTokenById(id))).ToHttpResult(resultMapper)) + .WithName("GetTokenById") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the token.", + }, + ResponseDescriptions = new() { + ["404"] = "The token could not be found.", + } + }); + + group.MapPost("tokens", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, [FromBody] NewToken token) => + { + var validation = await ApiValidation.ValidateAsync(token, serviceProvider); + if (validation is not null) + return validation; + + if (String.IsNullOrEmpty(token.ProjectId)) + return Microsoft.AspNetCore.Http.Results.ValidationProblem( + new Dictionary { ["project_id"] = ["The project_id field is required."] }, + statusCode: StatusCodes.Status400BadRequest); + + return (await mediator.InvokeAsync>(new TokenMessages.CreateToken(token))).ToHttpResult(resultMapper); + }) + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithSummary("Create") + .WithDescription("To create a new token, you must specify an organization_id. There are three valid scopes: client, user and admin.") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The token.", + ResponseDescriptions = new() { + ["201"] = "Created", + ["400"] = "An error occurred while creating the token.", + ["409"] = "The token already exists.", + } + }); + + group.MapPost("projects/{projectId:objectid}/tokens", async (string projectId, IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NewToken? token = null) => + { + if (token is not null) + { + var validation = await ApiValidation.ValidateAsync(token, serviceProvider); + if (validation is not null) + return validation; + } + + return (await mediator.InvokeAsync>(new TokenMessages.CreateTokenByProject(projectId, token))).ToHttpResult(resultMapper); + }) + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithSummary("Create for project") + .WithDescription("This is a helper action that makes it easier to create a token for a specific project. You may also specify a scope when creating a token. There are three valid scopes: client, user and admin.") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The token.", + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["201"] = "Created", + ["400"] = "An error occurred while creating the token.", + ["404"] = "The project could not be found.", + ["409"] = "The token already exists.", + } + }); + + group.MapPost("organizations/{organizationId:objectid}/tokens", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NewToken? token = null) => + { + if (token is not null) + { + var validation = await ApiValidation.ValidateAsync(token, serviceProvider); + if (validation is not null) + return validation; + } + + return (await mediator.InvokeAsync>(new TokenMessages.CreateTokenByOrganization(organizationId, token))).ToHttpResult(resultMapper); + }) + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithSummary("Create for organization") + .WithDescription("This is a helper action that makes it easier to create a token for a specific organization. You may also specify a scope when creating a token. There are three valid scopes: client, user and admin.") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The token.", + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["201"] = "Created", + ["400"] = "An error occurred while creating the token.", + ["409"] = "The token already exists.", + } + }); + + group.MapPatch("tokens/{id:tokens}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new TokenMessages.UpdateTokenMessage(id, patchDocument))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Update") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the token.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the token.", + ["404"] = "The token could not be found.", + } + }); + + group.MapPut("tokens/{id:tokens}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new TokenMessages.UpdateTokenMessage(id, patchDocument))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Update") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the token.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the token.", + ["404"] = "The token could not be found.", + } + }); + + group.MapDelete("tokens/{ids:tokens}", async (string ids, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new TokenMessages.DeleteTokens(ids.FromDelimitedString()))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Remove") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of token identifiers.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "One or more validation errors occurred.", + ["404"] = "One or more tokens were not found.", + ["500"] = "An error occurred while deleting one or more tokens.", + } + }); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs new file mode 100644 index 0000000000..34d4c28ddb --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -0,0 +1,319 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Storage; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using UserMessages = Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Utility.OpenApi; +using HttpIResult = Microsoft.AspNetCore.Http.IResult; +using HttpResults = Microsoft.AspNetCore.Http.Results; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class UserEndpoints +{ + public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .WithTags("User"); + + group.MapGet("users/me", async (IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new UserMessages.GetCurrentUser())).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get current user") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["404"] = "The current user could not be found.", + } + }); + + group.MapGet("users/{id:objectid}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new UserMessages.GetUserById(id))).ToHttpResult(resultMapper)) + .WithName("GetUserById") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["404"] = "The user could not be found.", + } + }); + + group.MapGet("organizations/{organizationId:objectid}/users", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new UserMessages.GetUsersByOrganization(organizationId, page, limit))).ToHttpResult(resultMapper)) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by organization") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["404"] = "The organization could not be found.", + } + }); + + group.MapPatch("users/{id:objectid}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new UserMessages.UpdateUserMessage(id, patchDocument))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Update") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the user.", + ["404"] = "The user could not be found.", + } + }); + + group.MapPut("users/{id:objectid}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new UserMessages.UpdateUserMessage(id, patchDocument))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Update") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The changes", + ParameterDescriptions = new() { + ["id"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the user.", + ["404"] = "The user could not be found.", + } + }); + + group.MapPost("users/{id:objectid}/avatar", UploadAvatarAsync) + .Accepts("multipart/form-data") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithMetadata( + new MultipartFileUploadAttribute(), + new RequestSizeLimitAttribute(ProfileImageStorage.MaxRequestBodySize), + new RequestFormLimitsAttribute { MultipartBodyLengthLimit = ProfileImageStorage.MaxRequestBodySize }) + .WithSummary("Upload avatar") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the user.", + ["file"] = "The avatar image file.", + }, + ResponseDescriptions = new() { + ["404"] = "The user could not be found.", + ["422"] = "The image file is invalid.", + } + }) + .DisableAntiforgery(); + + group.MapDelete("users/{id:objectid}/avatar", DeleteAvatarAsync) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove avatar") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["404"] = "The user could not be found.", + } + }); + + group.MapGet("users/{id:objectid}/avatar/{fileName}", GetAvatarAsync) + .AllowAnonymous() + .WithName("GetUserAvatar") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithMetadata(new ResponseCacheAttribute { Duration = 31536000, Location = ResponseCacheLocation.Any }) + .WithSummary("Get avatar") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the user.", + ["fileName"] = "The avatar file name.", + }, + ResponseDescriptions = new() { + ["404"] = "The avatar could not be found.", + } + }); + + group.MapDelete("users/me", async (IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new UserMessages.DeleteCurrentUser())).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Delete current user") + .WithMetadata(new EndpointDocumentation { + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["404"] = "The current user could not be found.", + } + }); + + group.MapDelete("users/{ids:objectids}", async (string ids, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new UserMessages.DeleteUsers(ids.FromDelimitedString()))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Remove") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of user identifiers.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "One or more validation errors occurred.", + ["404"] = "One or more users were not found.", + ["500"] = "An error occurred while deleting one or more users.", + } + }); + + group.MapPost("users/{id:objectid}/email-address/{email:minlength(1)}", async (string id, string email, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new UserMessages.UpdateEmailAddress(id, email))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status429TooManyRequests) + .WithSummary("Update email address") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the user.", + ["email"] = "The new email address.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the users email address.", + ["422"] = "Validation error", + ["429"] = "Update email address rate limit reached.", + } + }); + + group.MapGet("users/verify-email-address/{token:token}", async (string token, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new UserMessages.VerifyEmailAddress(token))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Verify email address") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["token"] = "The token identifier.", + }, + ResponseDescriptions = new() { + ["404"] = "The user could not be found.", + ["422"] = "Verify Email Address Token has expired.", + } + }); + + group.MapGet("users/{id:objectid}/resend-verification-email", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new UserMessages.ResendVerificationEmail(id))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Resend verification email") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the user.", + }, + ResponseDescriptions = new() { + ["200"] = "The user verification email has been sent.", + ["404"] = "The user could not be found.", + } + }); + + group.MapPost("users/unverify-email-address", async (IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new UserMessages.UnverifyEmailAddresses())).ToHttpResult(resultMapper)) + .Accepts("text/plain") + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ExcludeFromDescription(); + + group.MapPost("users/{id:objectid}/admin-role", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new UserMessages.AddAdminRole(id))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapDelete("users/{id:objectid}/admin-role", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new UserMessages.RemoveAdminRole(id))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + return endpoints; + } + + private static async Task UploadAvatarAsync(string id, IMediator mediator, IMediatorResultMapper resultMapper, [FromServices] IFileStorage fileStorage, [FromForm] IFormFile? file, CancellationToken cancellationToken) + { + var modelState = new ModelStateDictionary(); + var image = await ProfileImageStorage.SaveAsync(fileStorage, file, "users", id, modelState, cancellationToken); + if (image is null) + return ValidationProblem(modelState); + + try + { + var result = await mediator.InvokeAsync>>(new UserMessages.SetUserAvatar(id, image.FileName)); + if (!result.IsSuccess) + { + await ProfileImageStorage.TryDeleteAsync(fileStorage, image.FileName, "users", id, CancellationToken.None); + return result.ToHttpResult(resultMapper); + } + + var update = result.ValueOrDefault!; + await ProfileImageStorage.DeleteAsync(fileStorage, update.PreviousFileName, "users", id, cancellationToken); + return HttpResults.Ok(update.View); + } + catch + { + await ProfileImageStorage.TryDeleteAsync(fileStorage, image.FileName, "users", id, CancellationToken.None); + throw; + } + } + + private static async Task DeleteAvatarAsync(string id, IMediator mediator, IMediatorResultMapper resultMapper, [FromServices] IFileStorage fileStorage, CancellationToken cancellationToken) + { + var result = await mediator.InvokeAsync>>(new UserMessages.DeleteUserAvatar(id)); + if (!result.IsSuccess) + return result.ToHttpResult(resultMapper); + + var update = result.ValueOrDefault!; + await ProfileImageStorage.DeleteAsync(fileStorage, update.PreviousFileName, "users", id, cancellationToken); + return HttpResults.Ok(update.View); + } + + private static async Task GetAvatarAsync(string id, string fileName, [FromServices] IFileStorage fileStorage, CancellationToken cancellationToken) + { + if (!ProfileImageStorage.TryGetContentType(fileName, out string contentType)) + return HttpResults.NotFound(); + + var stream = await ProfileImageStorage.GetFileStreamAsync(fileStorage, fileName, "users", id, cancellationToken); + return stream is null ? HttpResults.NotFound() : HttpResults.File(stream, contentType); + } + + private static HttpIResult ValidationProblem(ModelStateDictionary modelState) + { + var errors = modelState + .Where(kvp => kvp.Value?.Errors.Count > 0) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()); + + return HttpResults.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/UtilityEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UtilityEndpoints.cs new file mode 100644 index 0000000000..9d31dbd552 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/UtilityEndpoints.cs @@ -0,0 +1,33 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Messages; +using Foundatio.Mediator; +using HttpResults = Microsoft.AspNetCore.Http.Results; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class UtilityEndpoints +{ + public static IEndpointRouteBuilder MapUtilityEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .ExcludeFromDescription(); + + group.MapGet("search/validate", async (IMediator mediator, string query) => + { + if (String.IsNullOrEmpty(query)) + return HttpResults.ValidationProblem(new Dictionary + { + ["query"] = ["The query field is required."] + }); + + var result = await mediator.InvokeAsync(new ValidateSearchQuery(query)); + return HttpResults.Ok(result); + }); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/WebHookEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/WebHookEndpoints.cs new file mode 100644 index 0000000000..a084e6db6d --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/WebHookEndpoints.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Mediator; +using Microsoft.AspNetCore.Mvc; +using WebHookMessages = Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Utility.OpenApi; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class WebHookEndpoints +{ + public static IEndpointRouteBuilder MapWebHookEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2") + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddEndpointFilter() + .WithTags("WebHook"); + + group.MapGet("projects/{projectId:objectid}/webhooks", async (string projectId, IMediator mediator, IMediatorResultMapper resultMapper, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new WebHookMessages.GetWebHooksByProject(projectId, page, limit))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by project") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["page"] = "The page parameter is used for pagination. This value must be greater than 0.", + ["limit"] = "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("webhooks/{id:objectid}", async (string id, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new WebHookMessages.GetWebHookById(id))).ToHttpResult(resultMapper)) + .WithName("GetWebHookById") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by id") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the web hook.", + }, + ResponseDescriptions = new() { + ["404"] = "The web hook could not be found.", + } + }); + + group.MapPost("webhooks", async (IMediator mediator, IMediatorResultMapper resultMapper, IServiceProvider serviceProvider, [FromBody] NewWebHook webHook) => + { + var validation = await ApiValidation.ValidateAsync(webHook, serviceProvider); + if (validation is not null) + return validation; + + return (await mediator.InvokeAsync>(new WebHookMessages.CreateWebHook(webHook))).ToHttpResult(resultMapper); + }) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithSummary("Create") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The web hook.", + ResponseDescriptions = new() { + ["201"] = "Created", + ["400"] = "An error occurred while creating the web hook.", + ["409"] = "The web hook already exists.", + } + }); + + group.MapDelete("webhooks/{ids:objectids}", async (string ids, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new WebHookMessages.DeleteWebHooks(ids.FromDelimitedString()))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status202Accepted) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Remove") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["ids"] = "A comma-delimited list of web hook identifiers.", + }, + ResponseDescriptions = new() { + ["202"] = "Accepted", + ["400"] = "One or more validation errors occurred.", + ["404"] = "One or more web hooks were not found.", + ["500"] = "An error occurred while deleting one or more web hooks.", + } + }); + + group.MapPost("webhooks/subscribe", async (IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync>(new WebHookMessages.SubscribeWebHook(data, 1))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + endpoints.MapPost("api/v{apiVersion:int}/webhooks/subscribe", async (int apiVersion, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync>(new WebHookMessages.SubscribeWebHook(data, apiVersion))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + group.MapPost("webhooks/unsubscribe", async (IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new WebHookMessages.UnsubscribeWebHook(data))).ToHttpResult(resultMapper)) + .AllowAnonymous() + .ExcludeFromDescription(); + + group.MapGet("webhooks/test", async (IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new WebHookMessages.TestWebHook())).ToHttpResult(resultMapper)) + .ExcludeFromDescription(); + + group.MapPost("webhooks/test", async (IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new WebHookMessages.TestWebHook())).ToHttpResult(resultMapper)) + .ExcludeFromDescription(); + + endpoints.MapPost("api/v1/projecthook/subscribe", async (IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync>(new WebHookMessages.SubscribeWebHook(data, 1))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + endpoints.MapPost("api/v1/projecthook/unsubscribe", async (IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new WebHookMessages.UnsubscribeWebHook(data))).ToHttpResult(resultMapper)) + .AllowAnonymous() + .ExcludeFromDescription(); + + endpoints.MapGet("api/v1/projecthook/test", async (IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new WebHookMessages.TestWebHook())).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + endpoints.MapPost("api/v1/projecthook/test", async (IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new WebHookMessages.TestWebHook())).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Filters/AutoValidationEndpointFilter.cs b/src/Exceptionless.Web/Api/Filters/AutoValidationEndpointFilter.cs new file mode 100644 index 0000000000..b2482bbe98 --- /dev/null +++ b/src/Exceptionless.Web/Api/Filters/AutoValidationEndpointFilter.cs @@ -0,0 +1,38 @@ +using Exceptionless.Core.Extensions; +using MiniValidation; + +namespace Exceptionless.Web.Api.Filters; + +/// +/// Endpoint filter that automatically validates all parameters with DataAnnotation attributes +/// using MiniValidation, equivalent to the old AutoValidationActionFilter for MVC controllers. +/// +public class AutoValidationEndpointFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var validatableArguments = context.Arguments + .Where(arg => arg is not null && ShouldValidate(arg.GetType())); + + foreach (var argument in validatableArguments) + { + if (!MiniValidator.TryValidate(argument!, out var errors)) + { + var normalizedErrors = errors.ToDictionary( + e => e.Key.ToLowerUnderscoredWords(), + e => e.Value); + + return Microsoft.AspNetCore.Http.Results.ValidationProblem(normalizedErrors, statusCode: StatusCodes.Status422UnprocessableEntity); + } + } + + return await next(context); + } + + private static bool ShouldValidate(Type type) => + !type.IsPrimitive + && type != typeof(string) + && !type.IsValueType + && type.Namespace?.StartsWith("Microsoft.") != true + && type.Namespace?.StartsWith("System.") != true; +} diff --git a/src/Exceptionless.Web/Api/Filters/ConfigurationResponseEndpointFilter.cs b/src/Exceptionless.Web/Api/Filters/ConfigurationResponseEndpointFilter.cs new file mode 100644 index 0000000000..61aa4ef37c --- /dev/null +++ b/src/Exceptionless.Web/Api/Filters/ConfigurationResponseEndpointFilter.cs @@ -0,0 +1,31 @@ +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Utility; + +namespace Exceptionless.Web.Api.Filters; + +public class ConfigurationResponseEndpointFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + // In Minimal API filters, the IResult hasn't been executed yet so httpContext.Response.StatusCode + // is still the default. Inspect the result object's status code directly. + if (result is IStatusCodeHttpResult { StatusCode: not (StatusCodes.Status200OK or StatusCodes.Status202Accepted) }) + return result; + + var httpContext = context.HttpContext; + var project = httpContext.Request.GetProject(); + if (project is null) + return result; + + string headerName = Headers.ConfigurationVersion; + if (httpContext.Request.Path.Value is not null && httpContext.Request.Path.Value.StartsWith("/api/v1")) + headerName = Headers.LegacyConfigurationVersion; + + // add the current configuration version to the response headers so the client will know if it should update its config. + httpContext.Response.Headers[headerName] = project.Configuration.Version.ToString(); + + return result; + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs new file mode 100644 index 0000000000..b62f92fdf0 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -0,0 +1,396 @@ +using Exceptionless.Core; +using Exceptionless.Core.Billing; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Utility; +using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models.Admin; +using Foundatio.Jobs; +using Foundatio.Messaging; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Repositories.Migrations; +using Foundatio.Storage; +using Foundatio.Mediator; + +namespace Exceptionless.Web.Api.Handlers; + +public class AdminHandler( + ExceptionlessElasticConfiguration configuration, + IFileStorage fileStorage, + IMessagePublisher messagePublisher, + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IStackRepository stackRepository, + IEventRepository eventRepository, + IUserRepository userRepository, + IQueue eventPostQueue, + IQueue workItemQueue, + AppOptions appOptions, + BillingManager billingManager, + BillingPlans plans, + IMigrationStateRepository migrationStateRepository, + SampleDataService sampleDataService, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + [HandlerEndpoint(HandlerMethod.Get, "settings", Group = "Admin")] + public Task> Handle(GetAdminSettings message) + { + return Task.FromResult>(appOptions); + } + + [HandlerEndpoint(HandlerMethod.Get, "stats", Group = "Admin")] + public async Task> Handle(GetAdminStats message) + { + var organizationCountTask = organizationRepository.CountAsync(q => q + .AggregationsExpression("terms:billing_status date:created_utc~1M")); + + var userCountTask = userRepository.CountAsync(); + var projectCountTask = projectRepository.CountAsync(); + + var stackCountTask = stackRepository.CountAsync(q => q + .AggregationsExpression("terms:status terms:(type terms:status)")); + + var eventCountTask = eventRepository.CountAsync(q => q + .AggregationsExpression("date:date~1M")); + + await Task.WhenAll(organizationCountTask, userCountTask, projectCountTask, stackCountTask, eventCountTask); + + return new AdminStatsResponse( + Organizations: await organizationCountTask, + Users: await userCountTask, + Projects: await projectCountTask, + Stacks: await stackCountTask, + Events: await eventCountTask + ); + } + + [HandlerEndpoint(HandlerMethod.Get, "migrations", Group = "Admin")] + public async Task> Handle(GetAdminMigrations message) + { + var result = await migrationStateRepository.GetAllAsync(o => o.SearchAfterPaging().PageLimit(1000)); + var migrationStates = new List(result.Documents.Count); + + while (result.Documents.Count > 0) + { + migrationStates.AddRange(result.Documents); + + if (!await result.NextPageAsync()) + break; + } + + var states = migrationStates + .OrderByDescending(s => s.Version) + .ThenByDescending(s => s.StartedUtc) + .ToArray(); + + int currentVersion = states + .Where(s => s.MigrationType != MigrationType.Repeatable && s.CompletedUtc.HasValue) + .Select(s => s.Version) + .DefaultIfEmpty(-1) + .Max(); + + return new MigrationsResponse(currentVersion, states); + } + + public Task> Handle(GetAdminEcho message) + { + var httpContext = message.Context; + return Task.FromResult>(new + { + httpContext.Request.Headers, + IpAddress = httpContext.Request.GetClientIpAddress() + }); + } + + [HandlerEndpoint(HandlerMethod.Get, "assemblies", Group = "Admin")] + public Task> Handle(GetAdminAssemblies message) + { + var details = AssemblyDetail.ExtractAll().Select(AssemblyDetailResponse.FromAssemblyDetail).ToArray(); + return Task.FromResult(Result.Success(details)); + } + + public async Task> Handle(AdminChangePlan message) + { + var httpContext = message.Context; + if (String.IsNullOrEmpty(message.OrganizationId) || !httpContext.Request.CanAccessOrganization(message.OrganizationId)) + return new ChangePlanResponse(false, "Invalid Organization Id."); + + var organization = await organizationRepository.GetByIdAsync(message.OrganizationId); + if (organization is null) + return new ChangePlanResponse(false, "Invalid Organization Id."); + + var plan = billingManager.GetBillingPlan(message.PlanId); + if (plan is null) + return new ChangePlanResponse(false, "Invalid PlanId."); + + organization.BillingStatus = !String.Equals(plan.Id, plans.FreePlan.Id) ? BillingStatus.Active : BillingStatus.Trialing; + organization.RemoveSuspension(); + var currentUser = httpContext.Request.GetUser(); + billingManager.ApplyBillingPlan(organization, plan, currentUser, false); + + await organizationRepository.SaveAsync(organization, o => o.Cache().Originals()); + await messagePublisher.PublishAsync(new PlanChanged + { + OrganizationId = organization.Id + }); + + return new ChangePlanResponse(true); + } + + public async Task Handle(AdminSetBonus message) + { + var httpContext = message.Context; + if (String.IsNullOrEmpty(message.OrganizationId) || !httpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.Invalid(ValidationError.Create("organizationId", "Invalid Organization Id")); + + var organization = await organizationRepository.GetByIdAsync(message.OrganizationId); + if (organization is null) + return Result.Invalid(ValidationError.Create("organizationId", "Invalid Organization Id")); + + billingManager.ApplyBonus(organization, message.BonusEvents, message.Expires); + await organizationRepository.SaveAsync(organization, o => o.Cache().Originals()); + + return Result.Success(); + } + + [HandlerEndpoint(HandlerMethod.Get, "requeue", Group = "Admin")] + public async Task> Handle(AdminRequeue message) + { + string path = message.Path ?? @"q\*"; + + int enqueued = 0; + foreach (var file in await fileStorage.GetFileListAsync(path)) + { + await eventPostQueue.EnqueueAsync(new EventPost(appOptions.EnableArchive && message.Archive) { FilePath = file.Path }); + enqueued++; + } + + return new { Enqueued = enqueued }; + } + + [HandlerEndpoint(HandlerMethod.Get, "maintenance/{name:minlength(1)}", Group = "Admin")] + public async Task Handle(AdminRunMaintenance message) + { + switch (message.Name.ToLowerInvariant()) + { + case "fix-stack-stats": + var effectiveUtcStart = message.UtcStart ?? timeProvider.GetUtcNow().UtcDateTime.AddDays(-90); + + if (message.UtcEnd.HasValue && message.UtcEnd.Value.IsBefore(effectiveUtcStart)) + return Result.Invalid(ValidationError.Create("utc_end", "utcEnd must be greater than or equal to utcStart.")); + + await workItemQueue.EnqueueAsync(new FixStackStatsWorkItem + { + UtcStart = effectiveUtcStart, + UtcEnd = message.UtcEnd, + OrganizationId = message.OrganizationId + }); + break; + case "increment-project-configuration-version": + await workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { IncrementConfigurationVersion = true }); + break; + case "indexes": + if (!appOptions.ElasticsearchOptions.DisableIndexConfiguration) + await configuration.ConfigureIndexesAsync(beginReindexingOutdated: false); + break; + case "normalize-user-email-address": + await workItemQueue.EnqueueAsync(new UserMaintenanceWorkItem { Normalize = true }); + break; + case "remove-old-organization-usage": + await workItemQueue.EnqueueAsync(new OrganizationMaintenanceWorkItem { RemoveOldUsageStats = true }); + break; + case "remove-old-project-usage": + await workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { RemoveOldUsageStats = true }); + break; + case "reset-verify-email-address-token-and-expiration": + await workItemQueue.EnqueueAsync(new UserMaintenanceWorkItem { ResetVerifyEmailAddressToken = true }); + break; + case "update-organization-plans": + await workItemQueue.EnqueueAsync(new OrganizationMaintenanceWorkItem { UpgradePlans = true }); + break; + case "update-project-default-bot-lists": + await workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { UpdateDefaultBotList = true, IncrementConfigurationVersion = true }); + break; + case "update-project-notification-settings": + await workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem + { + OrganizationId = message.OrganizationId + }); + break; + default: + return Result.NotFound("Maintenance action not found."); + } + + return Result.Success(); + } + + [HandlerEndpoint(HandlerMethod.Get, "elasticsearch", Group = "Admin")] + public async Task> Handle(GetAdminElasticsearch message) + { + var client = configuration.Client; + var healthTask = client.Cluster.HealthAsync(r => r.Level(Elastic.Clients.Elasticsearch.Level.Indices)); + var statsTask = client.Cluster.StatsAsync(); + var indicesStatsTask = client.Indices.StatsAsync(); + await Task.WhenAll(healthTask, statsTask, indicesStatsTask); + + var healthResponse = await healthTask; + var statsResponse = await statsTask; + var indicesStatsResponse = await indicesStatsTask; + + if (!healthResponse.IsValidResponse || !statsResponse.IsValidResponse || !indicesStatsResponse.IsValidResponse) + return Result.Error("Elasticsearch cluster information is unavailable."); + + var unassignedByIndex = (healthResponse.Indices ?? new Dictionary()) + .Where(kvp => kvp.Value.UnassignedShards > 0) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.UnassignedShards, StringComparer.OrdinalIgnoreCase); + + var indexDetails = (indicesStatsResponse.Indices ?? new Dictionary()) + .OrderByDescending(kvp => kvp.Value.Total?.Store?.SizeInBytes ?? 0) + .Select(kvp => new ElasticsearchIndexDetailResponse( + Index: kvp.Key, + Health: kvp.Value.Health?.ToString().ToLowerInvariant(), + Status: kvp.Value.Status?.ToString().ToLowerInvariant(), + Primary: healthResponse.Indices?.GetValueOrDefault(kvp.Key)?.NumberOfShards ?? 0, + Replica: healthResponse.Indices?.GetValueOrDefault(kvp.Key)?.NumberOfReplicas ?? 0, + DocsCount: kvp.Value.Total?.Docs?.Count ?? 0, + StoreSizeInBytes: kvp.Value.Total?.Store?.SizeInBytes ?? 0, + UnassignedShards: unassignedByIndex.GetValueOrDefault(kvp.Key, 0) + )) + .ToArray(); + + return new ElasticsearchInfoResponse( + Health: new ElasticsearchHealthResponse( + Status: (int)healthResponse.Status, + ClusterName: healthResponse.ClusterName, + NumberOfNodes: healthResponse.NumberOfNodes, + NumberOfDataNodes: healthResponse.NumberOfDataNodes, + ActiveShards: healthResponse.ActiveShards, + RelocatingShards: healthResponse.RelocatingShards, + UnassignedShards: healthResponse.UnassignedShards, + ActivePrimaryShards: healthResponse.ActivePrimaryShards + ), + Indices: new ElasticsearchIndicesResponse( + Count: statsResponse.Indices.Count, + DocsCount: statsResponse.Indices.Docs.Count, + StoreSizeInBytes: statsResponse.Indices.Store.SizeInBytes + ), + IndexDetails: indexDetails + ); + } + + [HandlerEndpoint(HandlerMethod.Get, "elasticsearch/snapshots", Group = "Admin")] + public async Task> Handle(GetAdminElasticsearchSnapshots message) + { + var client = configuration.Client; + try + { + var repositoryResponse = await client.Snapshot.GetRepositoryAsync(); + if (!repositoryResponse.IsValidResponse) + return Result.Error("Snapshot repository information is unavailable."); + + if (repositoryResponse.Repositories is null || !repositoryResponse.Repositories.Any()) + return new ElasticsearchSnapshotsResponse([], []); + + var repositoryNames = repositoryResponse.Repositories.Select(r => r.Key).ToArray(); + + var snapshotTasks = repositoryNames + .Select(async repositoryName => + { + var snapshotResponse = await client.Snapshot.GetAsync(repositoryName, "*"); + if (!snapshotResponse.IsValidResponse) + return ( + RepositoryName: repositoryName, + Snapshots: Array.Empty(), + Error: $"Unable to retrieve snapshots for repository: {repositoryName}." + ); + + var snapshots = snapshotResponse.Snapshots?.ToArray() ?? []; + return ( + RepositoryName: repositoryName, + Snapshots: snapshots.Select(s => new ElasticsearchSnapshotResponse( + Repository: repositoryName, + Name: s.Snapshot, + Status: s.State ?? String.Empty, + StartTime: s.StartTime?.UtcDateTime, + EndTime: s.EndTime?.UtcDateTime, + Duration: s.Duration?.ToString() ?? String.Empty, + IndicesCount: s.Indices?.Count ?? 0, + SuccessfulShards: s.Shards?.Successful ?? 0, + FailedShards: s.Shards?.Failed ?? 0, + TotalShards: s.Shards?.Total ?? 0 + )).ToArray(), + Error: (string?)null + ); + }) + .ToArray(); + + var snapshotResults = await Task.WhenAll(snapshotTasks); + + var failedSnapshotResults = snapshotResults + .Where(r => r.Error is not null) + .ToArray(); + + if (failedSnapshotResults.Length is > 0) + { + _logger.LogWarning("Unable to retrieve snapshots for one or more repositories: {Repositories}", + String.Join(", ", failedSnapshotResults.Select(r => r.RepositoryName))); + } + + var successfulSnapshotResults = snapshotResults + .Where(r => r.Error is null) + .ToArray(); + + if (successfulSnapshotResults.Length is 0) + return Result.Error("Unable to retrieve snapshot information."); + + var snapshots = successfulSnapshotResults + .SelectMany(r => r.Snapshots) + .OrderByDescending(s => s.StartTime) + .ToArray(); + + var successfulRepositoryNames = successfulSnapshotResults + .Select(r => r.RepositoryName) + .ToArray(); + + return new ElasticsearchSnapshotsResponse(successfulRepositoryNames, snapshots); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Unable to retrieve snapshot information"); + return Result.Error("Unable to retrieve snapshot information."); + } + catch (TimeoutException ex) + { + _logger.LogError(ex, "Unable to retrieve snapshot information"); + return Result.Error("Unable to retrieve snapshot information."); + } + } + + [HandlerEndpoint(HandlerMethod.Post, "generate-sample-events", Group = "Admin")] + public async Task> Handle(AdminGenerateSampleEvents message) + { + if (message.EventCount < 1 || message.EventCount > 10000) + return Result.Invalid(ValidationError.Create("eventCount", "Event count must be between 1 and 10,000.")); + + if (message.DaysBack < 1 || message.DaysBack > 365) + return Result.Invalid(ValidationError.Create("daysBack", "Days back must be between 1 and 365.")); + + await sampleDataService.EnqueueSampleEventsAsync(message.EventCount, message.DaysBack); + return new { Success = true, Message = $"Enqueued generation of {message.EventCount} sample events over {message.DaysBack} days. Events will appear shortly." }; + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs new file mode 100644 index 0000000000..cccbda5985 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -0,0 +1,753 @@ +using System.Configuration; +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using Exceptionless.Core.Authentication; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Mail; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Foundatio.Caching; +using Foundatio.Mediator; +using Foundatio.Repositories; +using Microsoft.IdentityModel.Tokens; +using OAuth2.Client; +using OAuth2.Client.Impl; +using OAuth2.Configuration; +using OAuth2.Infrastructure; +using OAuth2.Models; + +namespace Exceptionless.Web.Api.Handlers; + +public class AuthHandler( + AuthOptions authOptions, + IntercomOptions intercomOptions, + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + ITokenRepository tokenRepository, + ICacheClient cacheClient, + IMailer mailer, + IDomainLoginProvider domainLoginProvider, + TimeProvider timeProvider, + ILogger logger) +{ + private readonly ScopedCacheClient _cache = new(cacheClient, "Auth"); + private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60); + + public async Task> Handle(LoginMessage message) + { + var httpContext = message.Context; + var model = message.Model; + string email = model.Email.Trim().ToLowerInvariant(); + using var _ = logger.BeginScope(new ExceptionlessState().Tag("Login").Identity(email).SetHttpContext(httpContext)); + + string userLoginAttemptsCacheKey = $"user:{email}:attempts"; + long userLoginAttempts = await _cache.IncrementAsync(userLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); + + string ipLoginAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:attempts"; + long ipLoginAttempts = await _cache.IncrementAsync(ipLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); + + if (userLoginAttempts > 5) + { + logger.LogError("Login denied for {EmailAddress} for the {UserLoginAttempts} time", email, userLoginAttempts); + return Result.Unauthorized("Login denied."); + } + + if (ipLoginAttempts > 15) + { + logger.LogError("Login denied for {EmailAddress} for the {IPLoginAttempts} time", httpContext.Request.GetClientIpAddress(), ipLoginAttempts); + return Result.Unauthorized("Login denied."); + } + + User? user; + try + { + user = await userRepository.GetByEmailAddressAsync(email); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Login failed for {EmailAddress}: {Message}", email, ex.Message); + return Result.Unauthorized("Login failed."); + } + + if (user is null) + { + logger.LogError("Login failed for {EmailAddress}: User not found", email); + return Result.Unauthorized("Login failed."); + } + + if (!user.IsActive) + { + logger.LogError("Login failed for {EmailAddress}: The user is inactive", user.EmailAddress); + return Result.Unauthorized("Login failed."); + } + + if (!authOptions.EnableActiveDirectoryAuth) + { + if (String.IsNullOrEmpty(user.Salt)) + { + logger.LogError("Login failed for {EmailAddress}: The user has no salt defined", user.EmailAddress); + return Result.Unauthorized("Login failed."); + } + + if (!user.IsCorrectPassword(model.Password)) + { + logger.LogError("Login failed for {EmailAddress}: Invalid Password", user.EmailAddress); + return Result.Unauthorized("Login failed."); + } + } + else if (!IsValidActiveDirectoryLogin(email, model.Password)) + { + logger.LogError("Domain login failed for {EmailAddress}: Invalid Password or Account", user.EmailAddress); + return Result.Unauthorized("Login failed."); + } + + if (!String.IsNullOrEmpty(model.InviteToken)) + await AddInvitedUserToOrganizationAsync(model.InviteToken, user, httpContext); + + await _cache.RemoveAsync(userLoginAttemptsCacheKey); + await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); + + logger.UserLoggedIn(user.EmailAddress); + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; + } + + public Task> Handle(GetIntercomToken message) + { + var httpContext = message.Context; + + if (!intercomOptions.EnableIntercom || String.IsNullOrWhiteSpace(intercomOptions.IntercomSecret)) + return Task.FromResult(TokenValidationProblem("intercom", "Intercom is not enabled.")); + + var currentUser = httpContext.Request.GetUser(); + var issuedAt = timeProvider.GetUtcNow(); + var expiresAt = issuedAt.Add(IntercomJwtLifetime); + + var signingCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(intercomOptions.IntercomSecret!)), + SecurityAlgorithms.HmacSha256 + ); + + var token = new JwtSecurityToken( + header: new JwtHeader(signingCredentials), + payload: new JwtPayload + { + [JwtRegisteredClaimNames.Exp] = expiresAt.ToUnixTimeSeconds(), + [JwtRegisteredClaimNames.Iat] = issuedAt.ToUnixTimeSeconds(), + ["user_id"] = currentUser.Id, + } + ); + + return Task.FromResult>(new TokenResult { Token = new JwtSecurityTokenHandler().WriteToken(token) }); + } + + public async Task Handle(LogoutMessage message) + { + var httpContext = message.Context; + var currentUser = httpContext.Request.GetUser(); + using var _ = logger.BeginScope(new ExceptionlessState().Tag("Logout").Identity(currentUser.EmailAddress).SetHttpContext(httpContext)); + + if (httpContext.User.IsTokenAuthType()) + return Result.Forbidden("Logout not supported for current user access token"); + + string? id = httpContext.User.GetLoggedInUsersTokenId(); + if (String.IsNullOrEmpty(id)) + return Result.Forbidden("Logout not supported"); + + try + { + await tokenRepository.RemoveAsync(id); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Logout failed for {EmailAddress}: {Message}", currentUser.EmailAddress, ex.Message); + throw; + } + + return Result.Success(); + } + + public async Task> Handle(SignupMessage message) + { + var httpContext = message.Context; + var model = message.Model; + string email = model.Email.Trim().ToLowerInvariant(); + using var _ = logger.BeginScope(new ExceptionlessState().Tag("Signup").Identity(email).Property("Name", model.Name).Property("Password Length", model.Password.Length).SetHttpContext(httpContext)); + + bool valid = await IsAccountCreationEnabledAsync(model.InviteToken); + if (!valid) + return Result.Forbidden("Account Creation is currently disabled"); + + User? user; + try + { + user = await userRepository.GetByEmailAddressAsync(email); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Signup failed for {EmailAddress}: {Message}", email, ex.Message); + throw; + } + + if (user is not null) + return await Handle(new LoginMessage(model, httpContext)); + + string ipSignupAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:signup:attempts"; + bool hasValidInviteToken = !String.IsNullOrWhiteSpace(model.InviteToken) && await organizationRepository.GetByInviteTokenAsync(model.InviteToken) is not null; + if (!hasValidInviteToken) + { + long ipSignupAttempts = await _cache.IncrementAsync(ipSignupAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); + if (ipSignupAttempts > 10) + { + logger.LogError("Signup denied for {EmailAddress} for the {IPSignupAttempts} time", email, ipSignupAttempts); + return Result.Unauthorized("Signup denied."); + } + } + + if (authOptions.EnableActiveDirectoryAuth && !IsValidActiveDirectoryLogin(email, model.Password)) + { + logger.LogError("Signup failed for {EmailAddress}: Active Directory authentication failed", email); + return Result.Unauthorized("Signup failed."); + } + + user = new User + { + IsActive = true, + FullName = model.Name.Trim(), + EmailAddress = email, + IsEmailAddressVerified = authOptions.EnableActiveDirectoryAuth + }; + + if (user.IsEmailAddressVerified) + user.MarkEmailAddressVerified(); + else + user.ResetVerifyEmailAddressTokenAndExpiration(timeProvider); + + user.Roles.Add(AuthorizationRoles.Client); + user.Roles.Add(AuthorizationRoles.User); + await AddGlobalAdminRoleIfFirstUserAsync(user); + + if (!authOptions.EnableActiveDirectoryAuth) + { + user.Salt = Core.Extensions.StringExtensions.GetRandomString(16); + user.Password = model.Password.ToSaltedHash(user.Salt); + } + + try + { + user = await userRepository.AddAsync(user, o => o.Cache()); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Signup failed for {EmailAddress}: {Message}", email, ex.Message); + throw; + } + + if (hasValidInviteToken) + await AddInvitedUserToOrganizationAsync(model.InviteToken, user, httpContext); + + if (!user.IsEmailAddressVerified) + await mailer.SendUserEmailVerifyAsync(user); + + logger.UserSignedUp(user.EmailAddress); + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; + } + + public Task> Handle(GitHubLogin message) + { + return ExternalLoginAsync(message.AuthInfo, message.Context, + authOptions.GitHubId, + authOptions.GitHubSecret, + (factory, configuration) => + { + configuration.Scope = "user:email"; + return new GitHubClient(factory, configuration); + } + ); + } + + public Task> Handle(GoogleLogin message) + { + return ExternalLoginAsync(message.AuthInfo, message.Context, + authOptions.GoogleId, + authOptions.GoogleSecret, + (factory, configuration) => + { + configuration.Scope = "profile email"; + return new GoogleClient(factory, configuration); + } + ); + } + + public Task> Handle(FacebookLogin message) + { + return ExternalLoginAsync(message.AuthInfo, message.Context, + authOptions.FacebookId, + authOptions.FacebookSecret, + (factory, configuration) => + { + configuration.Scope = "email"; + return new FacebookClient(factory, configuration); + } + ); + } + + public Task> Handle(LiveLogin message) + { + return ExternalLoginAsync(message.AuthInfo, message.Context, + authOptions.MicrosoftId, + authOptions.MicrosoftSecret, + (factory, configuration) => + { + configuration.Scope = "wl.emails"; + return new WindowsLiveClient(factory, configuration); + } + ); + } + + public async Task> Handle(RemoveExternalLogin message) + { + var httpContext = message.Context; + var user = httpContext.Request.GetUser(); + using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(message.ProviderName).Identity(user.EmailAddress).Property("User", user).Property("Provider User Id", message.ProviderUserId?.Value).SetHttpContext(httpContext)); + + if (String.IsNullOrWhiteSpace(message.ProviderName) || String.IsNullOrWhiteSpace(message.ProviderUserId?.Value)) + { + logger.LogError("Remove external login failed for {EmailAddress}: Invalid Provider Name or Provider User Id", user.EmailAddress); + return Result.BadRequest("Invalid Provider Name or Provider User Id."); + } + + if (user.OAuthAccounts.Count <= 1 && String.IsNullOrEmpty(user.Password)) + { + logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); + return Result.BadRequest("You must set a local password before removing your external login."); + } + + try + { + if (user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) + await userRepository.SaveAsync(user, o => o.Cache()); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Error removing external login for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); + throw; + } + + await ResetUserTokensAsync(user, "RemoveExternalLoginAsync", httpContext); + + logger.UserRemovedExternalLogin(user.EmailAddress, message.ProviderName); + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; + } + + public async Task> Handle(ChangePassword message) + { + var httpContext = message.Context; + var model = message.Model; + var user = httpContext.Request.GetUser(); + using var _ = logger.BeginScope(new ExceptionlessState().Tag("Change Password").Identity(user.EmailAddress).Property("User", user).Property("Password Length", model.Password?.Length ?? 0).SetHttpContext(httpContext)); + + if (!String.IsNullOrWhiteSpace(user.Password)) + { + if (String.IsNullOrWhiteSpace(model.CurrentPassword)) + { + logger.LogError("Change password failed for {EmailAddress}: The current password is incorrect", user.EmailAddress); + return TokenValidationProblem("current_password", "The current password is incorrect."); + } + + string encodedPassword = model.CurrentPassword.ToSaltedHash(user.Salt!); + if (!String.Equals(encodedPassword, user.Password)) + { + logger.LogError("Change password failed for {EmailAddress}: The current password is incorrect", user.EmailAddress); + return TokenValidationProblem("current_password", "The current password is incorrect."); + } + + string newPasswordHash = model.Password!.ToSaltedHash(user.Salt!); + if (String.Equals(newPasswordHash, user.Password)) + { + logger.LogError("Change password failed for {EmailAddress}: The new password is the same as the current password", user.EmailAddress); + return TokenValidationProblem("password", "The new password must be different than the previous password."); + } + } + + await ChangePasswordAsync(user, model.Password!, nameof(ChangePasswordAsync), httpContext); + await ResetUserTokensAsync(user, nameof(ChangePasswordAsync), httpContext); + + string userLoginAttemptsCacheKey = $"user:{user.EmailAddress}:attempts"; + await _cache.RemoveAsync(userLoginAttemptsCacheKey); + + string ipLoginAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:attempts"; + long attempts = await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); + if (attempts <= 0) + await _cache.RemoveAsync(ipLoginAttemptsCacheKey); + + logger.UserChangedPassword(user.EmailAddress); + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; + } + + public async Task Handle(CheckEmailAddress message) + { + var httpContext = message.Context; + string email = message.Email; + + if (String.IsNullOrWhiteSpace(email)) + return Result.NoContent(); + + email = email.Trim().ToLowerInvariant(); + if (httpContext.User.IsUserAuthType() && String.Equals(httpContext.Request.GetUser().EmailAddress, email, StringComparison.InvariantCultureIgnoreCase)) + return Result.Created(); + + string ipEmailAddressAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:email:attempts"; + long attempts = await _cache.IncrementAsync(ipEmailAddressAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); + + if (attempts > 3 || await userRepository.GetByEmailAddressAsync(email) is null) + return Result.NoContent(); + + return Result.Created(); + } + + public async Task Handle(ForgotPassword message) + { + var httpContext = message.Context; + string email = message.Email; + using var _ = logger.BeginScope(new ExceptionlessState().Tag("Forgot Password").Identity(email).SetHttpContext(httpContext)); + + if (String.IsNullOrWhiteSpace(email)) + { + logger.LogError("Forgot password failed: Please specify a valid Email Address"); + return Result.BadRequest("Please specify a valid Email Address."); + } + + string ipResetPasswordAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:password:attempts"; + long attempts = await _cache.IncrementAsync(ipResetPasswordAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); + if (attempts > 3) + { + logger.LogError("Login denied for {EmailAddress} for the {ResetPasswordAttempts} time", email, attempts); + return Result.Success(); + } + + email = email.Trim().ToLowerInvariant(); + var user = await userRepository.GetByEmailAddressAsync(email); + if (user is null) + { + logger.LogError("Forgot password failed for {EmailAddress}: No user was found", email); + return Result.Success(); + } + + user.CreatePasswordResetToken(timeProvider); + await userRepository.SaveAsync(user, o => o.Cache()); + + await mailer.SendUserPasswordResetAsync(user); + logger.UserForgotPassword(user.EmailAddress); + return Result.Success(); + } + + public async Task Handle(ResetPassword message) + { + var httpContext = message.Context; + var model = message.Model; + var user = await userRepository.GetByPasswordResetTokenAsync(model.PasswordResetToken); + using var _ = logger.BeginScope(new ExceptionlessState().Tag("Reset Password").Identity(user?.EmailAddress).Property("User", user).Property("Password Length", model.Password?.Length ?? 0).SetHttpContext(httpContext)); + + if (user is null) + { + logger.LogError("Reset password failed: Invalid Password Reset Token"); + return Result.Invalid(ValidationError.Create("password_reset_token", "Invalid Password Reset Token")); + } + + if (!user.HasValidPasswordResetTokenExpiration(timeProvider)) + { + logger.LogError("Reset password failed for {EmailAddress}: Password Reset Token has expired", user.EmailAddress); + return Result.Invalid(ValidationError.Create("password_reset_token", "Password Reset Token has expired")); + } + + if (!String.IsNullOrWhiteSpace(user.Password)) + { + string newPasswordHash = model.Password!.ToSaltedHash(user.Salt!); + if (String.Equals(newPasswordHash, user.Password)) + { + logger.LogError("Reset password failed for {EmailAddress}: The new password is the same as the current password", user.EmailAddress); + return Result.Invalid(ValidationError.Create("password", "The new password must be different than the previous password")); + } + } + + user.MarkEmailAddressVerified(); + await ChangePasswordAsync(user, model.Password!, "ResetPasswordAsync", httpContext); + await ResetUserTokensAsync(user, "ResetPasswordAsync", httpContext); + + string userLoginAttemptsCacheKey = $"user:{user.EmailAddress}:attempts"; + await _cache.RemoveAsync(userLoginAttemptsCacheKey); + + string ipLoginAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:attempts"; + long attempts = await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); + if (attempts <= 0) + await _cache.RemoveAsync(ipLoginAttemptsCacheKey); + + logger.UserResetPassword(user.EmailAddress); + return Result.Success(); + } + + public async Task Handle(CancelResetPassword message) + { + var httpContext = message.Context; + string token = message.Token; + + if (String.IsNullOrEmpty(token)) + { + using (logger.BeginScope(new ExceptionlessState().Tag("Cancel Reset Password").SetHttpContext(httpContext))) + logger.LogError("Cancel reset password failed: Invalid Password Reset Token"); + + return Result.BadRequest("Invalid password reset token."); + } + + var user = await userRepository.GetByPasswordResetTokenAsync(token); + if (user is null) + return Result.Success(); + + user.ResetPasswordResetToken(); + await userRepository.SaveAsync(user, o => o.Cache()); + + using (logger.BeginScope(new ExceptionlessState().Tag("Cancel Reset Password").Identity(user.EmailAddress).Property("User", user).SetHttpContext(httpContext))) + logger.UserCanceledResetPassword(user.EmailAddress); + + return Result.Success(); + } + + private async Task AddGlobalAdminRoleIfFirstUserAsync(User user) + { + bool isFirstUser = await userRepository.CountAsync() == 0; + if (isFirstUser) + user.Roles.Add(AuthorizationRoles.GlobalAdmin); + } + + private async Task> ExternalLoginAsync(ExternalAuthInfo authInfo, HttpContext httpContext, string? appId, string? appSecret, Func createClient) where TClient : OAuth2Client + { + using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").SetHttpContext(httpContext)); + if (String.IsNullOrEmpty(appId) || String.IsNullOrEmpty(appSecret)) + throw new ConfigurationErrorsException("Missing Configuration for OAuth provider"); + + var client = createClient(new RequestFactory(), new OAuth2.Configuration.ClientConfiguration + { + ClientId = appId, + ClientSecret = appSecret, + RedirectUri = authInfo.RedirectUri + }); + + UserInfo userInfo; + try + { + userInfo = await client.GetUserInfoAsync(authInfo.Code, authInfo.RedirectUri); + } + catch (Exception ex) + { + logger.LogCritical(ex, "External login failed Code={AuthCode} RedirectUri={AuthRedirectUri}: {Message}", authInfo.Code, authInfo.RedirectUri, ex.Message); + throw; + } + + User? user; + try + { + user = await FromExternalLoginAsync(userInfo, httpContext); + } + catch (ApplicationException ex) + { + logger.LogCritical(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); + return Result.Forbidden("Account Creation is currently disabled"); + } + catch (Exception ex) + { + logger.LogCritical(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); + throw; + } + + if (!String.IsNullOrWhiteSpace(authInfo.InviteToken)) + await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user, httpContext); + + logger.UserLoggedIn(user.EmailAddress); + return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; + } + + private async Task FromExternalLoginAsync(UserInfo userInfo, HttpContext httpContext) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); + ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); + + var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id); + using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Property("User Info", userInfo).Property("ExistingUser", existingUser).SetHttpContext(httpContext)); + + if (httpContext.User.IsUserAuthType()) + { + var currentUser = httpContext.Request.GetUser(); + if (existingUser is not null) + { + if (existingUser.Id != currentUser.Id) + { + if (!existingUser.RemoveOAuthAccount(userInfo.ProviderName, userInfo.Id)) + throw new Exception($"Unable to remove existing oauth account for existing user: {existingUser.EmailAddress}"); + + await userRepository.SaveAsync(existingUser, o => o.Cache()); + } + else + { + return currentUser; + } + } + + currentUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); + return await userRepository.SaveAsync(currentUser, o => o.Cache()); + } + + if (existingUser is not null) + { + if (!existingUser.IsEmailAddressVerified) + { + existingUser.MarkEmailAddressVerified(); + await userRepository.SaveAsync(existingUser, o => o.Cache()); + } + + return existingUser; + } + + var user = !String.IsNullOrEmpty(userInfo.Email) ? await userRepository.GetByEmailAddressAsync(userInfo.Email) : null; + if (user is null) + { + if (!authOptions.EnableAccountCreation) + throw new ApplicationException("Account Creation is currently disabled."); + + user = new User { FullName = userInfo.GetFullName()!, EmailAddress = userInfo.Email }; + user.Roles.Add(AuthorizationRoles.Client); + user.Roles.Add(AuthorizationRoles.User); + await AddGlobalAdminRoleIfFirstUserAsync(user); + } + + user.MarkEmailAddressVerified(); + user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); + + if (String.IsNullOrEmpty(user.Id)) + await userRepository.AddAsync(user, o => o.Cache()); + else + await userRepository.SaveAsync(user, o => o.Cache()); + + return user; + } + + private async Task IsAccountCreationEnabledAsync(string? token) + { + if (authOptions.EnableAccountCreation) + return true; + + if (String.IsNullOrEmpty(token)) + return false; + + var organization = await organizationRepository.GetByInviteTokenAsync(token); + return organization is not null; + } + + private async Task AddInvitedUserToOrganizationAsync(string? token, User user, HttpContext httpContext) + { + if (String.IsNullOrWhiteSpace(token)) + return; + + using var _ = logger.BeginScope(new ExceptionlessState().Tag("Invite").Identity(user.EmailAddress).Property("User", user).SetHttpContext(httpContext)); + var organization = await organizationRepository.GetByInviteTokenAsync(token); + var invite = organization?.GetInvite(token); + if (organization is null || invite is null) + { + logger.UnableToAddInvitedUserInvalidToken(user.EmailAddress, token); + return; + } + + if (!user.IsEmailAddressVerified && String.Equals(user.EmailAddress, invite.EmailAddress, StringComparison.OrdinalIgnoreCase)) + { + logger.MarkedInvitedUserAsVerified(user.EmailAddress); + user.MarkEmailAddressVerified(); + await userRepository.SaveAsync(user, o => o.Cache()); + } + + if (!user.OrganizationIds.Contains(organization.Id)) + { + logger.UserJoinedFromInvite(user.EmailAddress); + user.OrganizationIds.Add(organization.Id); + await userRepository.SaveAsync(user, o => o.Cache()); + } + + organization.Invites.Remove(invite); + await organizationRepository.SaveAsync(organization, o => o.Cache()); + } + + private async Task ChangePasswordAsync(User user, string password, string tag, HttpContext httpContext) + { + using var _ = logger.BeginScope(new ExceptionlessState().Tag(tag).Identity(user.EmailAddress).SetHttpContext(httpContext)); + if (String.IsNullOrEmpty(user.Salt)) + user.Salt = Core.Extensions.StringExtensions.GetNewToken(); + + user.Password = password.ToSaltedHash(user.Salt); + user.ResetPasswordResetToken(); + + try + { + await userRepository.SaveAsync(user, o => o.Cache()); + logger.ChangedUserPassword(user.EmailAddress); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Error changing password for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); + throw; + } + } + + private async Task ResetUserTokensAsync(User user, string tag, HttpContext httpContext) + { + using var _ = logger.BeginScope(new ExceptionlessState().Tag(tag).Identity(user.EmailAddress).SetHttpContext(httpContext)); + try + { + long total = await tokenRepository.RemoveAllByUserIdAsync(user.Id); + logger.RemovedUserTokens(total, user.EmailAddress); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Error removing user tokens for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); + } + } + + private async Task GetOrCreateAuthenticationTokenAsync(User user) + { + var userTokens = await tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id); + + var utcNow = timeProvider.GetUtcNow().UtcDateTime; + var validAccessToken = userTokens.Documents.FirstOrDefault(token => !token.ExpiresUtc.HasValue || token.ExpiresUtc > utcNow); + if (validAccessToken is not null) + return validAccessToken.Id; + + var token = await tokenRepository.AddAsync(new Token + { + Id = Core.Extensions.StringExtensions.GetNewToken(), + UserId = user.Id, + CreatedUtc = utcNow, + UpdatedUtc = utcNow, + ExpiresUtc = utcNow.AddMonths(3), + CreatedBy = user.Id, + Type = TokenType.Authentication + }, o => o.Cache()); + + return token.Id; + } + + private bool IsValidActiveDirectoryLogin(string email, string? password) + { + if (String.IsNullOrEmpty(password)) + return false; + + string? domainUsername = domainLoginProvider.GetUsernameFromEmailAddress(email); + return domainUsername is not null && domainLoginProvider.Login(domainUsername, password); + } + + private static Result TokenValidationProblem(string key, string error) + => Result.Invalid(ValidationError.Create(key.ToLowerUnderscoredWords(), error)); +} diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs new file mode 100644 index 0000000000..36b6d7f5c7 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -0,0 +1,1064 @@ +using System.Text; +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Geo; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Plugins.Formatting; +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Base; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Repositories.Queries; +using Exceptionless.Core.Services; +using Exceptionless.Core.Validation; +using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Caching; +using Foundatio.Mediator; +using Foundatio.Queues; +using Foundatio.Serializer; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Foundatio.Repositories; +using Foundatio.Repositories.Elasticsearch.Extensions; +using Foundatio.Repositories.Extensions; +using Foundatio.Repositories.Models; +using Microsoft.Net.Http.Headers; +using PermissionResult = Exceptionless.Web.Controllers.PermissionResult; + +namespace Exceptionless.Web.Api.Handlers; + +public class EventHandler( + IEventRepository eventRepository, + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IStackRepository stackRepository, + EventPostService eventPostService, + IQueue eventUserDescriptionQueue, + MiniValidationValidator miniValidationValidator, + FormattingPluginManager formattingPluginManager, + ICacheClient cacheClient, + ITextSerializer serializer, + IAppQueryValidator validator, + AppOptions appOptions, + UsageService usageService, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) +{ + private static readonly HashSet _ignoredKeys = new(StringComparer.OrdinalIgnoreCase) { "access_token", "api_key", "apikey" }; + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private static readonly ICollection _allowedDateFields = new List { EventIndex.Alias.Date }; + private const string DefaultDateField = EventIndex.Alias.Date; + private static Result PlanLimitResult(string message) => Result.Invalid(ValidationError.Create("plan_limit", message)); + + public async Task> Handle(GetEventCount message) + { + var httpContext = message.Context; + var organizations = await GetSelectedOrganizationsAsync(httpContext, message.Filter); + if (organizations.All(o => o.IsSuspended)) + return CountResult.Empty; + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organizations.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + return await CountInternalAsync(sf, ti, httpContext, message.Filter, message.Aggregations, message.Mode); + } + + public async Task> Handle(GetEventCountByOrganization message) + { + var httpContext = message.Context; + var organization = await GetOrganizationAsync(message.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organization); + return await CountInternalAsync(sf, ti, httpContext, message.Filter, message.Aggregations, message.Mode); + } + + public async Task> Handle(GetEventCountByProject message) + { + var httpContext = message.Context; + var project = await GetProjectAsync(message.ProjectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + var organization = await GetOrganizationAsync(project.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(project, appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(project, organization); + return await CountInternalAsync(sf, ti, httpContext, message.Filter, message.Aggregations, message.Mode); + } + + public async Task> Handle(GetEventById message) + { + var httpContext = message.Context; + var model = await GetModelAsync(message.Id, httpContext, false); + if (model is null) + return Result.NotFound("Event not found."); + + string? expectedStackId = httpContext.Request.Query["expected_stack_id"].FirstOrDefault(); + if (!String.IsNullOrEmpty(expectedStackId) && !String.Equals(model.StackId, expectedStackId, StringComparison.Ordinal)) + return Result.BadRequest($"The event \"{model.Id}\" belongs to stack \"{model.StackId}\", not stack \"{expectedStackId}\". Open the event from its current stack."); + + var organization = await GetOrganizationAsync(model.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended || organization.RetentionDays > 0 && model.Date.UtcDateTime < timeProvider.GetUtcNow().UtcDateTime.SubtractDays(organization.RetentionDays)) + return PlanLimitResult("Unable to view event occurrence due to plan limits."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organization); + var result = await eventRepository.GetPreviousAndNextEventIdsAsync(model, sf, ti.Range.UtcStart, ti.Range.UtcEnd); + + var links = new List(); + if (!String.IsNullOrEmpty(result.Previous)) + links.Add($"; rel=\"previous\""); + if (!String.IsNullOrEmpty(result.Next)) + links.Add($"; rel=\"next\""); + links.Add($"; rel=\"parent\""); + + if (links.Count > 0) + httpContext.Response.Headers[HeaderNames.Link] = links.ToArray(); + + return model; + } + + public async Task>> Handle(GetAllEvents message) + { + var httpContext = message.Context; + var organizations = await GetSelectedOrganizationsAsync(httpContext, message.Filter); + if (organizations.All(o => o.IsSuspended)) + return new PagedResult(Array.Empty(), false); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organizations.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + return await GetInternalAsync(sf, ti, httpContext, message.Filter, message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After); + } + + public async Task>> Handle(GetEventsByOrganization message) + { + var httpContext = message.Context; + var organization = await GetOrganizationAsync(message.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organization); + return await GetInternalAsync(sf, ti, httpContext, message.Filter, message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After); + } + + public async Task>> Handle(GetEventsByProject message) + { + var httpContext = message.Context; + var project = await GetProjectAsync(message.ProjectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + var organization = await GetOrganizationAsync(project.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(project, appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(project, organization); + return await GetInternalAsync(sf, ti, httpContext, message.Filter, message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After); + } + + public async Task>> Handle(GetEventsByStack message) + { + var httpContext = message.Context; + var stack = await GetStackAsync(message.StackId, httpContext); + if (stack is null) + return Result.NotFound("Stack not found."); + + var organization = await GetOrganizationAsync(stack.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(stack, appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(stack, organization); + return await GetInternalAsync(sf, ti, httpContext, message.Filter, message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After); + } + + public async Task>> Handle(GetEventsByReferenceId message) + { + var httpContext = message.Context; + var organizations = await GetSelectedOrganizationsAsync(httpContext); + if (organizations.All(o => o.IsSuspended)) + return new PagedResult(Array.Empty(), false); + + var ti = TimeRangeParser.GetTimeInfo(null, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organizations.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + return await GetInternalAsync(sf, ti, httpContext, String.Concat("reference:", message.ReferenceId), null, message.Mode, message.Page, message.Limit, message.Before, message.After); + } + + public async Task>> Handle(GetEventsByReferenceIdAndProject message) + { + var httpContext = message.Context; + var project = await GetProjectAsync(message.ProjectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + var organization = await GetOrganizationAsync(project.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(null, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(project, appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(project, organization); + return await GetInternalAsync(sf, ti, httpContext, String.Concat("reference:", message.ReferenceId), null, message.Mode, message.Page, message.Limit, message.Before, message.After); + } + + public async Task>> Handle(GetEventsBySessionId message) + { + var httpContext = message.Context; + var organizations = await GetSelectedOrganizationsAsync(httpContext, message.Filter); + if (organizations.All(o => o.IsSuspended)) + return new PagedResult(Array.Empty(), false); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organizations.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + return await GetInternalAsync(sf, ti, httpContext, $"(reference:{message.SessionId} OR ref.session:{message.SessionId}) {message.Filter}", message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After, true); + } + + public async Task>> Handle(GetEventsBySessionIdAndProject message) + { + var httpContext = message.Context; + var project = await GetProjectAsync(message.ProjectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + var organization = await GetOrganizationAsync(project.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(project, appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(project, organization); + return await GetInternalAsync(sf, ti, httpContext, $"(reference:{message.SessionId} OR ref.session:{message.SessionId}) {message.Filter}", message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After, true); + } + + public async Task>> Handle(GetSessions message) + { + var httpContext = message.Context; + var organizations = await GetSelectedOrganizationsAsync(httpContext, message.Filter); + if (organizations.All(o => o.IsSuspended)) + return new PagedResult(Array.Empty(), false); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organizations.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + return await GetInternalAsync(sf, ti, httpContext, $"type:{Event.KnownTypes.Session} {message.Filter}", message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After, true); + } + + public async Task>> Handle(GetSessionsByOrganization message) + { + var httpContext = message.Context; + var organization = await GetOrganizationAsync(message.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organization); + return await GetInternalAsync(sf, ti, httpContext, $"type:{Event.KnownTypes.Session} {message.Filter}", message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After, true); + } + + public async Task>> Handle(GetSessionsByProject message) + { + var httpContext = message.Context; + var project = await GetProjectAsync(message.ProjectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + var organization = await GetOrganizationAsync(project.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view event occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(project, appOptions.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(project, organization); + return await GetInternalAsync(sf, ti, httpContext, $"type:{Event.KnownTypes.Session} {message.Filter}", message.Sort, message.Mode, message.Page, message.Limit, message.Before, message.After, true); + } + + public async Task Handle(SetEventUserDescription message) + { + var httpContext = message.Context; + string? claimProjectId = httpContext.Request.GetProjectId(); + if (message.ProjectId is not null && claimProjectId is not null && !String.Equals(message.ProjectId, claimProjectId)) + { + _logger.ProjectRouteDoesNotMatch(claimProjectId, message.ProjectId); + return Result.NotFound("Project not found."); + } + + if (String.IsNullOrEmpty(message.ReferenceId)) + return Result.NotFound("Event not found."); + + string? projectId = message.ProjectId ?? claimProjectId ?? httpContext.Request.GetDefaultProjectId(); + + if (String.IsNullOrEmpty(projectId)) + return Result.BadRequest("No project id specified and no default project was found"); + + var (isValid, errors) = await miniValidationValidator.ValidateAsync(message.Description); + if (!isValid) + { + return Result.Invalid(errors.SelectMany(e => e.Value.Select(validationMessage => ValidationError.Create(e.Key, validationMessage)))); + } + + var project = await GetProjectAsync(projectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + // Set the project for the configuration response filter. + httpContext.Request.SetProject(project); + + var eventUserDescription = new EventUserDescription + { + ProjectId = project.Id, + ReferenceId = message.ReferenceId, + EmailAddress = message.Description.EmailAddress, + Description = message.Description.Description, + Data = message.Description.Data + }; + + await eventUserDescriptionQueue.EnqueueAsync(eventUserDescription); + return Result.Accepted(); + } + + public async Task Handle(LegacyPatchEvent message) + { + var httpContext = message.Context; + if (message.PatchDocument.IsEmpty()) + return Result.Success(); + + NormalizeLegacyUpdateEventPatch(message.PatchDocument); + + var validationResult = JsonPatchValidation.ValidateOperations(message.PatchDocument); + if (!validationResult.IsSuccess) + return validationResult; + + // Apply patch to a blank DTO — v1 clients send full values for user description fields + var dto = new UpdateEvent(); + var patchResult = JsonPatchValidation.ApplyPatch(message.PatchDocument, dto); + if (!patchResult.IsSuccess) + return patchResult; + + var userDescription = new UserDescription { + EmailAddress = dto.EmailAddress, + Description = dto.Description + }; + + // The id from v1 URL (/api/v1/error/{id}) is a reference_id, not an event ID + return await Handle(new SetEventUserDescription(message.Id, userDescription, null, httpContext)); + } + + private static void NormalizeLegacyUpdateEventPatch(JsonPatchDocument patchDocument) + { + foreach (var operation in patchDocument.Operations) + { + if (String.Equals(operation.path, "/UserEmail", StringComparison.OrdinalIgnoreCase) + || String.Equals(operation.path, "/user_email", StringComparison.OrdinalIgnoreCase)) + { + operation.path = "/email_address"; + continue; + } + + if (String.Equals(operation.path, "/UserDescription", StringComparison.OrdinalIgnoreCase) + || String.Equals(operation.path, "/user_description", StringComparison.OrdinalIgnoreCase)) + { + operation.path = "/description"; + } + } + } + + public async Task Handle(RecordEventHeartbeat message) + { + var httpContext = message.Context; + if (appOptions.EventSubmissionDisabled || String.IsNullOrEmpty(message.Id)) + return Result.Success(); + + string? projectId = httpContext.Request.GetDefaultProjectId(); + if (String.IsNullOrEmpty(projectId)) + return Result.BadRequest("No project id specified and no default project was found."); + + string identityHash = message.Id.ToSHA1(); + string heartbeatCacheKey = String.Concat("Project:", projectId, ":heartbeat:", identityHash); + try + { + await Task.WhenAll( + cacheClient.SetAsync(heartbeatCacheKey, timeProvider.GetUtcNow().UtcDateTime, TimeSpan.FromHours(2)), + message.Close ? cacheClient.SetAsync(String.Concat(heartbeatCacheKey, "-close"), true, TimeSpan.FromHours(2)) : Task.CompletedTask + ); + } + catch (Exception ex) + { + if (projectId != appOptions.InternalProjectId) + { + using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).Property("Id", message.Id).Property("Close", message.Close).SetHttpContext(httpContext)); + _logger.LogError(ex, "Error enqueuing session heartbeat: {Message}", ex.Message); + } + + throw; + } + + return Result.Success(); + } + + public async Task Handle(SubmitEventByGet message) + { + var httpContext = message.Context; + string? claimProjectId = httpContext.Request.GetProjectId(); + if (message.ProjectId is not null && claimProjectId is not null && !String.Equals(message.ProjectId, claimProjectId)) + { + _logger.ProjectRouteDoesNotMatch(claimProjectId, message.ProjectId); + return Result.NotFound("Project not found."); + } + + var filteredParameters = httpContext.Request.Query.Where(p => !String.IsNullOrEmpty(p.Key) && !p.Value.All(String.IsNullOrEmpty) && !_ignoredKeys.Contains(p.Key)).ToList(); + if (filteredParameters.Count == 0) + return Result.Success(); + + string? projectId = message.ProjectId ?? claimProjectId ?? httpContext.Request.GetDefaultProjectId(); + + if (String.IsNullOrEmpty(projectId)) + return Result.BadRequest("No project id specified and no default project was found"); + + var project = await GetProjectAsync(projectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + // Set the project for the configuration response filter. + httpContext.Request.SetProject(project); + + string? contentEncoding = httpContext.Request.Headers.TryGetAndReturn(Headers.ContentEncoding); + var ev = new Event + { + Type = !String.IsNullOrEmpty(message.Type) ? message.Type : Event.KnownTypes.Log + }; + + string? identity = null; + string? identityName = null; + + var exclusions = project.Configuration.Settings.GetStringCollection(SettingsDictionary.KnownKeys.DataExclusions).ToList(); + foreach (var kvp in filteredParameters) + { + switch (kvp.Key.ToLowerInvariant()) + { + case "type": + ev.Type = kvp.Value.FirstOrDefault(); + break; + case "source": + ev.Source = kvp.Value.FirstOrDefault(); + break; + case "message": + ev.Message = kvp.Value.FirstOrDefault(); + break; + case "reference": + ev.ReferenceId = kvp.Value.FirstOrDefault(); + break; + case "date": + if (DateTimeOffset.TryParse(kvp.Value.FirstOrDefault(), out var dtValue)) + ev.Date = dtValue; + break; + case "count": + if (Int32.TryParse(kvp.Value.FirstOrDefault(), out int intValue)) + ev.Count = intValue; + break; + case "value": + if (Decimal.TryParse(kvp.Value.FirstOrDefault(), out decimal decValue)) + ev.Value = decValue; + break; + case "geo": + if (GeoResult.TryParse(kvp.Value.FirstOrDefault(), out var geo)) + ev.Geo = geo?.ToString(); + break; + case "tags": + ev.Tags ??= []; + ev.Tags.AddRange(kvp.Value.SelectMany(t => t?.Split([","], StringSplitOptions.RemoveEmptyEntries) ?? []).Distinct()); + break; + case "identity": + identity = kvp.Value.FirstOrDefault(); + break; + case "identity.name": + identityName = kvp.Value.FirstOrDefault(); + break; + default: + if (kvp.Key.AnyWildcardMatches(exclusions, true)) + continue; + + ev.Data![kvp.Key] = kvp.Value.Count > 1 ? kvp.Value : kvp.Value.FirstOrDefault(); + + break; + } + } + + if (identity != null) + ev.SetUserIdentity(identity, identityName); + + try + { + string mediaType = String.Empty; + string charSet = String.Empty; + if (httpContext.Request.ContentType is not null && MediaTypeHeaderValue.TryParse(httpContext.Request.ContentType, out var contentTypeHeader)) + { + mediaType = contentTypeHeader.MediaType.ToString(); + charSet = contentTypeHeader.Charset.ToString(); + } + + using var stream = new MemoryStream(ev.GetBytes(serializer)); + await eventPostService.EnqueueAsync(new EventPost(appOptions.EnableArchive) + { + ApiVersion = message.ApiVersion, + CharSet = charSet, + ContentEncoding = contentEncoding, + IpAddress = httpContext.Request.GetClientIpAddress(), + MediaType = mediaType, + OrganizationId = project.OrganizationId, + ProjectId = project.Id, + UserAgent = message.UserAgent + }, stream); + } + catch (Exception ex) + { + if (projectId != appOptions.InternalProjectId) + { + using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).SetHttpContext(httpContext)); + _logger.LogError(ex, "Error enqueuing event post: {Message}", ex.Message); + } + + throw; + } + + return Result.Success(); + } + + public async Task Handle(SubmitEventByPost message) + { + var httpContext = message.Context; + string? claimProjectId = httpContext.Request.GetProjectId(); + if (message.ProjectId is not null && claimProjectId is not null && !String.Equals(message.ProjectId, claimProjectId)) + { + _logger.ProjectRouteDoesNotMatch(claimProjectId, message.ProjectId); + return Result.NotFound("Project not found."); + } + + if (httpContext.Request.ContentLength is <= 0) + return Result.Accepted(); + + string? projectId = message.ProjectId ?? claimProjectId ?? httpContext.Request.GetDefaultProjectId(); + + if (String.IsNullOrEmpty(projectId)) + return Result.BadRequest("No project id specified and no default project was found"); + + var project = await GetProjectAsync(projectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + // Set the project for the configuration response filter. + httpContext.Request.SetProject(project); + + try + { + string mediaType = String.Empty; + string charSet = String.Empty; + if (httpContext.Request.ContentType is not null) + { + var contentType = MediaTypeHeaderValue.Parse(httpContext.Request.ContentType); + mediaType = contentType.MediaType.ToString(); + charSet = contentType.Charset.ToString(); + } + + await eventPostService.EnqueueAsync(new EventPost(appOptions.EnableArchive) + { + ApiVersion = message.ApiVersion, + CharSet = charSet, + ContentEncoding = httpContext.Request.Headers.TryGetAndReturn(Headers.ContentEncoding), + IpAddress = httpContext.Request.GetClientIpAddress(), + MediaType = mediaType, + OrganizationId = project.OrganizationId, + ProjectId = project.Id, + UserAgent = message.UserAgent, + }, httpContext.Request.Body); + } + catch (Exception ex) + { + if (projectId != appOptions.InternalProjectId) + { + using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).SetHttpContext(httpContext)); + _logger.LogError(ex, "Error enqueuing event post: {Message}", ex.Message); + } + + throw; + } + + return Result.Accepted(); + } + + public async Task> Handle(DeleteEvents message) + { + var httpContext = message.Context; + var ids = message.Ids.FromDelimitedString(); + var items = await GetModelsAsync(ids, httpContext, false); + if (items.Count == 0) + return Result.NotFound("Events not found."); + + var results = new ModelActionResults(); + results.AddNotFound(ids.Except(items.Select(i => i.Id))); + + var denied = items.Where(model => !httpContext.Request.CanAccessOrganization(model.OrganizationId)).ToList(); + foreach (var model in denied) + results.Failure.Add(PermissionResult.DenyWithNotFound(model.Id)); + + var list = items.Where(model => httpContext.Request.CanAccessOrganization(model.OrganizationId)).ToList(); + + if (list.Count == 0) + return results.Failure.Count == 1 ? PermissionToResult(results.Failure.First()) : results; + + var currentUser = httpContext.Request.GetUser(); + var projectGroups = list.GroupBy(ev => new { ev.OrganizationId, ev.ProjectId }).ToList(); + foreach (var projectGroup in projectGroups) + { + var ev = projectGroup.First(); + using var _ = _logger.BeginScope(new ExceptionlessState().Organization(ev.OrganizationId).Project(ev.ProjectId).Tag("Delete").Identity(currentUser.EmailAddress).Property("User", currentUser).SetHttpContext(httpContext)); + _logger.LogInformation("User {User} deleted {RemovedCount} events in project ({ProjectId})", currentUser.Id, projectGroup.Count(), ev.ProjectId); + } + + await eventRepository.RemoveAsync(list); + + foreach (var projectGroup in projectGroups) + { + try + { + await usageService.IncrementDeletedAsync(projectGroup.Key.OrganizationId, projectGroup.Key.ProjectId, projectGroup.Count()); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to increment deleted usage metrics for org {OrganizationId} project {ProjectId}: {Message}", projectGroup.Key.OrganizationId, projectGroup.Key.ProjectId, ex.Message); + } + } + + if (results.Failure.Count == 0) + return new WorkInProgressResult(); + + results.Success.AddRange(list.Select(i => i.Id)); + return results; + } + + #region Private Helpers + + private async Task> CountInternalAsync(AppFilter sf, TimeInfo ti, HttpContext httpContext, string? filter = null, string? aggregations = null, string? mode = null) + { + var pr = await validator.ValidateQueryAsync(filter); + if (!pr.IsValid) + return Result.BadRequest(pr.Message ?? "Invalid filter."); + + var far = await validator.ValidateAggregationsAsync(aggregations); + if (!far.IsValid) + return Result.BadRequest(far.Message ?? "Invalid aggregations."); + + sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || far.UsesPremiumFeatures; + + if (mode == "stack_new") + filter = AddFirstOccurrenceFilter(ti.Range, filter); + + var query = new RepositoryQuery() + .AppFilter(ShouldApplySystemFilter(sf, filter, httpContext.Request) ? sf : null) + .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field) + .Index(ti.Range.UtcStart, ti.Range.UtcEnd); + + CountResult result; + try + { + result = await eventRepository.CountAsync(q => q.SystemFilter(query).FilterExpression(filter).EnforceEventStackFilter().AggregationsExpression(aggregations)); + } + catch (Exception ex) + { + var currentUser = httpContext.Request.GetUser(); + using var _ = _logger.BeginScope(new ExceptionlessState().Property("Search Filter", new { SystemFilter = sf, UserFilter = filter, Time = ti, Aggregations = aggregations }).Tag("Search").Identity(currentUser.EmailAddress).Property("User", currentUser).SetHttpContext(httpContext)); + _logger.LogError(ex, "An error has occurred. Please check your filter or aggregations: {Message}", ex.Message); + + throw; + } + + return result; + } + + private async Task>> GetInternalAsync(AppFilter sf, TimeInfo ti, HttpContext httpContext, string? filter = null, string? sort = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null, bool usesPremiumFeatures = false) + { + var currentUser = httpContext.Request.GetUser(); + using var _ = _logger.BeginScope(new ExceptionlessState() + .Property("Search Filter", new + { + Mode = mode, + SystemFilter = sf, + UserFilter = filter, + Time = ti, + Page = page, + Limit = limit, + Before = before, + After = after + }) + .Tag("Search") + .Identity(currentUser.EmailAddress) + .Property("User", currentUser) + .SetHttpContext(httpContext) + ); + + int resolvedPage = Pagination.GetPage(page.GetValueOrDefault(1)); + limit = Pagination.GetLimit(limit); + int skip = Pagination.GetSkip(resolvedPage, limit); + if (skip > Pagination.MaximumSkip) + return new PagedResult(Array.Empty(), false); + + var pr = await validator.ValidateQueryAsync(filter); + if (!pr.IsValid) + return Result.BadRequest(pr.Message ?? "Invalid filter."); + + sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures; + + try + { + FindResults events; + switch (mode) + { + case "summary": + events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, before, after, httpContext.Request); + var summaries = events.Documents.Select(e => + { + var summaryData = formattingPluginManager.GetEventSummaryData(e); + return new EventSummaryModel + { + Id = summaryData.Id, + TemplateKey = summaryData.TemplateKey, + Date = e.Date, + Type = e.Type, + Data = summaryData.Data + }; + }).ToList(); + return new PagedResult(summaries.Cast().ToList(), events.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, events.Total, events.Hits.FirstOrDefault()?.GetSortToken(serializer), events.Hits.LastOrDefault()?.GetSortToken(serializer)); + case "stack_recent": + case "stack_frequent": + case "stack_new": + case "stack_users": + if (!String.IsNullOrEmpty(sort)) + return Result.BadRequest("Sort is not supported in stack mode."); + + var systemFilter = new RepositoryQuery() + .AppFilter(ShouldApplySystemFilter(sf, filter, httpContext.Request) ? sf : null) + .EnforceEventStackFilter() + .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, (PersistentEvent e) => e.Date) + .Index(ti.Range.UtcStart, ti.Range.UtcEnd); + + string? stackAggregations = mode switch + { + "stack_recent" => "cardinality:user sum:count~1 min:date -max:date", + "stack_frequent" => "cardinality:user -sum:count~1 min:date max:date", + "stack_new" => "cardinality:user sum:count~1 -min:date max:date", + "stack_users" => "-cardinality:user sum:count~1 min:date max:date", + _ => null + }; + + if (mode == "stack_new") + filter = AddFirstOccurrenceFilter(ti.Range, filter); + + var countResponse = await eventRepository.CountAsync(q => q + .SystemFilter(systemFilter) + .FilterExpression(filter) + .EnforceEventStackFilter() + .AggregationsExpression($"terms:(stack_id~{Pagination.GetSkip(resolvedPage + 1, limit) + 1} {stackAggregations})") + ); + + var stackTerms = countResponse.Aggregations.Terms("terms_stack_id"); + if (stackTerms is null || stackTerms.Buckets.Count == 0) + return new PagedResult(Array.Empty(), false); + + string[] stackIds = stackTerms.Buckets.Skip(skip).Take(limit + 1).Select(t => t.Key).ToArray(); + var stacks = (await stackRepository.GetByIdsAsync(stackIds)).Select(s => s.ApplyOffset(ti.Offset)).ToList(); + + var stackSummaries = await GetStackSummariesAsync(stacks, stackTerms.Buckets, sf, ti); + + long total = (stackTerms.Data?.GetValueOrDefault("SumOtherDocCount") as long? ?? 0L) + stackTerms.Buckets.Count; + return new PagedResult(stackSummaries.Take(limit).Cast().ToList(), stackSummaries.Count > limit && !Pagination.NextPageExceedsSkipLimit(resolvedPage, limit), resolvedPage, total); + default: + events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, before, after, httpContext.Request); + return new PagedResult(events.Documents.Cast().ToList(), events.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, events.Total, events.Hits.FirstOrDefault()?.GetSortToken(serializer), events.Hits.LastOrDefault()?.GetSortToken(serializer)); + } + } + catch (ApplicationException ex) + { + string message = "An error has occurred: Please check your search filter."; + if (ex is DocumentLimitExceededException) + message = $"An error has occurred: {ex.Message ?? "Please limit your search criteria."}"; + + _logger.LogError(ex, message); + throw; + } + } + + private static string AddFirstOccurrenceFilter(DateTimeRange timeRange, string? filter) + { + bool inverted = false; + if (filter is not null && filter.StartsWith("@!")) + { + inverted = true; + filter = filter.Substring(2); + } + + var sb = new StringBuilder(); + if (inverted) + sb.Append("@!"); + + sb.Append("first_occurrence:[\""); + sb.Append(timeRange.UtcStart.ToString("O")); + sb.Append("\" TO \""); + sb.Append(timeRange.UtcEnd.ToString("O")); + sb.Append("\"]"); + + if (String.IsNullOrEmpty(filter)) + return sb.ToString(); + + sb.Append(' '); + + bool isGrouped = filter.StartsWith('(') && filter.EndsWith(')'); + + if (isGrouped) + sb.Append(filter); + else + sb.Append('(').Append(filter).Append(')'); + + return sb.ToString(); + } + + private Task> GetEventsInternalAsync(AppFilter sf, TimeInfo ti, string? filter, string? sort, int? page, int limit, string? before, string? after, HttpRequest? request = null) + { + if (String.IsNullOrEmpty(sort)) + sort = $"-{EventIndex.Alias.Date}"; + + return eventRepository.FindAsync( + q => q.AppFilter(ShouldApplySystemFilter(sf, filter, request) ? sf : null) + .FilterExpression(filter) + .EnforceEventStackFilter() + .SortExpression(sort) + .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field) + .Index(ti.Range.UtcStart, ti.Range.UtcEnd), + o => page.HasValue + ? o.PageNumber(page).PageLimit(limit) + : o.SearchBeforeToken(before, serializer).SearchAfterToken(after, serializer).PageLimit(limit)); + } + + private static bool ShouldApplySystemFilter(AppFilter sf, string? filter, HttpRequest? request = null) + { + // Apply filter to non admin users. + if (request is null || !request.IsGlobalAdmin()) + return true; + + // Apply filter as it's scoped via a controller action. + if (!sf.IsUserOrganizationsFilter) + return true; + + // Empty user filter + if (String.IsNullOrEmpty(filter)) + return true; + + // Used for impersonating a user. Only skip the filter if it contains an org, project or stack. + var scope = GetFilterScopeVisitor.Run(filter); + return !scope.HasScope; + } + + private async Task> GetStackSummariesAsync(List stacks, IReadOnlyCollection> stackTerms, AppFilter sf, TimeInfo ti) + { + if (stacks.Count == 0) + return new List(0); + + var totalUsers = await GetUserCountByProjectIdsAsync(stacks, sf, ti.Range.UtcStart, ti.Range.UtcEnd); + return stacks.Join(stackTerms, s => s.Id, tk => tk.Key, (stack, term) => + { + var data = formattingPluginManager.GetStackSummaryData(stack); + var summary = new StackSummaryModel + { + Id = data.Id, + TemplateKey = data.TemplateKey, + Data = data.Data, + Title = stack.Title, + Status = stack.Status, + FirstOccurrence = term.Aggregations.Min("min_date")?.Value ?? stack.FirstOccurrence, + LastOccurrence = term.Aggregations.Max("max_date")?.Value ?? stack.LastOccurrence, + Total = (long)(term.Aggregations.Sum("sum_count")?.Value ?? term.Total.GetValueOrDefault()), + + Users = term.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0, + TotalUsers = totalUsers.GetOrDefault(stack.ProjectId) + }; + + return summary; + }).ToList(); + } + + private async Task> GetUserCountByProjectIdsAsync(ICollection stacks, AppFilter sf, DateTime utcStart, DateTime utcEnd) + { + using var scopedCacheClient = new ScopedCacheClient(cacheClient, $"Project:user-count:{utcStart.Floor(TimeSpan.FromMinutes(15)).Ticks}-{utcEnd.Floor(TimeSpan.FromMinutes(15)).Ticks}"); + var projectIds = stacks.Select(s => s.ProjectId).Distinct().ToList(); + var cachedTotals = await scopedCacheClient.GetAllAsync(projectIds); + + var totals = cachedTotals.Where(kvp => kvp.Value.HasValue).ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value); + if (totals.Count == projectIds.Count) + return totals; + + var systemFilter = new RepositoryQuery().AppFilter(sf).DateRange(utcStart, utcEnd, (PersistentEvent e) => e.Date).Index(utcStart, utcEnd); + var projects = cachedTotals + .Where(kvp => !kvp.Value.HasValue && stacks.Contains(s => s.ProjectId == kvp.Key)) + .Select(kvp => new Project { Id = kvp.Key, OrganizationId = stacks.First(s => s.ProjectId == kvp.Key).OrganizationId }) + .ToList(); + var countResult = await eventRepository.CountAsync(q => q.SystemFilter(systemFilter).FilterExpression(projects.BuildFilter()).EnforceEventStackFilter().AggregationsExpression("terms:(project_id cardinality:user)")); + + var projectTerms = countResult.Aggregations.Terms("terms_project_id")?.Buckets ?? []; + var aggregations = projectTerms.ToDictionary(t => t.Key, t => t.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0); + await scopedCacheClient.SetAllAsync(aggregations.Where(t => t.Value >= 10).ToDictionary(k => k.Key, v => v.Value), TimeSpan.FromMinutes(5)); + totals.AddRange(aggregations); + + return totals; + } + + private async Task GetModelAsync(string id, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + return null; + + var model = await eventRepository.GetByIdAsync(id, o => o.Cache(useCache)); + if (model is null) + return null; + + if (!httpContext.Request.CanAccessOrganization(model.OrganizationId)) + return null; + + return model; + } + + private async Task> GetModelsAsync(string[] ids, HttpContext httpContext, bool useCache = true) + { + if (ids.Length == 0) + return []; + + var models = await eventRepository.GetByIdsAsync(ids, o => o.Cache(useCache)); + return models.Where(m => httpContext.Request.CanAccessOrganization(m.OrganizationId)).ToList(); + } + + private Task GetOrganizationAsync(string organizationId, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(organizationId) || !httpContext.Request.CanAccessOrganization(organizationId)) + return Task.FromResult(null); + + return organizationRepository.GetByIdAsync(organizationId, o => o.Cache(useCache)); + } + + private async Task GetProjectAsync(string? projectId, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(projectId)) + return null; + + var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache(useCache)); + if (project is null || !httpContext.Request.CanAccessOrganization(project.OrganizationId)) + return null; + + return project; + } + + private async Task GetStackAsync(string stackId, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(stackId)) + return null; + + var stack = await stackRepository.GetByIdAsync(stackId, o => o.Cache(useCache)); + if (stack is null || !httpContext.Request.CanAccessOrganization(stack.OrganizationId)) + return null; + + return stack; + } + + private async Task> GetSelectedOrganizationsAsync(HttpContext httpContext, string? filter = null) + { + var associatedOrganizationIds = httpContext.Request.GetAssociatedOrganizationIds(); + if (associatedOrganizationIds.Count == 0) + return Array.Empty(); + + if (!String.IsNullOrEmpty(filter)) + { + var scope = GetFilterScopeVisitor.Run(filter); + if (scope.IsScopable) + { + Organization? organization = null; + if (scope.OrganizationId is not null) + { + organization = await organizationRepository.GetByIdAsync(scope.OrganizationId, o => o.Cache()); + } + else if (scope.ProjectId is not null) + { + var project = await projectRepository.GetByIdAsync(scope.ProjectId, o => o.Cache()); + if (project is not null) + organization = await organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + } + else if (scope.StackId is not null) + { + var stack = await stackRepository.GetByIdAsync(scope.StackId, o => o.Cache()); + if (stack is not null) + organization = await organizationRepository.GetByIdAsync(stack.OrganizationId, o => o.Cache()); + } + + if (organization is not null) + { + if (associatedOrganizationIds.Contains(organization.Id) || httpContext.Request.IsGlobalAdmin()) + return new[] { organization }; + + return Array.Empty(); + } + } + } + + return await organizationRepository.GetByIdsAsync(associatedOrganizationIds.ToArray(), o => o.Cache()); + } + + private static Result PermissionToResult(PermissionResult permission) + { + if (!String.IsNullOrEmpty(permission.Message)) + return Result.NotFound(permission.Message); + + return Result.NotFound("Access denied."); + } + + #endregion +} diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs new file mode 100644 index 0000000000..d7db3e1a2f --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -0,0 +1,944 @@ +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Billing; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Mail; +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Billing; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Queries; +using Exceptionless.Core.Services; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Foundatio.Caching; +using Foundatio.Messaging; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Stripe; +using Foundatio.Mediator; +using PermissionResult = Exceptionless.Web.Controllers.PermissionResult; +using DataDictionary = Exceptionless.Core.Models.DataDictionary; +using Invoice = Exceptionless.Web.Models.Invoice; +using InvoiceLineItem = Exceptionless.Web.Models.InvoiceLineItem; + +namespace Exceptionless.Web.Api.Handlers; + +public class OrganizationHandler( + OrganizationService organizationService, + IOrganizationRepository repository, + ICacheClient cacheClient, + IEventRepository eventRepository, + IUserRepository userRepository, + IProjectRepository projectRepository, + BillingManager billingManager, + BillingPlans plans, + UsageService usageService, + IStripeBillingClient stripeBillingClient, + IMailer mailer, + IMessagePublisher messagePublisher, + ApiMapper mapper, + AppOptions options, + TimeProvider timeProvider, + IHttpContextAccessor httpContextAccessor, + ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); + + public async Task>> Handle(GetOrganizations message) + { + var organizations = await GetModelsAsync(message.Context.Request.GetAssociatedOrganizationIds().ToArray()); + if (organizations.Count == 0) + return Result>.Success(Array.Empty()); + + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + organizations = String.IsNullOrWhiteSpace(message.Filter) + ? organizations + : (await repository.GetByFilterAsync(sf, message.Filter, null, o => o.PageLimit(Pagination.MaximumSkip))).Documents; + var viewOrganizations = mapper.MapToViewOrganizations(organizations); + await AfterResultMapAsync(viewOrganizations); + + if (IsStatsMode(message.Mode)) + return Result>.Success(await PopulateOrganizationStatsAsync(viewOrganizations)); + + return Result>.Success(viewOrganizations); + } + + public async Task>> Handle(GetAdminOrganizations message) + { + int page = Pagination.GetPage(message.Page); + int limit = Pagination.GetLimit(message.Limit); + var organizations = await repository.GetByCriteriaAsync(message.Criteria, o => o.PageNumber(page).PageLimit(limit), message.Sort, message.Paid, message.Suspended); + var viewOrganizations = mapper.MapToViewOrganizations(organizations.Documents); + await AfterResultMapAsync(viewOrganizations); + + if (IsStatsMode(message.Mode)) + return new PagedResult(await PopulateOrganizationStatsAsync(viewOrganizations), organizations.HasMore, page, organizations.Total); + + return new PagedResult(viewOrganizations, organizations.HasMore, page, organizations.Total); + } + + public async Task> Handle(GetOrganizationPlanStats message) + { + return await repository.GetBillingPlanStatsAsync(); + } + + public async Task> Handle(GetOrganizationById message) + { + var organization = await GetModelAsync(message.Id); + if (organization is null) + return Result.NotFound("Organization not found."); + + var viewOrganization = mapper.MapToViewOrganization(organization); + await AfterResultMapAsync([viewOrganization]); + + if (IsStatsMode(message.Mode)) + return await PopulateOrganizationStatsAsync(viewOrganization); + + return viewOrganization; + } + + public async Task> Handle(CreateOrganization message) + { + if (message.Organization is null) + return Result.BadRequest("Organization value is required."); + + var model = mapper.MapToOrganization(message.Organization); + var error = await CanAddAsync(model, message.Context); + if (error is not null) + return error; + + model = await AddModelAsync(model, message.Context); + var viewModel = mapper.MapToViewOrganization(model); + await AfterResultMapAsync([viewModel]); + return Result.Created(viewModel, $"/api/v2/organizations/{model.Id}"); + } + + public async Task> Handle(UpdateOrganizationMessage message) + { + var original = await GetModelAsync(message.Id, useCache: false); + if (original is null) + return Result.NotFound("Organization not found."); + + if (message.PatchDocument.IsEmpty()) + return await MapToViewAsync(original); + + var validationResult = JsonPatchValidation.ValidateOperations(message.PatchDocument, "/organization_id"); + if (!validationResult.IsSuccess) + return Result.FromResult(validationResult); + + var dto = new NewOrganization { + Name = original.Name + }; + + var patchResult = JsonPatchValidation.ApplyPatch(message.PatchDocument, dto); + if (!patchResult.IsSuccess) + return Result.FromResult(patchResult); + + var error = await CanUpdateAsync(original, dto, message.PatchDocument, message.Context); + if (error is not null) + return error; + + original.Name = dto.Name; + + await repository.SaveAsync(original, o => o.Cache()); + return await MapToViewAsync(original); + } + + public async Task>> Handle(SetOrganizationIcon message) + { + var organization = await GetModelAsync(message.Id, false); + if (organization is null) + return Result.NotFound("Organization not found."); + + string? oldIconFileName = organization.IconFileName; + organization.IconFileName = message.FileName; + + await repository.SaveAsync(organization, o => o.Cache()); + return new ProfileImageUpdate(await MapToViewAsync(organization), oldIconFileName); + } + + public async Task>> Handle(DeleteOrganizationIcon message) + { + var organization = await GetModelAsync(message.Id, false); + if (organization is null) + return Result.NotFound("Organization not found."); + + string? oldIconFileName = organization.IconFileName; + organization.IconFileName = null; + + await repository.SaveAsync(organization, o => o.Cache()); + return new ProfileImageUpdate(await MapToViewAsync(organization), oldIconFileName); + } + + public async Task> Handle(DeleteOrganizations message) + { + var items = await GetModelsAsync(message.Ids, useCache: false); + if (items.Count == 0) + return Result.NotFound("Organization not found."); + + var results = new ModelActionResults(); + results.AddNotFound(message.Ids.Except(items.Select(i => i.Id))); + + var deletableItems = items.ToList(); + foreach (var model in items) + { + var permission = await CanDeleteAsync(model, message.Context); + if (permission.Allowed) + continue; + + deletableItems.Remove(model); + results.Failure.Add(permission); + } + + if (deletableItems.Count == 0) + return results.Failure.Count == 1 ? Result.FromResult(PermissionToResult(results.Failure.First())) : results; + + IEnumerable workIds = await DeleteModelsAsync(deletableItems, message.Context); + if (results.Failure.Count == 0) + return new ModelActionResults { Workers = workIds.ToList() }; + + results.Workers.AddRange(workIds); + results.Success.AddRange(deletableItems.Select(i => i.Id)); + return results; + } + + public async Task> Handle(GetInvoice message) + { + if (!options.StripeOptions.EnableBilling) + return Result.NotFound("Organization not found."); + + using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Invoice").Identity(GetCurrentUser(message.Context).EmailAddress) + .Property("User", GetCurrentUser(message.Context)).SetHttpContext(message.Context)); + + string invoiceId = message.Id; + if (!invoiceId.StartsWith("in_", StringComparison.Ordinal)) + invoiceId = "in_" + invoiceId; + + Stripe.Invoice? stripeInvoice = null; + try + { + stripeInvoice = await stripeBillingClient.GetInvoiceAsync(invoiceId); + } + catch (StripeException ex) + { + _logger.LogCritical(ex, "Error getting invoice ({InvoiceId}): {Message}", invoiceId, ex.Message); + } + + if (String.IsNullOrEmpty(stripeInvoice?.CustomerId)) + return Result.NotFound("Organization not found."); + + var organization = await repository.GetByStripeCustomerIdAsync(stripeInvoice.CustomerId); + if (organization is null || !message.Context.Request.CanAccessOrganization(organization.Id)) + return Result.NotFound("Organization not found."); + + var invoice = new Invoice + { + Id = stripeInvoice.Id.Substring(3), + OrganizationId = organization.Id, + OrganizationName = organization.Name, + Date = stripeInvoice.Created, + Paid = String.Equals(stripeInvoice.Status, "paid", StringComparison.OrdinalIgnoreCase), + Total = stripeInvoice.Total / 100.0m + }; + + foreach (var line in stripeInvoice.Lines.Data) + { + var item = new InvoiceLineItem { Amount = line.Amount / 100.0m, Description = line.Description }; + + var priceId = line.Pricing?.PriceDetails?.PriceId; + if (!String.IsNullOrEmpty(priceId)) + { + var billingPlan = billingManager.GetBillingPlan(priceId); + if (billingPlan is null) + _logger.LogWarning("Billing plan not found for price {PriceId} on invoice {InvoiceId}", priceId, invoiceId); + + string planName = billingPlan?.Name ?? priceId; + string interval = priceId.EndsWith("_YEARLY", StringComparison.OrdinalIgnoreCase) ? "year" : "month"; + item.Description = $"Exceptionless - {planName} Plan ({line.Amount / 100.0m:c}/{interval})"; + } + + var periodStart = line.Period.Start >= DateTime.MinValue ? line.Period.Start : stripeInvoice.PeriodStart; + var periodEnd = line.Period.End >= DateTime.MinValue ? line.Period.End : stripeInvoice.PeriodEnd; + item.Date = $"{periodStart.ToShortDateString()} - {periodEnd.ToShortDateString()}"; + invoice.Items.Add(item); + } + + var coupon = stripeInvoice.Discounts?.FirstOrDefault(d => d.Deleted is not true)?.Source?.Coupon; + if (coupon is not null) + { + if (coupon.AmountOff.HasValue) + { + decimal discountAmount = coupon.AmountOff.GetValueOrDefault() / 100.0m; + string description = $"{coupon.Id} ({discountAmount:C} off)"; + invoice.Items.Add(new InvoiceLineItem { Description = description, Amount = discountAmount }); + } + else + { + decimal discountAmount = (stripeInvoice.Subtotal / 100.0m) * (coupon.PercentOff.GetValueOrDefault() / 100.0m); + string description = $"{coupon.Id} ({coupon.PercentOff.GetValueOrDefault()}% off)"; + invoice.Items.Add(new InvoiceLineItem { Description = description, Amount = discountAmount }); + } + } + + return invoice; + } + + public async Task>> Handle(GetInvoices message) + { + if (!options.StripeOptions.EnableBilling) + return Result.NotFound("Organization not found."); + + var organization = await GetModelAsync(message.Id); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (String.IsNullOrWhiteSpace(organization.StripeCustomerId)) + return new PagedResult(new List(), false); + + string? before = message.Before; + string? after = message.After; + if (!String.IsNullOrEmpty(before) && !before.StartsWith("in_", StringComparison.Ordinal)) + before = "in_" + before; + if (!String.IsNullOrEmpty(after) && !after.StartsWith("in_", StringComparison.Ordinal)) + after = "in_" + after; + + var invoiceOptions = new InvoiceListOptions { Customer = organization.StripeCustomerId, Limit = message.Limit + 1, EndingBefore = before, StartingAfter = after }; + var invoices = mapper.MapToInvoiceGridModels(await stripeBillingClient.ListInvoicesAsync(invoiceOptions)); + return new PagedResult(invoices.Take(message.Limit).ToList(), invoices.Count > message.Limit); + } + + public async Task>> Handle(GetPlans message) + { + var organization = await GetModelAsync(message.Id); + if (organization is null) + return Result.NotFound("Organization not found."); + + var availablePlans = message.Context.Request.IsGlobalAdmin() + ? plans.Plans.ToList() + : plans.Plans.Where(p => !p.IsHidden || String.Equals(p.Id, organization.PlanId, StringComparison.OrdinalIgnoreCase)).ToList(); + + var currentPlan = new BillingPlan + { + Id = organization.PlanId, + Name = organization.PlanName, + Description = organization.PlanDescription, + IsHidden = false, + Price = organization.BillingPrice, + MaxProjects = organization.MaxProjects, + MaxUsers = organization.MaxUsers, + RetentionDays = organization.RetentionDays, + MaxEventsPerMonth = organization.MaxEventsPerMonth, + HasPremiumFeatures = organization.HasPremiumFeatures + }; + + int idx = availablePlans.FindIndex(p => String.Equals(p.Id, organization.PlanId, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + availablePlans[idx] = currentPlan; + else + availablePlans.Add(currentPlan); + + return Result>.Success(availablePlans); + } + + public async Task> Handle(ChangeOrganizationPlan message) + { + var model = message.Model ?? new ChangePlanRequest { PlanId = message.PlanId ?? String.Empty }; + if (String.IsNullOrEmpty(model.PlanId) && !String.IsNullOrEmpty(message.PlanId)) + model.PlanId = message.PlanId; + if (String.IsNullOrEmpty(model.StripeToken) && !String.IsNullOrEmpty(message.StripeToken)) + model.StripeToken = message.StripeToken; + if (String.IsNullOrEmpty(model.Last4) && !String.IsNullOrEmpty(message.Last4)) + model.Last4 = message.Last4; + if (String.IsNullOrEmpty(model.CouponId) && !String.IsNullOrEmpty(message.CouponId)) + model.CouponId = message.CouponId; + + if (String.IsNullOrEmpty(message.Id) || !message.Context.Request.CanAccessOrganization(message.Id)) + return Result.NotFound("Organization not found."); + + using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Change Plan").Organization(message.Id) + .Identity(GetCurrentUser(message.Context).EmailAddress).Property("User", GetCurrentUser(message.Context)).SetHttpContext(message.Context)); + + if (!options.StripeOptions.EnableBilling) + return Result.NotFound("Organization not found."); + + var organization = await GetModelAsync(message.Id, useCache: false); + if (organization is null) + return Result.NotFound("Organization not found."); + + var plan = billingManager.GetBillingPlan(model.PlanId); + if (plan is null) + { + _logger.LogWarning("Plan {PlanId} not found for organization {OrganizationId}", model.PlanId, message.Id); + return Result.Invalid(ValidationError.Create("general", "Invalid plan. Please select a valid plan.")); + } + + if (plan.IsHidden && !String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Hidden plan {PlanId} is not selectable for organization {OrganizationId}", model.PlanId, message.Id); + return Result.Invalid(ValidationError.Create("general", "Invalid plan. Please select a valid plan.")); + } + + if (String.Equals(organization.PlanId, plan.Id) && String.Equals(plans.FreePlan.Id, plan.Id)) + return ChangePlanResult.SuccessWithMessage("Your plan was not changed as you were already on the free plan."); + + if (!String.Equals(organization.PlanId, plan.Id)) + { + var result = await billingManager.CanDownGradeAsync(organization, plan, GetCurrentUser(message.Context)); + if (!result.Success) + return result; + } + + bool isPaymentMethod = model.StripeToken?.StartsWith("pm_", StringComparison.Ordinal) is true; + + try + { + if (!String.Equals(organization.PlanId, plans.FreePlan.Id) && String.Equals(plan.Id, plans.FreePlan.Id)) + { + if (!String.IsNullOrEmpty(organization.StripeCustomerId)) + { + var subs = await stripeBillingClient.ListSubscriptionsAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); + foreach (var sub in subs.Where(s => !s.CanceledAt.HasValue)) + await stripeBillingClient.CancelSubscriptionAsync(sub.Id, new SubscriptionCancelOptions()); + } + + organization.BillingStatus = BillingStatus.Trialing; + organization.RemoveSuspension(); + } + else if (String.IsNullOrEmpty(organization.StripeCustomerId)) + { + if (String.IsNullOrEmpty(model.StripeToken)) + return ChangePlanResult.FailWithMessage("Billing information was not set."); + + organization.SubscribeDate = timeProvider.GetUtcNow().UtcDateTime; + + var createCustomer = new CustomerCreateOptions + { + Description = organization.Name, + Email = GetCurrentUser(message.Context).EmailAddress + }; + + if (isPaymentMethod) + { + createCustomer.PaymentMethod = model.StripeToken; + createCustomer.InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = model.StripeToken + }; + } + else + { + createCustomer.Source = model.StripeToken; + } + + var customer = await stripeBillingClient.CreateCustomerAsync(createCustomer); + organization.StripeCustomerId = customer.Id; + organization.CardLast4 = model.Last4; + await repository.SaveAsync(organization, o => o.Cache()); + + var subscriptionOptions = new SubscriptionCreateOptions + { + Customer = customer.Id, + Items = [new SubscriptionItemOptions { Price = model.PlanId }] + }; + + if (isPaymentMethod) + subscriptionOptions.DefaultPaymentMethod = model.StripeToken; + + if (!String.IsNullOrWhiteSpace(model.CouponId)) + subscriptionOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; + + await stripeBillingClient.CreateSubscriptionAsync(subscriptionOptions); + + organization.BillingStatus = BillingStatus.Active; + organization.RemoveSuspension(); + } + else + { + var update = new SubscriptionUpdateOptions { Items = [] }; + var create = new SubscriptionCreateOptions { Customer = organization.StripeCustomerId, Items = [] }; + bool cardUpdated = false; + + var customerUpdateOptions = new CustomerUpdateOptions { Description = organization.Name }; + if (!message.Context.Request.IsGlobalAdmin()) + customerUpdateOptions.Email = GetCurrentUser(message.Context).EmailAddress; + + var listSubscriptionsTask = stripeBillingClient.ListSubscriptionsAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); + + if (!String.IsNullOrEmpty(model.StripeToken)) + { + if (isPaymentMethod) + { + await stripeBillingClient.AttachPaymentMethodAsync(model.StripeToken, new PaymentMethodAttachOptions + { + Customer = organization.StripeCustomerId + }); + customerUpdateOptions.InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = model.StripeToken + }; + } + else + { + customerUpdateOptions.Source = model.StripeToken; + } + + cardUpdated = true; + } + + await Task.WhenAll( + stripeBillingClient.UpdateCustomerAsync(organization.StripeCustomerId, customerUpdateOptions), + listSubscriptionsTask + ); + + var subscriptionList = await listSubscriptionsTask; + var subscription = subscriptionList.FirstOrDefault(s => !s.CanceledAt.HasValue); + if (subscription is not null && subscription.Items.Data.Count > 0) + { + update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + update.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; + await stripeBillingClient.UpdateSubscriptionAsync(subscription.Id, update); + } + else if (subscription is not null) + { + _logger.LogWarning("Subscription {SubscriptionId} has no items for organization {OrganizationId}, adding new item", subscription.Id, message.Id); + update.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + update.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; + await stripeBillingClient.UpdateSubscriptionAsync(subscription.Id, update); + } + else + { + create.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + create.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; + await stripeBillingClient.CreateSubscriptionAsync(create); + } + + if (cardUpdated) + organization.CardLast4 = model.Last4; + + if (organization.SubscribeDate is null || organization.SubscribeDate == DateTime.MinValue) + organization.SubscribeDate = timeProvider.GetUtcNow().UtcDateTime; + + organization.BillingStatus = BillingStatus.Active; + organization.RemoveSuspension(); + } + + billingManager.ApplyBillingPlan(organization, plan, GetCurrentUser(message.Context)); + await repository.SaveAsync(organization, o => o.Cache().Originals()); + await messagePublisher.PublishAsync(new PlanChanged { OrganizationId = organization.Id }); + } + catch (StripeException ex) + { + _logger.LogCritical(ex, "Error occurred update billing plan: {Message}", ex.Message); + return ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again or contact support."); + } + + return new ChangePlanResult { Success = true }; + } + + public async Task> Handle(AddOrganizationUser message) + { + if (String.IsNullOrEmpty(message.Id) || !message.Context.Request.CanAccessOrganization(message.Id) || String.IsNullOrEmpty(message.Email)) + return Result.NotFound("Organization not found."); + + var organization = await GetModelAsync(message.Id); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (!await billingManager.CanAddUserAsync(organization)) + return Result.Invalid(ValidationError.Create("plan_limit", "Please upgrade your plan to add an additional user.")); + + var user = await userRepository.GetByEmailAddressAsync(message.Email); + if (user is not null) + { + if (!user.OrganizationIds.Contains(organization.Id)) + { + user.OrganizationIds.Add(organization.Id); + await userRepository.SaveAsync(user, o => o.Cache()); + await messagePublisher.PublishAsync(new UserMembershipChanged + { + ChangeType = ChangeType.Added, + UserId = user.Id, + OrganizationId = organization.Id + }); + } + + await mailer.SendOrganizationAddedAsync(GetCurrentUser(message.Context), organization, user); + } + else + { + var invite = organization.Invites.FirstOrDefault(i => String.Equals(i.EmailAddress, message.Email, StringComparison.OrdinalIgnoreCase)); + if (invite is null) + { + invite = new Invite + { + Token = StringExtensions.GetNewToken(), + EmailAddress = message.Email.ToLowerInvariant(), + DateAdded = timeProvider.GetUtcNow().UtcDateTime + }; + organization.Invites.Add(invite); + await repository.SaveAsync(organization, o => o.Cache()); + } + + await mailer.SendOrganizationInviteAsync(GetCurrentUser(message.Context), organization, invite); + } + + return new User { EmailAddress = message.Email }; + } + + public async Task Handle(RemoveOrganizationUser message) + { + var organization = await GetModelAsync(message.Id, useCache: false); + if (organization is null) + return Result.NotFound("Organization not found."); + + var user = await userRepository.GetByEmailAddressAsync(message.Email); + if (user is null || !user.OrganizationIds.Contains(message.Id)) + { + var invite = organization.Invites.FirstOrDefault(i => String.Equals(i.EmailAddress, message.Email, StringComparison.OrdinalIgnoreCase)); + if (invite is null) + return Result.Success(); + + organization.Invites.Remove(invite); + await repository.SaveAsync(organization, o => o.Cache()); + } + else + { + if (!user.OrganizationIds.Contains(organization.Id)) + return Result.BadRequest("Invalid organization user."); + + var organizationUsers = await userRepository.GetByOrganizationIdAsync(organization.Id); + if (organizationUsers.Total is 1) + return Result.BadRequest("An organization must contain at least one user."); + + await organizationService.CleanupProjectNotificationSettingsAsync(organization, [user.Id]); + await organizationService.RemoveUserSavedViewsAsync(organization.Id, user.Id); + + user.OrganizationIds.Remove(organization.Id); + await userRepository.SaveAsync(user, o => o.Cache()); + await messagePublisher.PublishAsync(new UserMembershipChanged + { + ChangeType = ChangeType.Removed, + UserId = user.Id, + OrganizationId = organization.Id + }); + } + + return Result.Success(); + } + + public async Task Handle(SuspendOrganization message) + { + var organization = await GetModelAsync(message.Id, useCache: false); + if (organization is null) + return Result.NotFound("Organization not found."); + + organization.IsSuspended = true; + organization.SuspensionDate = timeProvider.GetUtcNow().UtcDateTime; + organization.SuspendedByUserId = GetCurrentUser(message.Context).Id; + organization.SuspensionCode = message.Code; + organization.SuspensionNotes = message.Notes; + await repository.SaveAsync(organization, o => o.Cache().Originals()); + + return Result.Success(); + } + + public async Task Handle(UnsuspendOrganization message) + { + var organization = await GetModelAsync(message.Id, useCache: false); + if (organization is null) + return Result.NotFound("Organization not found."); + + organization.IsSuspended = false; + organization.SuspensionDate = null; + organization.SuspendedByUserId = null; + organization.SuspensionCode = null; + organization.SuspensionNotes = null; + await repository.SaveAsync(organization, o => o.Cache().Originals()); + + return Result.Success(); + } + + public async Task Handle(SetOrganizationData message) + { + if (String.IsNullOrWhiteSpace(message.Key) || String.IsNullOrWhiteSpace(message.Value?.Value) || message.Key.StartsWith('-')) + return Result.BadRequest("Invalid key or value."); + + var organization = await GetModelAsync(message.Id, useCache: false); + if (organization is null) + return Result.NotFound("Organization not found."); + + organization.Data ??= new DataDictionary(); + organization.Data[message.Key.Trim()] = message.Value.Value.Trim(); + await repository.SaveAsync(organization, o => o.Cache()); + + return Result.Success(); + } + + public async Task Handle(DeleteOrganizationData message) + { + var organization = await GetModelAsync(message.Id, useCache: false); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.Data is not null && organization.Data.Remove(message.Key)) + await repository.SaveAsync(organization, o => o.Cache()); + + return Result.Success(); + } + + public async Task Handle(SetOrganizationFeature message) + { + var organization = await GetModelAsync(message.Id, useCache: false); + if (organization is null) + return Result.NotFound("Organization not found."); + + var normalizedFeature = message.Feature.Trim().ToLowerInvariant(); + if (String.IsNullOrEmpty(normalizedFeature)) + return Result.BadRequest("Invalid feature flag."); + + organization.Features.Add(normalizedFeature); + await repository.SaveAsync(organization, o => o.Cache()); + return Result.Success(); + } + + public async Task Handle(RemoveOrganizationFeature message) + { + var organization = await GetModelAsync(message.Id, useCache: false); + if (organization is null) + return Result.NotFound("Organization not found."); + + var normalizedFeature = message.Feature.Trim().ToLowerInvariant(); + if (String.IsNullOrEmpty(normalizedFeature)) + return Result.BadRequest("Invalid feature flag."); + + if (organization.Features.Remove(normalizedFeature)) + await repository.SaveAsync(organization, o => o.Cache()); + + return Result.Success(); + } + + public async Task Handle(CheckOrganizationName message) + { + if (await IsOrganizationNameAvailableInternalAsync(message.Name, message.Context)) + return Result.NoContent(); + + return Result.Created(); + } + + private async Task MapToViewAsync(Organization model) + { + var viewModel = mapper.MapToViewOrganization(model); + await AfterResultMapAsync([viewModel]); + return viewModel; + } + + private async Task?> CanAddAsync(Organization value, HttpContext httpContext) + { + if (String.IsNullOrEmpty(value.Name)) + return Result.BadRequest("Organization name is required."); + + if (!await IsOrganizationNameAvailableInternalAsync(value.Name, httpContext)) + return Result.BadRequest("A organization with this name already exists."); + + if (!await billingManager.CanAddOrganizationAsync(GetCurrentUser(httpContext))) + return Result.Invalid(ValidationError.Create("plan_limit", "Please upgrade your plan to add an additional organization.")); + + return null; + } + + private async Task AddModelAsync(Organization value, HttpContext httpContext) + { + var user = GetCurrentUser(httpContext); + var plan = !options.StripeOptions.EnableBilling || user.Roles.Contains(AuthorizationRoles.GlobalAdmin) + ? plans.UnlimitedPlan + : plans.FreePlan; + billingManager.ApplyBillingPlan(value, plan, user); + + var organization = await repository.AddAsync(value, o => o.Cache()); + + user.OrganizationIds.Add(organization.Id); + await userRepository.SaveAsync(user, o => o.Cache()); + await messagePublisher.PublishAsync(new UserMembershipChanged + { + UserId = user.Id, + OrganizationId = organization.Id, + ChangeType = ChangeType.Added + }); + + return organization; + } + + private async Task?> CanUpdateAsync(Organization original, NewOrganization dto, JsonPatchDocument patch, HttpContext httpContext) + { + if (!await IsOrganizationNameAvailableInternalAsync(dto.Name, httpContext)) + return Result.BadRequest("A organization with this name already exists."); + + if (patch.AffectsPath("/organization_id")) + return Result.BadRequest("OrganizationId cannot be modified."); + + return null; + } + + private async Task CanDeleteAsync(Organization value, HttpContext httpContext) + { + if (!String.IsNullOrEmpty(value.StripeCustomerId) && !messageIsGlobalAdmin(httpContext)) + return PermissionResult.DenyWithMessage("An organization cannot be deleted if it has a subscription.", value.Id); + + var organizationProjects = await projectRepository.GetByOrganizationIdAsync(value.Id); + var projects = organizationProjects.Documents.ToList(); + if (!messageIsGlobalAdmin(httpContext) && projects.Count > 0) + return PermissionResult.DenyWithMessage("An organization cannot be deleted if it contains any projects.", value.Id); + + return PermissionResult.Allow; + } + + private async Task> DeleteModelsAsync(ICollection organizations, HttpContext httpContext) + { + var user = GetCurrentUser(httpContext); + foreach (var organization in organizations) + { + using var _ = _logger.BeginScope(new ExceptionlessState().Organization(organization.Id).Tag("Delete").Identity(user.EmailAddress).Property("User", user).SetHttpContext(httpContext)); + _logger.UserDeletingOrganization(user.Id, organization.Name, organization.Id); + await organizationService.SoftDeleteOrganizationAsync(organization, user.Id); + } + + return []; + } + + private async Task GetModelAsync(string id, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + return null; + + var model = await repository.GetByIdAsync(id, o => o.Cache(useCache)); + if (model is null) + return null; + + if (!HttpContext.Request.CanAccessOrganization(model.Id)) + return null; + + return model; + } + + private async Task> GetModelsAsync(string[] ids, bool useCache = true) + { + if (ids.Length == 0) + return []; + + var models = await repository.GetByIdsAsync(ids, o => o.Cache(useCache)); + return models.Where(m => HttpContext.Request.CanAccessOrganization(m.Id)).ToList(); + } + + private async Task AfterResultMapAsync(ICollection models) + { + foreach (var model in models.OfType()) + model.Data?.RemoveSensitiveData(); + + var viewOrganizations = models.OfType().ToList(); + foreach (var viewOrganization in viewOrganizations) + { + viewOrganization.IconUrl = GetOrganizationIconUrl(viewOrganization.Id, viewOrganization.IconUrl); + + var realTimeUsage = await usageService.GetUsageAsync(viewOrganization.Id); + viewOrganization.EnsureUsage(timeProvider); + viewOrganization.TrimUsage(timeProvider); + + var currentUsage = viewOrganization.GetCurrentUsage(timeProvider); + currentUsage.Limit = realTimeUsage.CurrentUsage.Limit; + currentUsage.Total = realTimeUsage.CurrentUsage.Total; + currentUsage.Blocked = realTimeUsage.CurrentUsage.Blocked; + currentUsage.Discarded = realTimeUsage.CurrentUsage.Discarded; + currentUsage.TooBig = realTimeUsage.CurrentUsage.TooBig; + currentUsage.Deleted = realTimeUsage.CurrentUsage.Deleted; + + var currentHourUsage = viewOrganization.GetCurrentHourlyUsage(timeProvider); + currentHourUsage.Total = realTimeUsage.CurrentHourUsage.Total; + currentHourUsage.Blocked = realTimeUsage.CurrentHourUsage.Blocked; + currentHourUsage.Discarded = realTimeUsage.CurrentHourUsage.Discarded; + currentHourUsage.TooBig = realTimeUsage.CurrentHourUsage.TooBig; + currentHourUsage.Deleted = realTimeUsage.CurrentHourUsage.Deleted; + + viewOrganization.IsThrottled = realTimeUsage.IsThrottled; + viewOrganization.IsOverRequestLimit = await OrganizationExtensions.IsOverRequestLimitAsync(viewOrganization.Id, cacheClient, options.ApiThrottleLimit, timeProvider); + } + } + + private static string? GetOrganizationIconUrl(string id, string? fileName) + { + if (String.IsNullOrWhiteSpace(fileName)) + return null; + + return $"/api/v2/organizations/{id}/icon/{fileName}"; + } + + private async Task PopulateOrganizationStatsAsync(ViewOrganization organization) + { + return (await PopulateOrganizationStatsAsync([organization])).Single(); + } + + private async Task> PopulateOrganizationStatsAsync(List viewOrganizations) + { + if (viewOrganizations.Count == 0) + return viewOrganizations; + + int maximumRetentionDays = options.MaximumRetentionDays; + var organizations = viewOrganizations.Select(o => new Organization { Id = o.Id, CreatedUtc = o.CreatedUtc, RetentionDays = o.RetentionDays }).ToList(); + var sf = new AppFilter(organizations); + DateTime utcNow = timeProvider.GetUtcNow().UtcDateTime; + var retentionUtcCutoff = organizations.GetRetentionUtcCutoff(maximumRetentionDays, timeProvider); + var systemFilter = new RepositoryQuery() + .AppFilter(sf) + .DateRange(retentionUtcCutoff, utcNow, (PersistentEvent e) => e.Date) + .Index(retentionUtcCutoff, utcNow); + var result = await eventRepository.CountAsync(q => q + .SystemFilter(systemFilter) + .AggregationsExpression($"terms:(organization_id~{viewOrganizations.Count} cardinality:stack_id)") + .EnforceEventStackFilter(false)); + + foreach (var organization in viewOrganizations) + { + var organizationStats = result.Aggregations.Terms("terms_organization_id")?.Buckets.FirstOrDefault(t => t.Key == organization.Id); + organization.EventCount = organizationStats?.Total ?? 0; + organization.StackCount = (long?)organizationStats?.Aggregations.Cardinality("cardinality_stack_id")?.Value ?? 0; + organization.ProjectCount = await projectRepository.GetCountByOrganizationIdAsync(organization.Id); + } + + return viewOrganizations; + } + + private async Task IsOrganizationNameAvailableInternalAsync(string name, HttpContext httpContext) + { + if (String.IsNullOrWhiteSpace(name)) + return false; + + string decodedName = Uri.UnescapeDataString(name).Trim().ToLowerInvariant(); + var results = await repository.GetByIdsAsync(httpContext.Request.GetAssociatedOrganizationIds().ToArray(), o => o.Cache()); + return !results.Any(o => String.Equals(o.Name.Trim().ToLowerInvariant(), decodedName, StringComparison.OrdinalIgnoreCase)); + } + + private static Result PermissionToResult(PermissionResult permission) + { + if (permission.StatusCode == StatusCodes.Status404NotFound) + return Result.NotFound(permission.Message ?? "Organization not found."); + + if (permission.StatusCode == StatusCodes.Status422UnprocessableEntity) + return Result.Invalid(ValidationError.Create("general", permission.Message ?? "Validation failed.")); + + return Result.Forbidden(permission.Message ?? "Access denied."); + } + + private static User GetCurrentUser(HttpContext httpContext) => httpContext.Request.GetUser(); + private static bool IsStatsMode(string? mode) => !String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase); + private static bool messageIsGlobalAdmin(HttpContext httpContext) => httpContext.Request.IsGlobalAdmin(); +} diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs new file mode 100644 index 0000000000..1601240460 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -0,0 +1,759 @@ +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Billing; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Queries; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Jobs; +using Foundatio.Mediator; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Foundatio.Serializer; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using PermissionResult = Exceptionless.Web.Controllers.PermissionResult; +using DataDictionary = Exceptionless.Core.Models.DataDictionary; + +namespace Exceptionless.Web.Api.Handlers; + +public class ProjectHandler( + IOrganizationRepository organizationRepository, + IProjectRepository repository, + IStackRepository stackRepository, + IEventRepository eventRepository, + ITokenRepository tokenRepository, + IQueue workItemQueue, + BillingManager billingManager, + SlackService slackService, + SampleDataService sampleDataService, + ApiMapper mapper, + ITextSerializer serializer, + AppOptions options, + UsageService usageService, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public async Task>> Handle(GetProjects message) + { + var organizations = await GetSelectedOrganizationsAsync(message.Context, message.Filter); + if (organizations.Count == 0) + return new PagedResult(Array.Empty(), false, 1, 0); + + int page = Pagination.GetPage(message.Page); + int limit = Pagination.GetLimit(message.Limit, Pagination.MaximumSkip); + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + var projects = await repository.GetByFilterAsync(sf, message.Filter, message.Sort, o => o.PageNumber(page).PageLimit(limit)); + var viewProjects = mapper.MapToViewProjects(projects.Documents); + await AfterResultMapAsync(viewProjects); + + if (IsStatsMode(message.Mode)) + return new PagedResult(await PopulateProjectStatsAsync(viewProjects), projects.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, projects.Total); + + return new PagedResult(viewProjects, projects.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, projects.Total); + } + + public async Task>> Handle(GetProjectsByOrganization message) + { + var organization = await GetOrganizationAsync(message.OrganizationId, message.Context); + if (organization is null) + return Result.NotFound("Project not found."); + + int page = Pagination.GetPage(message.Page); + int limit = Pagination.GetLimit(message.Limit, Pagination.MaximumSkip); + var sf = new AppFilter(organization); + var projects = await repository.GetByFilterAsync(sf, message.Filter, message.Sort, o => o.PageNumber(page).PageLimit(limit)); + var viewProjects = mapper.MapToViewProjects(projects.Documents); + await AfterResultMapAsync(viewProjects); + + if (IsStatsMode(message.Mode)) + return new PagedResult(await PopulateProjectStatsAsync(viewProjects), projects.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, projects.Total); + + return new PagedResult(viewProjects, projects.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, projects.Total); + } + + public async Task> Handle(GetProjectById message) + { + var project = await GetModelAsync(message.Id, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + var viewProject = mapper.MapToViewProject(project); + await AfterResultMapAsync([viewProject]); + + if (IsStatsMode(message.Mode)) + return await PopulateProjectStatsAsync(viewProject); + + return viewProject; + } + + public async Task> Handle(CreateProject message) + { + if (message.Project is null) + return Result.BadRequest("Project value is required."); + + var model = mapper.MapToProject(message.Project); + if (String.IsNullOrEmpty(model.OrganizationId) && message.Context.Request.GetAssociatedOrganizationIds().Count > 0) + model.OrganizationId = message.Context.Request.GetDefaultOrganizationId()!; + + var error = await CanAddAsync(model, message.Context); + if (error is not null) + return error; + + model = await AddModelAsync(model, message.Context); + var viewModel = mapper.MapToViewProject(model); + await AfterResultMapAsync([viewModel]); + return Result.Created(viewModel, $"/api/v2/projects/{model.Id}"); + } + + public async Task> Handle(UpdateProjectMessage message) + { + var original = await GetModelAsync(message.Id, message.Context, useCache: false); + if (original is null) + return Result.NotFound("Project not found."); + + if (message.PatchDocument.IsEmpty()) + return await MapToViewAsync(original); + + var validationResult = JsonPatchValidation.ValidateOperations(message.PatchDocument, "/organization_id"); + if (!validationResult.IsSuccess) + return Result.FromResult(validationResult); + + var dto = new UpdateProject { + Name = original.Name, + DeleteBotDataEnabled = original.DeleteBotDataEnabled, + PromotedTabs = original.PromotedTabs?.ToList() + }; + + var patchResult = JsonPatchValidation.ApplyPatch(message.PatchDocument, dto); + if (!patchResult.IsSuccess) + return Result.FromResult(patchResult); + + var error = await CanUpdateAsync(original, dto, message.PatchDocument, message.Context); + if (error is not null) + return error; + + original.Name = dto.Name; + original.DeleteBotDataEnabled = dto.DeleteBotDataEnabled; + if (message.PatchDocument.AffectsProperty(p => p.PromotedTabs)) + original.PromotedTabs = NormalizePromotedTabs(dto.PromotedTabs); + + await repository.SaveAsync(original, o => o.Cache()); + return await MapToViewAsync(original); + } + + public async Task> Handle(DeleteProjects message) + { + var items = await GetModelsAsync(message.Ids, message.Context, useCache: false); + if (items.Count == 0) + return Result.NotFound("Project not found."); + + var results = new ModelActionResults(); + results.AddNotFound(message.Ids.Except(items.Select(i => i.Id))); + + var deletableItems = items.ToList(); + foreach (var model in items) + { + var permission = await CanDeleteAsync(model, message.Context); + if (permission.Allowed) + continue; + + deletableItems.Remove(model); + results.Failure.Add(permission); + } + + if (deletableItems.Count == 0) + return results.Failure.Count == 1 ? Result.FromResult(PermissionToResult(results.Failure.First())) : results; + + IEnumerable workIds = await DeleteModelsAsync(deletableItems, message.Context); + if (results.Failure.Count == 0) + return new ModelActionResults { Workers = workIds.ToList() }; + + results.Workers.AddRange(workIds); + results.Success.AddRange(deletableItems.Select(i => i.Id)); + return results; + } + + public Task> Handle(GetLegacyProjectConfig message) + { + return GetConfigAsync(null, message.Version, message.Context); + } + + public Task> Handle(GetProjectConfig message) + { + return GetConfigAsync(message.Id, message.Version, message.Context); + } + + public async Task Handle(SetProjectConfig message) + { + if (String.IsNullOrWhiteSpace(message.Key) || String.IsNullOrWhiteSpace(message.Value?.Value)) + return Result.BadRequest("Invalid configuration value."); + + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + project.Configuration.Settings[message.Key.Trim()] = message.Value.Value.Trim(); + project.Configuration.IncrementVersion(); + await repository.SaveAsync(project, o => o.Cache()); + return Result.Success(); + } + + public async Task Handle(DeleteProjectConfig message) + { + if (String.IsNullOrWhiteSpace(message.Key)) + return Result.BadRequest("Invalid key value."); + + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + if (project.Configuration.Settings.Remove(message.Key.Trim())) + { + project.Configuration.IncrementVersion(); + await repository.SaveAsync(project, o => o.Cache()); + } + + return Result.Success(); + } + + public async Task> Handle(GenerateProjectSampleData message) + { + var project = await GetModelAsync(message.Id, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + string workItemId = await sampleDataService.EnqueueSampleEventsAsync(project.OrganizationId, project.Id); + return new WorkInProgressResult([workItemId]); + } + + public async Task> Handle(ResetProjectData message) + { + var project = await GetModelAsync(message.Id, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + string workItemId = await workItemQueue.EnqueueAsync(new ResetProjectDataWorkItem + { + OrganizationId = project.OrganizationId, + ProjectId = project.Id + }); + + return new WorkInProgressResult([workItemId]); + } + + public async Task>> Handle(GetProjectNotificationSettings message) + { + var project = await GetModelAsync(message.Id, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + return project.NotificationSettings; + } + + public async Task> Handle(GetProjectUserNotificationSettings message) + { + var project = await GetModelAsync(message.Id, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + if (!message.Context.Request.IsGlobalAdmin() && !String.Equals(GetCurrentUserId(message.Context), message.UserId, StringComparison.Ordinal)) + return Result.NotFound("Project not found."); + + return project.NotificationSettings.TryGetValue(message.UserId, out var settings) ? settings : new NotificationSettings(); + } + + public async Task> Handle(GetProjectIntegrationNotificationSettings message) + { + var project = await GetModelAsync(message.Id, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + if (!String.Equals(Project.NotificationIntegrations.Slack, message.Integration, StringComparison.Ordinal)) + return Result.NotFound("Project not found."); + + return project.NotificationSettings.TryGetValue(Project.NotificationIntegrations.Slack, out var settings) ? settings : new NotificationSettings(); + } + + public async Task Handle(SetProjectUserNotificationSettings message) + { + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + if (!message.Context.Request.IsGlobalAdmin() && !String.Equals(GetCurrentUserId(message.Context), message.UserId, StringComparison.Ordinal)) + return Result.NotFound("Project not found."); + + if (message.Settings is null) + project.NotificationSettings.Remove(message.UserId); + else + project.NotificationSettings[message.UserId] = message.Settings; + + await repository.SaveAsync(project, o => o.Cache()); + return Result.Success(); + } + + public async Task Handle(SetProjectIntegrationNotificationSettings message) + { + if (!String.Equals(Project.NotificationIntegrations.Slack, message.Integration, StringComparison.Ordinal)) + return Result.NotFound("Project not found."); + + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + var organization = await organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + if (organization is null) + return Result.NotFound("Project not found."); + + if (!organization.HasPremiumFeatures) + return Result.Invalid(ValidationError.Create("plan_limit", $"Please upgrade your plan to enable {message.Integration} integration.")); + + if (message.Settings is null) + project.NotificationSettings.Remove(message.Integration); + else + project.NotificationSettings[message.Integration] = message.Settings; + + await repository.SaveAsync(project, o => o.Cache()); + return Result.Success(); + } + + public async Task Handle(DeleteProjectNotificationSettings message) + { + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + if (!message.Context.Request.IsGlobalAdmin() && !String.Equals(GetCurrentUserId(message.Context), message.UserId, StringComparison.Ordinal)) + return Result.NotFound("Project not found."); + + if (project.NotificationSettings.Remove(message.UserId)) + await repository.SaveAsync(project, o => o.Cache()); + + return Result.Success(); + } + + public async Task Handle(PromoteProjectTab message) + { + if (String.IsNullOrWhiteSpace(message.Name)) + return Result.BadRequest("Invalid tab name."); + + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + string normalizedName = message.Name.Trim(); + project.PromotedTabs ??= []; + if (!project.PromotedTabs.Contains(normalizedName, StringComparer.Ordinal)) + { + project.PromotedTabs.Add(normalizedName); + await repository.SaveAsync(project, o => o.Cache()); + } + + return Result.Success(); + } + + public async Task Handle(DemoteProjectTab message) + { + if (String.IsNullOrWhiteSpace(message.Name)) + return Result.BadRequest("Invalid tab name."); + + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + if (project.PromotedTabs is not null) + { + project.PromotedTabs.Remove(message.Name.Trim()); + await repository.SaveAsync(project, o => o.Cache()); + } + + return Result.Success(); + } + + public async Task Handle(CheckProjectName message) + { + if (await IsProjectNameAvailableInternalAsync(message.OrganizationId, message.Name, message.Context)) + return Result.NoContent(); + + return Result.Created(); + } + + public async Task Handle(SetProjectData message) + { + if (String.IsNullOrWhiteSpace(message.Key) || String.IsNullOrWhiteSpace(message.Value?.Value) || message.Key.StartsWith('-')) + return Result.BadRequest("Invalid key or value."); + + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + project.Data ??= new DataDictionary(); + project.Data[message.Key.Trim()] = message.Value.Value.Trim(); + await repository.SaveAsync(project, o => o.Cache()); + + return Result.Success(); + } + + public async Task Handle(DeleteProjectData message) + { + if (String.IsNullOrWhiteSpace(message.Key) || message.Key.StartsWith('-')) + return Result.BadRequest("Invalid key value."); + + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + if (project.Data is not null && project.Data.Remove(message.Key.Trim())) + await repository.SaveAsync(project, o => o.Cache()); + + return Result.Success(); + } + + public async Task> Handle(AddProjectSlack message) + { + if (String.IsNullOrWhiteSpace(message.Code)) + return Result.BadRequest("Invalid Slack authorization code."); + + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + using var _ = _logger.BeginScope(new ExceptionlessState().Organization(project.OrganizationId).Project(project.Id).Property("Code", message.Code).Tag("Slack").Identity(GetCurrentUser(message.Context).EmailAddress).Property("User", GetCurrentUser(message.Context)).SetHttpContext(message.Context)); + + if (project.Data is not null && project.Data.ContainsKey(Project.KnownDataKeys.SlackToken)) + return new NotModifiedResponse(); + + SlackToken? token; + try + { + token = await slackService.GetAccessTokenAsync(message.Code); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting slack access token: {Message}", ex.Message); + throw; + } + + project.AddDefaultNotificationSettings(Project.NotificationIntegrations.Slack); + project.Data ??= new DataDictionary(); + project.Data[Project.KnownDataKeys.SlackToken] = token; + await repository.SaveAsync(project, o => o.Cache()); + + return Result.Success().Cast(); + } + + public async Task Handle(RemoveProjectSlack message) + { + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + var token = project.GetSlackToken(serializer, _logger); + using var _ = _logger.BeginScope(new ExceptionlessState().Property("Token", token).Tag("Slack").Identity(GetCurrentUser(message.Context).EmailAddress).Property("User", GetCurrentUser(message.Context)).SetHttpContext(message.Context)); + + if (token is not null) + await slackService.RevokeAccessTokenAsync(token.AccessToken); + + bool shouldSave = project.NotificationSettings.Remove(Project.NotificationIntegrations.Slack); + if (project.Data is not null && project.Data.Remove(Project.KnownDataKeys.SlackToken)) + shouldSave = true; + + if (shouldSave) + await repository.SaveAsync(project, o => o.Cache()); + + return Result.Success(); + } + + private async Task> GetConfigAsync(string? id, int? version, HttpContext httpContext) + { + if (String.IsNullOrEmpty(id)) + id = httpContext.User.GetProjectId(); + + var project = await repository.GetConfigAsync(id); + if (project is null) + return Result.NotFound("Project not found."); + + if (!httpContext.Request.CanAccessOrganization(project.OrganizationId)) + return Result.NotFound("Project not found."); + + if (version.HasValue && version == project.Configuration.Version) + return new NotModifiedResponse(); + + return project.Configuration; + } + + private async Task MapToViewAsync(Project model) + { + var viewModel = mapper.MapToViewProject(model); + await AfterResultMapAsync([viewModel]); + return viewModel; + } + + private async Task AfterResultMapAsync(ICollection models) + { + foreach (var model in models.OfType()) + model.Data?.RemoveSensitiveData(); + + var viewProjects = models.OfType().ToList(); + if (viewProjects.Count == 0) + return; + + var organizations = await organizationRepository.GetByIdsAsync(viewProjects.Select(p => p.OrganizationId).Distinct().ToArray(), o => o.Cache()); + foreach (var viewProject in viewProjects) + { + if (!viewProject.IsConfigured.HasValue) + { + viewProject.IsConfigured = true; + await workItemQueue.EnqueueAsync(new SetProjectIsConfiguredWorkItem { ProjectId = viewProject.Id }); + } + + var organization = organizations.SingleOrDefault(o => o.Id == viewProject.OrganizationId); + if (organization is null) + continue; + + viewProject.OrganizationName = organization.Name; + viewProject.HasPremiumFeatures = organization.HasPremiumFeatures; + + var realTimeUsage = await usageService.GetUsageAsync(organization.Id, viewProject.Id); + viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider); + viewProject.TrimUsage(timeProvider); + + var currentUsage = viewProject.GetCurrentUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider); + currentUsage.Limit = realTimeUsage.CurrentUsage.Limit; + currentUsage.Total = realTimeUsage.CurrentUsage.Total; + currentUsage.Blocked = realTimeUsage.CurrentUsage.Blocked; + currentUsage.Discarded = realTimeUsage.CurrentUsage.Discarded; + currentUsage.TooBig = realTimeUsage.CurrentUsage.TooBig; + currentUsage.Deleted = realTimeUsage.CurrentUsage.Deleted; + + var currentHourUsage = viewProject.GetCurrentHourlyUsage(timeProvider); + currentHourUsage.Total = realTimeUsage.CurrentHourUsage.Total; + currentHourUsage.Blocked = realTimeUsage.CurrentHourUsage.Blocked; + currentHourUsage.Discarded = realTimeUsage.CurrentHourUsage.Discarded; + currentHourUsage.TooBig = realTimeUsage.CurrentHourUsage.TooBig; + currentHourUsage.Deleted = realTimeUsage.CurrentHourUsage.Deleted; + } + } + + private async Task?> CanAddAsync(Project value, HttpContext httpContext) + { + if (String.IsNullOrEmpty(value.Name)) + return Result.BadRequest("Project name is required."); + + if (!await IsProjectNameAvailableInternalAsync(value.OrganizationId, value.Name, httpContext)) + return Result.BadRequest("A project with this name already exists."); + + if (!await billingManager.CanAddProjectAsync(value)) + return Result.Invalid(ValidationError.Create("plan_limit", "Please upgrade your plan to add additional projects.")); + + if (!httpContext.Request.CanAccessOrganization(value.OrganizationId)) + return Result.BadRequest("Invalid organization id specified."); + + return null; + } + + private Task AddModelAsync(Project value, HttpContext httpContext) + { + value.IsConfigured = false; + value.NextSummaryEndOfDayTicks = timeProvider.GetUtcNow().UtcDateTime.Date.AddDays(1).AddHours(1).Ticks; + value.AddDefaultNotificationSettings(GetCurrentUserId(httpContext)); + value.SetDefaultUserAgentBotPatterns(); + value.Configuration.IncrementVersion(); + return repository.AddAsync(value, o => o.Cache()); + } + + private async Task?> CanUpdateAsync(Project original, UpdateProject dto, JsonPatchDocument patch, HttpContext httpContext) + { + if (patch.AffectsProperty(p => p.Name) && !await IsProjectNameAvailableInternalAsync(original.OrganizationId, dto.Name, httpContext)) + return Result.BadRequest("A project with this name already exists."); + + if (!httpContext.Request.CanAccessOrganization(original.OrganizationId)) + return Result.BadRequest("Invalid organization id specified."); + + if (patch.AffectsPath("/organization_id")) + return Result.BadRequest("OrganizationId cannot be modified."); + + return null; + } + + private Task CanDeleteAsync(Project value, HttpContext httpContext) + { + if (!httpContext.Request.CanAccessOrganization(value.OrganizationId)) + return Task.FromResult(PermissionResult.DenyWithNotFound(value.Id)); + + return Task.FromResult(PermissionResult.Allow); + } + + private async Task> DeleteModelsAsync(ICollection projects, HttpContext httpContext) + { + var user = GetCurrentUser(httpContext); + foreach (var project in projects) + { + using var _ = _logger.BeginScope(new ExceptionlessState().Organization(project.OrganizationId).Project(project.Id).Tag("Delete").Identity(user.EmailAddress).Property("User", user).SetHttpContext(httpContext)); + _logger.UserDeletingProject(user.Id, project.Name); + await tokenRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); + } + + foreach (var project in projects.OfType()) + project.IsDeleted = true; + + await repository.SaveAsync(projects); + return []; + } + + private async Task GetModelAsync(string id, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + return null; + + var model = await repository.GetByIdAsync(id, o => o.Cache(useCache)); + if (model is null) + return null; + + if (!httpContext.Request.CanAccessOrganization(model.OrganizationId)) + return null; + + return model; + } + + private async Task> GetModelsAsync(string[] ids, HttpContext httpContext, bool useCache = true) + { + if (ids.Length == 0) + return []; + + var models = await repository.GetByIdsAsync(ids, o => o.Cache(useCache)); + return models.Where(m => httpContext.Request.CanAccessOrganization(m.OrganizationId)).ToList(); + } + + private Task GetOrganizationAsync(string organizationId, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(organizationId) || !httpContext.Request.CanAccessOrganization(organizationId)) + return Task.FromResult(null); + + return organizationRepository.GetByIdAsync(organizationId, o => o.Cache(useCache)); + } + + private async Task> GetSelectedOrganizationsAsync(HttpContext httpContext, string? filter = null) + { + var associatedOrganizationIds = httpContext.Request.GetAssociatedOrganizationIds(); + if (associatedOrganizationIds.Count == 0) + return Array.Empty(); + + if (!String.IsNullOrEmpty(filter)) + { + var scope = GetFilterScopeVisitor.Run(filter); + if (scope.IsScopable) + { + Organization? organization = null; + if (scope.OrganizationId is not null) + { + organization = await organizationRepository.GetByIdAsync(scope.OrganizationId, o => o.Cache()); + } + else if (scope.ProjectId is not null) + { + var project = await repository.GetByIdAsync(scope.ProjectId, o => o.Cache()); + if (project is not null) + organization = await organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + } + else if (scope.StackId is not null) + { + var stack = await stackRepository.GetByIdAsync(scope.StackId, o => o.Cache()); + if (stack is not null) + organization = await organizationRepository.GetByIdAsync(stack.OrganizationId, o => o.Cache()); + } + + if (organization is not null) + { + if (associatedOrganizationIds.Contains(organization.Id) || httpContext.Request.IsGlobalAdmin()) + return new[] { organization }; + + return Array.Empty(); + } + } + } + + return await organizationRepository.GetByIdsAsync(associatedOrganizationIds.ToArray(), o => o.Cache()); + } + + private async Task IsProjectNameAvailableInternalAsync(string? organizationId, string name, HttpContext httpContext) + { + if (String.IsNullOrWhiteSpace(name)) + return false; + + var organizationIds = organizationId is not null && httpContext.Request.IsInOrganization(organizationId) + ? new[] { organizationId } + : httpContext.Request.GetAssociatedOrganizationIds().ToArray(); + var projects = await repository.GetByOrganizationIdsAsync(organizationIds); + string decodedName = Uri.UnescapeDataString(name).Trim().ToLowerInvariant(); + return !projects.Documents.Any(p => String.Equals(p.Name.Trim().ToLowerInvariant(), decodedName, StringComparison.OrdinalIgnoreCase)); + } + + private async Task PopulateProjectStatsAsync(ViewProject project) + { + return (await PopulateProjectStatsAsync([project])).Single(); + } + + private async Task> PopulateProjectStatsAsync(List viewProjects) + { + if (viewProjects.Count == 0) + return viewProjects; + + int maximumRetentionDays = options.MaximumRetentionDays; + var organizations = await organizationRepository.GetByIdsAsync(viewProjects.Select(p => p.OrganizationId).ToArray(), o => o.Cache()); + var projects = viewProjects.Select(p => new Project { Id = p.Id, CreatedUtc = p.CreatedUtc, OrganizationId = p.OrganizationId }).ToList(); + var sf = new AppFilter(projects, organizations); + DateTime utcNow = timeProvider.GetUtcNow().UtcDateTime; + var retentionUtcCutoff = organizations.GetRetentionUtcCutoff(maximumRetentionDays, timeProvider); + var systemFilter = new RepositoryQuery() + .AppFilter(sf) + .DateRange(retentionUtcCutoff, utcNow, (PersistentEvent e) => e.Date) + .Index(retentionUtcCutoff, utcNow); + var result = await eventRepository.CountAsync(q => q + .SystemFilter(systemFilter) + .AggregationsExpression($"terms:(project_id~{viewProjects.Count} cardinality:stack_id)") + .EnforceEventStackFilter(false)); + + foreach (var project in viewProjects) + { + var term = result.Aggregations.Terms("terms_project_id")?.Buckets.FirstOrDefault(t => t.Key == project.Id); + project.EventCount = term?.Total ?? 0; + project.StackCount = (long)(term?.Aggregations.Cardinality("cardinality_stack_id")?.Value ?? 0); + } + + return viewProjects; + } + + private static Result PermissionToResult(PermissionResult permission) + { + if (permission.StatusCode == StatusCodes.Status404NotFound) + return Result.NotFound(permission.Message ?? "Project not found."); + + if (permission.StatusCode == StatusCodes.Status422UnprocessableEntity) + return Result.Invalid(ValidationError.Create("general", permission.Message ?? "Validation failed.")); + + return Result.Forbidden(permission.Message ?? "Access denied."); + } + + private static User GetCurrentUser(HttpContext httpContext) => httpContext.Request.GetUser(); + private static string GetCurrentUserId(HttpContext httpContext) => GetCurrentUser(httpContext).Id; + private static bool IsStatsMode(string? mode) => !String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase); + private static List NormalizePromotedTabs(IEnumerable? tabs) => tabs? + .Select(tab => tab.Trim()) + .Where(tab => !String.IsNullOrEmpty(tab)) + .Distinct(StringComparer.Ordinal) + .ToList() ?? []; +} diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs similarity index 51% rename from src/Exceptionless.Web/Controllers/SavedViewController.cs rename to src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index 06ca1ce741..10bc512f79 100644 --- a/src/Exceptionless.Web/Controllers/SavedViewController.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -4,191 +4,119 @@ using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; -using Exceptionless.Core.Queries.Validation; using Exceptionless.Core.Repositories; using Exceptionless.Core.Seed; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; -using Exceptionless.Web.Utility; using Foundatio.Lock; using Foundatio.Repositories; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using PermissionResult = Exceptionless.Web.Controllers.PermissionResult; using DataDictionary = Exceptionless.Core.Models.DataDictionary; -namespace Exceptionless.App.Controllers.API; +namespace Exceptionless.Web.Api.Handlers; -[Route(API_PREFIX + "/saved-views")] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public partial class SavedViewController : RepositoryApiController +public partial class SavedViewHandler( + ISavedViewRepository repository, + IOrganizationRepository organizationRepository, + ILockProvider lockProvider, + ApiMapper mapper, + IHttpContextAccessor httpContextAccessor) { private const int MaxViewsPerOrganization = 100; private const string PredefinedSavedViewsDataKey = "@@PredefinedSavedViewsVersion"; - private const int PredefinedSavedViewsVersion = 1; + private const int PredefinedSavedViewsVersion = 4; - private readonly IOrganizationRepository _organizationRepository; - private readonly ILockProvider _lockProvider; + private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); - public SavedViewController( - ISavedViewRepository repository, - IOrganizationRepository organizationRepository, - ILockProvider lockProvider, - ApiMapper mapper, - IAppQueryValidator validator, - TimeProvider timeProvider, - ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) + public async Task>> Handle(GetSavedViewsByOrganization message) { - _organizationRepository = organizationRepository; - _lockProvider = lockProvider; - } - - protected override SavedView MapToModel(NewSavedView newModel) - { - var model = _mapper.MapToSavedView(newModel); - model.Slug = ToSlug(String.IsNullOrWhiteSpace(model.Slug) ? model.Name : model.Slug); - return model; - } - - protected override ViewSavedView MapToViewModel(SavedView model) - { - return _mapper.MapToViewSavedView(model); - } - - protected override List MapToViewModels(IEnumerable models) => models.Select(MapToViewModel).ToList(); - - /// - /// Get by organization - /// - /// The identifier of the organization. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The organization could not be found. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views")] - public async Task>> GetByOrganizationAsync(string organizationId, int page = 1, int limit = 25) - { - if (!CanAccessOrganization(organizationId)) - return NotFound(); + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); - // Reads remain available even when the feature is disabled to preserve access to existing saved views. - await EnsurePredefinedSavedViewsCreatedAsync(organizationId); + await EnsurePredefinedSavedViewsCreatedAsync(message.OrganizationId); - page = GetPage(page); - limit = GetLimit(limit); - var results = await _repository.GetByOrganizationForUserAsync(organizationId, CurrentUser.Id, o => o.PageNumber(page).PageLimit(limit)); + int page = GetPage(message.Page); + int limit = GetLimit(message.Limit); + var results = await repository.GetByOrganizationForUserAsync(message.OrganizationId, GetCurrentUserId(), o => o.PageNumber(page).PageLimit(limit)); AppDiagnostics.SavedViewsSize.Add((int)results.Total); var viewModels = MapToViewModels(results.Documents); - return OkWithResourceLinks(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); + return new PagedResult(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); } - /// - /// Get by organization and view - /// - /// The identifier of the organization. - /// The dashboard view type (events, stacks, stream). - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The organization could not be found. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views/{viewType}")] - public async Task>> GetByViewAsync(string organizationId, string viewType, int page = 1, int limit = 25) + public async Task>> Handle(GetSavedViewsByView message) { - if (!CanAccessOrganization(organizationId)) - return NotFound(); + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); - if (!NewSavedView.ValidViewTypes.Contains(viewType)) - return NotFound(); + if (!NewSavedView.ValidViewTypes.Contains(message.ViewType)) + return Result.NotFound("Organization not found."); - // Reads remain available even when the feature is disabled to preserve access to existing saved views. - await EnsurePredefinedSavedViewsCreatedAsync(organizationId); + await EnsurePredefinedSavedViewsCreatedAsync(message.OrganizationId); - page = GetPage(page); - limit = GetLimit(limit); - var results = await _repository.GetByViewForUserAsync(organizationId, viewType, CurrentUser.Id, o => o.PageNumber(page).PageLimit(limit)); + int page = GetPage(message.Page); + int limit = GetLimit(message.Limit); + var results = await repository.GetByViewForUserAsync(message.OrganizationId, message.ViewType, GetCurrentUserId(), o => o.PageNumber(page).PageLimit(limit)); AppDiagnostics.SavedViewsViewTypeSize.Add((int)results.Total); var viewModels = MapToViewModels(results.Documents); - return OkWithResourceLinks(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); + return new PagedResult(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); } - /// - /// Get by id - /// - /// The identifier of the saved view. - /// The saved view could not be found. - [HttpGet("{id:objectid}", Name = "GetSavedViewById")] - public Task> GetAsync(string id) + public async Task> Handle(GetSavedViewById message) { - return GetByIdImplAsync(id); + var model = await GetModelAsync(message.Id); + if (model is null) + return Result.NotFound("Saved view not found."); + + return MapToViewModel(model); } - /// - /// Create - /// - /// The identifier of the organization. - /// The saved view. - /// An error occurred while creating the saved view. - /// The saved view already exists. - [HttpPost("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views")] - [Consumes("application/json")] - [ProducesResponseType(StatusCodes.Status201Created)] - public async Task> PostAsync(string organizationId, NewSavedView savedView) + public async Task> Handle(CreateSavedView message) { - if (!IsInOrganization(organizationId)) - return BadRequest(); + if (!HttpContext.Request.IsInOrganization(message.OrganizationId)) + return Result.BadRequest("Invalid organization."); - savedView.OrganizationId = organizationId; + var savedView = message.SavedView; + savedView.OrganizationId = message.OrganizationId; if (savedView.IsPrivate is true) - savedView.UserId = CurrentUser.Id; + savedView.UserId = GetCurrentUserId(); return await PostImplAsync(savedView); } - /// - /// Create or update predefined saved views - /// - /// The identifier of the organization. - /// The predefined saved views were created or updated. - /// The organization could not be found. - [HttpPost("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views/predefined")] - public async Task>> PostPredefinedAsync(string organizationId) + public async Task>> Handle(CreatePredefinedSavedViews message) { - if (!IsInOrganization(organizationId)) - return NotFound(); + if (!HttpContext.Request.IsInOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); - var savedViews = await UpsertPredefinedSavedViewsAsync(organizationId); - return Ok(MapToViewModels(savedViews)); + var savedViews = await UpsertPredefinedSavedViewsAsync(message.OrganizationId); + return MapToViewModels(savedViews); } - /// - /// Get global predefined saved views as seed JSON - /// - /// The current predefined saved views. - [HttpGet("predefined")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task>> GetPredefinedAsync() + public async Task>> Handle(GetPredefinedSavedViews message) { - return Ok(await GetPredefinedSavedViewsAsync()); + var definitions = await GetPredefinedSavedViewsAsync(); + return Result>.Success(definitions); } - /// - /// Get an organization's saved views exported as predefined definitions - /// - /// The identifier of the organization to export from. - /// The organization's saved views as predefined definitions. - /// The organization could not be found. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views/export")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task>> ExportOrganizationSavedViewsAsync(string organizationId) + public async Task>> Handle(ExportOrganizationSavedViews message) { - if (!CanAccessOrganization(organizationId)) - return NotFound(); + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); var definitions = new List(); foreach (var viewType in NewSavedView.ValidViewTypes) { - var results = await _repository.GetByViewAsync(organizationId, viewType, o => o.PageLimit(1000)); + var results = await repository.GetByViewAsync(message.OrganizationId, viewType, o => o.PageLimit(1000)); foreach (var savedView in results.Documents.Where(v => v.UserId is null)) { var key = GetPredefinedKey(savedView); @@ -196,30 +124,22 @@ public async Task - /// Replace all predefined saved views with the provided definitions - /// - /// The full set of predefined saved view definitions. - /// The predefined saved views were replaced. - [HttpPut("predefined")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task>> PutPredefinedAsync([FromBody] IReadOnlyCollection definitions) + public async Task>> Handle(ReplacePredefinedSavedViews message) { - // Remove all existing system predefined views foreach (var viewType in NewSavedView.ValidViewTypes) { var existingViews = await GetSystemPredefinedSavedViewsAsync(viewType); if (existingViews.Count > 0) - await _repository.RemoveAsync(existingViews.Select(v => v.Id).ToList(), o => o.ImmediateConsistency()); + await repository.RemoveAsync(existingViews.Select(v => v.Id).ToList(), o => o.ImmediateConsistency()); } - var savedViews = definitions.Select(definition => new SavedView + var savedViews = message.Definitions.Select(definition => new SavedView { OrganizationId = PredefinedSavedViewsDataSeed.SystemOrganizationId, - CreatedByUserId = CurrentUser.Id, + CreatedByUserId = GetCurrentUserId(), PredefinedKey = definition.Key, Name = definition.Name, Slug = definition.Slug, @@ -227,8 +147,8 @@ public async Task(cols) : null, + FilterDefinitions = definition.FilterDefinitions is { } filterDefinitions ? JsonSerializer.Serialize(filterDefinitions) : null, + Columns = definition.Columns is { } columns ? new Dictionary(columns) : null, ColumnOrder = definition.ColumnOrder?.ToList(), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, @@ -237,259 +157,337 @@ public async Task 0) - await _repository.AddAsync(savedViews, o => o.Cache().ImmediateConsistency()); + await repository.AddAsync(savedViews, o => o.Cache().ImmediateConsistency()); - return Ok(await GetPredefinedSavedViewsAsync()); + return Result>.Success(await GetPredefinedSavedViewsAsync()); } - /// - /// Save a saved view as a global predefined saved view - /// - /// The identifier of the saved view to promote. - /// The predefined saved view was created or updated. - /// The saved view could not be found. - [HttpPost("{id:objectid}/predefined")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task> PostPredefinedSavedViewAsync(string id) + public async Task> Handle(PromoteToPredefinedSavedView message) { - var source = await _repository.GetByIdAsync(id); + var source = await repository.GetByIdAsync(message.Id); if (source is null) - return NotFound(); + return Result.NotFound("Saved view not found."); var savedView = await UpsertSystemPredefinedSavedViewAsync(source); - return Ok(MapToViewModel(savedView)); + return MapToViewModel(savedView); } - /// - /// Delete a global predefined saved view - /// - /// The identifier of the saved view whose predefined saved view should be deleted. - /// The predefined saved view was deleted. - /// The saved view could not be found. - [HttpDelete("{id:objectid}/predefined")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task DeletePredefinedSavedViewAsync(string id) + public async Task Handle(DeletePredefinedSavedView message) { - var source = await _repository.GetByIdAsync(id); + var source = await repository.GetByIdAsync(message.Id); if (source is null) - return NotFound(); + return Result.NotFound("Saved view not found."); await DeleteSystemPredefinedSavedViewAsync(source); - return NoContent(); + return Result.NoContent(); } - /// - /// Update - /// - /// The identifier of the saved view. - /// The changes - /// An error occurred while updating the saved view. - /// The saved view could not be found. - [HttpPatch("{id:objectid}")] - [HttpPut("{id:objectid}")] - [Consumes("application/json")] - public Task> PatchAsync(string id, Delta changes) + public async Task> Handle(UpdateSavedViewMessage message) { - return PatchImplAsync(id, changes); + var original = await GetModelAsync(message.Id, useCache: false); + if (original is null) + return Result.NotFound("Saved view not found."); + + if (message.PatchDocument.IsEmpty()) + return MapToViewModel(original); + + var validationResult = JsonPatchValidation.ValidateOperations(message.PatchDocument); + if (!validationResult.IsSuccess) + return Result.FromResult(validationResult); + + var dto = new UpdateSavedView { + Name = original.Name, + Filter = original.Filter, + Time = original.Time, + Sort = original.Sort, + Slug = original.Slug, + FilterDefinitions = original.FilterDefinitions, + Columns = original.Columns is not null ? new Dictionary(original.Columns) : null, + ColumnOrder = original.ColumnOrder is not null ? [.. original.ColumnOrder] : null, + ShowStats = original.ShowStats, + ShowChart = original.ShowChart + }; + + var patchResult = JsonPatchValidation.ApplyPatch(message.PatchDocument, dto); + if (!patchResult.IsSuccess) + return Result.FromResult(patchResult); + + var error = await CanUpdateAsync(original, dto, message.PatchDocument); + if (error is not null) + return error; + + var changedNames = message.PatchDocument.GetAffectedPropertyNames(); + original.Name = dto.Name!; + original.Filter = dto.Filter; + original.Time = dto.Time; + original.Sort = dto.Sort; + original.Slug = dto.Slug!; + original.FilterDefinitions = dto.FilterDefinitions; + original.Columns = dto.Columns is not null ? new Dictionary(dto.Columns) : null; + original.ColumnOrder = dto.ColumnOrder is not null ? [.. dto.ColumnOrder] : null; + original.ShowStats = dto.ShowStats; + original.ShowChart = dto.ShowChart; + + if (changedNames.Contains(nameof(UpdateSavedView.Slug))) + original.Slug = ToSlug(original.Slug); + + if (String.IsNullOrWhiteSpace(original.Slug)) + original.Slug = ToFallbackSlug(original.Name, original.Id); + + original.UpdatedByUserId = GetCurrentUserId(); + + await repository.SaveAsync(original, o => o.Cache()); + return MapToViewModel(original); } - /// - /// Remove - /// - /// A comma-delimited list of saved view identifiers. - /// Accepted - /// One or more validation errors occurred. - /// One or more saved views were not found. - /// An error occurred while deleting one or more saved views. - [HttpDelete("{ids:objectids}")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task> DeleteAsync(string ids) + public async Task> Handle(DeleteSavedViews message) { - return DeleteImplAsync(ids.FromDelimitedString()); + var items = await GetModelsAsync(message.Ids, useCache: false); + if (items.Count == 0) + return Result.NotFound("No saved views found."); + + var results = new ModelActionResults(); + results.AddNotFound(message.Ids.Except(items.Select(i => i.Id))); + + var deletableItems = items.ToList(); + foreach (var model in items) + { + var permission = CanDelete(model); + if (permission.Allowed) + continue; + + deletableItems.Remove(model); + results.Failure.Add(permission); + } + + if (deletableItems.Count == 0) + return results.Failure.Count == 1 ? Result.FromResult(PermissionToResult(results.Failure.First())) : results; + + await repository.RemoveAsync(deletableItems); + + if (results.Failure.Count == 0) + return new ModelActionResults(); + + results.Success.AddRange(deletableItems.Select(i => i.Id)); + return results; } - protected override async Task GetModelAsync(string id, bool useCache = true) + private async Task> PostImplAsync(NewSavedView value) { - if (String.IsNullOrEmpty(id)) - return null; + if (value is null) + return Result.BadRequest("Saved view value is required."); - var model = await _repository.GetByIdAsync(id, o => o.Cache(useCache)); - if (model is null) - return null; + var mapped = mapper.MapToSavedView(value); + mapped.Slug = ToSlug(String.IsNullOrWhiteSpace(mapped.Slug) ? mapped.Name : mapped.Slug); - if (!String.IsNullOrEmpty(model.OrganizationId) && !IsInOrganization(model.OrganizationId)) - return null; + if (String.IsNullOrEmpty(mapped.OrganizationId) && HttpContext.Request.GetAssociatedOrganizationIds().Count > 0) + mapped.OrganizationId = HttpContext.Request.GetDefaultOrganizationId()!; - if (model.UserId is not null && model.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) - return null; + var error = await CanAddAsync(mapped); + if (error is not null) + return error; - return model; + mapped.CreatedByUserId = GetCurrentUserId(); + mapped.Version = 1; + + var model = await repository.AddAsync(mapped, o => o.Cache()); + var viewModel = MapToViewModel(model); + return Result.Created(viewModel, $"/api/v2/saved-views/{model.Id}"); } - protected override async Task CanAddAsync(SavedView value) + private async Task?> CanAddAsync(SavedView value) { - if (String.IsNullOrEmpty(value.OrganizationId) || !IsInOrganization(value.OrganizationId)) - return PermissionResult.Deny; + if (String.IsNullOrEmpty(value.OrganizationId) || !HttpContext.Request.IsInOrganization(value.OrganizationId)) + return Result.Forbidden("Access denied."); - var count = await _repository.CountByOrganizationIdAsync(value.OrganizationId); + var count = await repository.CountByOrganizationIdAsync(value.OrganizationId); if (count >= MaxViewsPerOrganization) - return PermissionResult.DenyWithMessage($"Organization is limited to {MaxViewsPerOrganization} saved views."); + return Result.BadRequest($"Organization is limited to {MaxViewsPerOrganization} saved views."); if (String.IsNullOrWhiteSpace(value.Slug)) - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name cannot be empty. Use at least one letter or number."); + return Result.Invalid(ValidationError.Create("slug", "URL name cannot be empty. Use at least one letter or number.")); if (IsReservedSlug(value.Slug)) - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name cannot look like an event or issue id."); + return Result.Invalid(ValidationError.Create("general", "URL name cannot look like an event or issue id.")); if (!IsValidSlug(value.Slug)) - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name can only contain lowercase letters, numbers, and single dashes."); + return Result.Invalid(ValidationError.Create("general", "URL name can only contain lowercase letters, numbers, and single dashes.")); if (await NameExistsAsync(value.OrganizationId, value.ViewType, value.Name, null)) - return PermissionResult.DenyWithStatus(StatusCodes.Status409Conflict, $"A saved view named '{value.Name.Trim()}' already exists."); + return Result.Conflict($"A saved view named '{value.Name.Trim()}' already exists."); if (await SlugExistsAsync(value.OrganizationId, value.ViewType, value.Slug, null)) - return PermissionResult.DenyWithStatus(StatusCodes.Status409Conflict, $"A saved view with URL name '{value.Slug}' already exists."); + return Result.Conflict($"A saved view with URL name '{value.Slug}' already exists."); - return await base.CanAddAsync(value); + if (!HttpContext.Request.CanAccessOrganization(value.OrganizationId)) + return Result.Invalid(ValidationError.Create("organization_id", "Invalid organization id specified.")); + + return null; } - protected override async Task CanUpdateAsync(SavedView original, Delta changes) + private async Task?> CanUpdateAsync(SavedView original, UpdateSavedView dto, JsonPatchDocument patch) { - if (original.UserId is not null && original.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) - return PermissionResult.DenyWithNotFound(original.Id); + if (original.UserId is not null && original.UserId != GetCurrentUserId() && !HttpContext.User.IsInRole(AuthorizationRoles.GlobalAdmin)) + return Result.NotFound("Saved view not found."); - // Delta bypasses IValidatableObject — enforce data-annotation and custom validation manually. - var changedNames = changes.GetChangedPropertyNames(); + var changedNames = patch.GetAffectedPropertyNames(); - if (changedNames.Contains(nameof(UpdateSavedView.Name)) - && changes.TryGetPropertyValue(nameof(UpdateSavedView.Name), out object? nameValue) - && nameValue is string name && String.IsNullOrWhiteSpace(name)) - { - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "Name cannot be empty or whitespace."); - } + if (changedNames.Contains(nameof(UpdateSavedView.Name)) && String.IsNullOrWhiteSpace(dto.Name)) + return Result.Invalid(ValidationError.Create("name", "Name cannot be empty or whitespace.")); + + if (changedNames.Contains(nameof(UpdateSavedView.Slug)) && String.IsNullOrWhiteSpace(dto.Slug)) + return Result.Invalid(ValidationError.Create("slug", "URL name cannot be empty. Use at least one letter or number.")); + + if (dto.Name is { Length: > 100 }) + return Result.Invalid(ValidationError.Create("name", "Name cannot exceed 100 characters.")); + + if (dto.Slug is { Length: > 100 }) + return Result.Invalid(ValidationError.Create("slug", "Slug cannot exceed 100 characters.")); + + if (dto.Filter is { Length: > 2000 }) + return Result.Invalid(ValidationError.Create("filter", "Filter cannot exceed 2000 characters.")); - if (changedNames.Contains(nameof(UpdateSavedView.Slug)) - && changes.TryGetPropertyValue(nameof(UpdateSavedView.Slug), out object? slugValue) - && (slugValue is not string slug || String.IsNullOrWhiteSpace(slug))) + if (dto.Time is { Length: > 100 }) + return Result.Invalid(ValidationError.Create("time", "Time cannot exceed 100 characters.")); + + if (dto.Sort is { Length: > 100 }) + return Result.Invalid(ValidationError.Create("sort", "Sort cannot exceed 100 characters.")); + + if (dto.FilterDefinitions is { Length: > SavedView.MaxFilterDefinitionsLength }) { - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name cannot be empty. Use at least one letter or number."); + return Result.Invalid(ValidationError.Create("filter_definitions", $"FilterDefinitions cannot exceed {SavedView.MaxFilterDefinitionsLength} characters.")); } - var lengthResult = ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Name), 100) - ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Slug), 100) - ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Filter), 2000) - ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Time), 100) - ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Sort), 100) - ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.FilterDefinitions), SavedView.MaxFilterDefinitionsLength); - if (lengthResult is not null) - return lengthResult; - if (changedNames.Contains(nameof(UpdateSavedView.Name)) - && changes.TryGetPropertyValue(nameof(UpdateSavedView.Name), out nameValue) - && nameValue is string changedName + && dto.Name is string changedName && await NameExistsAsync(original.OrganizationId, original.ViewType, changedName, original.Id)) { - return PermissionResult.DenyWithStatus(StatusCodes.Status409Conflict, $"A saved view named '{changedName.Trim()}' already exists."); + return Result.Conflict($"A saved view named '{changedName.Trim()}' already exists."); } - if (changedNames.Contains(nameof(UpdateSavedView.Slug)) - && changes.TryGetPropertyValue(nameof(UpdateSavedView.Slug), out slugValue) - && slugValue is string changedSlug) + if (changedNames.Contains(nameof(UpdateSavedView.Slug)) && dto.Slug is string changedSlug) { var normalizedSlug = ToSlug(changedSlug); if (IsReservedSlug(normalizedSlug)) - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name cannot look like an event or issue id."); + return Result.Invalid(ValidationError.Create("general", "URL name cannot look like an event or issue id.")); if (!IsValidSlug(normalizedSlug)) - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name can only contain lowercase letters, numbers, and single dashes."); + return Result.Invalid(ValidationError.Create("general", "URL name can only contain lowercase letters, numbers, and single dashes.")); if (await SlugExistsAsync(original.OrganizationId, original.ViewType, normalizedSlug, original.Id)) - return PermissionResult.DenyWithStatus(StatusCodes.Status409Conflict, $"A saved view with URL name '{normalizedSlug}' already exists."); + return Result.Conflict($"A saved view with URL name '{normalizedSlug}' already exists."); } if (changedNames.Contains(nameof(UpdateSavedView.FilterDefinitions)) - && changes.TryGetPropertyValue(nameof(UpdateSavedView.FilterDefinitions), out object? filterDefsValue) - && filterDefsValue is string filterDefs + && dto.FilterDefinitions is { Length: > 0 } filterDefs && !NewSavedView.IsValidJsonArray(filterDefs)) { - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "FilterDefinitions must be a valid JSON array."); + return Result.Invalid(ValidationError.Create("filter_definitions", "FilterDefinitions must be a valid JSON array.")); } if (changedNames.Contains(nameof(UpdateSavedView.Columns)) || changedNames.Contains(nameof(UpdateSavedView.ColumnOrder))) { - var patchedChanges = new UpdateSavedView(); - changes.Patch(patchedChanges); - - var validationError = ValidateColumns(original.ViewType, patchedChanges); + var validationError = ValidateColumns(original.ViewType, dto); if (validationError is not null) - { - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, validationError.ErrorMessage ?? "Invalid column keys."); - } + return Result.Invalid(ValidationError.Create("columns", validationError.ErrorMessage ?? "Invalid column keys.")); } - return await base.CanUpdateAsync(original, changes); + if (!HttpContext.Request.CanAccessOrganization(original.OrganizationId)) + return Result.Invalid(ValidationError.Create("organization_id", "Invalid organization id specified.")); + + return null; } - private static PermissionResult? ValidateStringLength(Delta changes, IEnumerable changedNames, string propertyName, int maxLength) where T : class, new() + private PermissionResult CanDelete(SavedView value) { - if (changedNames.Contains(propertyName) - && changes.TryGetPropertyValue(propertyName, out object? value) - && value is string s && s.Length > maxLength) - { - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, $"{propertyName} cannot exceed {maxLength} characters."); - } + if (value.UserId is not null && value.UserId != GetCurrentUserId() && !HttpContext.User.IsInRole(AuthorizationRoles.GlobalAdmin)) + return PermissionResult.DenyWithNotFound(value.Id); - return null; + if (!HttpContext.Request.CanAccessOrganization(value.OrganizationId)) + return PermissionResult.DenyWithNotFound(value.Id); + + return PermissionResult.Allow; } - private static ValidationResult? ValidateColumns(string viewType, UpdateSavedView changes) + private async Task GetModelAsync(string id, bool useCache = true) { - if (changes.Columns is not null && changes.Columns.Count > 50) - return new ValidationResult("Columns cannot exceed 50 items.", [nameof(UpdateSavedView.Columns)]); + if (String.IsNullOrEmpty(id)) + return null; - if (changes.ColumnOrder is not null && changes.ColumnOrder.Count > 50) - return new ValidationResult("ColumnOrder cannot exceed 50 items.", [nameof(UpdateSavedView.ColumnOrder)]); + var model = await repository.GetByIdAsync(id, o => o.Cache(useCache)); + if (model is null) + return null; - return NewSavedView.ValidateColumnKeys(viewType, changes.Columns) - .Concat(NewSavedView.ValidateColumnOrder(viewType, changes.ColumnOrder)) - .FirstOrDefault(); + if (!String.IsNullOrEmpty(model.OrganizationId) && !HttpContext.Request.IsInOrganization(model.OrganizationId)) + return null; + + if (model.UserId is not null && model.UserId != GetCurrentUserId() && !HttpContext.User.IsInRole(AuthorizationRoles.GlobalAdmin)) + return null; + + return model; } - protected override Task AddModelAsync(SavedView value) + private async Task> GetModelsAsync(string[] ids, bool useCache = true) { - value.CreatedByUserId = CurrentUser.Id; - value.Version = 1; + if (ids.Length == 0) + return []; - return base.AddModelAsync(value); + var models = await repository.GetByIdsAsync(ids, o => o.Cache(useCache)); + return models.Where(m => HttpContext.Request.CanAccessOrganization(m.OrganizationId)).ToList(); } - protected override Task UpdateModelAsync(SavedView original, Delta changes) + private ViewSavedView MapToViewModel(SavedView model) { - var changedNames = changes.GetChangedPropertyNames(); - changes.Patch(original); + var viewModel = mapper.MapToViewSavedView(model); + if (String.IsNullOrWhiteSpace(viewModel.Slug)) + viewModel.Slug = ToFallbackSlug(viewModel.Name, viewModel.Id); - if (changedNames.Contains(nameof(UpdateSavedView.Slug))) - original.Slug = ToSlug(original.Slug); + AfterResultMap([viewModel]); + return viewModel; + } - if (String.IsNullOrWhiteSpace(original.Slug)) - original.Slug = ToSlug(original.Name); + private List MapToViewModels(IEnumerable models) => models.Select(MapToViewModel).ToList(); - original.UpdatedByUserId = CurrentUser.Id; + private string GetCurrentUserId() => HttpContext.Request.GetUser().Id; - return _repository.SaveAsync(original, o => o.Cache()); + private static void AfterResultMap(ICollection models) + { + foreach (var model in models.OfType()) + model.Data?.RemoveSensitiveData(); } - protected override async Task CanDeleteAsync(SavedView value) + private static Result PermissionToResult(PermissionResult permission) { - if (value.UserId is not null && value.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) - return PermissionResult.DenyWithNotFound(value.Id); + if (permission.StatusCode is StatusCodes.Status404NotFound) + return Result.NotFound(permission.Message ?? "Saved view not found."); + + if (permission.StatusCode is StatusCodes.Status409Conflict) + return Result.Conflict(permission.Message ?? "Conflict."); - return await base.CanDeleteAsync(value); + if (permission.StatusCode is StatusCodes.Status422UnprocessableEntity) + return Result.Invalid(ValidationError.Create("general", permission.Message ?? "Validation failed.")); + + return Result.Forbidden(permission.Message ?? "Access denied."); } + private static ValidationResult? ValidateColumns(string viewType, UpdateSavedView changes) + { + if (changes.Columns is not null && changes.Columns.Count > 50) + return new ValidationResult("Columns cannot exceed 50 items.", [nameof(UpdateSavedView.Columns)]); + + if (changes.ColumnOrder is not null && changes.ColumnOrder.Count > 50) + return new ValidationResult("ColumnOrder cannot exceed 50 items.", [nameof(UpdateSavedView.ColumnOrder)]); + + return NewSavedView.ValidateColumnKeys(viewType, changes.Columns) + .Concat(NewSavedView.ValidateColumnOrder(viewType, changes.ColumnOrder)) + .FirstOrDefault(); + } + + // --- Predefined saved views logic --- + private async Task EnsurePredefinedSavedViewsCreatedAsync(string organizationId) { - var organization = await _organizationRepository.GetByIdAsync(organizationId); + var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization is null || HasCreatedPredefinedSavedViews(organization)) return; @@ -500,9 +498,9 @@ private async Task> UpsertPredefinedSavedViewsAsy { List savedViews = []; - bool lockAcquired = await _lockProvider.TryUsingAsync($"predefined-saved-views:{organizationId}", async () => + bool lockAcquired = await lockProvider.TryUsingAsync($"predefined-saved-views:{organizationId}", async () => { - var organization = await _organizationRepository.GetByIdAsync(organizationId); + var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization is null) return; @@ -515,7 +513,7 @@ private async Task> UpsertPredefinedSavedViewsAsy savedViews = await UpsertPredefinedSavedViewsForOrganizationAsync(organizationId); organization.Data ??= new DataDictionary(); organization.Data[PredefinedSavedViewsDataKey] = PredefinedSavedViewsVersion.ToString(); - await _organizationRepository.SaveAsync(organization, o => o.Cache().ImmediateConsistency()); + await organizationRepository.SaveAsync(organization, o => o.Cache().ImmediateConsistency()); }, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15)); if (!lockAcquired) @@ -534,7 +532,7 @@ private async Task> UpsertPredefinedSavedViewsForOrganizationAsy { if (!savedViewsByView.TryGetValue(definition.ViewType, out var existingViews)) { - var results = await _repository.GetByViewAsync(organizationId, definition.ViewType, o => o.PageLimit(1000)); + var results = await repository.GetByViewAsync(organizationId, definition.ViewType, o => o.PageLimit(1000)); existingViews = results.Documents.ToList(); savedViewsByView.Add(definition.ViewType, existingViews); } @@ -545,7 +543,7 @@ private async Task> UpsertPredefinedSavedViewsForOrganizationAsy if (existing is null) { var savedView = CreatePredefinedSavedView(organizationId, definition, slug); - await _repository.AddAsync(savedView, o => o.Cache().ImmediateConsistency()); + await repository.AddAsync(savedView, o => o.Cache().ImmediateConsistency()); existingViews.Add(savedView); upserted.Add(savedView); continue; @@ -553,8 +551,8 @@ private async Task> UpsertPredefinedSavedViewsForOrganizationAsy if (ApplyPredefinedSavedView(existing, definition, slug)) { - existing.UpdatedByUserId = CurrentUser.Id; - await _repository.SaveAsync(existing, o => o.Cache().ImmediateConsistency()); + existing.UpdatedByUserId = GetCurrentUserId(); + await repository.SaveAsync(existing, o => o.Cache().ImmediateConsistency()); } upserted.Add(existing); @@ -573,7 +571,7 @@ private async Task> GetExistingPredefinedSavedViewsForOrganizati { if (!savedViewsByView.TryGetValue(definition.ViewType, out var existingViews)) { - var results = await _repository.GetByViewAsync(organizationId, definition.ViewType, o => o.PageLimit(1000)); + var results = await repository.GetByViewAsync(organizationId, definition.ViewType, o => o.PageLimit(1000)); existingViews = results.Documents.ToList(); savedViewsByView.Add(definition.ViewType, existingViews); } @@ -605,7 +603,7 @@ private SavedView CreatePredefinedSavedView(string organizationId, PredefinedSav return new SavedView { OrganizationId = organizationId, - CreatedByUserId = CurrentUser.Id, + CreatedByUserId = GetCurrentUserId(), PredefinedKey = definition.Key, Name = definition.Name, Slug = slug, @@ -652,13 +650,13 @@ private async Task UpsertSystemPredefinedSavedViewAsync(SavedView sou if (existing is null) { var savedView = CreateSystemPredefinedSavedView(source, key, slug); - await _repository.AddAsync(savedView, o => o.Cache().ImmediateConsistency()); + await repository.AddAsync(savedView, o => o.Cache().ImmediateConsistency()); return savedView; } ApplySavedViewConfiguration(existing, source, key, slug); - existing.UpdatedByUserId = CurrentUser.Id; - await _repository.SaveAsync(existing, o => o.Cache().ImmediateConsistency()); + existing.UpdatedByUserId = GetCurrentUserId(); + await repository.SaveAsync(existing, o => o.Cache().ImmediateConsistency()); return existing; } @@ -667,7 +665,7 @@ private SavedView CreateSystemPredefinedSavedView(SavedView source, string key, var savedView = new SavedView { OrganizationId = PredefinedSavedViewsDataSeed.SystemOrganizationId, - CreatedByUserId = CurrentUser.Id, + CreatedByUserId = GetCurrentUserId(), Version = 1 }; @@ -717,12 +715,12 @@ private async Task DeleteSystemPredefinedSavedViewAsync(SavedView source) ?? existingPredefinedViews.FirstOrDefault(view => String.IsNullOrWhiteSpace(view.PredefinedKey) && String.Equals(view.Slug, source.Slug, StringComparison.OrdinalIgnoreCase)); if (existing is not null) - await _repository.RemoveAsync(existing.Id, o => o.ImmediateConsistency()); + await repository.RemoveAsync(existing.Id, o => o.ImmediateConsistency()); } private async Task> GetSystemPredefinedSavedViewsAsync(string viewType) { - var results = await _repository.GetByViewAsync(PredefinedSavedViewsDataSeed.SystemOrganizationId, viewType, o => o.PageLimit(1000)); + var results = await repository.GetByViewAsync(PredefinedSavedViewsDataSeed.SystemOrganizationId, viewType, o => o.PageLimit(1000)); return results.Documents.Where(view => view.UserId is null).ToList(); } @@ -837,13 +835,13 @@ private static string GetUniqueSlug(string slug, IReadOnlyCollection private async Task SlugExistsAsync(string organizationId, string viewType, string slug, string? excludingId) { - var results = await _repository.GetByViewForUserAsync(organizationId, viewType, CurrentUser.Id, o => o.PageLimit(1000)); - return results.Documents.Any(view => view.Id != excludingId && String.Equals(view.Slug, slug, StringComparison.OrdinalIgnoreCase)); + var results = await repository.GetByViewForUserAsync(organizationId, viewType, GetCurrentUserId(), o => o.PageLimit(1000)); + return results.Documents.Any(view => view.Id != excludingId && String.Equals(ToFallbackSlug(String.IsNullOrWhiteSpace(view.Slug) ? view.Name : view.Slug, view.Id), slug, StringComparison.OrdinalIgnoreCase)); } private async Task NameExistsAsync(string organizationId, string viewType, string name, string? excludingId) { - var results = await _repository.GetByViewForUserAsync(organizationId, viewType, CurrentUser.Id, o => o.PageLimit(1000)); + var results = await repository.GetByViewForUserAsync(organizationId, viewType, GetCurrentUserId(), o => o.PageLimit(1000)); return results.Documents.Any(view => view.Id != excludingId && String.Equals(view.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase)); } @@ -873,6 +871,10 @@ private static string ToFallbackSlug(string value, string id) return String.IsNullOrWhiteSpace(id) ? "saved-view" : $"saved-view-{id}"; } + private static int GetPage(int page) => page < 1 ? 1 : page; + private static int GetLimit(int limit) => limit < 1 ? 10 : limit > 100 ? 100 : limit; + private static bool NextPageExceedsSkipLimit(int page, int limit) => (page + 1) * limit >= 1000; + [GeneratedRegex("^[a-f0-9]{24}$")] private static partial Regex ObjectIdSlugRegex(); } diff --git a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs new file mode 100644 index 0000000000..61a0ab367e --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -0,0 +1,610 @@ +using System.Text.Json; +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Plugins.Formatting; +using Exceptionless.Core.Plugins.WebHook; +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Repositories.Queries; +using Exceptionless.Core.Utility; +using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Caching; +using Foundatio.Mediator; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Repositories.Extensions; +using Foundatio.Repositories.Models; +using McSherry.SemanticVersioning; +using PermissionResult = Exceptionless.Web.Controllers.PermissionResult; + +namespace Exceptionless.Web.Api.Handlers; + +public class StackHandler( + IStackRepository stackRepository, + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IEventRepository eventRepository, + IWebHookRepository webHookRepository, + WebHookDataPluginManager webHookDataPluginManager, + IQueue webHookNotificationQueue, + ICacheClient cacheClient, + FormattingPluginManager formattingPluginManager, + SemanticVersionParser semanticVersionParser, + IAppQueryValidator validator, + AppOptions options, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private static readonly ICollection _allowedDateFields = new List { StackIndex.Alias.FirstOccurrence, StackIndex.Alias.LastOccurrence }; + private const string DefaultDateField = StackIndex.Alias.LastOccurrence; + private static Result PlanLimitResult(string message) => Result.Invalid(ValidationError.Create("plan_limit", message)); + + public async Task> Handle(GetStackById message) + { + var stack = await GetModelAsync(message.Id, message.Context); + if (stack is null) + return Result.NotFound("Stack not found."); + + var offset = TimeRangeParser.GetOffset(message.Offset); + return stack.ApplyOffset(offset); + } + + public async Task Handle(MarkStacksFixed message) + { + SemanticVersion? semanticVersion = null; + + if (!String.IsNullOrEmpty(message.Version)) + { + semanticVersion = semanticVersionParser.Parse(message.Version); + if (semanticVersion is null) + return Result.BadRequest("Invalid semantic version"); + } + + var stacks = await GetModelsAsync(message.Ids.FromDelimitedString(), message.Context, false); + if (stacks.Count is 0) + return Result.NotFound("Stacks not found."); + + foreach (var stack in stacks) + stack.MarkFixed(semanticVersion, timeProvider); + + await stackRepository.SaveAsync(stacks); + + return Result.Success(); + } + + public async Task Handle(MarkStacksFixedByZapier message) + { + string? id = null; + if (message.Data.RootElement.TryGetProperty("ErrorStack", out var errorStackProp)) + id = errorStackProp.GetString(); + + if (message.Data.RootElement.TryGetProperty("Stack", out var stackProp)) + id = stackProp.GetString(); + + if (String.IsNullOrEmpty(id)) + return Result.NotFound("Stack not found."); + + if (id.StartsWith("http")) + id = id.Substring(id.LastIndexOf('/') + 1); + + return await Handle(new MarkStacksFixed(id, null, message.Context)); + } + + public async Task Handle(SnoozeStacks message) + { + if (message.SnoozeUntilUtc < timeProvider.GetUtcNow().UtcDateTime.AddMinutes(5)) + return Result.BadRequest("Must snooze for at least 5 minutes."); + + var stacks = await GetModelsAsync(message.Ids.FromDelimitedString(), message.Context, false); + if (stacks.Count is 0) + return Result.NotFound("Stacks not found."); + + foreach (var stack in stacks) + { + stack.Status = StackStatus.Snoozed; + stack.SnoozeUntilUtc = message.SnoozeUntilUtc; + stack.FixedInVersion = null; + stack.DateFixed = null; + } + + await stackRepository.SaveAsync(stacks); + + return Result.Success(); + } + + public async Task Handle(AddStackLink message) + { + if (String.IsNullOrWhiteSpace(message.Url?.Value)) + return Result.BadRequest("URL is required."); + + var stack = await GetModelAsync(message.Id, message.Context, false); + if (stack is null) + return Result.NotFound("Stack not found."); + + if (!stack.References.Contains(message.Url.Value.Trim())) + { + stack.References.Add(message.Url.Value.Trim()); + await stackRepository.SaveAsync(stack); + } + + return Result.Success(); + } + + public async Task Handle(AddStackLinkByZapier message) + { + string? id = null; + if (message.Data.RootElement.TryGetProperty("ErrorStack", out var errorStackProp)) + id = errorStackProp.GetString(); + + if (message.Data.RootElement.TryGetProperty("Stack", out var stackProp)) + id = stackProp.GetString(); + + if (String.IsNullOrEmpty(id)) + return Result.NotFound("Stack not found."); + + if (id.StartsWith("http")) + id = id.Substring(id.LastIndexOf('/') + 1); + + string? url = message.Data.RootElement.TryGetProperty("Link", out var linkProp) ? linkProp.GetString() : null; + return await Handle(new AddStackLink(id, new ValueFromBody(url), message.Context)); + } + + public async Task Handle(RemoveStackLink message) + { + if (String.IsNullOrWhiteSpace(message.Url?.Value)) + return Result.BadRequest("URL is required."); + + var stack = await GetModelAsync(message.Id, message.Context, false); + if (stack is null) + return Result.NotFound("Stack not found."); + + if (stack.References.Contains(message.Url.Value.Trim())) + { + stack.References.Remove(message.Url.Value.Trim()); + await stackRepository.SaveAsync(stack); + } + + return Result.NoContent(); + } + + public async Task Handle(MarkStacksCritical message) + { + var stacks = await GetModelsAsync(message.Ids.FromDelimitedString(), message.Context, false); + if (stacks.Count is 0) + return Result.NotFound("Stacks not found."); + + stacks = stacks.Where(s => !s.OccurrencesAreCritical).ToList(); + if (stacks.Count > 0) + { + foreach (var stack in stacks) + stack.OccurrencesAreCritical = true; + + await stackRepository.SaveAsync(stacks); + } + + return Result.Success(); + } + + public async Task Handle(MarkStacksNotCritical message) + { + var stacks = await GetModelsAsync(message.Ids.FromDelimitedString(), message.Context, false); + if (stacks.Count is 0) + return Result.NotFound("Stacks not found."); + + stacks = stacks.Where(s => s.OccurrencesAreCritical).ToList(); + if (stacks.Count > 0) + { + foreach (var stack in stacks) + stack.OccurrencesAreCritical = false; + + await stackRepository.SaveAsync(stacks); + } + + return Result.NoContent(); + } + + public async Task Handle(ChangeStacksStatus message) + { + if (message.Status is StackStatus.Regressed or StackStatus.Snoozed) + return Result.BadRequest("Can't set stack status to regressed or snoozed."); + + var stacks = await GetModelsAsync(message.Ids.FromDelimitedString(), message.Context, false); + if (stacks.Count is 0) + return Result.NotFound("Stacks not found."); + + stacks = stacks.Where(s => s.Status != message.Status).ToList(); + if (stacks.Count > 0) + { + foreach (var stack in stacks) + { + stack.Status = message.Status; + if (message.Status == StackStatus.Fixed) + { + stack.DateFixed = timeProvider.GetUtcNow().UtcDateTime; + } + else + { + stack.DateFixed = null; + stack.FixedInVersion = null; + } + + stack.SnoozeUntilUtc = null; + } + + await stackRepository.SaveAsync(stacks); + } + + return Result.Success(); + } + + public async Task Handle(PromoteStack message) + { + var httpContext = message.Context; + if (String.IsNullOrEmpty(message.Id)) + return Result.NotFound("Stack not found."); + + var stack = await stackRepository.GetByIdAsync(message.Id); + if (stack is null || !httpContext.Request.CanAccessOrganization(stack.OrganizationId)) + return Result.NotFound("Stack not found."); + + var organization = await GetOrganizationAsync(stack.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (!organization.HasPremiumFeatures) + return Result.Invalid(ValidationError.Create("plan_limit", "Promote to External is a premium feature used to promote an error stack to an external system. Please upgrade your plan to enable this feature.")); + + var promotedProjectHooks = (await webHookRepository.GetByProjectIdAsync(stack.ProjectId)).Documents.Where(p => p.EventTypes.Contains(WebHook.KnownEventTypes.StackPromoted)).ToList(); + if (promotedProjectHooks.Count is 0) + return Result.Invalid(ValidationError.Create("not_implemented", "No promoted web hooks are configured for this project. Please add a promoted web hook to use this feature.")); + + var currentUser = httpContext.Request.GetUser(); + using var _ = _logger.BeginScope(new ExceptionlessState() + .Organization(stack.OrganizationId) + .Project(stack.ProjectId) + .Tag("Promote") + .Identity(currentUser.EmailAddress) + .Property("User", currentUser) + .SetHttpContext(httpContext)); + + var project = await GetProjectAsync(stack.ProjectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + foreach (var hook in promotedProjectHooks) + { + if (!hook.IsEnabled) + { + _logger.LogWarning("Unable to promote to disabled WebHook Id={WebHookId}, Url={WebHookUrl}", hook.Id, hook.Url); + continue; + } + + var context = new WebHookDataContext(hook, organization, project, stack, null, stack.TotalOccurrences == 1, stack.Status == StackStatus.Regressed); + object? data = await webHookDataPluginManager.CreateFromStackAsync(context); + if (data is null) + { + _logger.LogWarning("Unable to promote to WebHook with null payload Id={WebHookId}, Url={WebHookUrl}", hook.Id, hook.Url); + continue; + } + + await webHookNotificationQueue.EnqueueAsync(new WebHookNotification + { + OrganizationId = stack.OrganizationId, + ProjectId = stack.ProjectId, + WebHookId = hook.Id, + Url = hook.Url, + Type = WebHookType.General, + Data = data + }); + } + + return Result.Success(); + } + + public async Task> Handle(DeleteStacks message) + { + var httpContext = message.Context; + var ids = message.Ids.FromDelimitedString(); + var items = await GetModelsAsync(ids, httpContext, false); + if (items.Count == 0) + return Result.NotFound("Stacks not found."); + + var results = new ModelActionResults(); + results.AddNotFound(ids.Except(items.Select(i => i.Id))); + + var denied = items.Where(model => model is IOwnedByOrganization orgModel && !httpContext.Request.CanAccessOrganization(orgModel.OrganizationId)).ToList(); + foreach (var model in denied) + results.Failure.Add(PermissionResult.DenyWithNotFound(model.Id)); + + var list = items.Except(denied).ToList(); + + if (list.Count == 0) + return results.Failure.Count == 1 ? PermissionToResult(results.Failure.First()) : results; + + var currentUser = httpContext.Request.GetUser(); + foreach (var projectStacks in list.GroupBy(ev => ev.ProjectId)) + { + var stack = projectStacks.First(); + using var _ = _logger.BeginScope(new ExceptionlessState().Organization(stack.OrganizationId).Project(stack.ProjectId).Tag("Delete").Identity(currentUser.EmailAddress).Property("User", currentUser).SetHttpContext(httpContext)); + _logger.LogInformation("User {User} deleted {RemovedCount} stacks in project ({ProjectId})", currentUser.Id, projectStacks.Count(), stack.ProjectId); + } + + list.ForEach(v => v.IsDeleted = true); + await stackRepository.SaveAsync(list); + + if (results.Failure.Count == 0) + return new WorkInProgressResult(); + + results.Success.AddRange(list.Select(i => i.Id)); + return results; + } + + public async Task>> Handle(GetAllStacks message) + { + var httpContext = message.Context; + var organizations = await GetSelectedOrganizationsAsync(httpContext, message.Filter); + if (organizations.All(o => o.IsSuspended)) + return new PagedResult(Array.Empty(), false); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organizations.GetRetentionUtcCutoff(options.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + return await GetInternalAsync(sf, ti, httpContext, message.Filter, message.Sort, message.Mode, message.Page, message.Limit); + } + + public async Task>> Handle(GetStacksByOrganization message) + { + var httpContext = message.Context; + var organization = await GetOrganizationAsync(message.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view stack occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(options.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(organization); + return await GetInternalAsync(sf, ti, httpContext, message.Filter, message.Sort, message.Mode, message.Page, message.Limit); + } + + public async Task>> Handle(GetStacksByProject message) + { + var httpContext = message.Context; + var project = await GetProjectAsync(message.ProjectId, httpContext); + if (project is null) + return Result.NotFound("Project not found."); + + var organization = await GetOrganizationAsync(project.OrganizationId, httpContext); + if (organization is null) + return Result.NotFound("Organization not found."); + + if (organization.IsSuspended) + return PlanLimitResult>("Unable to view stack occurrences for the suspended organization."); + + var ti = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(project, options.MaximumRetentionDays, timeProvider)); + var sf = new AppFilter(project, organization); + return await GetInternalAsync(sf, ti, httpContext, message.Filter, message.Sort, message.Mode, message.Page, message.Limit); + } + + private async Task>> GetInternalAsync(AppFilter sf, TimeInfo ti, HttpContext httpContext, string? filter = null, string? sort = null, string? mode = null, int page = 1, int limit = 10) + { + page = Pagination.GetPage(page); + limit = Pagination.GetLimit(limit); + int skip = Pagination.GetSkip(page, limit); + if (skip > Pagination.MaximumSkip) + return new PagedResult(Array.Empty(), false); + + var pr = await validator.ValidateQueryAsync(filter); + if (!pr.IsValid) + return Result.BadRequest(pr.Message ?? "Invalid filter."); + + sf.UsesPremiumFeatures = pr.UsesPremiumFeatures; + + try + { + var results = await stackRepository.FindAsync(q => q.AppFilter(ShouldApplySystemFilter(sf, filter, httpContext.Request) ? sf : null).FilterExpression(filter).SortExpression(sort).DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field), o => o.PageNumber(page).PageLimit(limit)); + + var stacks = results.Documents.Select(s => s.ApplyOffset(ti.Offset)).ToList(); + if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "summary", StringComparison.OrdinalIgnoreCase)) + return new PagedResult((await GetStackSummariesAsync(stacks, sf, ti)).Cast().ToList(), results.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page); + + return new PagedResult(stacks.Cast().ToList(), results.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page); + } + catch (ApplicationException ex) + { + var currentUser = httpContext.Request.GetUser(); + using (_logger.BeginScope(new ExceptionlessState().Property("Search Filter", new { SystemFilter = sf, UserFilter = filter, Time = ti, Page = page, Limit = limit }).Tag("Search").Identity(currentUser?.EmailAddress).Property("User", currentUser).SetHttpContext(httpContext))) + _logger.LogError(ex, "An error has occurred. Please check your search filter"); + + throw; + } + } + + private static bool ShouldApplySystemFilter(AppFilter sf, string? filter, HttpRequest? request = null) + { + // Apply filter to non admin users. + if (request is null || !request.IsGlobalAdmin()) + return true; + + // Apply filter as it's scoped via a controller action. + if (!sf.IsUserOrganizationsFilter) + return true; + + // Empty user filter + if (String.IsNullOrEmpty(filter)) + return true; + + // Used for impersonating a user. Only skip the filter if it contains an org, project or stack. + var scope = GetFilterScopeVisitor.Run(filter); + return !scope.HasScope; + } + + private async Task> GetStackSummariesAsync(ICollection stacks, AppFilter eventSystemFilter, TimeInfo ti) + { + if (stacks.Count == 0) + return new List(); + + var systemFilter = new RepositoryQuery().AppFilter(eventSystemFilter).DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, (PersistentEvent e) => e.Date).Index(ti.Range.UtcStart, ti.Range.UtcEnd); + var stackTerms = await eventRepository.CountAsync(q => q.SystemFilter(systemFilter).Stack(stacks.Select(r => r.Id)).AggregationsExpression($"terms:(stack_id~{stacks.Count} cardinality:user sum:count~1 min:date max:date)")); + var buckets = stackTerms.Aggregations.Terms("terms_stack_id")?.Buckets ?? []; + return await GetStackSummariesAsync(stacks, buckets, eventSystemFilter, ti); + } + + private async Task> GetStackSummariesAsync(ICollection stacks, IReadOnlyCollection> stackTerms, AppFilter sf, TimeInfo ti) + { + if (stacks.Count == 0) + return new List(0); + + var totalUsers = await GetUserCountByProjectIdsAsync(stacks, sf, ti.Range.UtcStart, ti.Range.UtcEnd); + return stacks.Join(stackTerms, s => s.Id, tk => tk.Key, (stack, term) => + { + var data = formattingPluginManager.GetStackSummaryData(stack); + var summary = new StackSummaryModel + { + Id = data.Id, + TemplateKey = data.TemplateKey, + Data = data.Data, + Title = stack.Title, + Status = stack.Status, + FirstOccurrence = term.Aggregations.Min("min_date")?.Value ?? stack.FirstOccurrence, + LastOccurrence = term.Aggregations.Max("max_date")?.Value ?? stack.LastOccurrence, + Total = (long)(term.Aggregations.Sum("sum_count")?.Value ?? term.Total.GetValueOrDefault()), + + Users = term.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0, + TotalUsers = totalUsers.GetOrDefault(stack.ProjectId) + }; + + return summary; + }).ToList(); + } + + private async Task> GetUserCountByProjectIdsAsync(ICollection stacks, AppFilter sf, DateTime utcStart, DateTime utcEnd) + { + using var scopedCacheClient = new ScopedCacheClient(cacheClient, $"Project:user-count:{utcStart.Floor(TimeSpan.FromMinutes(15)).Ticks}-{utcEnd.Floor(TimeSpan.FromMinutes(15)).Ticks}"); + var projectIds = stacks.Select(s => s.ProjectId).Distinct().ToList(); + var cachedTotals = await scopedCacheClient.GetAllAsync(projectIds); + + var totals = cachedTotals.Where(kvp => kvp.Value.HasValue).ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value); + if (totals.Count == projectIds.Count) + return totals; + + var systemFilter = new RepositoryQuery().AppFilter(sf).DateRange(utcStart, utcEnd, (PersistentEvent e) => e.Date).Index(utcStart, utcEnd); + var projects = cachedTotals + .Where(kvp => !kvp.Value.HasValue && stacks.Contains(s => s.ProjectId == kvp.Key)) + .Select(kvp => new Project { Id = kvp.Key, OrganizationId = stacks.First(s => s.ProjectId == kvp.Key).OrganizationId }) + .ToList(); + var countResult = await eventRepository.CountAsync(q => q.SystemFilter(systemFilter).FilterExpression(projects.BuildFilter()).AggregationsExpression("terms:(project_id cardinality:user)")); + + var projectTerms = countResult.Aggregations.Terms("terms_project_id")?.Buckets ?? []; + var aggregations = projectTerms.ToDictionary(t => t.Key, t => t.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0); + await scopedCacheClient.SetAllAsync(aggregations.Where(t => t.Value >= 10).ToDictionary(k => k.Key, v => v.Value), TimeSpan.FromMinutes(5)); + totals.AddRange(aggregations); + + return totals; + } + + private async Task GetModelAsync(string id, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + return null; + + var model = await stackRepository.GetByIdAsync(id, o => o.Cache(useCache)); + if (model is null) + return null; + + if (!httpContext.Request.CanAccessOrganization(model.OrganizationId)) + return null; + + return model; + } + + private async Task> GetModelsAsync(string[] ids, HttpContext httpContext, bool useCache = true) + { + if (ids.Length == 0) + return []; + + var models = await stackRepository.GetByIdsAsync(ids, o => o.Cache(useCache)); + return models.Where(m => httpContext.Request.CanAccessOrganization(m.OrganizationId)).ToList(); + } + + private Task GetOrganizationAsync(string? organizationId, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(organizationId) || !httpContext.Request.CanAccessOrganization(organizationId)) + return Task.FromResult(null); + + return organizationRepository.GetByIdAsync(organizationId, o => o.Cache(useCache)); + } + + private async Task GetProjectAsync(string? projectId, HttpContext httpContext, bool useCache = true) + { + if (String.IsNullOrEmpty(projectId)) + return null; + + var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache(useCache)); + if (project is null || !httpContext.Request.CanAccessOrganization(project.OrganizationId)) + return null; + + return project; + } + + private async Task> GetSelectedOrganizationsAsync(HttpContext httpContext, string? filter = null) + { + var associatedOrganizationIds = httpContext.Request.GetAssociatedOrganizationIds(); + if (associatedOrganizationIds.Count == 0) + return Array.Empty(); + + if (!String.IsNullOrEmpty(filter)) + { + var scope = GetFilterScopeVisitor.Run(filter); + if (scope.IsScopable) + { + Organization? organization = null; + if (scope.OrganizationId is not null) + { + organization = await organizationRepository.GetByIdAsync(scope.OrganizationId, o => o.Cache()); + } + else if (scope.ProjectId is not null) + { + var project = await projectRepository.GetByIdAsync(scope.ProjectId, o => o.Cache()); + if (project is not null) + organization = await organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + } + else if (scope.StackId is not null) + { + var stack = await stackRepository.GetByIdAsync(scope.StackId, o => o.Cache()); + if (stack is not null) + organization = await organizationRepository.GetByIdAsync(stack.OrganizationId, o => o.Cache()); + } + + if (organization is not null) + { + if (associatedOrganizationIds.Contains(organization.Id) || httpContext.Request.IsGlobalAdmin()) + return new[] { organization }; + + return Array.Empty(); + } + } + } + + return await organizationRepository.GetByIdsAsync(associatedOrganizationIds.ToArray(), o => o.Cache()); + } + + private static Result PermissionToResult(PermissionResult permission) + { + if (!String.IsNullOrEmpty(permission.Message)) + return Result.NotFound(permission.Message); + + return Result.NotFound("Access denied."); + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs b/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs new file mode 100644 index 0000000000..d6504de611 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs @@ -0,0 +1,102 @@ +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Services; +using Exceptionless.Web.Api.Messages; +using Foundatio.Mediator; +using Foundatio.Queues; + +namespace Exceptionless.Web.Api.Handlers; + +public class StatusHandler( + NotificationService notificationService, + IQueue eventQueue, + IQueue mailQueue, + IQueue notificationQueue, + IQueue webHooksQueue, + IQueue userDescriptionQueue, + AppOptions appOptions) +{ + [HandlerAllowAnonymous] + [HandlerAuthorize(Policies = [AuthorizationRoles.UserPolicy])] + [HandlerEndpoint(HandlerMethod.Get, "/api/v2/about", Name = "GetAboutInfo", ExcludeFromDescription = true)] + public object Handle(GetAboutInfo message) + { + return new + { + appOptions.InformationalVersion, + AppMode = appOptions.AppMode.ToString(), + appOptions.AppScope, + Environment.MachineName + }; + } + + [HandlerAuthorize(Policies = [AuthorizationRoles.UserPolicy, AuthorizationRoles.GlobalAdminPolicy])] + [HandlerEndpoint(HandlerMethod.Get, "/api/v2/queue-stats", ExcludeFromDescription = true)] + public async Task Handle(GetQueueStats message) + { + var eventQueueStats = await eventQueue.GetQueueStatsAsync(); + var mailQueueStats = await mailQueue.GetQueueStatsAsync(); + var userDescriptionQueueStats = await userDescriptionQueue.GetQueueStatsAsync(); + var notificationQueueStats = await notificationQueue.GetQueueStatsAsync(); + var webHooksQueueStats = await webHooksQueue.GetQueueStatsAsync(); + + return new + { + EventPosts = new + { + Active = eventQueueStats.Enqueued, + eventQueueStats.Deadletter, + eventQueueStats.Working + }, + MailMessages = new + { + Active = mailQueueStats.Enqueued, + mailQueueStats.Deadletter, + mailQueueStats.Working + }, + UserDescriptions = new + { + Active = userDescriptionQueueStats.Enqueued, + userDescriptionQueueStats.Deadletter, + userDescriptionQueueStats.Working + }, + Notifications = new + { + Active = notificationQueueStats.Enqueued, + notificationQueueStats.Deadletter, + notificationQueueStats.Working + }, + WebHooks = new + { + Active = webHooksQueueStats.Enqueued, + webHooksQueueStats.Deadletter, + webHooksQueueStats.Working + } + }; + } + + public Task Handle(PostReleaseNotification message) + { + return notificationService.SendReleaseNotificationAsync(message.Message, message.Critical); + } + + public async Task Handle(GetSystemNotification message) + { + return await notificationService.GetSystemNotificationAsync() ?? new SystemNotification { Date = DateTime.MinValue }; + } + + public async Task Handle(PostSystemNotification message) + { + if (String.IsNullOrWhiteSpace(message.Message)) + return new SystemNotification { Date = DateTime.MinValue }; + + return await notificationService.SetSystemNotificationAsync(message.Message, message.Level, message.Target, message.Publish); + } + + public Task Handle(RemoveSystemNotification message) + { + return notificationService.ClearSystemNotificationAsync(message.Publish); + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/StripeHandler.cs b/src/Exceptionless.Web/Api/Handlers/StripeHandler.cs new file mode 100644 index 0000000000..85f8630245 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/StripeHandler.cs @@ -0,0 +1,51 @@ +using Exceptionless.Core.Billing; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Extensions; +using Foundatio.Mediator; +using Stripe; + +namespace Exceptionless.Web.Api.Handlers; + +public class StripeHandler( + StripeEventHandler stripeEventHandler, + StripeOptions stripeOptions, + IHttpContextAccessor httpContextAccessor, + ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); + + public async Task Handle(HandleStripeWebhook message) + { + using (_logger.BeginScope(new ExceptionlessState().SetHttpContext(HttpContext).Property("event", message.Json))) + { + if (String.IsNullOrEmpty(message.Json)) + { + _logger.LogWarning("Unable to get json of incoming event"); + return Result.BadRequest("Unable to get json of incoming event."); + } + + Event stripeEvent; + try + { + stripeEvent = EventUtility.ConstructEvent(message.Json, message.Signature ?? String.Empty, stripeOptions.StripeWebHookSigningSecret, throwOnApiVersionMismatch: false); + } + catch (Exception ex) when (ex is StripeException or System.Text.Json.JsonException or ArgumentException) + { + _logger.LogError(ex, "Unable to parse incoming event with {Signature}: {Message}", message.Signature, ex.Message); + return Result.BadRequest("Unable to parse incoming event."); + } + + if (stripeEvent is null) + { + _logger.LogWarning("Null stripe event"); + return Result.BadRequest("Null stripe event."); + } + + await stripeEventHandler.HandleEventAsync(stripeEvent); + return Result.Success(); + } + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/TokenHandler.cs b/src/Exceptionless.Web/Api/Handlers/TokenHandler.cs new file mode 100644 index 0000000000..8740320b90 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/TokenHandler.cs @@ -0,0 +1,401 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Core.Repositories; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Foundatio.Mediator; +using Foundatio.Repositories; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using PermissionResult = Exceptionless.Web.Controllers.PermissionResult; + +namespace Exceptionless.Web.Api.Handlers; + +public class TokenHandler( + ITokenRepository repository, + IProjectRepository projectRepository, + ApiMapper mapper, + IAppQueryValidator validator, + TimeProvider timeProvider, + IHttpContextAccessor httpContextAccessor) +{ + private readonly IAppQueryValidator _validator = validator; + private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); + + public async Task>> Handle(GetTokensByOrganization message) + { + if (HttpContext.User.IsTokenAuthType()) + return Result.Forbidden("Token authentication cannot access tokens."); + + if (String.IsNullOrEmpty(message.OrganizationId) || !HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); + + int page = GetPage(message.Page); + int limit = GetLimit(message.Limit); + var tokens = await repository.GetByTypeAndOrganizationIdAsync(TokenType.Access, message.OrganizationId, o => o.PageNumber(page).PageLimit(limit)); + var viewTokens = mapper.MapToViewTokens(tokens.Documents); + AfterResultMap(viewTokens); + return new PagedResult(viewTokens, tokens.HasMore && !NextPageExceedsSkipLimit(page, limit), page, tokens.Total); + } + + public async Task>> Handle(GetTokensByProject message) + { + if (HttpContext.User.IsTokenAuthType()) + return Result.Forbidden("Token authentication cannot access tokens."); + + var project = await GetProjectAsync(message.ProjectId); + if (project is null) + return Result.NotFound("Project not found."); + + int page = GetPage(message.Page); + int limit = GetLimit(message.Limit); + var tokens = await repository.GetByTypeAndProjectIdAsync(TokenType.Access, message.ProjectId, o => o.PageNumber(page).PageLimit(limit)); + var viewTokens = mapper.MapToViewTokens(tokens.Documents); + AfterResultMap(viewTokens); + return new PagedResult(viewTokens, tokens.HasMore && !NextPageExceedsSkipLimit(page, limit), page, tokens.Total); + } + + public async Task> Handle(GetDefaultToken message) + { + if (HttpContext.User.IsTokenAuthType()) + return Result.Forbidden("Token authentication cannot access tokens."); + + var project = await GetProjectAsync(message.ProjectId); + if (project is null) + return Result.NotFound("Project not found."); + + var defaultTokenResults = await repository.GetByTypeAndProjectIdAsync(TokenType.Access, message.ProjectId, o => o.PageLimit(1)); + var token = defaultTokenResults.Documents.FirstOrDefault(); + if (token is not null) + return MapToView(token); + + return await CreateTokenImplAsync(new NewToken { OrganizationId = project.OrganizationId, ProjectId = message.ProjectId }); + } + + public async Task> Handle(GetTokenById message) + { + if (HttpContext.User.IsTokenAuthType()) + return Result.Forbidden("Token authentication cannot access tokens."); + + var model = await GetModelAsync(message.Id); + if (model is null) + return Result.NotFound("Token not found."); + + return MapToView(model); + } + + public Task> Handle(CreateToken message) + { + if (HttpContext.User.IsTokenAuthType()) + return Task.FromResult>(Result.Forbidden("Token authentication cannot create tokens.")); + + return CreateTokenImplAsync(message.Token); + } + + public async Task> Handle(CreateTokenByProject message) + { + if (HttpContext.User.IsTokenAuthType()) + return Result.Forbidden("Token authentication cannot create tokens."); + + var project = await GetProjectAsync(message.ProjectId); + if (project is null) + return Result.NotFound("Project not found."); + + var token = message.Token ?? new NewToken(); + token.OrganizationId = project.OrganizationId; + token.ProjectId = message.ProjectId; + return await CreateTokenImplAsync(token); + } + + public Task> Handle(CreateTokenByOrganization message) + { + if (HttpContext.User.IsTokenAuthType()) + return Task.FromResult>(Result.Forbidden("Token authentication cannot create tokens.")); + + if (!HttpContext.Request.IsInOrganization(message.OrganizationId)) + return Task.FromResult>(Result.BadRequest("Invalid organization.")); + + var token = message.Token ?? new NewToken(); + token.OrganizationId = message.OrganizationId; + return CreateTokenImplAsync(token); + } + + public async Task> Handle(UpdateTokenMessage message) + { + if (HttpContext.User.IsTokenAuthType()) + return Result.Forbidden("Token authentication cannot update tokens."); + + var original = await GetModelAsync(message.Id, useCache: false); + if (original is null) + return Result.NotFound("Token not found."); + + if (message.PatchDocument.IsEmpty()) + return MapToView(original); + + var validationResult = JsonPatchValidation.ValidateOperations(message.PatchDocument, "/organization_id"); + if (!validationResult.IsSuccess) + return Result.FromResult(validationResult); + + var dto = new UpdateToken { + IsDisabled = original.IsDisabled, + Notes = original.Notes + }; + + var patchResult = JsonPatchValidation.ApplyPatch(message.PatchDocument, dto); + if (!patchResult.IsSuccess) + return Result.FromResult(patchResult); + + var error = CanUpdate(original, dto, message.PatchDocument); + if (error is not null) + return error; + + original.IsDisabled = dto.IsDisabled; + original.Notes = dto.Notes; + + await repository.SaveAsync(original, o => o.Cache()); + return MapToView(original); + } + + public async Task> Handle(DeleteTokens message) + { + if (HttpContext.User.IsTokenAuthType()) + return Result.Forbidden("Token authentication cannot delete tokens."); + + var items = await GetModelsAsync(message.Ids, useCache: false); + if (items.Count == 0) + return Result.NotFound("No tokens found."); + + var results = new ModelActionResults(); + results.AddNotFound(message.Ids.Except(items.Select(i => i.Id))); + + var deletableItems = items.ToList(); + foreach (var model in items) + { + var permission = await CanDeleteAsync(model); + if (permission.Allowed) + continue; + + deletableItems.Remove(model); + results.Failure.Add(permission); + } + + if (deletableItems.Count == 0) + { + if (results.Failure.Count == 1) + return PermissionToResult(results.Failure.First()); + return results; + } + + await repository.RemoveAsync(deletableItems); + + if (results.Failure.Count == 0) + return results; + + results.Success.AddRange(deletableItems.Select(i => i.Id)); + return results; + } + + private async Task> CreateTokenImplAsync(NewToken value) + { + if (value is null) + return Result.BadRequest("Token value is required."); + + var mapped = mapper.MapToToken(value); + if (String.IsNullOrEmpty(mapped.OrganizationId) && HttpContext.Request.GetAssociatedOrganizationIds().Count > 0) + mapped.OrganizationId = HttpContext.Request.GetDefaultOrganizationId()!; + + var error = await CanAddAsync(mapped); + if (error is not null) + return error; + + var model = await AddModelAsync(mapped); + var viewModel = mapper.MapToViewToken(model); + AfterResultMap([viewModel]); + return Result.Created(viewModel, $"/api/v2/tokens/{model.Id}"); + } + + private async Task?> CanAddAsync(Token value) + { + if (String.IsNullOrEmpty(value.OrganizationId)) + return Result.Forbidden("Organization is required."); + + if (String.IsNullOrEmpty(value.ProjectId)) + return Result.Invalid(ValidationError.Create("project_id", "The project_id field is required.")); + + bool hasUserRole = HttpContext.User.IsInRole(AuthorizationRoles.User); + bool hasGlobalAdminRole = HttpContext.User.IsInRole(AuthorizationRoles.GlobalAdmin); + if (!hasGlobalAdminRole && !String.IsNullOrEmpty(value.UserId) && value.UserId != GetCurrentUserId()) + return Result.Forbidden("Cannot create tokens for other users."); + + if (!String.IsNullOrEmpty(value.ProjectId) && !String.IsNullOrEmpty(value.UserId)) + return Result.Invalid(ValidationError.Create("", "Token can't be associated to both user and project.")); + + foreach (string scope in value.Scopes.ToList()) + { + string lowerCaseScope = scope.ToLowerInvariant(); + if (!String.Equals(scope, lowerCaseScope, StringComparison.Ordinal)) + { + value.Scopes.Remove(scope); + value.Scopes.Add(lowerCaseScope); + } + + if (!AuthorizationRoles.AllScopes.Contains(lowerCaseScope)) + return Result.Invalid(ValidationError.Create("scopes", "Invalid token scope requested.")); + } + + if (value.Scopes.Count == 0) + value.Scopes.Add(AuthorizationRoles.Client); + + if ((value.Scopes.Contains(AuthorizationRoles.Client) && !hasUserRole) + || (value.Scopes.Contains(AuthorizationRoles.User) && !hasUserRole) + || (value.Scopes.Contains(AuthorizationRoles.GlobalAdmin) && !hasGlobalAdminRole)) + return Result.Invalid(ValidationError.Create("scopes", "Invalid token scope requested.")); + + if (!String.IsNullOrEmpty(value.ProjectId)) + { + var project = await GetProjectAsync(value.ProjectId); + if (project is null) + return Result.Invalid(ValidationError.Create("project_id", "Please specify a valid project id.")); + + value.OrganizationId = project.OrganizationId; + value.DefaultProjectId = null; + } + + if (!String.IsNullOrEmpty(value.DefaultProjectId)) + { + var project = await GetProjectAsync(value.DefaultProjectId); + if (project is null) + return Result.Invalid(ValidationError.Create("default_project_id", "Please specify a valid default project id.")); + } + + if (!HttpContext.Request.CanAccessOrganization(value.OrganizationId)) + return Result.Invalid(ValidationError.Create("organization_id", "Invalid organization id specified.")); + + return null; + } + + private Task AddModelAsync(Token value) + { + value.Id = StringExtensions.GetNewToken(); + value.CreatedUtc = value.UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime; + value.Type = TokenType.Access; + value.CreatedBy = GetCurrentUserId(); + + if (value.Scopes.Contains(AuthorizationRoles.GlobalAdmin)) + value.Scopes.Add(AuthorizationRoles.User); + + if (value.Scopes.Contains(AuthorizationRoles.User)) + value.Scopes.Add(AuthorizationRoles.Client); + + return repository.AddAsync(value, o => o.Cache()); + } + + private async Task CanDeleteAsync(Token value) + { + if (!HttpContext.User.IsInRole(AuthorizationRoles.GlobalAdmin) && !String.IsNullOrEmpty(value.UserId) && value.UserId != GetCurrentUserId()) + return PermissionResult.DenyWithMessage("Can only delete tokens created by you."); + + if (!String.IsNullOrEmpty(value.ProjectId) && !await IsInProjectAsync(value.ProjectId)) + return PermissionResult.DenyWithNotFound(value.Id); + + if (!HttpContext.Request.CanAccessOrganization(value.OrganizationId)) + return PermissionResult.DenyWithNotFound(value.Id); + + return PermissionResult.Allow; + } + + private async Task GetModelAsync(string id, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + return null; + + var model = await repository.GetByIdAsync(id, o => o.Cache(useCache)); + if (model is null) + return null; + + if (!String.IsNullOrEmpty(model.OrganizationId) && !HttpContext.Request.IsInOrganization(model.OrganizationId)) + return null; + + if (!HttpContext.User.IsInRole(AuthorizationRoles.GlobalAdmin) && !String.IsNullOrEmpty(model.UserId) && model.UserId != GetCurrentUserId()) + return null; + + if (model.Type != TokenType.Access) + return null; + + if (!String.IsNullOrEmpty(model.ProjectId) && !await IsInProjectAsync(model.ProjectId)) + return null; + + return model; + } + + private async Task> GetModelsAsync(string[] ids, bool useCache = true) + { + if (ids.Length == 0) + return []; + + var models = await repository.GetByIdsAsync(ids, o => o.Cache(useCache)); + return models.Where(m => HttpContext.Request.CanAccessOrganization(m.OrganizationId)).ToList(); + } + + private async Task GetProjectAsync(string projectId, bool useCache = true) + { + if (String.IsNullOrEmpty(projectId)) + return null; + + var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache(useCache)); + if (project is null || !HttpContext.Request.CanAccessOrganization(project.OrganizationId)) + return null; + + return project; + } + + private async Task IsInProjectAsync(string projectId) + { + var project = await GetProjectAsync(projectId); + return project is not null; + } + + private ViewToken MapToView(Token model) + { + var viewModel = mapper.MapToViewToken(model); + AfterResultMap([viewModel]); + return viewModel; + } + + private string GetCurrentUserId() => HttpContext.Request.GetUser().Id; + + private static void AfterResultMap(ICollection models) + { + foreach (var model in models.OfType()) + model.Data?.RemoveSensitiveData(); + } + + private static Result PermissionToResult(PermissionResult permission) + { + if (permission.StatusCode is StatusCodes.Status404NotFound) + return Result.NotFound(permission.Message ?? "Not found."); + + return Result.Forbidden(permission.Message ?? "Access denied."); + } + + private Result? CanUpdate(Token original, UpdateToken dto, JsonPatchDocument patch) + { + if (!HttpContext.Request.CanAccessOrganization(original.OrganizationId)) + return Result.Invalid(ValidationError.Create("organization_id", "Invalid organization id specified.")); + + if (patch.AffectsPath("/organization_id")) + return Result.Invalid(ValidationError.Create("organization_id", "OrganizationId cannot be modified.")); + + return null; + } + + private static int GetPage(int page) => page < 1 ? 1 : page; + private static int GetLimit(int limit) => limit < 1 ? 10 : limit > 100 ? 100 : limit; + private static bool NextPageExceedsSkipLimit(int page, int limit) => (page + 1) * limit >= 1000; +} diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs new file mode 100644 index 0000000000..52788f68a1 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -0,0 +1,457 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Mail; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Foundatio.Caching; +using Foundatio.Repositories; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using PermissionResult = Exceptionless.Web.Controllers.PermissionResult; + +namespace Exceptionless.Web.Api.Handlers; + +public class UserHandler( + IUserRepository repository, + IOrganizationRepository organizationRepository, + ITokenRepository tokenRepository, + ICacheClient cacheClient, + IMailer mailer, + ApiMapper mapper, + IntercomOptions intercomOptions, + TimeProvider timeProvider, + IHttpContextAccessor httpContextAccessor, + ILoggerFactory loggerFactory) +{ + private readonly ICacheClient _cache = new ScopedCacheClient(cacheClient, "User"); + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); + + public async Task> Handle(GetCurrentUser message) + { + var currentUser = await GetModelAsync(GetCurrentUserId()); + if (currentUser is null) + return Result.NotFound("User not found."); + + return new ViewCurrentUser(currentUser, intercomOptions); + } + + public async Task> Handle(GetUserById message) + { + var model = await GetModelAsync(message.Id); + if (model is null) + return Result.NotFound("User not found."); + + return Result.Success(MapToView(model)); + } + + public async Task>> Handle(GetUsersByOrganization message) + { + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("User not found."); + + var organization = await organizationRepository.GetByIdAsync(message.OrganizationId, o => o.Cache()); + if (organization is null) + return Result.NotFound("User not found."); + + int page = GetPage(message.Page); + int limit = GetLimit(message.Limit); + int skip = GetSkip(page, limit); + if (skip > 1000) + return new PagedResult(Array.Empty(), false, page, 0); + + var results = await repository.GetByOrganizationIdAsync(message.OrganizationId, o => o.PageLimit(1000)); + var users = mapper.MapToViewUsers(results.Documents); + AfterResultMap(users); + if (!HttpContext.Request.IsGlobalAdmin()) + users.ForEach(u => u.Roles.Remove(AuthorizationRoles.GlobalAdmin)); + + if (organization.Invites.Count > 0) + { + users.AddRange(organization.Invites.Select(i => new ViewUser + { + EmailAddress = i.EmailAddress, + IsInvite = true + })); + } + + long total = results.Total + organization.Invites.Count; + var pagedUsers = users.Skip(skip).Take(limit).ToList(); + return new PagedResult(pagedUsers, total > GetSkip(page + 1, limit), page, total); + } + + public async Task> Handle(UpdateUserMessage message) + { + var original = await GetModelAsync(message.Id, useCache: false); + if (original is null) + return Result.NotFound("User not found."); + + if (message.PatchDocument.IsEmpty()) + return Result.Success(MapToView(original)); + + var validationResult = JsonPatchValidation.ValidateOperations(message.PatchDocument, "/organization_id"); + if (!validationResult.IsSuccess) + return Result.FromResult(validationResult); + + var dto = new UpdateUser { + FullName = original.FullName, + EmailNotificationsEnabled = original.EmailNotificationsEnabled + }; + + var patchResult = JsonPatchValidation.ApplyPatch(message.PatchDocument, dto); + if (!patchResult.IsSuccess) + return Result.FromResult(patchResult); + + var permission = CanUpdate(original, dto, message.PatchDocument); + if (permission is not null) + return permission; + + original.FullName = dto.FullName; + original.EmailNotificationsEnabled = dto.EmailNotificationsEnabled; + + await repository.SaveAsync(original, o => o.Cache()); + return Result.Success(MapToView(original)); + } + + public async Task>> Handle(SetUserAvatar message) + { + var user = await GetModelAsync(message.Id, false); + if (user is null) + return Result.NotFound("User not found."); + + string? oldAvatarFileName = user.AvatarFileName; + user.AvatarFileName = message.FileName; + + await repository.SaveAsync(user, o => o.Cache()); + return new ProfileImageUpdate(MapToView(user), oldAvatarFileName); + } + + public async Task>> Handle(DeleteUserAvatar message) + { + var user = await GetModelAsync(message.Id, false); + if (user is null) + return Result.NotFound("User not found."); + + string? oldAvatarFileName = user.AvatarFileName; + user.AvatarFileName = null; + + await repository.SaveAsync(user, o => o.Cache()); + return new ProfileImageUpdate(MapToView(user), oldAvatarFileName); + } + + public Task> Handle(DeleteCurrentUser message) + { + string userId = GetCurrentUserId(); + string[] userIds = !String.IsNullOrEmpty(userId) ? [userId] : []; + return DeleteImplAsync(userIds); + } + + public Task> Handle(DeleteUsers message) + { + return DeleteImplAsync(message.Ids); + } + + public async Task> Handle(UpdateEmailAddress message) + { + var user = await GetModelAsync(message.Id, false); + if (user is null) + return Result.NotFound("User not found."); + + using var _ = _logger.BeginScope(new ExceptionlessState().Property("User", user).SetHttpContext(HttpContext)); + + string email = message.Email.Trim().ToLowerInvariant(); + var currentUser = HttpContext.Request.GetUser(); + if (String.Equals(currentUser.EmailAddress, email, StringComparison.InvariantCultureIgnoreCase)) + return new UpdateEmailAddressResult { IsVerified = user.IsEmailAddressVerified }; + + // Only allow 3 email address updates per hour period by a single user. + string updateEmailAddressAttemptsCacheKey = $"{currentUser.Id}:attempts"; + long attempts = await _cache.IncrementAsync(updateEmailAddressAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); + if (attempts > 3) + return Result.Invalid(ValidationError.Create("rate_limit", "Unable to update email address. Please try later.")); + + if (!await IsEmailAddressAvailableInternalAsync(email)) + return Result.Invalid(ValidationError.Create("email_address", "A user already exists with this email address.")); + + user.ResetPasswordResetToken(); + user.EmailAddress = email; + user.IsEmailAddressVerified = user.OAuthAccounts.Any(oa => String.Equals(oa.EmailAddress(), email, StringComparison.InvariantCultureIgnoreCase)); + if (user.IsEmailAddressVerified) + user.MarkEmailAddressVerified(); + else + user.ResetVerifyEmailAddressTokenAndExpiration(timeProvider); + + try + { + await repository.SaveAsync(user, o => o.Cache()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating user Email Address: {Message}", ex.Message); + throw; + } + + if (!user.IsEmailAddressVerified) + await ResendVerificationEmailInternalAsync(user); + + return new UpdateEmailAddressResult { IsVerified = user.IsEmailAddressVerified }; + } + + public async Task Handle(VerifyEmailAddress message) + { + var user = await repository.GetByVerifyEmailAddressTokenAsync(message.Token); + if (user is null) + { + var currentUser = HttpContext.Request.GetUser(); + if (currentUser.IsEmailAddressVerified) + return Result.Success(); + + return Result.NotFound("User not found."); + } + + if (!user.HasValidVerifyEmailAddressTokenExpiration(timeProvider)) + return Result.Invalid(ValidationError.Create("verify_email_address_token_expiration", "Verify Email Address Token has expired.")); + + user.MarkEmailAddressVerified(); + await repository.SaveAsync(user, o => o.Cache()); + + return Result.Success(); + } + + public async Task Handle(ResendVerificationEmail message) + { + var user = await GetModelAsync(message.Id, false); + if (user is null) + return Result.NotFound("User not found."); + + if (!user.IsEmailAddressVerified) + { + await ResendVerificationEmailInternalAsync(user); + } + + return Result.Success(); + } + + public async Task Handle(UnverifyEmailAddresses message) + { + using var reader = new StreamReader(HttpContext.Request.Body); + string[] emailAddresses = (await reader.ReadToEndAsync()).SplitAndTrim([',']); + + foreach (string emailAddress in emailAddresses) + { + var user = await repository.GetByEmailAddressAsync(emailAddress); + if (user is null) + { + _logger.LogWarning("Unable to mark user with email address {EmailAddress} as unverified: User not Found", emailAddress); + continue; + } + + user.ResetVerifyEmailAddressTokenAndExpiration(timeProvider); + await repository.SaveAsync(user, o => o.Cache()); + _logger.LogInformation("User {UserId} with email address {EmailAddress} is now unverified", user.Id, emailAddress); + } + + return Result.Success(); + } + + public async Task Handle(AddAdminRole message) + { + var user = await GetModelAsync(message.Id, false); + if (user is null) + return Result.NotFound("User not found."); + + if (!user.Roles.Contains(AuthorizationRoles.GlobalAdmin)) + { + user.Roles.Add(AuthorizationRoles.GlobalAdmin); + await repository.SaveAsync(user, o => o.Cache()); + } + + return Result.Success(); + } + + public async Task Handle(RemoveAdminRole message) + { + var user = await GetModelAsync(message.Id, false); + if (user is null) + return Result.NotFound("User not found."); + + if (user.Roles.Remove(AuthorizationRoles.GlobalAdmin)) + { + await repository.SaveAsync(user, o => o.Cache()); + } + + return Result.NoContent(); + } + + private async Task> DeleteImplAsync(string[] ids) + { + var items = await GetModelsAsync(ids, useCache: false); + if (items.Count == 0) + return Result.NotFound("User not found."); + + var results = new ModelActionResults(); + results.AddNotFound(ids.Except(items.Select(i => i.Id))); + + var deletableItems = items.ToList(); + foreach (var model in items) + { + var permission = CanDelete(model); + if (permission.Allowed) + continue; + + deletableItems.Remove(model); + results.Failure.Add(permission); + } + + if (deletableItems.Count == 0) + return results.Failure.Count == 1 ? Result.FromResult(PermissionToResult(results.Failure.First())) : results; + + foreach (var user in deletableItems) + { + long removed = await tokenRepository.RemoveAllByUserIdAsync(user.Id); + _logger.RemovedTokens(removed, user.Id); + } + + await repository.RemoveAsync(deletableItems); + + if (results.Failure.Count == 0) + return new ModelActionResults(); + + results.Success.AddRange(deletableItems.Select(i => i.Id)); + return results; + } + + private PermissionResult CanDelete(User value) + { + if (value.OrganizationIds.Count > 0) + return PermissionResult.DenyWithMessage("Please delete or leave any organizations before deleting your account."); + + if (!HttpContext.User.IsInRole(AuthorizationRoles.GlobalAdmin) && value.Id != GetCurrentUserId()) + return PermissionResult.Deny; + + return PermissionResult.Allow; + } + + private async Task GetModelAsync(string id, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + return null; + + if (HttpContext.Request.IsGlobalAdmin() || String.Equals(GetCurrentUserId(), id)) + { + return await repository.GetByIdAsync(id, o => o.Cache(useCache)); + } + + return null; + } + + private async Task> GetModelsAsync(string[] ids, bool useCache = true) + { + if (ids.Length == 0) + return []; + + if (HttpContext.Request.IsGlobalAdmin()) + { + var models = await repository.GetByIdsAsync(ids, o => o.Cache(useCache)); + return models.ToList(); + } + + string currentUserId = GetCurrentUserId(); + var filteredIds = ids.Where(id => String.Equals(currentUserId, id)).ToArray(); + if (filteredIds.Length == 0) + return []; + + var filteredModels = await repository.GetByIdsAsync(filteredIds, o => o.Cache(useCache)); + return filteredModels.ToList(); + } + + private object MapToView(User model) + { + if (String.Equals(GetCurrentUserId(), model.Id)) + { + var currentUserViewModel = new ViewCurrentUser(model, intercomOptions); + AfterResultMap([currentUserViewModel]); + return currentUserViewModel; + } + + var viewModel = mapper.MapToViewUser(model); + AfterResultMap([viewModel]); + return viewModel; + } + + private Result? CanUpdate(User original, UpdateUser dto, JsonPatchDocument patch) + { + // Users don't have a single OrganizationId - only check if not global admin and not self + if (!HttpContext.Request.CanAccessOrganization(original.OrganizationIds.FirstOrDefault() ?? "") + && !HttpContext.Request.IsGlobalAdmin() && original.Id != GetCurrentUserId()) + return Result.FromResult(Result.Invalid(ValidationError.Create("organization_id", "Invalid organization id specified."))); + + if (patch.AffectsPath("/organization_id")) + return Result.FromResult(Result.Invalid(ValidationError.Create("organization_id", "OrganizationId cannot be modified."))); + + return null; + } + + private async Task ResendVerificationEmailInternalAsync(User user) + { + user.ResetVerifyEmailAddressTokenAndExpiration(timeProvider); + await repository.SaveAsync(user, o => o.Cache()); + await mailer.SendUserEmailVerifyAsync(user); + } + + private async Task IsEmailAddressAvailableInternalAsync(string email) + { + if (String.IsNullOrWhiteSpace(email)) + return false; + + email = email.Trim().ToLowerInvariant(); + var currentUser = HttpContext.Request.GetUser(); + if (String.Equals(currentUser.EmailAddress, email, StringComparison.InvariantCultureIgnoreCase)) + return true; + + return await repository.GetByEmailAddressAsync(email) is null; + } + + private string GetCurrentUserId() => HttpContext.Request.GetUser().Id; + + private void AfterResultMap(ICollection models) + { + foreach (var model in models.OfType()) + model.Data?.RemoveSensitiveData(); + + foreach (var user in models.OfType()) + user.AvatarUrl = GetUserAvatarUrl(user.Id, user.AvatarUrl); + } + + private static string? GetUserAvatarUrl(string id, string? fileName) + { + if (String.IsNullOrWhiteSpace(fileName)) + return null; + + return $"/api/v2/users/{id}/avatar/{fileName}"; + } + + private static Result PermissionToResult(PermissionResult permission) + { + if (permission.StatusCode is StatusCodes.Status404NotFound) + return Result.NotFound(permission.Message ?? "User not found."); + + if (permission.StatusCode is StatusCodes.Status422UnprocessableEntity) + return Result.Invalid(ValidationError.Create("general", permission.Message ?? "Validation failed.")); + + return Result.Forbidden(permission.Message ?? "Access denied."); + } + + private static int GetPage(int page) => page < 1 ? 1 : page; + private static int GetLimit(int limit) => limit < 1 ? 10 : limit > 100 ? 100 : limit; + private static int GetSkip(int currentPage, int limit) => (currentPage < 1 ? 0 : (currentPage - 1)) * limit; +} diff --git a/src/Exceptionless.Web/Api/Handlers/UtilityHandler.cs b/src/Exceptionless.Web/Api/Handlers/UtilityHandler.cs new file mode 100644 index 0000000000..719c4c86cc --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/UtilityHandler.cs @@ -0,0 +1,32 @@ +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Web.Api.Messages; + +namespace Exceptionless.Web.Api.Handlers; + +public class UtilityHandler( + PersistentEventQueryValidator eventQueryValidator, + StackQueryValidator stackQueryValidator) +{ + public async Task Handle(ValidateSearchQuery message) + { + try + { + var eventResults = await eventQueryValidator.ValidateQueryAsync(message.Query); + var stackResults = await stackQueryValidator.ValidateQueryAsync(message.Query); + return new AppQueryValidator.QueryProcessResult + { + IsValid = eventResults.IsValid || stackResults.IsValid, + UsesPremiumFeatures = eventResults.UsesPremiumFeatures && stackResults.UsesPremiumFeatures, + Message = eventResults.Message ?? stackResults.Message + }; + } + catch (Exception) + { + return new AppQueryValidator.QueryProcessResult + { + IsValid = false, + Message = $"Error parsing query: \"{message.Query}\"" + }; + } + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/WebHookHandler.cs b/src/Exceptionless.Web/Api/Handlers/WebHookHandler.cs new file mode 100644 index 0000000000..71b9b8dcce --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/WebHookHandler.cs @@ -0,0 +1,275 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Billing; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Mediator; +using Foundatio.Repositories; +using PermissionResult = Exceptionless.Web.Controllers.PermissionResult; + +namespace Exceptionless.Web.Api.Handlers; + +public class WebHookHandler( + IWebHookRepository repository, + IProjectRepository projectRepository, + BillingManager billingManager, + ApiMapper mapper, + IHttpContextAccessor httpContextAccessor, + ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); + + public async Task>> Handle(GetWebHooksByProject message) + { + var project = await GetProjectAsync(message.ProjectId); + if (project is null) + return Result.NotFound("Project not found."); + + int page = GetPage(message.Page); + int limit = GetLimit(message.Limit); + var results = await repository.GetByProjectIdAsync(message.ProjectId, o => o.PageNumber(page).PageLimit(limit)); + return new PagedResult(results.Documents.ToArray(), results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); + } + + public async Task> Handle(GetWebHookById message) + { + var model = await GetModelAsync(message.Id); + return model is null ? Result.NotFound("Web hook not found.") : model; + } + + public Task> Handle(CreateWebHook message) => PostImplAsync(message.WebHook); + + public async Task> Handle(DeleteWebHooks message) + { + var items = await GetModelsAsync(message.Ids, useCache: false); + if (items.Count == 0) + return Result.NotFound("No web hooks found."); + + var results = new ModelActionResults(); + results.AddNotFound(message.Ids.Except(items.Select(i => i.Id))); + + var deletableItems = items.ToList(); + foreach (var model in items) + { + var permission = await CanDeleteAsync(model); + if (permission.Allowed) + continue; + + deletableItems.Remove(model); + results.Failure.Add(permission); + } + + if (deletableItems.Count == 0) + { + if (results.Failure.Count == 1) + return Result.FromResult(PermissionToResult(results.Failure.First())); + + return results; + } + + await repository.RemoveAsync(deletableItems); + + if (results.Failure.Count == 0) + return new ModelActionResults(); + + results.Success.AddRange(deletableItems.Select(i => i.Id)); + return results; + } + + public async Task> Handle(SubscribeWebHook message) + { + string? eventType = message.Data.RootElement.TryGetProperty("event", out var eventProp) ? eventProp.GetString() : null; + string? url = message.Data.RootElement.TryGetProperty("target_url", out var urlProp) ? urlProp.GetString() : null; + if (String.IsNullOrEmpty(eventType) || String.IsNullOrEmpty(url)) + return Result.BadRequest("Webhook subscription event and target_url are required."); + + string? projectId = HttpContext.User.GetProjectId(); + if (projectId is null) + return Result.BadRequest("Project id is required."); + + string? organizationId = HttpContext.Request.GetDefaultOrganizationId(); + if (organizationId is null) + return Result.BadRequest("Organization id is required."); + + var webHook = new NewWebHook + { + OrganizationId = organizationId, + ProjectId = projectId, + EventTypes = [eventType], + Url = url, + Version = new Version(message.ApiVersion >= 0 ? message.ApiVersion : 0, 0) + }; + + if (!webHook.Url.StartsWith("https://hooks.zapier.com", StringComparison.OrdinalIgnoreCase)) + return Result.NotFound("Webhook target not found."); + + return await PostImplAsync(webHook); + } + + public async Task Handle(UnsubscribeWebHook message) + { + string? targetUrl = message.Data.RootElement.TryGetProperty("target_url", out var urlProp) ? urlProp.GetString() : null; + if (targetUrl is null || !targetUrl.StartsWith("https://hooks.zapier.com", StringComparison.OrdinalIgnoreCase)) + return Result.NotFound("Webhook target not found."); + + var results = await repository.GetByUrlAsync(targetUrl); + if (results.Documents.Count > 0) + { + string organizationId = results.Documents.First().OrganizationId; + if (results.Documents.Any(h => h.OrganizationId != organizationId)) + throw new ArgumentException("All OrganizationIds must be the same."); + + _logger.RemovingZapierUrls(results.Documents.Count, targetUrl); + await repository.RemoveAsync(results.Documents); + } + + return Result.Success(); + } + + public Result Handle(TestWebHook message) + { + return new object[] { + new { id = 1, Message = "Test message 1." }, + new { id = 2, Message = "Test message 2." } + }; + } + + private async Task> PostImplAsync(NewWebHook value) + { + if (value is null) + return Result.BadRequest("Web hook value is required."); + + var mapped = mapper.MapToWebHook(value); + if (String.IsNullOrEmpty(mapped.OrganizationId) && HttpContext.Request.GetAssociatedOrganizationIds().Count > 0) + mapped.OrganizationId = HttpContext.Request.GetDefaultOrganizationId()!; + + var error = await CanAddAsync(mapped); + if (error is not null) + return error; + + if (!IsValidWebHookVersion(mapped.Version)) + mapped.Version = WebHook.KnownVersions.Version2; + + var model = await repository.AddAsync(mapped, o => o.Cache()); + return Result.Created(model, $"/api/v2/webhooks/{model.Id}"); + } + + private async Task?> CanAddAsync(Exceptionless.Core.Models.WebHook value) + { + if (String.IsNullOrEmpty(value.Url) || value.EventTypes is null || value.EventTypes.Length == 0) + return Result.BadRequest("Url and EventTypes are required."); + + if (String.IsNullOrEmpty(value.ProjectId) && String.IsNullOrEmpty(value.OrganizationId)) + return Result.Forbidden("Access denied."); + + if (!String.IsNullOrEmpty(value.OrganizationId) && !HttpContext.Request.IsInOrganization(value.OrganizationId)) + return Result.Invalid(ValidationError.Create("organization_id", "Invalid organization id specified.")); + + Project? project = null; + if (!String.IsNullOrEmpty(value.ProjectId)) + { + project = await GetProjectAsync(value.ProjectId); + if (project is null) + return Result.Invalid(ValidationError.Create("project_id", "Invalid project id specified.")); + + value.OrganizationId = project.OrganizationId; + } + + if (!await billingManager.HasPremiumFeaturesAsync(project is not null ? project.OrganizationId : value.OrganizationId)) + return Result.Invalid(ValidationError.Create("plan_limit", "Please upgrade your plan to add integrations.")); + + return null; + } + + private async Task CanDeleteAsync(WebHook value) + { + if (!String.IsNullOrEmpty(value.ProjectId) && !await IsInProjectAsync(value.ProjectId)) + return PermissionResult.DenyWithNotFound(value.Id); + + if (!String.IsNullOrEmpty(value.OrganizationId) && !HttpContext.Request.IsInOrganization(value.OrganizationId)) + return PermissionResult.DenyWithNotFound(value.Id); + + return PermissionResult.Allow; + } + + private async Task GetModelAsync(string id, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + return null; + + var webHook = await repository.GetByIdAsync(id, o => o.Cache(useCache)); + if (webHook is null) + return null; + + if (!String.IsNullOrEmpty(webHook.OrganizationId) && !HttpContext.Request.IsInOrganization(webHook.OrganizationId)) + return null; + + if (!String.IsNullOrEmpty(webHook.ProjectId) && !await IsInProjectAsync(webHook.ProjectId)) + return null; + + return webHook; + } + + private async Task> GetModelsAsync(string[] ids, bool useCache = true) + { + if (ids.Length == 0) + return []; + + var webHooks = await repository.GetByIdsAsync(ids, o => o.Cache(useCache)); + if (webHooks.Count == 0) + return []; + + var results = new List(); + foreach (var webHook in webHooks) + { + if ((!String.IsNullOrEmpty(webHook.OrganizationId) && HttpContext.Request.IsInOrganization(webHook.OrganizationId)) + || (!String.IsNullOrEmpty(webHook.ProjectId) && await IsInProjectAsync(webHook.ProjectId))) + results.Add(webHook); + } + + return results; + } + + private async Task GetProjectAsync(string projectId, bool useCache = true) + { + if (String.IsNullOrEmpty(projectId)) + return null; + + var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache(useCache)); + if (project is null || !HttpContext.Request.CanAccessOrganization(project.OrganizationId)) + return null; + + return project; + } + + private async Task IsInProjectAsync(string projectId) + { + var project = await GetProjectAsync(projectId); + return project is not null; + } + + private static Result PermissionToResult(PermissionResult permission) + { + if (permission.StatusCode is StatusCodes.Status404NotFound) + return Result.NotFound(permission.Message ?? "Not found."); + + return Result.Forbidden(permission.Message ?? "Access denied."); + } + + private static bool IsValidWebHookVersion(string version) + { + return String.Equals(version, WebHook.KnownVersions.Version1) || String.Equals(version, WebHook.KnownVersions.Version2); + } + + private static int GetPage(int page) => page < 1 ? 1 : page; + private static int GetLimit(int limit) => limit < 1 ? 10 : limit > 100 ? 100 : limit; + private static bool NextPageExceedsSkipLimit(int page, int limit) => (page + 1) * limit >= 1000; +} diff --git a/src/Exceptionless.Web/Api/Infrastructure/ApiValidation.cs b/src/Exceptionless.Web/Api/Infrastructure/ApiValidation.cs new file mode 100644 index 0000000000..199c275712 --- /dev/null +++ b/src/Exceptionless.Web/Api/Infrastructure/ApiValidation.cs @@ -0,0 +1,44 @@ +using Exceptionless.Core.Extensions; +using Microsoft.AspNetCore.Http.HttpResults; +using MiniValidation; + +namespace Exceptionless.Web.Api.Infrastructure; + +public static class ApiValidation +{ + /// + /// Validates an object using MiniValidation and returns a problem details result if invalid. + /// + public static async Task ValidateAsync(T instance, IServiceProvider serviceProvider, int statusCode = StatusCodes.Status422UnprocessableEntity) where T : class + { + var (isValid, errors) = await MiniValidator.TryValidateAsync(instance, serviceProvider, recurse: true); + if (isValid) + return null; + + var problemErrors = new Dictionary(); + foreach (var error in errors) + { + problemErrors[error.Key.ToLowerUnderscoredWords()] = error.Value; + } + + return global::Microsoft.AspNetCore.Http.Results.ValidationProblem(problemErrors, statusCode: statusCode); + } + + /// + /// Validates an object synchronously using MiniValidation. + /// + public static IResult? Validate(T instance, int statusCode = StatusCodes.Status422UnprocessableEntity) where T : class + { + bool isValid = MiniValidator.TryValidate(instance, recurse: true, out var errors); + if (isValid) + return null; + + var problemErrors = new Dictionary(); + foreach (var error in errors) + { + problemErrors[error.Key.ToLowerUnderscoredWords()] = error.Value; + } + + return global::Microsoft.AspNetCore.Http.Results.ValidationProblem(problemErrors, statusCode: statusCode); + } +} diff --git a/src/Exceptionless.Web/Api/Infrastructure/CurrentUserAccessor.cs b/src/Exceptionless.Web/Api/Infrastructure/CurrentUserAccessor.cs new file mode 100644 index 0000000000..7beee6bf6b --- /dev/null +++ b/src/Exceptionless.Web/Api/Infrastructure/CurrentUserAccessor.cs @@ -0,0 +1,26 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Extensions; + +namespace Exceptionless.Web.Api.Infrastructure; + +public static class CurrentUserAccessor +{ + public static User GetCurrentUser(HttpContext context) => context.Request.GetUser(); + + public static bool CanAccessOrganization(HttpContext context, string organizationId) + => context.Request.CanAccessOrganization(organizationId); + + public static bool IsInOrganization(HttpContext context, string? organizationId) + { + if (String.IsNullOrEmpty(organizationId)) + return false; + + return context.Request.IsInOrganization(organizationId); + } + + public static ICollection GetAssociatedOrganizationIds(HttpContext context) + => context.Request.GetAssociatedOrganizationIds(); + + public static bool IsGlobalAdmin(HttpContext context) + => context.Request.IsGlobalAdmin(); +} diff --git a/src/Exceptionless.Web/Api/Infrastructure/EndpointConventionExtensions.cs b/src/Exceptionless.Web/Api/Infrastructure/EndpointConventionExtensions.cs new file mode 100644 index 0000000000..2cf736edac --- /dev/null +++ b/src/Exceptionless.Web/Api/Infrastructure/EndpointConventionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http.Metadata; + +namespace Exceptionless.Web.Api.Infrastructure; + +public static class EndpointConventionExtensions +{ + public static RouteHandlerBuilder AcceptAnyJsonContentType(this RouteHandlerBuilder builder) + { + builder.Add(endpointBuilder => + { + for (int index = endpointBuilder.Metadata.Count - 1; index >= 0; index--) + { + if (endpointBuilder.Metadata[index] is IAcceptsMetadata) + endpointBuilder.Metadata.RemoveAt(index); + } + }); + + return builder; + } +} diff --git a/src/Exceptionless.Web/Api/Infrastructure/JsonPatchValidation.cs b/src/Exceptionless.Web/Api/Infrastructure/JsonPatchValidation.cs new file mode 100644 index 0000000000..c4d179d2f9 --- /dev/null +++ b/src/Exceptionless.Web/Api/Infrastructure/JsonPatchValidation.cs @@ -0,0 +1,178 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Exceptionless.Web.Api.Infrastructure; + +/// +/// Validates and applies RFC 6902 JSON Patch documents with immutable path protection and operation whitelisting. +/// +public static class JsonPatchValidation +{ + private const int MaxOperationsCount = 50; + + /// + /// Validates that no operation targets a disallowed (immutable) path, and restricts operations + /// to replace and test only (matching the original Delta semantics of top-level property replacement). + /// + public static Result ValidateOperations(JsonPatchDocument patch, params string[] immutablePaths) where T : class + { + if (patch.Operations.Count == 0) + return Result.Success(); + + if (patch.Operations.Count > MaxOperationsCount) + return Result.Invalid(ValidationError.Create("patch", $"Patch document exceeds maximum of {MaxOperationsCount} operations.")); + + foreach (var operation in patch.Operations) + { + // Only allow replace and test operations (matching original Delta behavior) + if (operation.OperationType != OperationType.Replace && operation.OperationType != OperationType.Test) + return Result.Invalid(ValidationError.Create("patch", $"Operation '{operation.op}' is not supported. Only 'replace' and 'test' operations are allowed.")); + + // Reject empty/root paths — must target a specific property + if (String.IsNullOrWhiteSpace(operation.path) || operation.path == "/") + return Result.Invalid(ValidationError.Create("patch", "Path must target a specific property (root path is not allowed).")); + + if (!operation.path.StartsWith('/')) + return Result.Invalid(ValidationError.Create("patch", $"Path '{operation.path}' is not valid. JSON Patch paths must start with '/'.")); + + // Validate path format: must start with / and have exactly one segment + var normalizedPath = NormalizePath(operation.path); + var segments = normalizedPath.Split('/'); + // segments[0] is always "" (before the leading /), segments[1] should be the property name + if (segments.Length != 2 || String.IsNullOrEmpty(segments[1])) + return Result.Invalid(ValidationError.Create("patch", $"Path '{operation.path}' is not valid. Only top-level property modifications are allowed.")); + + // Check immutable paths (case-insensitive to handle any casing variant) + if (immutablePaths.Any(p => normalizedPath.Equals(NormalizePath(p), StringComparison.OrdinalIgnoreCase))) + return Result.Invalid(ValidationError.Create(segments[1], $"The property '{segments[1]}' cannot be modified.")); + } + + return Result.Success(); + } + + /// + /// Applies a patch document to a target DTO, collecting any errors from the patch engine. + /// Returns a Result with validation errors if any operation fails. + /// + public static Result ApplyPatch(JsonPatchDocument patch, T target) where T : class + { + if (patch.Operations.Count == 0) + return Result.Success(); + + List? errors = null; + + patch.ApplyTo(target, error => + { + errors ??= []; + errors.Add(error.ErrorMessage); + }); + + if (errors is not null) + return Result.Invalid(errors.Select(e => ValidationError.Create("patch", e)).ToArray()); + + return Result.Success(); + } + + /// + /// Checks whether any operation in the patch targets a specific property path. + /// Path comparison uses the JSON naming convention (snake_case). + /// + public static bool AffectsPath(this JsonPatchDocument patch, string path) where T : class + { + var normalized = NormalizePath(path); + return patch.Operations.Any(op => + NormalizePath(op.path).Equals(normalized, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Checks whether any operation in the patch targets the specified property. + /// Uses the configured naming policy to derive the JSON path from the property expression. + /// + public static bool AffectsProperty(this JsonPatchDocument patch, Expression> property) where T : class + { + var memberName = GetMemberName(property); + var jsonName = patch.SerializerOptions?.PropertyNamingPolicy?.ConvertName(memberName) ?? memberName; + return patch.AffectsPath("/" + jsonName); + } + + /// + /// Gets all top-level property names (in their original C# PascalCase form) affected by the patch. + /// Uses the naming policy in reverse to map from JSON paths back to property names. + /// + public static IReadOnlySet GetAffectedPropertyNames(this JsonPatchDocument patch) where T : class + { + var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); + var policy = patch.SerializerOptions?.PropertyNamingPolicy; + var affected = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var pathSegment in patch.Operations.Select(op => NormalizePath(op.path).TrimStart('/'))) + { + foreach (var prop in properties) + { + var jsonName = policy?.ConvertName(prop.Name) ?? prop.Name; + if (pathSegment.Equals(jsonName, StringComparison.OrdinalIgnoreCase) + || pathSegment.Equals(prop.Name, StringComparison.OrdinalIgnoreCase)) + { + affected.Add(prop.Name); + break; + } + } + } + + return affected; + } + + /// + /// Returns true if the patch document has no operations (nothing to update). + /// + public static bool IsEmpty(this JsonPatchDocument patch) where T : class + => patch.Operations.Count == 0; + + private static string NormalizePath(string path) + { + // Ensure path starts with / + if (!path.StartsWith('/')) + path = "/" + path; + // Decode JSON Pointer escapes (RFC 6901) + return path.Replace("~1", "/").Replace("~0", "~"); + } + + private static string GetMemberName(Expression> expression) + { + var body = expression.Body; + if (body is UnaryExpression unary) + body = unary.Operand; + if (body is MemberExpression member) + return member.Member.Name; + throw new ArgumentException("Expression must be a member access expression.", nameof(expression)); + } + + /// + /// Converts a partial JSON object (e.g., from legacy v1 clients) into a typed JsonPatchDocument + /// with "replace" operations for each property in the object. + /// + public static JsonPatchDocument? FromPartialObject(JsonElement body, JsonSerializerOptions options) where T : class + { + if (body.ValueKind != JsonValueKind.Object) + return null; + + var ops = new JsonArray(body.EnumerateObject() + .Select(prop => new JsonObject + { + ["op"] = "replace", + ["path"] = $"/{prop.Name}", + ["value"] = JsonNode.Parse(prop.Value.GetRawText()) + }) + .ToArray()); + + if (ops.Count == 0) + return new JsonPatchDocument([], options); + + return JsonSerializer.Deserialize>(ops.ToJsonString(), options); + } +} diff --git a/src/Exceptionless.Web/Api/Infrastructure/Pagination.cs b/src/Exceptionless.Web/Api/Infrastructure/Pagination.cs new file mode 100644 index 0000000000..b5185b3187 --- /dev/null +++ b/src/Exceptionless.Web/Api/Infrastructure/Pagination.cs @@ -0,0 +1,46 @@ +namespace Exceptionless.Web.Api.Infrastructure; + +public static class Pagination +{ + public const int DefaultLimit = 10; + public const int MaximumLimit = 100; + public const int MaximumSkip = 1000; + + public static int GetLimit(int limit, int maximumLimit = MaximumLimit) + { + if (limit < 1) + limit = DefaultLimit; + else if (limit > maximumLimit) + limit = maximumLimit; + + return limit; + } + + public static int GetPage(int page) + { + if (page < 1) + page = 1; + + return page; + } + + public static int GetSkip(int currentPage, int limit) + { + if (currentPage < 1) + currentPage = 1; + + int skip = (currentPage - 1) * limit; + if (skip < 0) + skip = 0; + + return skip; + } + + public static bool NextPageExceedsSkipLimit(int? page, int limit) + { + if (page is null) + return false; + + return (page + 1) * limit >= MaximumSkip; + } +} diff --git a/src/Exceptionless.Web/Api/Infrastructure/TimeRangeParser.cs b/src/Exceptionless.Web/Api/Infrastructure/TimeRangeParser.cs new file mode 100644 index 0000000000..b16b65d413 --- /dev/null +++ b/src/Exceptionless.Web/Api/Infrastructure/TimeRangeParser.cs @@ -0,0 +1,40 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Controllers; + +namespace Exceptionless.Web.Api.Infrastructure; + +public static class TimeRangeParser +{ + private static readonly char[] TimeParts = ['|']; + + public static TimeSpan GetOffset(string? offset) + { + if (!String.IsNullOrEmpty(offset) && TimeUnit.TryParse(offset, out var value) && value.HasValue) + return value.Value; + + return TimeSpan.Zero; + } + + public static TimeInfo GetTimeInfo(string? time, string? offset, TimeProvider timeProvider, ICollection? allowedDateFields = null, string defaultDateField = "created_utc", DateTime? minimumUtcStartDate = null) + { + string field = defaultDateField; + if (!String.IsNullOrEmpty(time) && time.Contains('|')) + { + string[] parts = time.Split(TimeParts, StringSplitOptions.RemoveEmptyEntries); + field = parts.Length > 0 && allowedDateFields?.Contains(parts[0]) == true ? parts[0] : defaultDateField; + time = parts.Length > 1 ? parts[1] : null; + } + + var utcOffset = GetOffset(offset); + + // range parsing needs to be based on the user's local time. + var range = DateTimeRange.Parse(time, timeProvider.GetUtcNow().ToOffset(utcOffset)); + var timeInfo = new TimeInfo { Field = field, Offset = utcOffset, Range = range }; + if (minimumUtcStartDate.HasValue) + timeInfo.ApplyMinimumUtcStartDate(minimumUtcStartDate.Value); + + timeInfo.AdjustEndTimeIfMaxValue(timeProvider); + return timeInfo; + } +} diff --git a/src/Exceptionless.Web/Api/MediatorEndpointConfiguration.cs b/src/Exceptionless.Web/Api/MediatorEndpointConfiguration.cs new file mode 100644 index 0000000000..c38347e265 --- /dev/null +++ b/src/Exceptionless.Web/Api/MediatorEndpointConfiguration.cs @@ -0,0 +1,11 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Web.Api.Filters; +using Foundatio.Mediator; + +[assembly: MediatorConfiguration(EndpointDiscovery = EndpointDiscovery.Explicit)] +[assembly: MediatorEndpointGroup( + Name = "Admin", + RoutePrefix = "/api/v2/admin", + Policies = [AuthorizationRoles.GlobalAdminPolicy], + EndpointFilters = [typeof(AutoValidationEndpointFilter)], + ExcludeFromDescription = true)] diff --git a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs new file mode 100644 index 0000000000..ee051b8ef2 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs @@ -0,0 +1,16 @@ +using Foundatio.Repositories.Models; + +namespace Exceptionless.Web.Api.Messages; + +public record GetAdminSettings; +public record GetAdminStats; +public record GetAdminMigrations; +public record GetAdminEcho(HttpContext Context); +public record GetAdminAssemblies; +public record AdminChangePlan(string OrganizationId, string PlanId, HttpContext Context); +public record AdminSetBonus(string OrganizationId, int BonusEvents, DateTime? Expires, HttpContext Context); +public record AdminRequeue(string? Path, bool Archive); +public record AdminRunMaintenance(string Name, DateTime? UtcStart, DateTime? UtcEnd, string? OrganizationId); +public record GetAdminElasticsearch; +public record GetAdminElasticsearchSnapshots; +public record AdminGenerateSampleEvents(int EventCount, int DaysBack); diff --git a/src/Exceptionless.Web/Api/Messages/AuthMessages.cs b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs new file mode 100644 index 0000000000..068710fd2d --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs @@ -0,0 +1,18 @@ +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Api.Messages; + +public record LoginMessage(Login Model, HttpContext Context); +public record GetIntercomToken(HttpContext Context); +public record LogoutMessage(HttpContext Context); +public record SignupMessage(Signup Model, HttpContext Context); +public record GitHubLogin(ExternalAuthInfo AuthInfo, HttpContext Context); +public record GoogleLogin(ExternalAuthInfo AuthInfo, HttpContext Context); +public record FacebookLogin(ExternalAuthInfo AuthInfo, HttpContext Context); +public record LiveLogin(ExternalAuthInfo AuthInfo, HttpContext Context); +public record RemoveExternalLogin(string ProviderName, ValueFromBody ProviderUserId, HttpContext Context); +public record ChangePassword(ChangePasswordModel Model, HttpContext Context); +public record CheckEmailAddress(string Email, HttpContext Context); +public record ForgotPassword(string Email, HttpContext Context); +public record ResetPassword(ResetPasswordModel Model, HttpContext Context); +public record CancelResetPassword(string Token, HttpContext Context); diff --git a/src/Exceptionless.Web/Api/Messages/EventMessages.cs b/src/Exceptionless.Web/Api/Messages/EventMessages.cs new file mode 100644 index 0000000000..3f2daf55fe --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/EventMessages.cs @@ -0,0 +1,42 @@ +using Exceptionless.Core.Models.Data; +using Exceptionless.Web.Models; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +namespace Exceptionless.Web.Api.Messages; + +// Count messages +public record GetEventCount(string? Filter, string? Aggregations, string? Time, string? Offset, string? Mode, HttpContext Context); +public record GetEventCountByOrganization(string OrganizationId, string? Filter, string? Aggregations, string? Time, string? Offset, string? Mode, HttpContext Context); +public record GetEventCountByProject(string ProjectId, string? Filter, string? Aggregations, string? Time, string? Offset, string? Mode, HttpContext Context); + +// Get events +public record GetEventById(string Id, string? Time, string? Offset, HttpContext Context); +public record GetAllEvents(string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetEventsByOrganization(string OrganizationId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetEventsByProject(string ProjectId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetEventsByStack(string StackId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetEventsByReferenceId(string ReferenceId, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetEventsByReferenceIdAndProject(string ReferenceId, string ProjectId, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); + +// Sessions +public record GetEventsBySessionId(string SessionId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetEventsBySessionIdAndProject(string SessionId, string ProjectId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetSessions(string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetSessionsByOrganization(string OrganizationId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); +public record GetSessionsByProject(string ProjectId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, HttpContext Context); + +// User description +public record SetEventUserDescription(string ReferenceId, UserDescription Description, string? ProjectId, HttpContext Context); +public record LegacyPatchEvent(string Id, JsonPatchDocument PatchDocument, HttpContext Context); + +// Heartbeat +public record RecordEventHeartbeat(string? Id, bool Close, HttpContext Context); + +// Submit via GET +public record SubmitEventByGet(string? ProjectId, int ApiVersion, string? Type, string? UserAgent, HttpContext Context); + +// Submit via POST +public record SubmitEventByPost(string? ProjectId, int ApiVersion, string? UserAgent, HttpContext Context); + +// Delete +public record DeleteEvents(string Ids, HttpContext Context); diff --git a/src/Exceptionless.Web/Api/Messages/OrganizationMessages.cs b/src/Exceptionless.Web/Api/Messages/OrganizationMessages.cs new file mode 100644 index 0000000000..d98dfe07c6 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/OrganizationMessages.cs @@ -0,0 +1,30 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Web.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +namespace Exceptionless.Web.Api.Messages; + +public record GetOrganizations(string? Filter, string? Mode, HttpContext Context); +public record GetAdminOrganizations(string? Criteria, bool? Paid, bool? Suspended, string? Mode, int Page, int Limit, OrganizationSortBy Sort, HttpContext Context); +public record GetOrganizationPlanStats(HttpContext Context); +public record GetOrganizationById(string Id, string? Mode, HttpContext Context); +public record CreateOrganization(NewOrganization Organization, HttpContext Context); +public record UpdateOrganizationMessage(string Id, JsonPatchDocument PatchDocument, HttpContext Context); +public record SetOrganizationIcon(string Id, string FileName, HttpContext Context); +public record DeleteOrganizationIcon(string Id, HttpContext Context); +public record DeleteOrganizations(string[] Ids, HttpContext Context); +public record GetInvoice(string Id, HttpContext Context); +public record GetInvoices(string Id, string? Before, string? After, int Limit, HttpContext Context); +public record GetPlans(string Id, HttpContext Context); +public record ChangeOrganizationPlan(string Id, ChangePlanRequest? Model, string? PlanId, string? StripeToken, string? Last4, string? CouponId, HttpContext Context); +public record AddOrganizationUser(string Id, string Email, HttpContext Context); +public record RemoveOrganizationUser(string Id, string Email, HttpContext Context); +public record SuspendOrganization(string Id, SuspensionCode Code, string? Notes, HttpContext Context); +public record UnsuspendOrganization(string Id, HttpContext Context); +public record SetOrganizationData(string Id, string Key, ValueFromBody Value, HttpContext Context); +public record DeleteOrganizationData(string Id, string Key, HttpContext Context); +public record SetOrganizationFeature(string Id, string Feature, HttpContext Context); +public record RemoveOrganizationFeature(string Id, string Feature, HttpContext Context); +public record CheckOrganizationName(string Name, HttpContext Context); diff --git a/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs b/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs new file mode 100644 index 0000000000..7e5858145d --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs @@ -0,0 +1,32 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +namespace Exceptionless.Web.Api.Messages; + +public record GetProjects(string? Filter, string? Sort, int Page, int Limit, string? Mode, HttpContext Context); +public record GetProjectsByOrganization(string OrganizationId, string? Filter, string? Sort, int Page, int Limit, string? Mode, HttpContext Context); +public record GetProjectById(string Id, string? Mode, HttpContext Context); +public record CreateProject(NewProject Project, HttpContext Context); +public record UpdateProjectMessage(string Id, JsonPatchDocument PatchDocument, HttpContext Context); +public record DeleteProjects(string[] Ids, HttpContext Context); +public record GetLegacyProjectConfig(int? Version, HttpContext Context); +public record GetProjectConfig(string? Id, int? Version, HttpContext Context); +public record SetProjectConfig(string Id, string Key, ValueFromBody Value, HttpContext Context); +public record DeleteProjectConfig(string Id, string Key, HttpContext Context); +public record GenerateProjectSampleData(string Id, HttpContext Context); +public record ResetProjectData(string Id, HttpContext Context); +public record GetProjectNotificationSettings(string Id, HttpContext Context); +public record GetProjectUserNotificationSettings(string Id, string UserId, HttpContext Context); +public record GetProjectIntegrationNotificationSettings(string Id, string Integration, HttpContext Context); +public record SetProjectUserNotificationSettings(string Id, string UserId, NotificationSettings? Settings, HttpContext Context); +public record SetProjectIntegrationNotificationSettings(string Id, string Integration, NotificationSettings? Settings, HttpContext Context); +public record DeleteProjectNotificationSettings(string Id, string UserId, HttpContext Context); +public record PromoteProjectTab(string Id, string Name, HttpContext Context); +public record DemoteProjectTab(string Id, string Name, HttpContext Context); +public record CheckProjectName(string Name, string? OrganizationId, HttpContext Context); +public record SetProjectData(string Id, string Key, ValueFromBody Value, HttpContext Context); +public record DeleteProjectData(string Id, string Key, HttpContext Context); +public record AddProjectSlack(string Id, string Code, HttpContext Context); +public record RemoveProjectSlack(string Id, HttpContext Context); diff --git a/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs b/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs new file mode 100644 index 0000000000..2552e3453f --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs @@ -0,0 +1,18 @@ +using Exceptionless.Core.Seed; +using Exceptionless.Web.Models; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +namespace Exceptionless.Web.Api.Messages; + +public record GetSavedViewsByOrganization(string OrganizationId, int Page, int Limit); +public record GetSavedViewsByView(string OrganizationId, string ViewType, int Page, int Limit); +public record GetSavedViewById(string Id); +public record CreateSavedView(string OrganizationId, NewSavedView SavedView); +public record CreatePredefinedSavedViews(string OrganizationId); +public record GetPredefinedSavedViews; +public record ExportOrganizationSavedViews(string OrganizationId); +public record ReplacePredefinedSavedViews(IReadOnlyCollection Definitions); +public record PromoteToPredefinedSavedView(string Id); +public record DeletePredefinedSavedView(string Id); +public record UpdateSavedViewMessage(string Id, JsonPatchDocument PatchDocument); +public record DeleteSavedViews(string[] Ids); diff --git a/src/Exceptionless.Web/Api/Messages/StackMessages.cs b/src/Exceptionless.Web/Api/Messages/StackMessages.cs new file mode 100644 index 0000000000..26fba9556c --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/StackMessages.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Api.Messages; + +public record GetStackById(string Id, string? Offset, HttpContext Context); +public record MarkStacksFixed(string Ids, string? Version, HttpContext Context); +public record MarkStacksFixedByZapier(JsonDocument Data, HttpContext Context); +public record SnoozeStacks(string Ids, DateTime SnoozeUntilUtc, HttpContext Context); +public record AddStackLink(string Id, ValueFromBody Url, HttpContext Context); +public record AddStackLinkByZapier(JsonDocument Data, HttpContext Context); +public record RemoveStackLink(string Id, ValueFromBody Url, HttpContext Context); +public record MarkStacksCritical(string Ids, HttpContext Context); +public record MarkStacksNotCritical(string Ids, HttpContext Context); +public record ChangeStacksStatus(string Ids, StackStatus Status, HttpContext Context); +public record PromoteStack(string Id, HttpContext Context); +public record DeleteStacks(string Ids, HttpContext Context); +public record GetAllStacks(string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int Page, int Limit, HttpContext Context); +public record GetStacksByOrganization(string OrganizationId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int Page, int Limit, HttpContext Context); +public record GetStacksByProject(string ProjectId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int Page, int Limit, HttpContext Context); diff --git a/src/Exceptionless.Web/Api/Messages/StatusMessages.cs b/src/Exceptionless.Web/Api/Messages/StatusMessages.cs new file mode 100644 index 0000000000..1e859becf4 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/StatusMessages.cs @@ -0,0 +1,18 @@ +using Exceptionless.Core.Messaging.Models; + +namespace Exceptionless.Web.Api.Messages; + +public record GetAboutInfo; +public record GetQueueStats; +public record PostReleaseNotification(string Message, bool Critical); +public record GetSystemNotification; +public record PostSystemNotification(string Message, SystemNotificationLevel Level = SystemNotificationLevel.Info, SystemNotificationTarget Target = SystemNotificationTarget.Both, bool Publish = true); +public record RemoveSystemNotification(bool Publish = true); +public record ValidateSearchQuery(string Query); + +public record SetSystemNotificationRequest +{ + public string? Message { get; set; } + public SystemNotificationLevel Level { get; set; } = SystemNotificationLevel.Info; + public SystemNotificationTarget Target { get; set; } = SystemNotificationTarget.Both; +} diff --git a/src/Exceptionless.Web/Api/Messages/StripeMessages.cs b/src/Exceptionless.Web/Api/Messages/StripeMessages.cs new file mode 100644 index 0000000000..7e9c0169bf --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/StripeMessages.cs @@ -0,0 +1,3 @@ +namespace Exceptionless.Web.Api.Messages; + +public record HandleStripeWebhook(string Json, string? Signature); diff --git a/src/Exceptionless.Web/Api/Messages/TokenMessages.cs b/src/Exceptionless.Web/Api/Messages/TokenMessages.cs new file mode 100644 index 0000000000..2b283ca175 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/TokenMessages.cs @@ -0,0 +1,14 @@ +using Exceptionless.Web.Models; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +namespace Exceptionless.Web.Api.Messages; + +public record GetTokensByOrganization(string OrganizationId, int Page, int Limit); +public record GetTokensByProject(string ProjectId, int Page, int Limit); +public record GetDefaultToken(string ProjectId); +public record GetTokenById(string Id); +public record CreateToken(NewToken Token); +public record CreateTokenByProject(string ProjectId, NewToken? Token); +public record CreateTokenByOrganization(string OrganizationId, NewToken? Token); +public record UpdateTokenMessage(string Id, JsonPatchDocument PatchDocument); +public record DeleteTokens(string[] Ids); diff --git a/src/Exceptionless.Web/Api/Messages/UserMessages.cs b/src/Exceptionless.Web/Api/Messages/UserMessages.cs new file mode 100644 index 0000000000..cf382367d5 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/UserMessages.cs @@ -0,0 +1,19 @@ +using Exceptionless.Web.Models; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +namespace Exceptionless.Web.Api.Messages; + +public record GetCurrentUser; +public record GetUserById(string Id); +public record GetUsersByOrganization(string OrganizationId, int Page, int Limit); +public record UpdateUserMessage(string Id, JsonPatchDocument PatchDocument); +public record SetUserAvatar(string Id, string FileName); +public record DeleteUserAvatar(string Id); +public record DeleteCurrentUser; +public record DeleteUsers(string[] Ids); +public record UpdateEmailAddress(string Id, string Email); +public record VerifyEmailAddress(string Token); +public record ResendVerificationEmail(string Id); +public record UnverifyEmailAddresses; +public record AddAdminRole(string Id); +public record RemoveAdminRole(string Id); diff --git a/src/Exceptionless.Web/Api/Messages/WebHookMessages.cs b/src/Exceptionless.Web/Api/Messages/WebHookMessages.cs new file mode 100644 index 0000000000..c5e07cccff --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/WebHookMessages.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Api.Messages; + +public record GetWebHooksByProject(string ProjectId, int Page, int Limit); +public record GetWebHookById(string Id); +public record CreateWebHook(NewWebHook WebHook); +public record DeleteWebHooks(string[] Ids); +public record SubscribeWebHook(JsonDocument Data, int ApiVersion); +public record UnsubscribeWebHook(JsonDocument Data); +public record TestWebHook; diff --git a/src/Exceptionless.Web/Api/Results/ApiResultMapper.cs b/src/Exceptionless.Web/Api/Results/ApiResultMapper.cs new file mode 100644 index 0000000000..bef74006e8 --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/ApiResultMapper.cs @@ -0,0 +1,168 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Exceptionless.Web.Controllers; +using Foundatio.Mediator; +using Microsoft.AspNetCore.Http; +using HttpResults = Microsoft.AspNetCore.Http.Results; +using IResult = Microsoft.AspNetCore.Http.IResult; + +namespace Exceptionless.Web.Api.Results; + +/// +/// Maps Foundatio.Mediator Result types to ASP.NET Core IResult HTTP responses. +/// Registered before AddMediator() to customize how Result statuses become HTTP responses. +/// Preserves existing ProblemDetails shape (instance, reference-id, errors with snake_case keys). +/// +public sealed class ApiResultMapper : IMediatorResultMapper +{ + private static readonly ConcurrentDictionary s_valuePropertyCache = new(); + private readonly MediatorResultMapperOptions? _options; + + public ApiResultMapper(MediatorResultMapperOptions? options = null) + { + _options = options; + } + + public IResult MapResult(Foundatio.Mediator.IResult result) + { + if (result.Status is ResultStatus.Success) + return MapSuccess(result); + + if (result.Status is ResultStatus.Created) + return MapCreated(result); + + if (result.Status is ResultStatus.Accepted) + return MapAccepted(result); + + if (_options?.TryMap(result, out var mappedResult) == true) + return mappedResult; + + if (result.Status is ResultStatus.NoContent) + return HttpResults.NoContent(); + + return HttpResults.Problem( + detail: result.Message ?? "An unexpected error occurred", statusCode: StatusCodes.Status500InternalServerError); + } + + public static IResult MapBadRequest(Foundatio.Mediator.IResult result) + { + return HttpResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: result.Message ?? "Bad Request"); + } + + public static IResult MapNotFound(Foundatio.Mediator.IResult result) + { + return HttpResults.Problem(statusCode: StatusCodes.Status404NotFound, title: result.Message ?? "Not Found"); + } + + public static IResult MapUnauthorized(Foundatio.Mediator.IResult result) + { + return HttpResults.Problem(statusCode: StatusCodes.Status401Unauthorized, title: result.Message ?? "Unauthorized"); + } + + public static IResult MapForbidden(Foundatio.Mediator.IResult result) + { + return HttpResults.Problem(statusCode: StatusCodes.Status403Forbidden, title: result.Message ?? "Forbidden"); + } + + public static IResult MapConflict(Foundatio.Mediator.IResult result) + { + return HttpResults.Problem(statusCode: StatusCodes.Status409Conflict, title: result.Message ?? "Conflict"); + } + + public static IResult MapError(Foundatio.Mediator.IResult result) + { + return HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Internal Server Error"); + } + + public static IResult MapCriticalError(Foundatio.Mediator.IResult result) + { + return HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Critical Error"); + } + + public static IResult MapUnavailable(Foundatio.Mediator.IResult result) + { + return HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status503ServiceUnavailable, title: "Service Unavailable"); + } + + public static IResult MapValidation(Foundatio.Mediator.IResult result) + { + var errors = result.ValidationErrors?.ToList(); + if (errors is null || errors.Count == 0) + return HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status422UnprocessableEntity, title: "Validation failed"); + + var planLimitError = errors.FirstOrDefault(error => String.Equals(error.Identifier, "plan_limit", StringComparison.OrdinalIgnoreCase)); + if (planLimitError is not null) + return HttpResults.Problem(statusCode: StatusCodes.Status426UpgradeRequired, title: planLimitError.ErrorMessage); + + var notImplementedError = errors.FirstOrDefault(error => String.Equals(error.Identifier, "not_implemented", StringComparison.OrdinalIgnoreCase)); + if (notImplementedError is not null) + return HttpResults.Problem(statusCode: StatusCodes.Status501NotImplemented, title: notImplementedError.ErrorMessage); + + var rateLimitError = errors.FirstOrDefault(error => String.Equals(error.Identifier, "rate_limit", StringComparison.OrdinalIgnoreCase)); + if (rateLimitError is not null) + return HttpResults.Problem(statusCode: StatusCodes.Status429TooManyRequests, title: rateLimitError.ErrorMessage); + + var errorDict = new Dictionary(); + foreach (var error in errors) + { + var key = error.Identifier ?? ""; + errorDict[key] = errorDict.TryGetValue(key, out var existing) + ? [.. existing, error.ErrorMessage] + : [error.ErrorMessage]; + } + + return HttpResults.ValidationProblem(errorDict, title: result.Message ?? "Validation failed", statusCode: StatusCodes.Status422UnprocessableEntity); + } + + private static IResult MapSuccess(Foundatio.Mediator.IResult result) + { + var value = GetValue(result); + if (value is null) + return HttpResults.Ok(); + + // Handle PagedResult — serialize Items and set pagination headers + if (value is IPagedResult paged) + return new PagedHttpResult(paged); + + if (value is NotModifiedResponse) + return HttpResults.StatusCode(StatusCodes.Status304NotModified); + + if (value is ModelActionResults { Failure.Count: > 0 } modelAction) + return HttpResults.Json(modelAction, statusCode: StatusCodes.Status400BadRequest); + + if (value is WorkInProgressResult) + return HttpResults.Json(value, statusCode: StatusCodes.Status202Accepted); + + if (value is WorkInProgressResponse wip) + return HttpResults.Json(new { workers = wip.Workers }, statusCode: StatusCodes.Status202Accepted); + + return HttpResults.Ok(value); + } + + private static IResult MapCreated(Foundatio.Mediator.IResult result) + { + var value = GetValue(result); + var location = result.Location; + return HttpResults.Created(location, value); + } + + private static IResult MapAccepted(Foundatio.Mediator.IResult result) + { + var value = GetValue(result); + if (value is WorkInProgressResponse wip) + return HttpResults.Json(new { workers = wip.Workers }, statusCode: StatusCodes.Status202Accepted); + + return HttpResults.StatusCode(StatusCodes.Status202Accepted); + } + + private static object? GetValue(Foundatio.Mediator.IResult result) + { + var type = result.GetType(); + var valueProp = s_valuePropertyCache.GetOrAdd(type, t => t.GetProperty("ValueOrDefault")); + return valueProp?.GetValue(result); + } +} diff --git a/src/Exceptionless.Web/Api/Results/ApiResults.cs b/src/Exceptionless.Web/Api/Results/ApiResults.cs new file mode 100644 index 0000000000..2975288a94 --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/ApiResults.cs @@ -0,0 +1,178 @@ +using System.Collections.Specialized; +using System.Web; +using Exceptionless.Core.Extensions; +using Exceptionless.Web.Utility; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Net.Http.Headers; + +namespace Exceptionless.Web.Api.Results; + +public static class ApiResults +{ + public static IResult OkWithLinks(T content, params string?[] links) + { + var validLinks = links.Where(l => !String.IsNullOrEmpty(l)).ToArray(); + return new OkWithLinksResult(content, validLinks!); + } + + public static IResult OkWithResourceLinks(HttpContext context, ICollection content, bool hasMore, int? page = null, long? total = null, string? before = null, string? after = null) where TEntity : class + { + var headers = new Dictionary(); + + if (total.HasValue) + headers[Headers.ResultCount] = [total.Value.ToString()]; + + var linkValues = page.HasValue + ? GetPagedLinks(new Uri(context.Request.GetDisplayUrl()), page.Value, hasMore) + : GetBeforeAndAfterLinks(new Uri(context.Request.GetDisplayUrl()), before, after); + + if (linkValues.Count > 0) + headers[HeaderNames.Link.ToString()] = linkValues.ToArray(); + + return new OkWithHeadersResult>(content, headers); + } + + public static IResult WorkInProgress(IEnumerable workers) + { + return TypedResults.Json(new { workers = workers.ToArray() }, statusCode: StatusCodes.Status202Accepted); + } + + public static IResult Permission(PermissionResult permission) + { + if (String.IsNullOrEmpty(permission.Message)) + return TypedResults.Problem(statusCode: permission.StatusCode); + + return TypedResults.Problem(statusCode: permission.StatusCode, title: permission.Message); + } + + public static IResult PlanLimitReached(string message) + { + return TypedResults.Problem(statusCode: StatusCodes.Status426UpgradeRequired, title: message); + } + + public static IResult Forbidden(string? message = null) + { + if (String.IsNullOrEmpty(message)) + return TypedResults.StatusCode(StatusCodes.Status403Forbidden); + + return TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden, title: message); + } + + public static IResult TooManyRequests(string message) + { + return TypedResults.Problem(statusCode: StatusCodes.Status429TooManyRequests, title: message); + } + + public static IResult NotImplemented(string message) + { + return TypedResults.Problem(statusCode: StatusCodes.Status501NotImplemented, title: message); + } + + public static List GetPagedLinks(Uri url, int page, bool hasMore) + { + bool includePrevious = page > 1; + bool includeNext = hasMore; + + var previousParameters = HttpUtility.ParseQueryString(url.Query); + previousParameters["page"] = (page - 1).ToString(); + var nextParameters = new NameValueCollection(previousParameters) + { + ["page"] = (page + 1).ToString() + }; + + string baseUrl = url.GetBaseUrl(); + + var links = new List(2); + if (includePrevious) + links.Add($"<{baseUrl}?{previousParameters.ToQueryString()}>; rel=\"previous\""); + if (includeNext) + links.Add($"<{baseUrl}?{nextParameters.ToQueryString()}>; rel=\"next\""); + + return links; + } + + public static List GetBeforeAndAfterLinks(Uri url, string? before, string? after) + { + var previousParameters = HttpUtility.ParseQueryString(url.Query); + previousParameters.Remove("before"); + previousParameters.Remove("after"); + + var nextParameters = new NameValueCollection(previousParameters); + previousParameters.Add("before", before); + nextParameters.Add("after", after); + + string baseUrl = url.GetBaseUrl(); + var links = new List(2); + if (!String.IsNullOrEmpty(before)) + links.Add($"<{baseUrl}?{previousParameters.ToQueryString()}>; rel=\"previous\""); + if (!String.IsNullOrEmpty(after)) + links.Add($"<{baseUrl}?{nextParameters.ToQueryString()}>; rel=\"next\""); + + return links; + } +} + +public class OkWithLinksResult : IResult +{ + private readonly T _content; + private readonly string[] _links; + + public OkWithLinksResult(T content, string[] links) + { + _content = content; + _links = links; + } + + public Task ExecuteAsync(HttpContext httpContext) + { + if (_links.Length > 0) + httpContext.Response.Headers[HeaderNames.Link] = _links; + + httpContext.Response.StatusCode = StatusCodes.Status200OK; + return httpContext.Response.WriteAsJsonAsync(_content); + } +} + +public class OkWithHeadersResult : IResult +{ + private readonly T _content; + private readonly Dictionary _headers; + + public OkWithHeadersResult(T content, Dictionary headers) + { + _content = content; + _headers = headers; + } + + public Task ExecuteAsync(HttpContext httpContext) + { + foreach (var header in _headers) + httpContext.Response.Headers[header.Key] = header.Value; + + httpContext.Response.StatusCode = StatusCodes.Status200OK; + return httpContext.Response.WriteAsJsonAsync(_content); + } +} + +public record PermissionResult +{ + public bool Allowed { get; init; } + public string? Id { get; init; } + public string? Message { get; init; } + public int StatusCode { get; init; } = StatusCodes.Status200OK; + + public static PermissionResult Allow => new() { Allowed = true }; + public static PermissionResult Deny => new() { Allowed = false, StatusCode = StatusCodes.Status403Forbidden }; + + public static PermissionResult DenyWithMessage(string message, int statusCode = StatusCodes.Status403Forbidden) + => new() { Allowed = false, Message = message, StatusCode = statusCode }; + + public static PermissionResult DenyWithStatus(int statusCode) + => new() { Allowed = false, StatusCode = statusCode }; + + public static PermissionResult DenyWithNotFound(string? id = null) + => new() { Allowed = false, Id = id, StatusCode = StatusCodes.Status404NotFound }; + + public static PermissionResult DenyWithPlanLimitReached(string message) + => new() { Allowed = false, Message = message, StatusCode = StatusCodes.Status426UpgradeRequired }; +} diff --git a/src/Exceptionless.Web/Api/Results/NotModifiedResponse.cs b/src/Exceptionless.Web/Api/Results/NotModifiedResponse.cs new file mode 100644 index 0000000000..25b3d2a644 --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/NotModifiedResponse.cs @@ -0,0 +1,6 @@ +namespace Exceptionless.Web.Api.Results; + +/// +/// Transport-agnostic response marker mapped to HTTP 304 Not Modified by result mappers. +/// +public sealed record NotModifiedResponse; diff --git a/src/Exceptionless.Web/Api/Results/PagedResult.cs b/src/Exceptionless.Web/Api/Results/PagedResult.cs new file mode 100644 index 0000000000..84919cbc65 --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/PagedResult.cs @@ -0,0 +1,107 @@ +using System.Collections.Specialized; +using System.Web; +using Exceptionless.Core.Extensions; +using Exceptionless.Web.Utility; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Net.Http.Headers; + +namespace Exceptionless.Web.Api.Results; + +/// +/// Interface for paginated result detection in the result mapper. +/// +public interface IPagedResult +{ + object Items { get; } + bool HasMore { get; } + int? Page { get; } + long? Total { get; } + string? Before { get; } + string? After { get; } +} + +/// +/// Transport-agnostic paginated response. Handlers return this; the mapper +/// serializes only Items and projects metadata into HTTP headers. +/// +public sealed record PagedResult( + IReadOnlyCollection Items, + bool HasMore, + int? Page = null, + long? Total = null, + string? Before = null, + string? After = null) : IPagedResult where T : class +{ + object IPagedResult.Items => Items; +} + +/// +/// Response for async work-in-progress operations (202 Accepted). +/// +public sealed record WorkInProgressResponse(IReadOnlyCollection Workers); + +/// +/// Custom IResult that writes pagination headers (Link, X-Result-Count) and serializes items. +/// +internal sealed class PagedHttpResult : IResult +{ + private readonly IPagedResult _paged; + + public PagedHttpResult(IPagedResult paged) => _paged = paged; + + public Task ExecuteAsync(HttpContext httpContext) + { + if (_paged.Total.HasValue) + httpContext.Response.Headers[Headers.ResultCount] = _paged.Total.Value.ToString(); + + var linkValues = _paged.Page.HasValue + ? GetPagedLinks(new Uri(httpContext.Request.GetDisplayUrl()), _paged.Page.Value, _paged.HasMore) + : GetBeforeAndAfterLinks(new Uri(httpContext.Request.GetDisplayUrl()), _paged.Before, _paged.After); + + if (linkValues.Count > 0) + httpContext.Response.Headers[HeaderNames.Link.ToString()] = linkValues.ToArray(); + + httpContext.Response.StatusCode = StatusCodes.Status200OK; + return httpContext.Response.WriteAsJsonAsync(_paged.Items); + } + + private static List GetPagedLinks(Uri url, int page, bool hasMore) + { + bool includePrevious = page > 1; + bool includeNext = hasMore; + + var previousParameters = HttpUtility.ParseQueryString(url.Query); + previousParameters["page"] = (page - 1).ToString(); + var nextParameters = new NameValueCollection(previousParameters) + { + ["page"] = (page + 1).ToString() + }; + + string baseUrl = url.GetBaseUrl(); + var links = new List(2); + if (includePrevious) + links.Add($"<{baseUrl}?{previousParameters.ToQueryString()}>; rel=\"previous\""); + if (includeNext) + links.Add($"<{baseUrl}?{nextParameters.ToQueryString()}>; rel=\"next\""); + return links; + } + + private static List GetBeforeAndAfterLinks(Uri url, string? before, string? after) + { + var previousParameters = HttpUtility.ParseQueryString(url.Query); + previousParameters.Remove("before"); + previousParameters.Remove("after"); + + var nextParameters = new NameValueCollection(previousParameters); + previousParameters.Add("before", before); + nextParameters.Add("after", after); + + string baseUrl = url.GetBaseUrl(); + var links = new List(2); + if (!String.IsNullOrEmpty(before)) + links.Add($"<{baseUrl}?{previousParameters.ToQueryString()}>; rel=\"previous\""); + if (!String.IsNullOrEmpty(after)) + links.Add($"<{baseUrl}?{nextParameters.ToQueryString()}>; rel=\"next\""); + return links; + } +} diff --git a/src/Exceptionless.Web/Api/Results/ProfileImageUpdate.cs b/src/Exceptionless.Web/Api/Results/ProfileImageUpdate.cs new file mode 100644 index 0000000000..7ee152848c --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/ProfileImageUpdate.cs @@ -0,0 +1,3 @@ +namespace Exceptionless.Web.Api.Results; + +public sealed record ProfileImageUpdate(T View, string? PreviousFileName); diff --git a/src/Exceptionless.Web/Api/Results/ResultExtensions.cs b/src/Exceptionless.Web/Api/Results/ResultExtensions.cs new file mode 100644 index 0000000000..d43b411e19 --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/ResultExtensions.cs @@ -0,0 +1,27 @@ +using Foundatio.Mediator; +using IHttpResult = Microsoft.AspNetCore.Http.IResult; + +namespace Exceptionless.Web.Api.Results; + +/// +/// Extension methods to convert Foundatio.Mediator Result types to ASP.NET IResult. +/// Used in endpoint lambdas after invoking handlers via the mediator. +/// +public static class ResultExtensions +{ + /// + /// Converts a Result (non-generic) to an HTTP IResult through the registered mediator result mapper. + /// + public static IHttpResult ToHttpResult(this Result result, IMediatorResultMapper resultMapper) + { + return resultMapper.MapResult(result); + } + + /// + /// Converts a Result<T> to an HTTP IResult through the registered mediator result mapper. + /// + public static IHttpResult ToHttpResult(this Result result, IMediatorResultMapper resultMapper) + { + return resultMapper.MapResult(result); + } +} diff --git a/src/Exceptionless.Web/Bootstrapper.cs b/src/Exceptionless.Web/Bootstrapper.cs index e330addfdf..1ee0429723 100644 --- a/src/Exceptionless.Web/Bootstrapper.cs +++ b/src/Exceptionless.Web/Bootstrapper.cs @@ -23,7 +23,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO if (appOptions.RunJobsInProcess) Core.Bootstrapper.AddHostedJobs(services, loggerFactory); - var logger = loggerFactory.CreateLogger(); + var logger = loggerFactory.CreateLogger(); services.AddStartupAction(); services.AddStartupAction("Subscribe to Log Work Item Progress", (sp, ct) => { diff --git a/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js b/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js index ab0ba22699..bc5824d4dd 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js @@ -134,7 +134,15 @@ } function update(id, organization) { - return Restangular.one("organizations", id).patch(organization); + return Restangular.one("organizations", id).customPATCH(toJsonPatch(organization), "", {}, { "Content-Type": "application/json-patch+json" }); + } + + function toJsonPatch(obj) { + return Object.keys(obj).filter(function(key) { + return obj[key] !== undefined; + }).map(function(key) { + return { op: "replace", path: "/" + key, value: obj[key] }; + }); } var service = { diff --git a/src/Exceptionless.Web/ClientApp.angular/components/project/project-service.js b/src/Exceptionless.Web/ClientApp.angular/components/project/project-service.js index a1b8df6b5b..65e27e47ec 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/project/project-service.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/project/project-service.js @@ -121,7 +121,15 @@ } function update(id, project) { - return Restangular.one("projects", id).patch(project); + return Restangular.one("projects", id).customPATCH(toJsonPatch(project), "", {}, { "Content-Type": "application/json-patch+json" }); + } + + function toJsonPatch(obj) { + return Object.keys(obj).filter(function(key) { + return obj[key] !== undefined; + }).map(function(key) { + return { op: "replace", path: "/" + key, value: obj[key] }; + }); } function setConfig(id, key, value) { diff --git a/src/Exceptionless.Web/ClientApp.angular/components/token/token-service.js b/src/Exceptionless.Web/ClientApp.angular/components/token/token-service.js index b6d45f5679..10fcc2616c 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/token/token-service.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/token/token-service.js @@ -37,7 +37,15 @@ } function update(id, token) { - return Restangular.one("tokens", id).patch(token); + return Restangular.one("tokens", id).customPATCH(toJsonPatch(token), "", {}, { "Content-Type": "application/json-patch+json" }); + } + + function toJsonPatch(obj) { + return Object.keys(obj).filter(function(key) { + return obj[key] !== undefined; + }).map(function(key) { + return { op: "replace", path: "/" + key, value: obj[key] }; + }); } var service = { diff --git a/src/Exceptionless.Web/ClientApp.angular/components/user/user-service.js b/src/Exceptionless.Web/ClientApp.angular/components/user/user-service.js index 27a3564495..588417f0d2 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/user/user-service.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/user/user-service.js @@ -57,7 +57,15 @@ } function update(id, project) { - return Restangular.one("users", id).patch(project); + return Restangular.one("users", id).customPATCH(toJsonPatch(project), "", {}, { "Content-Type": "application/json-patch+json" }); + } + + function toJsonPatch(obj) { + return Object.keys(obj).filter(function(key) { + return obj[key] !== undefined; + }).map(function(key) { + return { op: "replace", path: "/" + key, value: obj[key] }; + }); } function updateEmailAddress(id, email) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts index ecc2301730..a6fb65eb0f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts @@ -4,6 +4,7 @@ import type { QueryClient } from '@tanstack/svelte-query'; import { accessToken } from '$features/auth/index.svelte'; import { fetchApiJson } from '$features/shared/api/api.svelte'; +import { jsonPatchRequestOptions, toJsonPatch } from '$features/shared/api/json-patch'; import { queryKeys as userQueryKeys } from '$features/users/api.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; @@ -417,7 +418,11 @@ export function patchOrganization(request: PatchOrganizationRequest) { enabled: () => !!accessToken.current && !!request.route.id, mutationFn: async (data: NewOrganization) => { const client = useFetchClient(); - const response = await client.patchJSON(`organizations/${request.route.id}`, data); + const response = await client.patchJSON( + `organizations/${request.route.id}`, + toJsonPatch(data as unknown as Record), + jsonPatchRequestOptions + ); return response.data!; }, onError: () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index 189193d7cd..0987c3183e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -3,6 +3,7 @@ import type { StringValueFromBody, WorkInProgressResult } from '$features/shared import type { WebSocketMessageValue } from '$features/websockets/models'; import { accessToken } from '$features/auth/index.svelte'; +import { jsonPatchRequestOptions, toJsonPatch } from '$features/shared/api/json-patch'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; @@ -569,7 +570,11 @@ export function updateProject(request: UpdateProjectRequest) { enabled: () => !!accessToken.current && !!request.route.id, mutationFn: async (data: UpdateProject) => { const client = useFetchClient(); - const response = await client.patchJSON(`projects/${request.route.id}`, data); + const response = await client.patchJSON( + `projects/${request.route.id}`, + toJsonPatch(data as unknown as Record), + jsonPatchRequestOptions + ); return response.data!; }, onError: () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts index 71d980a81c..adb884e80a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -1,6 +1,7 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; import { accessToken } from '$features/auth/index.svelte'; +import { jsonPatchRequestOptions, toJsonPatch } from '$features/shared/api/json-patch'; import { ChangeType } from '$features/websockets/models'; import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, type QueryClient, useQueryClient } from '@tanstack/svelte-query'; @@ -141,7 +142,11 @@ export function patchSavedView(request: { route: { id: string | undefined } }) { enabled: () => !!accessToken.current && !!request.route.id, mutationFn: async (data: UpdateSavedView) => { const client = useFetchClient(); - const response = await client.patchJSON(`saved-views/${request.route.id}`, data); + const response = await client.patchJSON( + `saved-views/${request.route.id}`, + toJsonPatch(data as unknown as Record), + jsonPatchRequestOptions + ); return response.data!; }, onSuccess: (savedView: SavedView) => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/json-patch.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/json-patch.ts new file mode 100644 index 0000000000..bb4d8a6ad4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/json-patch.ts @@ -0,0 +1,38 @@ +/** + * RFC 6902 JSON Patch utilities. + * Converts partial objects to JSON Patch operations for PATCH API calls. + */ + +import type { RequestOptions } from '@exceptionless/fetchclient'; + +export interface JsonPatchOperation { + op: 'replace' | 'test'; + path: string; + value?: unknown; +} + +/** Content-Type required for RFC 6902 JSON Patch requests. */ +export const JSON_PATCH_CONTENT_TYPE = 'application/json-patch+json'; + +/** RequestOptions preset with the correct JSON Patch content type header. */ +export const jsonPatchRequestOptions: RequestOptions = { + headers: { 'Content-Type': JSON_PATCH_CONTENT_TYPE } +}; + +/** + * Converts a partial object into an array of RFC 6902 JSON Patch "replace" operations. + * Each top-level property becomes a `replace` operation with a snake_case path. + * + * @example + * toJsonPatch({ name: "New Name", deleteBotDataEnabled: true }) + * // => [{ op: "replace", path: "/name", value: "New Name" }, { op: "replace", path: "/delete_bot_data_enabled", value: true }] + */ +export function toJsonPatch(data: Record): JsonPatchOperation[] { + return Object.entries(data) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => ({ + op: 'replace' as const, + path: `/${key}`, + value + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/api.svelte.ts index 1d73680b6f..f51721cfa8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/api.svelte.ts @@ -3,6 +3,7 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; import { accessToken } from '$features/auth/index.svelte'; import { DEFAULT_LIMIT } from '$features/shared/api/api.svelte'; +import { jsonPatchRequestOptions, toJsonPatch } from '$features/shared/api/json-patch'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; @@ -149,7 +150,11 @@ export function patchToken(request: PatchTokenRequest) { return createMutation(() => ({ mutationFn: async (data: UpdateToken) => { const client = useFetchClient(); - const response = await client.patchJSON(`tokens/${request.route.id}`, data); + const response = await client.patchJSON( + `tokens/${request.route.id}`, + toJsonPatch(data as unknown as Record), + jsonPatchRequestOptions + ); return response.data!; }, mutationKey: queryKeys.id(request.route.id), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts index a278afb31a..60d64f6028 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts @@ -4,6 +4,7 @@ import type { WorkInProgressResult } from '$shared/models'; import { setUserIdentity } from '$features/auth/exceptionless-session'; import { accessToken } from '$features/auth/index.svelte'; import { fetchApiJson } from '$features/shared/api/api.svelte'; +import { jsonPatchRequestOptions, toJsonPatch } from '$features/shared/api/json-patch'; import { type FetchClientResponse, ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; @@ -160,7 +161,11 @@ export function patchUser(request: PatchUserRequest) { enabled: () => !!accessToken.current && !!request.route.id, mutationFn: async (data: UpdateUser) => { const client = useFetchClient(); - const response = await client.patchJSON(`users/${request.route.id}`, data); + const response = await client.patchJSON( + `users/${request.route.id}`, + toJsonPatch(data as unknown as Record), + jsonPatchRequestOptions + ); return response.data!; }, mutationKey: queryKeys.patchUser(request.route.id), diff --git a/src/Exceptionless.Web/Controllers/AdminController.cs b/src/Exceptionless.Web/Controllers/AdminController.cs deleted file mode 100644 index a27988603d..0000000000 --- a/src/Exceptionless.Web/Controllers/AdminController.cs +++ /dev/null @@ -1,453 +0,0 @@ -using Exceptionless.Core; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Billing; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Messaging.Models; -using Exceptionless.Core.Models; -using Exceptionless.Core.Models.WorkItems; -using Exceptionless.Core.Queues.Models; -using Exceptionless.Core.Repositories; -using Exceptionless.Core.Repositories.Configuration; -using Exceptionless.Core.Utility; -using Exceptionless.DateTimeExtensions; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Models.Admin; -using Foundatio.Jobs; -using Foundatio.Messaging; -using Foundatio.Queues; -using Foundatio.Repositories; -using Foundatio.Repositories.Migrations; -using Foundatio.Storage; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX + "/admin")] -[Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] -[ApiExplorerSettings(IgnoreApi = true)] -public class AdminController : ExceptionlessApiController -{ - private readonly ILogger _logger; - private readonly ExceptionlessElasticConfiguration _configuration; - private readonly IFileStorage _fileStorage; - private readonly IMessagePublisher _messagePublisher; - private readonly IOrganizationRepository _organizationRepository; - private readonly IProjectRepository _projectRepository; - private readonly IStackRepository _stackRepository; - private readonly IEventRepository _eventRepository; - private readonly IUserRepository _userRepository; - private readonly IQueue _eventPostQueue; - private readonly IQueue _workItemQueue; - private readonly AppOptions _appOptions; - private readonly BillingManager _billingManager; - private readonly BillingPlans _plans; - private readonly IMigrationStateRepository _migrationStateRepository; - private readonly SampleDataService _sampleDataService; - - public AdminController( - ExceptionlessElasticConfiguration configuration, - IFileStorage fileStorage, - IMessagePublisher messagePublisher, - IOrganizationRepository organizationRepository, - IProjectRepository projectRepository, - IStackRepository stackRepository, - IEventRepository eventRepository, - IUserRepository userRepository, - IQueue eventPostQueue, - IQueue workItemQueue, - AppOptions appOptions, - BillingManager billingManager, - BillingPlans plans, - IMigrationStateRepository migrationStateRepository, - SampleDataService sampleDataService, - TimeProvider timeProvider, - ILoggerFactory loggerFactory) : base(timeProvider) - { - _logger = loggerFactory.CreateLogger(); - _configuration = configuration; - _fileStorage = fileStorage; - _messagePublisher = messagePublisher; - _organizationRepository = organizationRepository; - _projectRepository = projectRepository; - _stackRepository = stackRepository; - _eventRepository = eventRepository; - _userRepository = userRepository; - _eventPostQueue = eventPostQueue; - _workItemQueue = workItemQueue; - _appOptions = appOptions; - _billingManager = billingManager; - _plans = plans; - _migrationStateRepository = migrationStateRepository; - _sampleDataService = sampleDataService; - } - - [HttpGet("settings")] - public ActionResult SettingsRequest() - { - return Ok(_appOptions); - } - - [HttpGet("stats")] - public async Task> GetStatsAsync() - { - var organizationCountTask = _organizationRepository.CountAsync(q => q - .AggregationsExpression("terms:billing_status date:created_utc~1M")); - - var userCountTask = _userRepository.CountAsync(); - var projectCountTask = _projectRepository.CountAsync(); - - var stackCountTask = _stackRepository.CountAsync(q => q - .AggregationsExpression("terms:status terms:(type terms:status)")); - - var eventCountTask = _eventRepository.CountAsync(q => q - .AggregationsExpression("date:date~1M")); - - await Task.WhenAll(organizationCountTask, userCountTask, projectCountTask, stackCountTask, eventCountTask); - - return Ok(new AdminStatsResponse( - Organizations: await organizationCountTask, - Users: await userCountTask, - Projects: await projectCountTask, - Stacks: await stackCountTask, - Events: await eventCountTask - )); - } - - [HttpGet("migrations")] - public async Task> GetMigrationsAsync() - { - var result = await _migrationStateRepository.GetAllAsync(o => o.SearchAfterPaging().PageLimit(1000)); - var migrationStates = new List(result.Documents.Count); - - while (result.Documents.Count > 0) - { - migrationStates.AddRange(result.Documents); - - if (!await result.NextPageAsync()) - break; - } - - var states = migrationStates - .OrderByDescending(s => s.Version) - .ThenByDescending(s => s.StartedUtc) - .ToArray(); - - int currentVersion = states - .Where(s => s.MigrationType != MigrationType.Repeatable && s.CompletedUtc.HasValue) - .Select(s => s.Version) - .DefaultIfEmpty(-1) - .Max(); - - return Ok(new MigrationsResponse(currentVersion, states)); - } - - [HttpGet("echo")] - public ActionResult EchoRequest() - { - return Ok(new - { - Request.Headers, - IpAddress = Request.GetClientIpAddress() - }); - } - - [HttpGet("assemblies")] - public ActionResult> Assemblies() - { - var details = AssemblyDetail.ExtractAll().Select(AssemblyDetailResponse.FromAssemblyDetail); - return Ok(details); - } - - [HttpPost("change-plan")] - public async Task> ChangePlanAsync(string organizationId, string planId) - { - if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) - return Ok(new ChangePlanResponse(false, "Invalid Organization Id.")); - - var organization = await _organizationRepository.GetByIdAsync(organizationId); - if (organization is null) - return Ok(new ChangePlanResponse(false, "Invalid Organization Id.")); - - var plan = _billingManager.GetBillingPlan(planId); - if (plan is null) - return Ok(new ChangePlanResponse(false, "Invalid PlanId.")); - - organization.BillingStatus = !String.Equals(plan.Id, _plans.FreePlan.Id) ? BillingStatus.Active : BillingStatus.Trialing; - organization.RemoveSuspension(); - _billingManager.ApplyBillingPlan(organization, plan, CurrentUser, false); - - await _organizationRepository.SaveAsync(organization, o => o.Cache().Originals()); - await _messagePublisher.PublishAsync(new PlanChanged - { - OrganizationId = organization.Id - }); - - return Ok(new ChangePlanResponse(true)); - } - - /// - /// Applies a bonus event count to the specified organization, optionally with an expiration date. - /// - /// The unique identifier of the organization to receive the bonus. - /// The number of bonus events to apply. - /// The optional expiration date for the bonus events. - /// Bonus was applied successfully. - /// Validation error occurred. - [HttpPost("set-bonus")] - public async Task SetBonusAsync(string organizationId, int bonusEvents, DateTime? expires = null) - { - if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) - { - ModelState.AddModelError(nameof(organizationId), "Invalid Organization Id"); - return ValidationProblem(ModelState); - } - - var organization = await _organizationRepository.GetByIdAsync(organizationId); - if (organization is null) - { - ModelState.AddModelError(nameof(organizationId), "Invalid Organization Id"); - return ValidationProblem(ModelState); - } - - _billingManager.ApplyBonus(organization, bonusEvents, expires); - await _organizationRepository.SaveAsync(organization, o => o.Cache().Originals()); - - return Ok(); - } - - [HttpGet("requeue")] - public async Task RequeueAsync(string? path = null, bool archive = false) - { - if (String.IsNullOrEmpty(path)) - path = @"q\*"; - - int enqueued = 0; - foreach (var file in await _fileStorage.GetFileListAsync(path)) - { - await _eventPostQueue.EnqueueAsync(new EventPost(_appOptions.EnableArchive && archive) { FilePath = file.Path }); - enqueued++; - } - - return Ok(new { Enqueued = enqueued }); - } - - [HttpGet("maintenance/{name:minlength(1)}")] - public async Task RunJobAsync(string name, DateTime? utcStart = null, DateTime? utcEnd = null, string? organizationId = null) - { - if (!ModelState.IsValid) - return ValidationProblem(ModelState); - - switch (name.ToLowerInvariant()) - { - case "fix-stack-stats": - var effectiveUtcStart = utcStart ?? _timeProvider.GetUtcNow().UtcDateTime.AddDays(-90); - - if (utcEnd.HasValue && utcEnd.Value.IsBefore(effectiveUtcStart)) - { - ModelState.AddModelError(nameof(utcEnd), "utcEnd must be greater than or equal to utcStart."); - return ValidationProblem(ModelState); - } - - await _workItemQueue.EnqueueAsync(new FixStackStatsWorkItem - { - UtcStart = effectiveUtcStart, - UtcEnd = utcEnd, - OrganizationId = organizationId - }); - break; - case "increment-project-configuration-version": - await _workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { IncrementConfigurationVersion = true }); - break; - case "indexes": - if (!_appOptions.ElasticsearchOptions.DisableIndexConfiguration) - await _configuration.ConfigureIndexesAsync(beginReindexingOutdated: false); - break; - case "normalize-user-email-address": - await _workItemQueue.EnqueueAsync(new UserMaintenanceWorkItem { Normalize = true }); - break; - case "remove-old-organization-usage": - await _workItemQueue.EnqueueAsync(new OrganizationMaintenanceWorkItem { RemoveOldUsageStats = true }); - break; - case "remove-old-project-usage": - await _workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { RemoveOldUsageStats = true }); - break; - case "reset-verify-email-address-token-and-expiration": - await _workItemQueue.EnqueueAsync(new UserMaintenanceWorkItem { ResetVerifyEmailAddressToken = true }); - break; - case "update-organization-plans": - await _workItemQueue.EnqueueAsync(new OrganizationMaintenanceWorkItem { UpgradePlans = true }); - break; - case "update-project-default-bot-lists": - await _workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { UpdateDefaultBotList = true, IncrementConfigurationVersion = true }); - break; - case "update-project-notification-settings": - await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem - { - OrganizationId = organizationId - }); - break; - default: - return NotFound(); - } - - return Ok(); - } - - [HttpGet("elasticsearch")] - public async Task> GetElasticsearchInfoAsync() - { - var client = _configuration.Client; - var healthTask = client.Cluster.HealthAsync(r => r.Level(Elastic.Clients.Elasticsearch.Level.Indices)); - var statsTask = client.Cluster.StatsAsync(); - var indicesStatsTask = client.Indices.StatsAsync(); - await Task.WhenAll(healthTask, statsTask, indicesStatsTask); - - var healthResponse = await healthTask; - var statsResponse = await statsTask; - var indicesStatsResponse = await indicesStatsTask; - - if (!healthResponse.IsValidResponse || !statsResponse.IsValidResponse || !indicesStatsResponse.IsValidResponse) - return Problem(title: "Elasticsearch cluster information is unavailable."); - - // Count unassigned shards per index from health response - var unassignedByIndex = (healthResponse.Indices ?? new Dictionary()) - .Where(kvp => kvp.Value.UnassignedShards > 0) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.UnassignedShards, StringComparer.OrdinalIgnoreCase); - - var indexDetails = (indicesStatsResponse.Indices ?? new Dictionary()) - .OrderByDescending(kvp => kvp.Value.Total?.Store?.SizeInBytes ?? 0) - .Select(kvp => new ElasticsearchIndexDetailResponse( - Index: kvp.Key, - Health: kvp.Value.Health?.ToString().ToLowerInvariant(), - Status: kvp.Value.Status?.ToString().ToLowerInvariant(), - Primary: healthResponse.Indices?.GetValueOrDefault(kvp.Key)?.NumberOfShards ?? 0, - Replica: healthResponse.Indices?.GetValueOrDefault(kvp.Key)?.NumberOfReplicas ?? 0, - DocsCount: kvp.Value.Total?.Docs?.Count ?? 0, - StoreSizeInBytes: kvp.Value.Total?.Store?.SizeInBytes ?? 0, - UnassignedShards: unassignedByIndex.GetValueOrDefault(kvp.Key, 0) - )) - .ToArray(); - - return Ok(new ElasticsearchInfoResponse( - Health: new ElasticsearchHealthResponse( - Status: (int)healthResponse.Status, - ClusterName: healthResponse.ClusterName, - NumberOfNodes: healthResponse.NumberOfNodes, - NumberOfDataNodes: healthResponse.NumberOfDataNodes, - ActiveShards: healthResponse.ActiveShards, - RelocatingShards: healthResponse.RelocatingShards, - UnassignedShards: healthResponse.UnassignedShards, - ActivePrimaryShards: healthResponse.ActivePrimaryShards - ), - Indices: new ElasticsearchIndicesResponse( - Count: statsResponse.Indices.Count, - DocsCount: statsResponse.Indices.Docs.Count, - StoreSizeInBytes: statsResponse.Indices.Store.SizeInBytes - ), - IndexDetails: indexDetails - )); - } - - [HttpGet("elasticsearch/snapshots")] - public async Task> GetElasticsearchSnapshotsAsync() - { - var client = _configuration.Client; - try - { - var repositoryResponse = await client.Snapshot.GetRepositoryAsync(); - if (!repositoryResponse.IsValidResponse) - return Problem(title: "Snapshot repository information is unavailable."); - - if (repositoryResponse.Repositories is null || !repositoryResponse.Repositories.Any()) - return Ok(new ElasticsearchSnapshotsResponse([], [])); - - var repositoryNames = repositoryResponse.Repositories.Select(r => r.Key).ToArray(); - - var snapshotTasks = repositoryNames - .Select(async repositoryName => - { - var snapshotResponse = await client.Snapshot.GetAsync(repositoryName, "*"); - if (!snapshotResponse.IsValidResponse) - return ( - RepositoryName: repositoryName, - Snapshots: Array.Empty(), - Error: $"Unable to retrieve snapshots for repository: {repositoryName}." - ); - - var snapshots = snapshotResponse.Snapshots?.ToArray() ?? []; - return ( - RepositoryName: repositoryName, - Snapshots: snapshots.Select(s => new ElasticsearchSnapshotResponse( - Repository: repositoryName, - Name: s.Snapshot, - Status: s.State ?? String.Empty, - StartTime: s.StartTime?.UtcDateTime, - EndTime: s.EndTime?.UtcDateTime, - Duration: s.Duration?.ToString() ?? String.Empty, - IndicesCount: s.Indices?.Count ?? 0, - SuccessfulShards: s.Shards?.Successful ?? 0, - FailedShards: s.Shards?.Failed ?? 0, - TotalShards: s.Shards?.Total ?? 0 - )).ToArray(), - Error: (string?)null - ); - }) - .ToArray(); - - var snapshotResults = await Task.WhenAll(snapshotTasks); - - var failedSnapshotResults = snapshotResults - .Where(r => r.Error is not null) - .ToArray(); - - if (failedSnapshotResults.Length is > 0) - { - _logger.LogWarning("Unable to retrieve snapshots for one or more repositories: {Repositories}", - String.Join(", ", failedSnapshotResults.Select(r => r.RepositoryName))); - } - - var successfulSnapshotResults = snapshotResults - .Where(r => r.Error is null) - .ToArray(); - - if (successfulSnapshotResults.Length is 0) - return Problem(title: "Unable to retrieve snapshot information."); - - var snapshots = successfulSnapshotResults - .SelectMany(r => r.Snapshots) - .OrderByDescending(s => s.StartTime) - .ToArray(); - - var successfulRepositoryNames = successfulSnapshotResults - .Select(r => r.RepositoryName) - .ToArray(); - - return Ok(new ElasticsearchSnapshotsResponse(successfulRepositoryNames, snapshots)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Unable to retrieve snapshot information"); - return Problem(title: "Unable to retrieve snapshot information."); - } - } - - [HttpPost("generate-sample-events")] - public async Task GenerateSampleEventsAsync(int eventCount = 250, int daysBack = 7) - { - if (eventCount < 1 || eventCount > 10000) - { - ModelState.AddModelError(nameof(eventCount), "Event count must be between 1 and 10,000."); - return ValidationProblem(ModelState); - } - - if (daysBack < 1 || daysBack > 365) - { - ModelState.AddModelError(nameof(daysBack), "Days back must be between 1 and 365."); - return ValidationProblem(ModelState); - } - - await _sampleDataService.EnqueueSampleEventsAsync(eventCount, daysBack); - return Ok(new { Success = true, Message = $"Enqueued generation of {eventCount} sample events over {daysBack} days. Events will appear shortly." }); - } -} diff --git a/src/Exceptionless.Web/Controllers/AuthController.cs b/src/Exceptionless.Web/Controllers/AuthController.cs deleted file mode 100644 index 3ac42746fe..0000000000 --- a/src/Exceptionless.Web/Controllers/AuthController.cs +++ /dev/null @@ -1,904 +0,0 @@ -using System.Configuration; -using System.IdentityModel.Tokens.Jwt; -using System.Text; -using Exceptionless.Core.Authentication; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Configuration; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Mail; -using Exceptionless.Core.Models; -using Exceptionless.Core.Repositories; -using Exceptionless.DateTimeExtensions; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Models; -using Foundatio.Caching; -using Foundatio.Repositories; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.IdentityModel.Tokens; -using OAuth2.Client; -using OAuth2.Client.Impl; -using OAuth2.Configuration; -using OAuth2.Infrastructure; -using OAuth2.Models; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX + "/auth")] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class AuthController : ExceptionlessApiController -{ - private readonly AuthOptions _authOptions; - private readonly IntercomOptions _intercomOptions; - private readonly IDomainLoginProvider _domainLoginProvider; - private readonly IOrganizationRepository _organizationRepository; - private readonly IUserRepository _userRepository; - private readonly ITokenRepository _tokenRepository; - private readonly ScopedCacheClient _cache; - private readonly IMailer _mailer; - private readonly ILogger _logger; - - private static bool _isFirstUserChecked; - private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60); - - public AuthController(AuthOptions authOptions, IntercomOptions intercomOptions, IOrganizationRepository organizationRepository, IUserRepository userRepository, - ITokenRepository tokenRepository, ICacheClient cacheClient, IMailer mailer, IDomainLoginProvider domainLoginProvider, - TimeProvider timeProvider, ILogger logger) : base(timeProvider) - { - _authOptions = authOptions; - _intercomOptions = intercomOptions; - _domainLoginProvider = domainLoginProvider; - _organizationRepository = organizationRepository; - _userRepository = userRepository; - _tokenRepository = tokenRepository; - _cache = new ScopedCacheClient(cacheClient, "Auth"); - _mailer = mailer; - _logger = logger; - } - - /// - /// Login - /// - /// - /// Log in with your email address and password to generate a token scoped with your users roles. - /// - /// { "email": "noreply@exceptionless.io", "password": "exceptionless" } - /// - /// This token can then be used to access the api. You can use this token in the header (bearer authentication) - /// or append it onto the query string: ?access_token=MY_TOKEN - /// - /// Please note that you can also use this token on the documentation site by placing it in the - /// headers api_key input box. - /// - /// User Authentication Token - /// Login failed - /// Validation error - [AllowAnonymous] - [Consumes("application/json")] - [HttpPost("login")] - public async Task> LoginAsync(Login model) - { - string email = model.Email.Trim().ToLowerInvariant(); - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Login").Identity(email).SetHttpContext(HttpContext)); - - // Only allow 5 password attempts per 15-minute period. - string userLoginAttemptsCacheKey = $"user:{email}:attempts"; - long userLoginAttempts = await _cache.IncrementAsync(userLoginAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - - // Only allow 15 login attempts per 15-minute period by a single ip. - string ipLoginAttemptsCacheKey = $"ip:{Request.GetClientIpAddress()}:attempts"; - long ipLoginAttempts = await _cache.IncrementAsync(ipLoginAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - - if (userLoginAttempts > 5) - { - _logger.LogError("Login denied for {EmailAddress} for the {UserLoginAttempts} time", email, userLoginAttempts); - return Unauthorized(); - } - - if (ipLoginAttempts > 15) - { - _logger.LogError("Login denied for {EmailAddress} for the {IPLoginAttempts} time", Request.GetClientIpAddress(), ipLoginAttempts); - return Unauthorized(); - } - - User? user; - try - { - user = await _userRepository.GetByEmailAddressAsync(email); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Login failed for {EmailAddress}: {Message}", email, ex.Message); - return Unauthorized(); - } - - if (user is null) - { - _logger.LogError("Login failed for {EmailAddress}: User not found", email); - return Unauthorized(); - } - - if (!user.IsActive) - { - _logger.LogError("Login failed for {EmailAddress}: The user is inactive", user.EmailAddress); - return Unauthorized(); - } - - if (!_authOptions.EnableActiveDirectoryAuth) - { - if (String.IsNullOrEmpty(user.Salt)) - { - _logger.LogError("Login failed for {EmailAddress}: The user has no salt defined", user.EmailAddress); - return Unauthorized(); - } - - if (!user.IsCorrectPassword(model.Password)) - { - _logger.LogError("Login failed for {EmailAddress}: Invalid Password", user.EmailAddress); - return Unauthorized(); - } - } - else - { - if (!IsValidActiveDirectoryLogin(email, model.Password)) - { - _logger.LogError("Domain login failed for {EmailAddress}: Invalid Password or Account", user.EmailAddress); - return Unauthorized(); - } - } - - if (!String.IsNullOrEmpty(model.InviteToken)) - await AddInvitedUserToOrganizationAsync(model.InviteToken, user); - - await _cache.RemoveAsync(userLoginAttemptsCacheKey); - await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - - _logger.UserLoggedIn(user.EmailAddress); - return Ok(new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }); - } - - /// - /// Get the current user's Intercom messenger token. - /// - /// Intercom messenger token - /// User not logged in - /// Intercom is not enabled. - [HttpGet("intercom")] - public Task> GetIntercomTokenAsync() - { - if (!_intercomOptions.EnableIntercom || String.IsNullOrWhiteSpace(_intercomOptions.IntercomSecret)) - { - ModelState.AddModelError("intercom", "Intercom is not enabled."); - return Task.FromResult>(ValidationProblem(ModelState)); - } - - var issuedAt = _timeProvider.GetUtcNow(); - var expiresAt = issuedAt.Add(IntercomJwtLifetime); - - var signingCredentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_intercomOptions.IntercomSecret!)), - SecurityAlgorithms.HmacSha256 - ); - - var token = new JwtSecurityToken( - header: new JwtHeader(signingCredentials), - payload: new JwtPayload - { - [JwtRegisteredClaimNames.Exp] = expiresAt.ToUnixTimeSeconds(), - [JwtRegisteredClaimNames.Iat] = issuedAt.ToUnixTimeSeconds(), - ["user_id"] = CurrentUser.Id, - } - ); - - return Task.FromResult>(Ok(new TokenResult { Token = new JwtSecurityTokenHandler().WriteToken(token) })); - } - - /// - /// Logout the current user and remove the current access token - /// - /// User successfully logged-out - /// User not logged in - /// Current action is not supported with user access token - [HttpGet("logout")] - public async Task LogoutAsync() - { - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Logout").Identity(CurrentUser.EmailAddress).SetHttpContext(HttpContext)); - if (User.IsTokenAuthType()) - return Forbidden("Logout not supported for current user access token"); - - string? id = User.GetLoggedInUsersTokenId(); - if (String.IsNullOrEmpty(id)) - return Forbidden("Logout not supported"); - - try - { - await _tokenRepository.RemoveAsync(id); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Logout failed for {EmailAddress}: {Message}", CurrentUser.EmailAddress, ex.Message); - throw; - } - - return Ok(); - } - - /// - /// Sign up - /// - /// User Authentication Token - /// Sign-up failed - /// Account Creation is currently disabled - /// Validation error - [AllowAnonymous] - [Consumes("application/json")] - [HttpPost("signup")] - public async Task> SignupAsync(Signup model) - { - string email = model.Email.Trim().ToLowerInvariant(); - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Signup").Identity(email).Property("Name", model.Name).Property("Password Length", model.Password.Length).SetHttpContext(HttpContext)); - - bool valid = await IsAccountCreationEnabledAsync(model.InviteToken); - if (!valid) - return Forbidden("Account Creation is currently disabled"); - - User? user; - try - { - user = await _userRepository.GetByEmailAddressAsync(email); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Signup failed for {EmailAddress}: {Message}", email, ex.Message); - throw; - } - - if (user is not null) - return await LoginAsync(model); - - string ipSignupAttemptsCacheKey = $"ip:{Request.GetClientIpAddress()}:signup:attempts"; - bool hasValidInviteToken = !String.IsNullOrWhiteSpace(model.InviteToken) && await _organizationRepository.GetByInviteTokenAsync(model.InviteToken) is not null; - if (!hasValidInviteToken) - { - // Only allow 10 sign-ups per hour period by a single ip. - long ipSignupAttempts = await _cache.IncrementAsync(ipSignupAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); - if (ipSignupAttempts > 10) - { - _logger.LogError("Signup denied for {EmailAddress} for the {IPSignupAttempts} time", email, ipSignupAttempts); - return Unauthorized(); - } - } - - if (_authOptions.EnableActiveDirectoryAuth && !IsValidActiveDirectoryLogin(email, model.Password)) - { - _logger.LogError("Signup failed for {EmailAddress}: Active Directory authentication failed", email); - return Unauthorized(); - } - - user = new User - { - IsActive = true, - FullName = model.Name.Trim(), - EmailAddress = email, - IsEmailAddressVerified = _authOptions.EnableActiveDirectoryAuth - }; - - if (user.IsEmailAddressVerified) - user.MarkEmailAddressVerified(); - else - user.ResetVerifyEmailAddressTokenAndExpiration(_timeProvider); - - user.Roles.Add(AuthorizationRoles.Client); - user.Roles.Add(AuthorizationRoles.User); - await AddGlobalAdminRoleIfFirstUserAsync(user); - - if (!_authOptions.EnableActiveDirectoryAuth) - { - user.Salt = Core.Extensions.StringExtensions.GetRandomString(16); - user.Password = model.Password.ToSaltedHash(user.Salt); - } - - try - { - user = await _userRepository.AddAsync(user, o => o.Cache()); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Signup failed for {EmailAddress}: {Message}", email, ex.Message); - throw; - } - - if (hasValidInviteToken) - await AddInvitedUserToOrganizationAsync(model.InviteToken, user); - - if (!user.IsEmailAddressVerified) - await _mailer.SendUserEmailVerifyAsync(user); - - _logger.UserSignedUp(user.EmailAddress); - return Ok(new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }); - } - - /// - /// Sign in with GitHub - /// - /// User Authentication Token - /// Account Creation is currently disabled - /// Validation error - [AllowAnonymous] - [Consumes("application/json")] - [HttpPost("github")] - public Task> GitHubAsync(ExternalAuthInfo value) - { - return ExternalLoginAsync(value, - _authOptions.GitHubId, - _authOptions.GitHubSecret, - (f, c) => - { - c.Scope = "user:email"; - return new GitHubClient(f, c); - } - ); - } - - /// - /// Sign in with Google - /// - /// User Authentication Token - /// Account Creation is currently disabled - /// Validation error - [AllowAnonymous] - [Consumes("application/json")] - [HttpPost("google")] - public Task> GoogleAsync(ExternalAuthInfo value) - { - return ExternalLoginAsync(value, - _authOptions.GoogleId, - _authOptions.GoogleSecret, - (f, c) => - { - c.Scope = "profile email"; - return new GoogleClient(f, c); - } - ); - } - - /// - /// Sign in with Facebook - /// - /// User Authentication Token - /// Account Creation is currently disabled - /// Validation error - [AllowAnonymous] - [Consumes("application/json")] - [HttpPost("facebook")] - public Task> FacebookAsync(ExternalAuthInfo value) - { - return ExternalLoginAsync(value, - _authOptions.FacebookId, - _authOptions.FacebookSecret, - (f, c) => - { - c.Scope = "email"; - return new FacebookClient(f, c); - } - ); - } - - /// - /// Sign in with Microsoft - /// - /// User Authentication Token - /// Account Creation is currently disabled - /// Validation error - [AllowAnonymous] - [Consumes("application/json")] - [HttpPost("live")] - public Task> LiveAsync(ExternalAuthInfo value) - { - return ExternalLoginAsync(value, - _authOptions.MicrosoftId, - _authOptions.MicrosoftSecret, - (f, c) => - { - c.Scope = "wl.emails"; - return new WindowsLiveClient(f, c); - } - ); - } - - /// - /// Removes an external login provider from the account - /// - /// The provider name. - /// The provider user id. - /// User Authentication Token - /// Invalid provider name. - [Consumes("application/json")] - [HttpPost("unlink/{providerName:minlength(1)}")] - public async Task> RemoveExternalLoginAsync(string providerName, ValueFromBody providerUserId) - { - var user = CurrentUser; - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(providerName).Identity(user.EmailAddress).Property("User", user).Property("Provider User Id", providerUserId?.Value).SetHttpContext(HttpContext)); - if (String.IsNullOrWhiteSpace(providerName) || String.IsNullOrWhiteSpace(providerUserId?.Value)) - { - _logger.LogError("Remove external login failed for {EmailAddress}: Invalid Provider Name or Provider User Id", user.EmailAddress); - return BadRequest("Invalid Provider Name or Provider User Id."); - } - - if (user.OAuthAccounts.Count <= 1 && String.IsNullOrEmpty(user.Password)) - { - _logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); - return BadRequest("You must set a local password before removing your external login."); - } - - try - { - if (user.RemoveOAuthAccount(providerName, providerUserId.Value)) - await _userRepository.SaveAsync(user, o => o.Cache()); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Error removing external login for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); - throw; - } - - await ResetUserTokensAsync(user, nameof(RemoveExternalLoginAsync)); - - _logger.UserRemovedExternalLogin(user.EmailAddress, providerName); - return Ok(new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }); - } - - /// - /// Change password - /// - /// User Authentication Token - /// Validation error - [Consumes("application/json")] - [HttpPost("change-password")] - public async Task> ChangePasswordAsync(ChangePasswordModel model) - { - var user = CurrentUser; - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Change Password").Identity(user.EmailAddress).Property("User", user).Property("Password Length", model.Password?.Length ?? 0).SetHttpContext(HttpContext)); - - // User has a local account. - if (!String.IsNullOrWhiteSpace(user.Password)) - { - if (String.IsNullOrWhiteSpace(model.CurrentPassword)) - { - _logger.LogError("Change password failed for {EmailAddress}: The current password is incorrect", user.EmailAddress); - ModelState.AddModelError(m => m.CurrentPassword, "The current password is incorrect."); - return ValidationProblem(ModelState); - } - - string encodedPassword = model.CurrentPassword.ToSaltedHash(user.Salt!); - if (!String.Equals(encodedPassword, user.Password)) - { - _logger.LogError("Change password failed for {EmailAddress}: The current password is incorrect", user.EmailAddress); - ModelState.AddModelError(m => m.CurrentPassword, "The current password is incorrect."); - return ValidationProblem(ModelState); - } - - string newPasswordHash = model.Password!.ToSaltedHash(user.Salt!); - if (String.Equals(newPasswordHash, user.Password)) - { - _logger.LogError("Change password failed for {EmailAddress}: The new password is the same as the current password", user.EmailAddress); - ModelState.AddModelError(m => m.Password, "The new password must be different than the previous password."); - return ValidationProblem(ModelState); - } - } - - await ChangePasswordAsync(user, model.Password!, nameof(ChangePasswordAsync)); - await ResetUserTokensAsync(user, nameof(ChangePasswordAsync)); - - string userLoginAttemptsCacheKey = $"user:{user.EmailAddress}:attempts"; - await _cache.RemoveAsync(userLoginAttemptsCacheKey); - - string ipLoginAttemptsCacheKey = $"ip:{Request.GetClientIpAddress()}:attempts"; - long attempts = await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - if (attempts <= 0) - await _cache.RemoveAsync(ipLoginAttemptsCacheKey); - - _logger.UserChangedPassword(user.EmailAddress); - return Ok(new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }); - } - - /// - /// Checks to see if an Email Address is available for account creation - /// - /// - /// Email Address is not available (user exists) - /// Email Address is available - [ApiExplorerSettings(IgnoreApi = true)] - [AllowAnonymous] - [HttpGet("check-email-address/{email:minlength(1)}")] - public async Task IsEmailAddressAvailableAsync(string email) - { - if (String.IsNullOrWhiteSpace(email)) - return StatusCode(StatusCodes.Status204NoContent); - - email = email.Trim().ToLowerInvariant(); - if (User.IsUserAuthType() && String.Equals(CurrentUser.EmailAddress, email, StringComparison.InvariantCultureIgnoreCase)) - return StatusCode(StatusCodes.Status201Created); - - // Only allow 3 checks attempts per hour period by a single ip. - string ipEmailAddressAttemptsCacheKey = $"ip:{Request.GetClientIpAddress()}:email:attempts"; - long attempts = await _cache.IncrementAsync(ipEmailAddressAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); - - if (attempts > 3 || await _userRepository.GetByEmailAddressAsync(email) is null) - return StatusCode(StatusCodes.Status204NoContent); - - return StatusCode(StatusCodes.Status201Created); - } - - /// - /// Forgot password - /// - /// The email address. - /// Forgot password email was sent. - /// Invalid email address. - [AllowAnonymous] - [HttpGet("forgot-password/{email:minlength(1)}")] - public async Task ForgotPasswordAsync(string email) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Forgot Password").Identity(email).SetHttpContext(HttpContext)); - if (String.IsNullOrWhiteSpace(email)) - { - _logger.LogError("Forgot password failed: Please specify a valid Email Address"); - return BadRequest("Please specify a valid Email Address."); - } - - // Only allow 3 checks attempts per hour period by a single ip. - string ipResetPasswordAttemptsCacheKey = $"ip:{Request.GetClientIpAddress()}:password:attempts"; - long attempts = await _cache.IncrementAsync(ipResetPasswordAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); - if (attempts > 3) - { - _logger.LogError("Login denied for {EmailAddress} for the {ResetPasswordAttempts} time", email, attempts); - return Ok(); - } - - email = email.Trim().ToLowerInvariant(); - var user = await _userRepository.GetByEmailAddressAsync(email); - if (user is null) - { - _logger.LogError("Forgot password failed for {EmailAddress}: No user was found", email); - return Ok(); - } - - user.CreatePasswordResetToken(_timeProvider); - await _userRepository.SaveAsync(user, o => o.Cache()); - - await _mailer.SendUserPasswordResetAsync(user); - _logger.UserForgotPassword(user.EmailAddress); - return Ok(); - } - - /// - /// Reset password - /// - /// Password reset email was sent. - /// Invalid reset password model. - [AllowAnonymous] - [Consumes("application/json")] - [HttpPost("reset-password")] - public async Task ResetPasswordAsync(ResetPasswordModel model) - { - var user = await _userRepository.GetByPasswordResetTokenAsync(model.PasswordResetToken); - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Reset Password").Identity(user?.EmailAddress).Property("User", user).Property("Password Length", model.Password?.Length ?? 0).SetHttpContext(HttpContext)); - if (user is null) - { - _logger.LogError("Reset password failed: Invalid Password Reset Token"); - ModelState.AddModelError(m => m.PasswordResetToken, "Invalid Password Reset Token"); - return ValidationProblem(ModelState); - } - - if (!user.HasValidPasswordResetTokenExpiration(_timeProvider)) - { - _logger.LogError("Reset password failed for {EmailAddress}: Password Reset Token has expired", user.EmailAddress); - ModelState.AddModelError(m => m.PasswordResetToken, "Password Reset Token has expired"); - return ValidationProblem(ModelState); - } - - // User has a local account. - if (!String.IsNullOrWhiteSpace(user.Password)) - { - string newPasswordHash = model.Password!.ToSaltedHash(user.Salt!); - if (String.Equals(newPasswordHash, user.Password)) - { - _logger.LogError("Reset password failed for {EmailAddress}: The new password is the same as the current password", user.EmailAddress); - ModelState.AddModelError(m => m.Password, "The new password must be different than the previous password"); - return ValidationProblem(ModelState); - } - } - - user.MarkEmailAddressVerified(); - await ChangePasswordAsync(user, model.Password!, nameof(ResetPasswordAsync)); - await ResetUserTokensAsync(user, nameof(ResetPasswordAsync)); - - string userLoginAttemptsCacheKey = $"user:{user.EmailAddress}:attempts"; - await _cache.RemoveAsync(userLoginAttemptsCacheKey); - - string ipLoginAttemptsCacheKey = $"ip:{Request.GetClientIpAddress()}:attempts"; - long attempts = await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - if (attempts <= 0) - await _cache.RemoveAsync(ipLoginAttemptsCacheKey); - - _logger.UserResetPassword(user.EmailAddress); - return Ok(); - } - - /// - /// Cancel reset password - /// - /// The password reset token. - /// Password reset email was cancelled. - /// Invalid password reset token. - [AllowAnonymous] - [Consumes("application/json")] - [HttpPost("cancel-reset-password/{token:minlength(1)}")] - public async Task CancelResetPasswordAsync(string token) - { - if (String.IsNullOrEmpty(token)) - { - using (_logger.BeginScope(new ExceptionlessState().Tag("Cancel Reset Password").SetHttpContext(HttpContext))) - _logger.LogError("Cancel reset password failed: Invalid Password Reset Token"); - return BadRequest("Invalid password reset token."); - } - - var user = await _userRepository.GetByPasswordResetTokenAsync(token); - if (user is null) - return Ok(); - - user.ResetPasswordResetToken(); - await _userRepository.SaveAsync(user, o => o.Cache()); - - using (_logger.BeginScope(new ExceptionlessState().Tag("Cancel Reset Password").Identity(user.EmailAddress).Property("User", user).SetHttpContext(HttpContext))) - _logger.UserCanceledResetPassword(user.EmailAddress); - - return Ok(); - } - - private async Task AddGlobalAdminRoleIfFirstUserAsync(User user) - { - if (_isFirstUserChecked) - return; - - bool isFirstUser = await _userRepository.CountAsync() == 0; - if (isFirstUser) - user.Roles.Add(AuthorizationRoles.GlobalAdmin); - - _isFirstUserChecked = true; - } - - private async Task> ExternalLoginAsync(ExternalAuthInfo authInfo, string? appId, string? appSecret, Func createClient) where TClient : OAuth2Client - { - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("External Login").SetHttpContext(HttpContext)); - if (String.IsNullOrEmpty(appId) || String.IsNullOrEmpty(appSecret)) - throw new ConfigurationErrorsException("Missing Configuration for OAuth provider"); - - var client = createClient(new RequestFactory(), new OAuth2.Configuration.ClientConfiguration - { - ClientId = appId, - ClientSecret = appSecret, - RedirectUri = authInfo.RedirectUri - }); - - UserInfo userInfo; - try - { - userInfo = await client.GetUserInfoAsync(authInfo.Code, authInfo.RedirectUri); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "External login failed Code={AuthCode} RedirectUri={AuthRedirectUri}: {Message}", authInfo.Code, authInfo.RedirectUri, ex.Message); - throw; - } - - User? user; - try - { - user = await FromExternalLoginAsync(userInfo); - } - catch (ApplicationException ex) - { - _logger.LogCritical(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); - return Forbidden("Account Creation is currently disabled"); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); - throw; - } - - if (!String.IsNullOrWhiteSpace(authInfo.InviteToken)) - await AddInvitedUserToOrganizationAsync(authInfo.InviteToken, user); - - _logger.UserLoggedIn(user.EmailAddress); - return Ok(new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }); - } - - private async Task FromExternalLoginAsync(UserInfo userInfo) - { - ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); - ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); - - - var existingUser = await _userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id); - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("External Login").Property("User Info", userInfo).Property("ExistingUser", existingUser).SetHttpContext(HttpContext)); - - // Link user accounts. - if (User.IsUserAuthType()) - { - var currentUser = CurrentUser; - if (existingUser is not null) - { - if (existingUser.Id != currentUser.Id) - { - // Existing user account is not the current user. Remove it, and we'll add it to the current user below. - if (!existingUser.RemoveOAuthAccount(userInfo.ProviderName, userInfo.Id)) - { - throw new Exception($"Unable to remove existing oauth account for existing user: {existingUser.EmailAddress}"); - } - - await _userRepository.SaveAsync(existingUser, o => o.Cache()); - } - else - { - // User is already logged in. - return currentUser; - } - } - - // Add it to the current user if it doesn't already exist and save it. - currentUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); - return await _userRepository.SaveAsync(currentUser, o => o.Cache()); - } - - // Create a new user account or return an existing one. - if (existingUser is not null) - { - if (!existingUser.IsEmailAddressVerified) - { - existingUser.MarkEmailAddressVerified(); - await _userRepository.SaveAsync(existingUser, o => o.Cache()); - } - - return existingUser; - } - - // Check to see if a user already exists with this email address. - var user = !String.IsNullOrEmpty(userInfo.Email) ? await _userRepository.GetByEmailAddressAsync(userInfo.Email) : null; - if (user is null) - { - if (!_authOptions.EnableAccountCreation) - throw new ApplicationException("Account Creation is currently disabled."); - - user = new User { FullName = userInfo.GetFullName()!, EmailAddress = userInfo.Email }; - user.Roles.Add(AuthorizationRoles.Client); - user.Roles.Add(AuthorizationRoles.User); - await AddGlobalAdminRoleIfFirstUserAsync(user); - } - - user.MarkEmailAddressVerified(); - user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); - - if (String.IsNullOrEmpty(user.Id)) - await _userRepository.AddAsync(user, o => o.Cache()); - else - await _userRepository.SaveAsync(user, o => o.Cache()); - - return user; - } - - private async Task IsAccountCreationEnabledAsync(string? token) - { - if (_authOptions.EnableAccountCreation) - return true; - - if (String.IsNullOrEmpty(token)) - return false; - - var organization = await _organizationRepository.GetByInviteTokenAsync(token); - return organization is not null; - } - - private async Task AddInvitedUserToOrganizationAsync(string? token, User user) - { - if (String.IsNullOrWhiteSpace(token)) - return; - - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Invite").Identity(user.EmailAddress).Property("User", user).SetHttpContext(HttpContext)); - var organization = await _organizationRepository.GetByInviteTokenAsync(token); - var invite = organization?.GetInvite(token); - if (organization is null || invite is null) - { - _logger.UnableToAddInvitedUserInvalidToken(user.EmailAddress, token); - return; - } - - if (!user.IsEmailAddressVerified && String.Equals(user.EmailAddress, invite.EmailAddress, StringComparison.OrdinalIgnoreCase)) - { - _logger.MarkedInvitedUserAsVerified(user.EmailAddress); - user.MarkEmailAddressVerified(); - await _userRepository.SaveAsync(user, o => o.Cache()); - } - - if (!user.OrganizationIds.Contains(organization.Id)) - { - _logger.UserJoinedFromInvite(user.EmailAddress); - user.OrganizationIds.Add(organization.Id); - await _userRepository.SaveAsync(user, o => o.Cache()); - } - - organization.Invites.Remove(invite); - await _organizationRepository.SaveAsync(organization, o => o.Cache()); - } - - private async Task ChangePasswordAsync(User user, string password, string tag) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Tag(tag).Identity(user.EmailAddress).SetHttpContext(HttpContext)); - if (String.IsNullOrEmpty(user.Salt)) - user.Salt = Core.Extensions.StringExtensions.GetNewToken(); - - user.Password = password.ToSaltedHash(user.Salt); - user.ResetPasswordResetToken(); - - try - { - await _userRepository.SaveAsync(user, o => o.Cache()); - _logger.ChangedUserPassword(user.EmailAddress); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Error changing password for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); - throw; - } - } - - private async Task ResetUserTokensAsync(User user, string tag) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Tag(tag).Identity(user.EmailAddress).SetHttpContext(HttpContext)); - try - { - long total = await _tokenRepository.RemoveAllByUserIdAsync(user.Id); - _logger.RemovedUserTokens(total, user.EmailAddress); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Error removing user tokens for {EmailAddress}: {Message}", user.EmailAddress, ex.Message); - } - } - - private async Task GetOrCreateAuthenticationTokenAsync(User user) - { - var userTokens = await _tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id); - - var utcNow = _timeProvider.GetUtcNow().UtcDateTime; - var validAccessToken = userTokens.Documents.FirstOrDefault(t => (!t.ExpiresUtc.HasValue || t.ExpiresUtc > utcNow)); - if (validAccessToken is not null) - return validAccessToken.Id; - - var token = await _tokenRepository.AddAsync(new Token - { - Id = Core.Extensions.StringExtensions.GetNewToken(), - UserId = user.Id, - CreatedUtc = utcNow, - UpdatedUtc = utcNow, - ExpiresUtc = utcNow.AddMonths(3), - CreatedBy = user.Id, - Type = TokenType.Authentication - }, o => o.Cache()); - - return token.Id; - } - - private bool IsValidActiveDirectoryLogin(string email, string? password) - { - if (String.IsNullOrEmpty(password)) - return false; - - string? domainUsername = _domainLoginProvider.GetUsernameFromEmailAddress(email); - return domainUsername is not null && _domainLoginProvider.Login(domainUsername, password); - } -} diff --git a/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs b/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs deleted file mode 100644 index 662defc7a3..0000000000 --- a/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs +++ /dev/null @@ -1,280 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Exceptionless.Core.Models; -using Exceptionless.Core.Repositories; -using Exceptionless.Core.Repositories.Queries; -using Exceptionless.DateTimeExtensions; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Utility; -using Exceptionless.Web.Utility.Results; -using Foundatio.Repositories; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Net.Http.Headers; - -namespace Exceptionless.Web.Controllers; - -[Produces("application/json", "application/problem+json")] -[ApiController] -public abstract class ExceptionlessApiController : Controller -{ - public const string API_PREFIX = "api/v2"; - protected const int DEFAULT_LIMIT = 10; - protected const int MAXIMUM_LIMIT = 100; - protected const int MAXIMUM_SKIP = 1000; - protected static readonly char[] TIME_PARTS = ['|']; - protected TimeProvider _timeProvider; - - protected ExceptionlessApiController(TimeProvider timeProvider) - { - _timeProvider = timeProvider; - } - - protected TimeSpan GetOffset(string? offset) - { - if (!String.IsNullOrEmpty(offset) && TimeUnit.TryParse(offset, out var value) && value.HasValue) - return value.Value; - - return TimeSpan.Zero; - } - - protected ICollection AllowedDateFields { get; private set; } = new List(); - protected string DefaultDateField { get; set; } = "created_utc"; - - protected virtual TimeInfo GetTimeInfo(string? time, string? offset, DateTime? minimumUtcStartDate = null) - { - string field = DefaultDateField; - if (!String.IsNullOrEmpty(time) && time.Contains('|')) - { - string[] parts = time.Split(TIME_PARTS, StringSplitOptions.RemoveEmptyEntries); - field = parts.Length > 0 && AllowedDateFields.Contains(parts[0]) ? parts[0] : DefaultDateField; - time = parts.Length > 1 ? parts[1] : null; - } - - var utcOffset = GetOffset(offset); - - // range parsing needs to be based on the user's local time. - var range = DateTimeRange.Parse(time, _timeProvider.GetUtcNow().ToOffset(utcOffset)); - var timeInfo = new TimeInfo { Field = field, Offset = utcOffset, Range = range }; - if (minimumUtcStartDate.HasValue) - timeInfo.ApplyMinimumUtcStartDate(minimumUtcStartDate.Value); - - timeInfo.AdjustEndTimeIfMaxValue(_timeProvider); - return timeInfo; - } - - protected int GetLimit(int limit, int maximumLimit = MAXIMUM_LIMIT) - { - ArgumentOutOfRangeException.ThrowIfLessThan(maximumLimit, MAXIMUM_LIMIT); - - if (limit < 1) - limit = DEFAULT_LIMIT; - else if (limit > maximumLimit) - limit = maximumLimit; - - return limit; - } - - protected int GetPage(int page) - { - if (page < 1) - page = 1; - - return page; - } - - protected int GetSkip(int currentPage, int limit) - { - if (currentPage < 1) - currentPage = 1; - - int skip = (currentPage - 1) * limit; - if (skip < 0) - skip = 0; - - return skip; - } - - /// - /// This call will throw an exception if the user is a token auth type. - /// This is less than ideal, and we should refactor this to be a nullable user. - /// NOTE: The only endpoints that allow token auth types is - /// - post event - /// - post user event description - /// - post session heartbeat - /// - post session end - /// - project config - /// - protected virtual User CurrentUser => Request.GetUser(); - - protected bool CanAccessOrganization(string organizationId) - { - return Request.CanAccessOrganization(organizationId); - } - - protected bool IsInOrganization([NotNullWhen(true)] string? organizationId) - { - if (String.IsNullOrEmpty(organizationId)) - return false; - - return Request.IsInOrganization(organizationId); - } - - protected ICollection GetAssociatedOrganizationIds() - { - return Request.GetAssociatedOrganizationIds(); - } - - private static readonly IReadOnlyCollection EmptyOrganizations = new List(0).AsReadOnly(); - protected async Task> GetSelectedOrganizationsAsync(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IStackRepository stackRepository, string? filter = null) - { - var associatedOrganizationIds = GetAssociatedOrganizationIds(); - if (associatedOrganizationIds.Count == 0) - return EmptyOrganizations; - - if (!String.IsNullOrEmpty(filter)) - { - var scope = GetFilterScopeVisitor.Run(filter); - if (scope.IsScopable) - { - Organization? organization = null; - if (scope.OrganizationId is not null) - { - organization = await organizationRepository.GetByIdAsync(scope.OrganizationId, o => o.Cache()); - } - else if (scope.ProjectId is not null) - { - var project = await projectRepository.GetByIdAsync(scope.ProjectId, o => o.Cache()); - if (project is not null) - organization = await organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); - } - else if (scope.StackId is not null) - { - var stack = await stackRepository.GetByIdAsync(scope.StackId, o => o.Cache()); - if (stack is not null) - organization = await organizationRepository.GetByIdAsync(stack.OrganizationId, o => o.Cache()); - } - - if (organization is not null) - { - if (associatedOrganizationIds.Contains(organization.Id) || Request.IsGlobalAdmin()) - return new[] { organization }.ToList().AsReadOnly(); - - return EmptyOrganizations; - } - } - } - - var organizations = await organizationRepository.GetByIdsAsync(associatedOrganizationIds.ToArray(), o => o.Cache()); - return organizations.ToList().AsReadOnly(); - } - - protected bool ShouldApplySystemFilter(AppFilter sf, string? filter) - { - // Apply filter to non admin user. - if (!Request.IsGlobalAdmin()) - return true; - - // Apply filter as it's scoped via a controller action. - if (!sf.IsUserOrganizationsFilter) - return true; - - // Empty user filter - if (String.IsNullOrEmpty(filter)) - return true; - - // Used for impersonating a user. Only skip the filter if it contains an org, project or stack. - var scope = GetFilterScopeVisitor.Run(filter); - bool hasOrganizationOrProjectOrStackFilter = !String.IsNullOrEmpty(scope.OrganizationId) || !String.IsNullOrEmpty(scope.ProjectId) || !String.IsNullOrEmpty(scope.StackId); - return !hasOrganizationOrProjectOrStackFilter; - } - - protected ObjectResult Permission(PermissionResult permission) - { - if (permission.StatusCode is StatusCodes.Status422UnprocessableEntity) - { - if (!String.IsNullOrEmpty(permission.Message)) - ModelState.AddModelError("general", permission.Message); - - return (ObjectResult)ValidationProblem(ModelState); - } - - if (String.IsNullOrEmpty(permission.Message)) - return Problem(statusCode: permission.StatusCode); - - return Problem(statusCode: permission.StatusCode, title: permission.Message); - } - - protected ActionResult WorkInProgress(IEnumerable workers) - { - return StatusCode(StatusCodes.Status202Accepted, new WorkInProgressResult(workers)); - } - - protected ObjectResult BadRequest(ModelActionResults results) - { - return StatusCode(StatusCodes.Status400BadRequest, results); - } - - protected StatusCodeResult Forbidden() - { - return StatusCode(StatusCodes.Status403Forbidden); - } - - protected ObjectResult Forbidden(string message) - { - return Problem(statusCode: StatusCodes.Status403Forbidden, title: message); - } - - protected ObjectResult PlanLimitReached(string message) - { - return Problem(statusCode: StatusCodes.Status426UpgradeRequired, title: message); - } - - protected ObjectResult TooManyRequests(string message) - { - return Problem(statusCode: StatusCodes.Status429TooManyRequests, title: message); - } - - protected ObjectResult NotImplemented(string message) - { - return Problem(statusCode: StatusCodes.Status501NotImplemented, title: message); - } - - protected OkWithHeadersContentResult OkWithLinks(T content, string link) - { - return OkWithLinks(content, [link]); - } - - protected OkWithHeadersContentResult OkWithLinks(T content, string?[] links) - { - var headers = new HeaderDictionary(); - string[] linksToAdd = links.Where(l => !String.IsNullOrEmpty(l)).ToArray()!; - if (linksToAdd.Length > 0) - headers.Add(HeaderNames.Link, linksToAdd); - - return new OkWithHeadersContentResult(content, headers); - } - - protected OkWithResourceLinks OkWithResourceLinks(ICollection content, bool hasMore, int? page = null, long? total = null, string? before = null, string? after = null) where TEntity : class - { - return new OkWithResourceLinks(content, hasMore, page, total, before, after); - } - - protected string? GetResourceLink(string? url, string type) - { - return url is not null ? $"<{url}>; rel=\"{type}\"" : null; - } - - protected bool NextPageExceedsSkipLimit(int? page, int limit) - { - if (page is null) - return false; - - return (page + 1) * limit >= MAXIMUM_SKIP; - } - - // We need to override this to ensure Validation Problems return a 422 status code. - public override ActionResult ValidationProblem(string? detail = null, string? instance = null, int? statusCode = null, - string? title = null, string? type = null, ModelStateDictionary? modelStateDictionary = null, - IDictionary? extensions = null) => - base.ValidationProblem(detail, instance, statusCode ?? 422, title, type, modelStateDictionary, extensions); -} diff --git a/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs b/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs deleted file mode 100644 index c78f38c620..0000000000 --- a/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Web.Mapping; -using Foundatio.Repositories; -using Foundatio.Repositories.Models; -using Microsoft.AspNetCore.Mvc; - -namespace Exceptionless.Web.Controllers; - -public abstract class ReadOnlyRepositoryApiController : ExceptionlessApiController - where TRepository : ISearchableReadOnlyRepository - where TModel : class, IIdentity, new() - where TViewModel : class, IIdentity, new() -{ - protected readonly TRepository _repository; - protected static readonly bool _isOwnedByOrganization = typeof(IOwnedByOrganization).IsAssignableFrom(typeof(TModel)); - protected static readonly bool _isOrganization = typeof(TModel) == typeof(Organization); - protected static readonly bool _supportsSoftDeletes = typeof(ISupportSoftDeletes).IsAssignableFrom(typeof(TModel)); - protected static readonly IReadOnlyCollection EmptyModels = new List(0).AsReadOnly(); - protected readonly ApiMapper _mapper; - protected readonly IAppQueryValidator _validator; - protected readonly ILogger _logger; - - public ReadOnlyRepositoryApiController(TRepository repository, ApiMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider) - { - _repository = repository; - _mapper = mapper; - _validator = validator; - _logger = loggerFactory.CreateLogger(GetType()); - } - - protected async Task> GetByIdImplAsync(string id) - { - var model = await GetModelAsync(id); - if (model is null) - return NotFound(); - - return await OkModelAsync(model); - } - - protected virtual async Task> OkModelAsync(TModel model) - { - var viewModel = MapToViewModel(model); - await AfterResultMapAsync([viewModel]); - return Ok(viewModel); - } - - /// - /// Maps a domain model to a view model. Override in derived controllers. - /// - protected abstract TViewModel MapToViewModel(TModel model); - - /// - /// Maps a collection of domain models to view models. Override in derived controllers. - /// - protected abstract List MapToViewModels(IEnumerable models); - - protected virtual async Task GetModelAsync(string id, bool useCache = true) - { - if (String.IsNullOrEmpty(id)) - return null; - - var model = await _repository.GetByIdAsync(id, o => o.Cache(useCache)); - if (model is null) - return null; - - if (_isOwnedByOrganization && !CanAccessOrganization(((IOwnedByOrganization)model).OrganizationId)) - return null; - - return model; - } - - protected virtual async Task> GetModelsAsync(string[] ids, bool useCache = true) - { - if (ids.Length == 0) - return EmptyModels; - - var models = await _repository.GetByIdsAsync(ids, o => o.Cache(useCache)); - - if (_isOwnedByOrganization) - models = models.Where(m => CanAccessOrganization(((IOwnedByOrganization)m).OrganizationId)).ToList(); - - return models; - } - - protected virtual Task AfterResultMapAsync(ICollection models) - { - foreach (var model in models.OfType()) - model.Data?.RemoveSensitiveData(); - - return Task.CompletedTask; - } -} diff --git a/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs b/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs deleted file mode 100644 index c7820c6c9d..0000000000 --- a/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs +++ /dev/null @@ -1,246 +0,0 @@ -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Mapping; -using Exceptionless.Web.Utility; -using Foundatio.Repositories; -using Foundatio.Repositories.Models; -using Microsoft.AspNetCore.Mvc; - -namespace Exceptionless.Web.Controllers; - -public abstract class RepositoryApiController : ReadOnlyRepositoryApiController - where TRepository : ISearchableRepository - where TModel : class, IIdentity, new() - where TViewModel : class, IIdentity, new() - where TNewModel : class, new() - where TUpdateModel : class, new() -{ - public RepositoryApiController(TRepository repository, ApiMapper mapper, IAppQueryValidator validator, - TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) { } - - /// - /// Maps a new model (from API input) to a domain model. Override in derived controllers. - /// - protected abstract TModel MapToModel(TNewModel newModel); - - protected async Task> PostImplAsync(TNewModel value) - { - if (value is null) - return BadRequest(); - - var mapped = MapToModel(value); - // if no organization id is specified, default to the user's 1st associated org. - if (!_isOrganization && mapped is IOwnedByOrganization orgModel && String.IsNullOrEmpty(orgModel.OrganizationId) && GetAssociatedOrganizationIds().Count > 0) - orgModel.OrganizationId = Request.GetDefaultOrganizationId()!; - - var permission = await CanAddAsync(mapped); - if (!permission.Allowed) - return Permission(permission); - - var model = await AddModelAsync(mapped); - await AfterAddAsync(model); - - var viewModel = MapToViewModel(model); - await AfterResultMapAsync([viewModel]); - return Created(new Uri(GetEntityLink(model.Id) ?? throw new InvalidOperationException()), viewModel); - } - - protected async Task> UpdateModelAsync(string id, Func> modelUpdateFunc) - { - var model = await GetModelAsync(id); - if (model is null) - return NotFound(); - - if (modelUpdateFunc is not null) - model = await modelUpdateFunc(model); - - await _repository.SaveAsync(model, o => o.Cache()); - await AfterUpdateAsync(model); - - if (typeof(TViewModel) == typeof(TModel)) - return Ok(model); - - var viewModel = MapToViewModel(model); - await AfterResultMapAsync([viewModel]); - return Ok(viewModel); - } - - protected async Task> UpdateModelsAsync(string[] ids, Func> modelUpdateFunc) - { - var models = await GetModelsAsync(ids, false); - if (models is null || models.Count == 0) - return NotFound(); - - if (modelUpdateFunc is not null) - foreach (var model in models) - await modelUpdateFunc(model); - - await _repository.SaveAsync(models, o => o.Cache()); - foreach (var model in models) - await AfterUpdateAsync(model); - - if (typeof(TViewModel) == typeof(TModel)) - return Ok(models); - - var viewModels = MapToViewModels(models); - await AfterResultMapAsync(viewModels); - return Ok(viewModels); - } - - protected virtual string? GetEntityLink(string id) - { - return Url.Link($"Get{typeof(TModel).Name}ById", new - { - id - }); - } - - protected virtual string? GetEntityResourceLink(string? id, string type) - { - return GetResourceLink(Url.Link($"Get{typeof(TModel).Name}ById", new - { - id - }), type); - } - - protected virtual string? GetEntityLink(string id) - { - return Url.Link($"Get{typeof(TEntityType).Name}ById", new - { - id - }); - } - - protected virtual string? GetEntityResourceLink(string id, string type) - { - return GetResourceLink(Url.Link($"Get{typeof(TEntityType).Name}ById", new - { - id - }), type); - } - - protected virtual Task CanAddAsync(TModel value) - { - if (_isOrganization || !(value is IOwnedByOrganization orgModel)) - return Task.FromResult(PermissionResult.Allow); - - if (!CanAccessOrganization(orgModel.OrganizationId)) - return Task.FromResult(PermissionResult.DenyWithMessage("Invalid organization id specified.")); - - return Task.FromResult(PermissionResult.Allow); - } - - protected virtual Task AddModelAsync(TModel value) - { - return _repository.AddAsync(value, o => o.Cache()); - } - - protected virtual Task AfterAddAsync(TModel value) - { - return Task.FromResult(value); - } - - protected virtual Task AfterUpdateAsync(TModel value) - { - return Task.FromResult(value); - } - - protected async Task> PatchImplAsync(string id, Delta changes) - { - var original = await GetModelAsync(id, false); - if (original is null) - return NotFound(); - - // if there are no changes in the delta, then ignore the request - if (!changes.GetChangedPropertyNames().Any()) - return await OkModelAsync(original); - - var permission = await CanUpdateAsync(original, changes); - if (!permission.Allowed) - return Permission(permission); - - await UpdateModelAsync(original, changes); - await AfterPatchAsync(original); - - return await OkModelAsync(original); - } - - protected virtual Task CanUpdateAsync(TModel original, Delta changes) - { - if (original is IOwnedByOrganization orgModel && !CanAccessOrganization(orgModel.OrganizationId)) - return Task.FromResult(PermissionResult.DenyWithMessage("Invalid organization id specified.")); - - if (changes.GetChangedPropertyNames().Contains("OrganizationId")) - return Task.FromResult(PermissionResult.DenyWithMessage("OrganizationId cannot be modified.")); - - return Task.FromResult(PermissionResult.Allow); - } - - protected virtual Task UpdateModelAsync(TModel original, Delta changes) - { - changes.Patch(original); - return _repository.SaveAsync(original, o => o.Cache()); - } - - protected virtual Task AfterPatchAsync(TModel value) - { - return Task.FromResult(value); - } - - protected async Task> DeleteImplAsync(string[] ids) - { - var items = await GetModelsAsync(ids, false); - if (items.Count == 0) - return NotFound(); - - var results = new ModelActionResults(); - results.AddNotFound(ids.Except(items.Select(i => i.Id))); - - var list = items.ToList(); - foreach (var model in items) - { - var permission = await CanDeleteAsync(model); - if (permission.Allowed) - continue; - - list.Remove(model); - results.Failure.Add(permission); - } - - if (list.Count == 0) - return results.Failure.Count == 1 ? Permission(results.Failure.First()) : BadRequest(results); - - var workIds = await DeleteModelsAsync(list); - if (results.Failure.Count == 0) - return WorkInProgress(workIds); - - results.Workers.AddRange(workIds); - results.Success.AddRange(list.Select(i => i.Id)); - return BadRequest(results); - } - - protected virtual Task CanDeleteAsync(TModel value) - { - if (value is IOwnedByOrganization orgModel && !CanAccessOrganization(orgModel.OrganizationId)) - return Task.FromResult(PermissionResult.DenyWithNotFound(value.Id)); - - return Task.FromResult(PermissionResult.Allow); - } - - protected virtual async Task> DeleteModelsAsync(ICollection values) - { - if (_supportsSoftDeletes) - { - values.Cast().ForEach(v => v.IsDeleted = true); - await _repository.SaveAsync(values); - } - else - { - await _repository.RemoveAsync(values); - } - - return []; - } -} diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs deleted file mode 100644 index 9c3258dd11..0000000000 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ /dev/null @@ -1,1499 +0,0 @@ -using System.Text; -using Exceptionless.Core; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Geo; -using Exceptionless.Core.Models; -using Exceptionless.Core.Models.Data; -using Exceptionless.Core.Plugins.Formatting; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Core.Queues.Models; -using Exceptionless.Core.Repositories; -using Exceptionless.Core.Repositories.Base; -using Exceptionless.Core.Repositories.Configuration; -using Exceptionless.Core.Repositories.Queries; -using Exceptionless.Core.Services; -using Exceptionless.DateTimeExtensions; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Mapping; -using Exceptionless.Web.Models; -using Exceptionless.Web.Utility; -using Exceptionless.Web.Utility.OpenApi; -using Exceptionless.Core.Validation; -using Foundatio.Caching; -using Foundatio.Queues; -using Foundatio.Repositories; -using Foundatio.Repositories.Elasticsearch.Extensions; -using Foundatio.Repositories.Extensions; -using Foundatio.Repositories.Models; -using Foundatio.Serializer; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX + "/events")] -[Authorize(Policy = AuthorizationRoles.ClientPolicy)] -public class EventController : RepositoryApiController -{ - private static readonly HashSet _ignoredKeys = new(StringComparer.OrdinalIgnoreCase) { "access_token", "api_key", "apikey" }; - - private readonly IOrganizationRepository _organizationRepository; - private readonly IProjectRepository _projectRepository; - private readonly IStackRepository _stackRepository; - private readonly EventPostService _eventPostService; - private readonly IQueue _eventUserDescriptionQueue; - private readonly MiniValidationValidator _miniValidationValidator; - private readonly FormattingPluginManager _formattingPluginManager; - private readonly ICacheClient _cache; - private readonly ITextSerializer _serializer; - private readonly AppOptions _appOptions; - private readonly UsageService _usageService; - - public EventController(IEventRepository repository, - IOrganizationRepository organizationRepository, - IProjectRepository projectRepository, - IStackRepository stackRepository, - EventPostService eventPostService, - IQueue eventUserDescriptionQueue, - MiniValidationValidator miniValidationValidator, - FormattingPluginManager formattingPluginManager, - ICacheClient cacheClient, - ITextSerializer serializer, - ApiMapper mapper, - PersistentEventQueryValidator validator, - AppOptions appOptions, - UsageService usageService, - TimeProvider timeProvider, - ILoggerFactory loggerFactory - ) : base(repository, mapper, validator, timeProvider, loggerFactory) - { - _organizationRepository = organizationRepository; - _projectRepository = projectRepository; - _stackRepository = stackRepository; - _eventPostService = eventPostService; - _eventUserDescriptionQueue = eventUserDescriptionQueue; - _miniValidationValidator = miniValidationValidator; - _formattingPluginManager = formattingPluginManager; - _cache = cacheClient; - _serializer = serializer; - _appOptions = appOptions; - _usageService = usageService; - - AllowedDateFields.Add(EventIndex.Alias.Date); - DefaultDateField = EventIndex.Alias.Date; - } - - // Mapping implementations - PersistentEvent uses itself as view model (no mapping needed) - protected override PersistentEvent MapToModel(PersistentEvent newModel) => newModel; - protected override PersistentEvent MapToViewModel(PersistentEvent model) => model; - protected override List MapToViewModels(IEnumerable models) => models.ToList(); - - /// - /// Count - /// - /// A filter that controls what data is returned from the server. - /// A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// Invalid filter. - [HttpGet("count")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task> GetCountAsync(string? filter = null, string? aggregations = null, string? time = null, string? offset = null, string? mode = null) - { - var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); - if (organizations.All(o => o.IsSuspended)) - return Ok(CountResult.Empty); - - var ti = GetTimeInfo(time, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await CountInternalAsync(sf, ti, filter, aggregations, mode); - } - - /// - /// Count by organization - /// - /// The identifier of the organization. - /// A filter that controls what data is returned from the server. - /// A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// Invalid filter. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/events/count")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task> GetCountByOrganizationAsync(string organizationId, string? filter = null, string? aggregations = null, string? time = null, string? offset = null, string? mode = null) - { - var organization = await GetOrganizationAsync(organizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organization); - return await CountInternalAsync(sf, ti, filter, aggregations, mode); - } - - /// - /// Count by project - /// - /// The identifier of the project. - /// A filter that controls what data is returned from the server. - /// A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If mode is set to stack_new, then additional filters will be added. - /// Invalid filter. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events/count")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task> GetCountByProjectAsync(string projectId, string? filter = null, string? aggregations = null, string? time = null, string? offset = null, string? mode = null) - { - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - var organization = await GetOrganizationAsync(project.OrganizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(project, organization); - return await CountInternalAsync(sf, ti, filter, aggregations, mode); - } - - /// - /// Get by id - /// - /// The identifier of the event. - /// Optional stack identifier that the event must belong to. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// The event does not belong to the expected stack. - /// The event occurrence could not be found. - /// Unable to view event occurrence due to plan limits. - [HttpGet("{id:objectid}", Name = "GetPersistentEventById")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task> GetAsync(string id, [FromQuery(Name = "expected_stack_id")] string? expectedStackId = null, string? time = null, string? offset = null) - { - var model = await GetModelAsync(id, false); - if (model is null) - return NotFound(); - - if (!String.IsNullOrEmpty(expectedStackId) && !String.Equals(model.StackId, expectedStackId, StringComparison.Ordinal)) - return Problem(statusCode: StatusCodes.Status400BadRequest, title: $"The event \"{model.Id}\" belongs to stack \"{model.StackId}\", not stack \"{expectedStackId}\". Open the event from its current stack."); - - var organization = await GetOrganizationAsync(model.OrganizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended || organization.RetentionDays > 0 && model.Date.UtcDateTime < _timeProvider.GetUtcNow().UtcDateTime.SubtractDays(organization.RetentionDays)) - return PlanLimitReached("Unable to view event occurrence due to plan limits."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organization); - var result = await _repository.GetPreviousAndNextEventIdsAsync(model, sf, ti.Range.UtcStart, ti.Range.UtcEnd); - return OkWithLinks(model, [GetEntityResourceLink(result.Previous, "previous"), - GetEntityResourceLink(result.Next, "next"), - GetEntityResourceLink(model.StackId, "parent") - ]); - } - - /// - /// Get all - /// - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// Unable to view event occurrences for the suspended organization. - [HttpGet] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetAllAsync(string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); - if (organizations.All(o => o.IsSuspended)) - return Ok(EmptyModels); - - var ti = GetTimeInfo(time, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, before, after); - } - - private async Task> CountInternalAsync(AppFilter sf, TimeInfo ti, string? filter = null, string? aggregations = null, string? mode = null) - { - var pr = await _validator.ValidateQueryAsync(filter); - if (!pr.IsValid) - return BadRequest(pr.Message); - - var far = await _validator.ValidateAggregationsAsync(aggregations); - if (!far.IsValid) - return BadRequest(far.Message); - - sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || far.UsesPremiumFeatures; - - if (mode == "stack_new") - filter = AddFirstOccurrenceFilter(ti.Range, filter); - - var query = new RepositoryQuery() - .AppFilter(ShouldApplySystemFilter(sf, filter) ? sf : null) - .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field) - .Index(ti.Range.UtcStart, ti.Range.UtcEnd); - - CountResult result; - try - { - result = await _repository.CountAsync(q => q.SystemFilter(query).FilterExpression(filter).EnforceEventStackFilter().AggregationsExpression(aggregations)); - } - catch (Exception ex) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Property("Search Filter", new { SystemFilter = sf, UserFilter = filter, Time = ti, Aggregations = aggregations }).Tag("Search").Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)); - _logger.LogError(ex, "An error has occurred. Please check your filter or aggregations: {Message}", ex.Message); - - throw; - } - - return Ok(result); - } - - private async Task>> GetInternalAsync(AppFilter sf, TimeInfo ti, string? filter = null, string? sort = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null, bool usesPremiumFeatures = false) - { - using var _ = _logger.BeginScope(new ExceptionlessState() - .Property("Search Filter", new - { - Mode = mode, - SystemFilter = sf, - UserFilter = filter, - Time = ti, - Page = page, - Limit = limit, - Before = before, - After = after - }) - .Tag("Search") - .Identity(CurrentUser.EmailAddress) - .Property("User", CurrentUser) - .SetHttpContext(HttpContext) - ); - - int resolvedPage = GetPage(page.GetValueOrDefault(1)); - limit = GetLimit(limit); - int skip = GetSkip(resolvedPage, limit); - if (skip > MAXIMUM_SKIP) - return Ok(EmptyModels); - - var pr = await _validator.ValidateQueryAsync(filter); - if (!pr.IsValid) - return BadRequest(pr.Message); - - sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures; - - try - { - FindResults events; - switch (mode) - { - case "summary": - events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, before, after); - return OkWithResourceLinks(events.Documents.Select(e => - { - var summaryData = _formattingPluginManager.GetEventSummaryData(e); - return new EventSummaryModel - { - Id = summaryData.Id, - TemplateKey = summaryData.TemplateKey, - Date = e.Date, - Type = e.Type, - Data = summaryData.Data - }; - }).ToList(), events.HasMore && !NextPageExceedsSkipLimit(page, limit), page, events.Total, events.Hits.FirstOrDefault()?.GetSortToken(_serializer), events.Hits.LastOrDefault()?.GetSortToken(_serializer)); - case "stack_recent": - case "stack_frequent": - case "stack_new": - case "stack_users": - if (!String.IsNullOrEmpty(sort)) - return BadRequest("Sort is not supported in stack mode."); - - var systemFilter = new RepositoryQuery() - .AppFilter(ShouldApplySystemFilter(sf, filter) ? sf : null) - .EnforceEventStackFilter() - .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, (PersistentEvent e) => e.Date) - .Index(ti.Range.UtcStart, ti.Range.UtcEnd); - - string? stackAggregations = mode switch - { - "stack_recent" => "cardinality:user sum:count~1 min:date -max:date", - "stack_frequent" => "cardinality:user -sum:count~1 min:date max:date", - "stack_new" => "cardinality:user sum:count~1 -min:date max:date", - "stack_users" => "-cardinality:user sum:count~1 min:date max:date", - _ => null - }; - - if (mode == "stack_new") - filter = AddFirstOccurrenceFilter(ti.Range, filter); - - var countResponse = await _repository.CountAsync(q => q - .SystemFilter(systemFilter) - .FilterExpression(filter) - .EnforceEventStackFilter() - .AggregationsExpression($"terms:(stack_id~{GetSkip(resolvedPage + 1, limit) + 1} {stackAggregations})") - ); - - var stackTerms = countResponse.Aggregations.Terms("terms_stack_id"); - if (stackTerms is null || stackTerms.Buckets.Count == 0) - return Ok(EmptyModels); - - string[] stackIds = stackTerms.Buckets.Skip(skip).Take(limit + 1).Select(t => t.Key).ToArray(); - var stacks = (await _stackRepository.GetByIdsAsync(stackIds)).Select(s => s.ApplyOffset(ti.Offset)).ToList(); - - var summaries = await GetStackSummariesAsync(stacks, stackTerms.Buckets, sf, ti); - - long total = (stackTerms.Data?.GetValueOrDefault("SumOtherDocCount") as long? ?? 0L) + stackTerms.Buckets.Count; - return OkWithResourceLinks(summaries.Take(limit).ToList(), summaries.Count > limit && !NextPageExceedsSkipLimit(resolvedPage, limit), resolvedPage, total); - default: - events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, before, after); - return OkWithResourceLinks(events.Documents.ToArray(), events.HasMore && !NextPageExceedsSkipLimit(page, limit), page, events.Total, events.Hits.FirstOrDefault()?.GetSortToken(_serializer), events.Hits.LastOrDefault()?.GetSortToken(_serializer)); - } - } - catch (ApplicationException ex) - { - string message = "An error has occurred: Please check your search filter."; - if (ex is DocumentLimitExceededException) - message = $"An error has occurred: {ex.Message ?? "Please limit your search criteria."}"; - - _logger.LogError(ex, message); - throw; - } - } - - private static string AddFirstOccurrenceFilter(DateTimeRange timeRange, string? filter) - { - bool inverted = false; - if (filter is not null && filter.StartsWith("@!")) - { - inverted = true; - filter = filter.Substring(2); - } - - var sb = new StringBuilder(); - if (inverted) - sb.Append("@!"); - - sb.Append("first_occurrence:[\""); - sb.Append(timeRange.UtcStart.ToString("O")); - sb.Append("\" TO \""); - sb.Append(timeRange.UtcEnd.ToString("O")); - sb.Append("\"]"); - - if (String.IsNullOrEmpty(filter)) - return sb.ToString(); - - sb.Append(' '); - - bool isGrouped = filter.StartsWith('(') && filter.EndsWith(')'); - - if (isGrouped) - sb.Append(filter); - else - sb.Append('(').Append(filter).Append(')'); - - return sb.ToString(); - } - - private Task> GetEventsInternalAsync(AppFilter sf, TimeInfo ti, string? filter, string? sort, int? page, int limit, string? before, string? after) - { - if (String.IsNullOrEmpty(sort)) - sort = $"-{EventIndex.Alias.Date}"; - - return _repository.FindAsync( - q => q.AppFilter(ShouldApplySystemFilter(sf, filter) ? sf : null) - .FilterExpression(filter) - .EnforceEventStackFilter() - .SortExpression(sort) - .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field) - .Index(ti.Range.UtcStart, ti.Range.UtcEnd), - o => page.HasValue - ? o.PageNumber(page).PageLimit(limit) - : o.SearchBeforeToken(before, _serializer).SearchAfterToken(after, _serializer).PageLimit(limit)); - } - - /// - /// Get by organization - /// - /// The identifier of the organization. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// The organization could not be found. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/events")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetByOrganizationAsync(string organizationId, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var organization = await GetOrganizationAsync(organizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organization); - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, before, after); - } - - /// - /// Get by project - /// - /// The identifier of the project. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// The project could not be found. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetByProjectAsync(string projectId, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - var organization = await GetOrganizationAsync(project.OrganizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, before, after); - } - - /// - /// Get by stack - /// - /// The identifier of the stack. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// The stack could not be found. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/stacks/{stackId:objectid}/events")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetByStackAsync(string stackId, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var stack = await GetStackAsync(stackId); - if (stack is null) - return NotFound(); - - var organization = await GetOrganizationAsync(stack.OrganizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(stack, _appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(stack, organization); - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, before, after); - } - - /// - /// Get by reference id - /// - /// An identifier used that references an event instance. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("by-ref/{referenceId:identifier}")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetByReferenceIdAsync(string referenceId, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository); - if (organizations.All(o => o.IsSuspended)) - return Ok(EmptyModels); - - var ti = GetTimeInfo(null, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, String.Concat("reference:", referenceId), null, mode, page, limit, before, after); - } - - /// - /// Get by reference id - /// - /// An identifier used that references an event instance. - /// The identifier of the project. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// The project could not be found. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetByReferenceIdAsync(string referenceId, string projectId, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - var organization = await GetOrganizationAsync(project.OrganizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(null, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, String.Concat("reference:", referenceId), null, mode, page, limit, before, after); - } - - /// - /// Get a list of all sessions or events by a session id - /// - /// An identifier that represents a session of events. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("sessions/{sessionId:identifier}")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetBySessionIdAsync(string sessionId, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); - if (organizations.All(o => o.IsSuspended)) - return Ok(EmptyModels); - - var ti = GetTimeInfo(time, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, $"(reference:{sessionId} OR ref.session:{sessionId}) {filter}", sort, mode, page, limit, before, after, true); - } - - /// - /// Get a list of by a session id - /// - /// An identifier that represents a session of events. - /// The identifier of the project. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// The project could not be found. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events/sessions/{sessionId:identifier}")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetBySessionIdAndProjectAsync(string sessionId, string projectId, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - var organization = await GetOrganizationAsync(project.OrganizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, $"(reference:{sessionId} OR ref.session:{sessionId}) {filter}", sort, mode, page, limit, before, after, true); - } - - /// - /// Get a list of all sessions - /// - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - [HttpGet("sessions")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetSessionsAsync(string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); - if (organizations.All(o => o.IsSuspended)) - return Ok(EmptyModels); - - var ti = GetTimeInfo(time, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, before, after, true); - } - - /// - /// Get a list of all sessions - /// - /// The identifier of the organization. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// The project could not be found. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/events/sessions")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetSessionByOrganizationAsync(string organizationId, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var organization = await GetOrganizationAsync(organizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organization); - return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, before, after, true); - } - - /// - /// Get a list of all sessions - /// - /// The identifier of the project. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The before parameter is a cursor used for pagination and defines your place in the list of results. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. - /// Invalid filter. - /// The project could not be found. - /// Unable to view event occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events/sessions")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - [ProducesResponseType(typeof(ICollection), 200)] - public async Task>> GetSessionByProjectAsync(string projectId, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int? page = null, int limit = 10, string? before = null, string? after = null) - { - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - var organization = await GetOrganizationAsync(project.OrganizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view event occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, before, after, true); - } - - /// - /// Set user description - /// - /// You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description. - /// An identifier used that references an event instance. - /// The user description. - /// The identifier of the project. - /// Description must be specified. - /// The event occurrence with the specified reference id could not be found. - [HttpPost("by-ref/{referenceId:identifier}/user-description")] - [HttpPost("~/" + API_PREFIX + "/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}/user-description")] - [Consumes("application/json")] - [ConfigurationResponseFilter] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public async Task SetUserDescriptionAsync(string referenceId, UserDescription description, string? projectId = null) - { - string? claimProjectId = Request.GetProjectId(); - if (projectId is not null && claimProjectId is not null && !String.Equals(projectId, claimProjectId)) - { - _logger.ProjectRouteDoesNotMatch(claimProjectId, projectId); - return NotFound(); - } - - if (String.IsNullOrEmpty(referenceId)) - return NotFound(); - - projectId ??= claimProjectId ?? Request.GetDefaultProjectId(); - - // must have a project id - if (String.IsNullOrEmpty(projectId)) - return BadRequest("No project id specified and no default project was found"); - - var (isValid, errors) = await _miniValidationValidator.ValidateAsync(description); - if (!isValid) - { - foreach (var error in errors) - foreach (var message in error.Value) - ModelState.AddModelError(error.Key, message); - - return ValidationProblem(ModelState); - } - - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - // Set the project for the configuration response filter. - Request.SetProject(project); - - var eventUserDescription = new EventUserDescription - { - ProjectId = project.Id, - ReferenceId = referenceId, - EmailAddress = description.EmailAddress, - Description = description.Description, - Data = description.Data - }; - - await _eventUserDescriptionQueue.EnqueueAsync(eventUserDescription); - return StatusCode(StatusCodes.Status202Accepted); - } - - [Obsolete("Use PATCH /api/v2/events")] - [HttpPatch("~/api/v1/error/{id:objectid}")] - [Consumes("application/json")] - [ConfigurationResponseFilter] - public async Task LegacyPatchAsync(string id, Delta changes) - { - if (changes is null) - return Ok(); - - if (changes.UnknownProperties.TryGetValue("UserEmail", out object? value)) - changes.TrySetPropertyValue("EmailAddress", value); - if (changes.UnknownProperties.TryGetValue("UserDescription", out value)) - changes.TrySetPropertyValue("Description", value); - - var userDescription = new UserDescription(); - changes.Patch(userDescription); - - return await SetUserDescriptionAsync(id, userDescription); - } - - /// - /// Submit heartbeat - /// - /// The session id or user id. - /// If true, the session will be closed. - /// OK - /// No project id specified and no default project was found. - /// No project was found. - [HttpGet("session/heartbeat")] - public async Task RecordHeartbeatAsync(string? id = null, bool close = false) - { - if (_appOptions.EventSubmissionDisabled || String.IsNullOrEmpty(id)) - return Ok(); - - string? projectId = Request.GetDefaultProjectId(); - if (String.IsNullOrEmpty(projectId)) - return BadRequest("No project id specified and no default project was found."); - - string identityHash = id.ToSHA1(); - string heartbeatCacheKey = String.Concat("Project:", projectId, ":heartbeat:", identityHash); - try - { - await Task.WhenAll( - _cache.SetAsync(heartbeatCacheKey, _timeProvider.GetUtcNow().UtcDateTime, TimeSpan.FromHours(2)), - close ? _cache.SetAsync(String.Concat(heartbeatCacheKey, "-close"), true, TimeSpan.FromHours(2)) : Task.CompletedTask - ); - } - catch (Exception ex) - { - if (projectId != _appOptions.InternalProjectId) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).Property("Id", id).Property("Close", close).SetHttpContext(HttpContext)); - _logger.LogError(ex, "Error enqueuing session heartbeat: {Message}", ex.Message); - } - - throw; - } - - return Ok(); - } - - [Obsolete("Use GET /api/v2/events/submit")] - [HttpGet("~/api/v1/events/submit")] - [HttpGet("~/api/v1/events/submit/{type:minlength(1)}")] - [HttpGet("~/api/v1/projects/{projectId:objectid}/events/submit")] - [HttpGet("~/api/v1/projects/{projectId:objectid}/events/submit/{type:minlength(1)}")] - [ConfigurationResponseFilter] - public Task GetSubmitEventV1Async(string? projectId = null, string? type = null, [FromHeader][UserAgent] string? userAgent = null, [FromQuery][QueryStringParameters] IQueryCollection? parameters = null) - { - return GetSubmitEventAsync(projectId, 1, type, userAgent, parameters); - } - - /// - /// Submit event by GET - /// - /// - /// You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event. - /// - /// Feature usage named build with a duration of 10: - /// - /// - /// Log with message, geo and extended data - /// - /// - /// The event type (ie. error, log message, feature usage). - /// The event source (ie. machine name, log name, feature name). - /// The event message. - /// An optional identifier to be used for referencing this event instance at a later time. - /// The date that the event occurred on. - /// The number of duplicated events. - /// The value of the event if any. - /// The geo coordinates where the event happened. - /// A list of tags used to categorize this event (comma separated). - /// The user's identity that the event happened to. - /// The user's friendly name that the event happened to. - /// The user agent that submitted the event. - /// Query string parameters that control what properties are set on the event - /// OK - /// No project id specified and no default project was found. - /// No project was found. - [HttpGet("submit")] - [ConfigurationResponseFilter] - public Task GetSubmitEventV2Async(string? type = null, string? source = null, string? message = null, string? reference = null, - string? date = null, int? count = null, decimal? value = null, string? geo = null, string? tags = null, string? identity = null, - string? identityname = null, [FromHeader][UserAgent] string? userAgent = null, [FromQuery][QueryStringParameters] IQueryCollection? parameters = null) - { - return GetSubmitEventAsync(null, 2, null, userAgent, parameters); - } - - /// - /// Submit event type by GET - /// - /// - /// You can submit an event using an HTTP GET and query string parameters. - /// - /// Feature usage event named build with a value of 10: - /// - /// - /// Log event with message, geo and extended data - /// - /// - /// The event type (ie. error, log message, feature usage). - /// The event source (ie. machine name, log name, feature name). - /// The event message. - /// An optional identifier to be used for referencing this event instance at a later time. - /// The date that the event occurred on. - /// The number of duplicated events. - /// The value of the event if any. - /// The geo coordinates where the event happened. - /// A list of tags used to categorize this event (comma separated). - /// The user's identity that the event happened to. - /// The user's friendly name that the event happened to. - /// The user agent that submitted the event. - /// Query string parameters that control what properties are set on the event - /// OK - /// No project id specified and no default project was found. - /// No project was found. - [HttpGet("submit/{type:minlength(1)}")] - [ConfigurationResponseFilter] - public Task GetSubmitEventByTypeV2Async(string type, string? source = null, string? message = null, string? reference = null, - string? date = null, int? count = null, decimal? value = null, string? geo = null, string? tags = null, string? identity = null, - string? identityname = null, [FromHeader][UserAgent] string? userAgent = null, [FromQuery][QueryStringParameters] IQueryCollection? parameters = null) - { - return GetSubmitEventAsync(null, 2, type, userAgent, parameters); - } - - /// - /// Submit event type by GET for a specific project - /// - /// - /// You can submit an event using an HTTP GET and query string parameters. - /// - /// Feature usage named build with a duration of 10: - /// - /// - /// Log with message, geo and extended data - /// - /// - /// The identifier of the project. - /// The event type (ie. error, log message, feature usage). - /// The event source (ie. machine name, log name, feature name). - /// The event message. - /// An optional identifier to be used for referencing this event instance at a later time. - /// The date that the event occurred on. - /// The number of duplicated events. - /// The value of the event if any. - /// The geo coordinates where the event happened. - /// A list of tags used to categorize this event (comma separated). - /// The user's identity that the event happened to. - /// The user's friendly name that the event happened to. - /// The user agent that submitted the event. - /// Query String parameters that control what properties are set on the event - /// OK - /// No project id specified and no default project was found. - /// No project was found. - [HttpGet("~/api/v2/projects/{projectId:objectid}/events/submit")] - [HttpGet("~/api/v2/projects/{projectId:objectid}/events/submit/{type:minlength(1)}")] - [ConfigurationResponseFilter] - public Task GetSubmitEventByProjectV2Async(string projectId, string? type = null, string? source = null, string? message = null, string? reference = null, - string? date = null, int? count = null, decimal? value = null, string? geo = null, string? tags = null, string? identity = null, - string? identityname = null, [FromHeader][UserAgent] string? userAgent = null, [FromQuery][QueryStringParameters] IQueryCollection? parameters = null) - { - return GetSubmitEventAsync(projectId, 2, type, userAgent, parameters); - } - - private async Task GetSubmitEventAsync(string? projectId = null, int apiVersion = 2, string? type = null, string? userAgent = null, IQueryCollection? parameters = null) - { - string? claimProjectId = Request.GetProjectId(); - if (projectId is not null && claimProjectId is not null && !String.Equals(projectId, claimProjectId)) - { - _logger.ProjectRouteDoesNotMatch(claimProjectId, projectId); - return NotFound(); - } - - var filteredParameters = parameters?.Where(p => !String.IsNullOrEmpty(p.Key) && !p.Value.All(String.IsNullOrEmpty) && !_ignoredKeys.Contains(p.Key)).ToList(); - if (filteredParameters is null || filteredParameters.Count == 0) - return Ok(); - - projectId ??= claimProjectId ?? Request.GetDefaultProjectId(); - - // must have a project id - if (String.IsNullOrEmpty(projectId)) - return BadRequest("No project id specified and no default project was found"); - - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - // Set the project for the configuration response filter. - Request.SetProject(project); - - string? contentEncoding = Request.Headers.TryGetAndReturn(Headers.ContentEncoding); - var ev = new Event - { - Type = !String.IsNullOrEmpty(type) ? type : Event.KnownTypes.Log - }; - - string? identity = null; - string? identityName = null; - - var exclusions = project.Configuration.Settings.GetStringCollection(SettingsDictionary.KnownKeys.DataExclusions).ToList(); - foreach (var kvp in filteredParameters) - { - switch (kvp.Key.ToLowerInvariant()) - { - case "type": - ev.Type = kvp.Value.FirstOrDefault(); - break; - case "source": - ev.Source = kvp.Value.FirstOrDefault(); - break; - case "message": - ev.Message = kvp.Value.FirstOrDefault(); - break; - case "reference": - ev.ReferenceId = kvp.Value.FirstOrDefault(); - break; - case "date": - if (DateTimeOffset.TryParse(kvp.Value.FirstOrDefault(), out var dtValue)) - ev.Date = dtValue; - break; - case "count": - if (Int32.TryParse(kvp.Value.FirstOrDefault(), out int intValue)) - ev.Count = intValue; - break; - case "value": - if (Decimal.TryParse(kvp.Value.FirstOrDefault(), out decimal decValue)) - ev.Value = decValue; - break; - case "geo": - if (GeoResult.TryParse(kvp.Value.FirstOrDefault(), out var geo)) - ev.Geo = geo?.ToString(); - break; - case "tags": - ev.Tags ??= []; - ev.Tags.AddRange(kvp.Value.SelectMany(t => t?.Split([","], StringSplitOptions.RemoveEmptyEntries) ?? []).Distinct()); - break; - case "identity": - identity = kvp.Value.FirstOrDefault(); - break; - case "identity.name": - identityName = kvp.Value.FirstOrDefault(); - break; - default: - if (kvp.Key.AnyWildcardMatches(exclusions, true)) - continue; - - if (kvp.Value.Count > 1) - ev.Data![kvp.Key] = kvp.Value; - else - ev.Data![kvp.Key] = kvp.Value.FirstOrDefault(); - - break; - } - } - - if (identity != null) - ev.SetUserIdentity(identity, identityName); - - try - { - string mediaType = String.Empty; - string charSet = String.Empty; - if (Request.ContentType is not null && MediaTypeHeaderValue.TryParse(Request.ContentType, out var contentTypeHeader)) - { - mediaType = contentTypeHeader.MediaType.ToString(); - charSet = contentTypeHeader.Charset.ToString(); - } - - using var stream = new MemoryStream(ev.GetBytes(_serializer)); - await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive) - { - ApiVersion = apiVersion, - CharSet = charSet, - ContentEncoding = contentEncoding, - IpAddress = Request.GetClientIpAddress(), - MediaType = mediaType, - OrganizationId = project.OrganizationId, - ProjectId = project.Id, - UserAgent = userAgent - }, stream); - } - catch (Exception ex) - { - if (projectId != _appOptions.InternalProjectId) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).SetHttpContext(HttpContext)); - _logger.LogError(ex, "Error enqueuing event post: {Message}", ex.Message); - } - - throw; - } - - return Ok(); - } - - [Obsolete("Use POST /api/v2/events")] - [HttpPost("~/api/v1/error")] - [Consumes("application/json", "text/plain")] - [RequestBodyContentAttribute] - [ConfigurationResponseFilter] - public Task LegacyPostAsync([FromHeader][UserAgent] string? userAgent = null) - { - return PostAsync(null, 1, userAgent); - } - - [Obsolete("Use POST /api/v2/events")] - [HttpPost("~/api/v1/events")] - [HttpPost("~/api/v1/projects/{projectId:objectid}/events")] - [Consumes("application/json", "text/plain")] - [RequestBodyContentAttribute] - [ConfigurationResponseFilter] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task PostV1Async(string? projectId = null, [FromHeader][UserAgent] string? userAgent = null) - { - return PostAsync(projectId, 1, userAgent); - } - - /// - /// Submit event by POST - /// - /// - /// You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it - /// we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON - /// object into the events data collection. - /// - /// You can also post a multi-line string. We automatically split strings by the \n character and create a new log event for every line. - /// - /// Simple event: - /// - /// { "message": "Exceptionless is amazing!" } - /// - /// - /// Simple log event with user identity: - /// - /// { - /// "type": "log", - /// "message": "Exceptionless is amazing!", - /// "date":"2030-01-01T12:00:00.0000000-05:00", - /// "@user":{ "identity":"123456789", "name": "Test User" } - /// } - /// - /// - /// Multiple events from string content: - /// - /// Exceptionless is amazing! - /// Exceptionless is really amazing! - /// - /// - /// Simple error: - /// - /// { - /// "type": "error", - /// "date":"2030-01-01T12:00:00.0000000-05:00", - /// "@simple_error": { - /// "message": "Simple Exception", - /// "type": "System.Exception", - /// "stack_trace": " at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77" - /// } - /// } - /// - /// - /// The user agent that submitted the event. - /// Accepted - /// No project id specified and no default project was found. - /// No project was found. - [HttpPost] - [Consumes("application/json", "text/plain")] - [RequestBodyContentAttribute] - [ConfigurationResponseFilter] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task PostV2Async([FromHeader][UserAgent] string? userAgent = null) - { - return PostAsync(null, 2, userAgent); - } - - /// - /// Submit event by POST for a specific project - /// - /// - /// You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it - /// we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON - /// object into the events data collection. - /// - /// You can also post a multi-line string. We automatically split strings by the \n character and create a new log event for every line. - /// - /// Simple event: - /// - /// { "message": "Exceptionless is amazing!" } - /// - /// - /// Simple log event with user identity: - /// - /// { - /// "type": "log", - /// "message": "Exceptionless is amazing!", - /// "date":"2030-01-01T12:00:00.0000000-05:00", - /// "@user":{ "identity":"123456789", "name": "Test User" } - /// } - /// - /// - /// Multiple events from string content: - /// - /// Exceptionless is amazing! - /// Exceptionless is really amazing! - /// - /// - /// Simple error: - /// - /// { - /// "type": "error", - /// "date":"2030-01-01T12:00:00.0000000-05:00", - /// "@simple_error": { - /// "message": "Simple Exception", - /// "type": "System.Exception", - /// "stack_trace": " at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77" - /// } - /// } - /// - /// - /// The identifier of the project. - /// The user agent that submitted the event. - /// Accepted - /// No project id specified and no default project was found. - /// No project was found. - [HttpPost("~/api/v2/projects/{projectId:objectid}/events")] - [Consumes("application/json", "text/plain")] - [RequestBodyContentAttribute] - [ConfigurationResponseFilter] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task PostByProjectV2Async(string? projectId = null, [FromHeader][UserAgent] string? userAgent = null) - { - return PostAsync(projectId, 2, userAgent); - } - - private async Task PostAsync(string? projectId = null, int apiVersion = 2, [FromHeader][UserAgent] string? userAgent = null) - { - string? claimProjectId = Request.GetProjectId(); - if (projectId is not null && claimProjectId is not null && !String.Equals(projectId, claimProjectId)) - { - _logger.ProjectRouteDoesNotMatch(claimProjectId, projectId); - return NotFound(); - } - - if (Request.ContentLength is <= 0) - return StatusCode(StatusCodes.Status202Accepted); - - projectId ??= claimProjectId ?? Request.GetDefaultProjectId(); - - // must have a project id - if (String.IsNullOrEmpty(projectId)) - return BadRequest("No project id specified and no default project was found"); - - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - // Set the project for the configuration response filter. - Request.SetProject(project); - - try - { - string mediaType = String.Empty; - string charSet = String.Empty; - if (Request.ContentType is not null) - { - var contentType = MediaTypeHeaderValue.Parse(Request.ContentType); - mediaType = contentType.MediaType.ToString(); - charSet = contentType.Charset.ToString(); - } - - await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive) - { - ApiVersion = apiVersion, - CharSet = charSet, - ContentEncoding = Request.Headers.TryGetAndReturn(Headers.ContentEncoding), - IpAddress = Request.GetClientIpAddress(), - MediaType = mediaType, - OrganizationId = project.OrganizationId, - ProjectId = project.Id, - UserAgent = userAgent, - }, Request.Body); - } - catch (Exception ex) - { - if (projectId != _appOptions.InternalProjectId) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).SetHttpContext(HttpContext)); - _logger.LogError(ex, "Error enqueuing event post: {Message}", ex.Message); - } - - throw; - } - - return StatusCode(StatusCodes.Status202Accepted); - } - - /// - /// Remove - /// - /// A comma-delimited list of event identifiers. - /// Accepted - /// One or more validation errors occurred. - /// One or more event occurrences were not found. - /// An error occurred while deleting one or more event occurrences. - [HttpDelete("{ids:objectids}")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task> DeleteAsync(string ids) - { - return DeleteImplAsync(ids.FromDelimitedString()); - } - - private Task GetOrganizationAsync(string organizationId, bool useCache = true) - { - if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) - return Task.FromResult(null); - - return _organizationRepository.GetByIdAsync(organizationId, o => o.Cache(useCache)); - } - - private async Task GetProjectAsync(string projectId, bool useCache = true) - { - if (String.IsNullOrEmpty(projectId)) - return null; - - var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache(useCache)); - if (project is null || !CanAccessOrganization(project.OrganizationId)) - return null; - - return project; - } - - private async Task GetStackAsync(string stackId, bool useCache = true) - { - if (String.IsNullOrEmpty(stackId)) - return null; - - var stack = await _stackRepository.GetByIdAsync(stackId, o => o.Cache(useCache)); - if (stack is null || !CanAccessOrganization(stack.OrganizationId)) - return null; - - return stack; - } - - private async Task> GetStackSummariesAsync(List stacks, IReadOnlyCollection> stackTerms, AppFilter sf, TimeInfo ti) - { - if (stacks.Count == 0) - return new List(0); - - var totalUsers = await GetUserCountByProjectIdsAsync(stacks, sf, ti.Range.UtcStart, ti.Range.UtcEnd); - return stacks.Join(stackTerms, s => s.Id, tk => tk.Key, (stack, term) => - { - var data = _formattingPluginManager.GetStackSummaryData(stack); - var summary = new StackSummaryModel - { - Id = data.Id, - TemplateKey = data.TemplateKey, - Data = data.Data, - Title = stack.Title, - Status = stack.Status, - FirstOccurrence = term.Aggregations.Min("min_date")?.Value ?? stack.FirstOccurrence, - LastOccurrence = term.Aggregations.Max("max_date")?.Value ?? stack.LastOccurrence, - Total = (long)(term.Aggregations.Sum("sum_count")?.Value ?? term.Total.GetValueOrDefault()), - - Users = term.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0, - TotalUsers = totalUsers.GetOrDefault(stack.ProjectId) - }; - - return summary; - }).ToList(); - } - - private async Task> GetUserCountByProjectIdsAsync(ICollection stacks, AppFilter sf, DateTime utcStart, DateTime utcEnd) - { - var scopedCacheClient = new ScopedCacheClient(_cache, $"Project:user-count:{utcStart.Floor(TimeSpan.FromMinutes(15)).Ticks}-{utcEnd.Floor(TimeSpan.FromMinutes(15)).Ticks}"); - var projectIds = stacks.Select(s => s.ProjectId).Distinct().ToList(); - var cachedTotals = await scopedCacheClient.GetAllAsync(projectIds); - - var totals = cachedTotals.Where(kvp => kvp.Value.HasValue).ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value); - if (totals.Count == projectIds.Count) - return totals; - - var systemFilter = new RepositoryQuery().AppFilter(sf).DateRange(utcStart, utcEnd, (PersistentEvent e) => e.Date).Index(utcStart, utcEnd); - var projects = cachedTotals - .Where(kvp => !kvp.Value.HasValue && stacks.Contains(s => s.ProjectId == kvp.Key)) - .Select(kvp => new Project { Id = kvp.Key, OrganizationId = stacks.First(s => s.ProjectId == kvp.Key).OrganizationId }) - .ToList(); - var countResult = await _repository.CountAsync(q => q.SystemFilter(systemFilter).FilterExpression(projects.BuildFilter()).EnforceEventStackFilter().AggregationsExpression("terms:(project_id cardinality:user)")); - - // Cache all projects that have more than 10 users for 5 minutes. - var projectTerms = countResult.Aggregations.Terms("terms_project_id")?.Buckets ?? []; - var aggregations = projectTerms.ToDictionary(t => t.Key, t => t.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0); - await scopedCacheClient.SetAllAsync(aggregations.Where(t => t.Value >= 10).ToDictionary(k => k.Key, v => v.Value), TimeSpan.FromMinutes(5)); - totals.AddRange(aggregations); - - return totals; - } - - protected override async Task> DeleteModelsAsync(ICollection events) - { - var user = CurrentUser; - var projectGroups = events.GroupBy(ev => new { ev.OrganizationId, ev.ProjectId }).ToList(); - foreach (var projectGroup in projectGroups) - { - var ev = projectGroup.First(); - using var _ = _logger.BeginScope(new ExceptionlessState().Organization(ev.OrganizationId).Project(ev.ProjectId).Tag("Delete").Identity(user.EmailAddress).Property("User", user).SetHttpContext(HttpContext)); - _logger.LogInformation("User {User} deleted {RemovedCount} events in project ({ProjectId})", user.Id, projectGroup.Count(), ev.ProjectId); - } - - var result = await base.DeleteModelsAsync(events); - - foreach (var projectGroup in projectGroups) - { - try - { - await _usageService.IncrementDeletedAsync(projectGroup.Key.OrganizationId, projectGroup.Key.ProjectId, projectGroup.Count()); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to increment deleted usage metrics for org {OrganizationId} project {ProjectId}: {Message}", projectGroup.Key.OrganizationId, projectGroup.Key.ProjectId, ex.Message); - } - } - - return result; - } -} diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs deleted file mode 100644 index cd4efaeabb..0000000000 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ /dev/null @@ -1,1111 +0,0 @@ -using Exceptionless.Core; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Billing; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Mail; -using Exceptionless.Core.Messaging.Models; -using Exceptionless.Core.Models; -using Exceptionless.Core.Models.Billing; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Core.Repositories; -using Exceptionless.Core.Repositories.Queries; -using Exceptionless.Core.Services; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Mapping; -using Exceptionless.Web.Models; -using Exceptionless.Web.Utility; -using Exceptionless.Web.Utility.OpenApi; -using Foundatio.Caching; -using Foundatio.Messaging; -using Foundatio.Repositories; -using Foundatio.Repositories.Models; -using Foundatio.Storage; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Stripe; -using DataDictionary = Exceptionless.Core.Models.DataDictionary; -using Invoice = Exceptionless.Web.Models.Invoice; -using InvoiceLineItem = Exceptionless.Web.Models.InvoiceLineItem; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX + "/organizations")] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class OrganizationController : RepositoryApiController -{ - private readonly OrganizationService _organizationService; - private readonly ICacheClient _cacheClient; - private readonly IEventRepository _eventRepository; - private readonly IUserRepository _userRepository; - private readonly IProjectRepository _projectRepository; - private readonly IFileStorage _fileStorage; - private readonly BillingManager _billingManager; - private readonly UsageService _usageService; - private readonly BillingPlans _plans; - private readonly IStripeBillingClient _stripeBillingClient; - private readonly IMailer _mailer; - private readonly IMessagePublisher _messagePublisher; - private readonly AppOptions _options; - - public OrganizationController( - OrganizationService organizationService, - IOrganizationRepository organizationRepository, - ICacheClient cacheClient, - IEventRepository eventRepository, - IUserRepository userRepository, - IProjectRepository projectRepository, - IFileStorage fileStorage, - BillingManager billingManager, - BillingPlans plans, - UsageService usageService, - IStripeBillingClient stripeBillingClient, - IMailer mailer, - IMessagePublisher messagePublisher, - ApiMapper mapper, - IAppQueryValidator validator, - AppOptions options, - TimeProvider timeProvider, - ILoggerFactory loggerFactory) : base(organizationRepository, mapper, validator, timeProvider, loggerFactory) - { - _organizationService = organizationService; - _cacheClient = cacheClient; - _eventRepository = eventRepository; - _userRepository = userRepository; - _projectRepository = projectRepository; - _fileStorage = fileStorage; - _billingManager = billingManager; - _plans = plans; - _usageService = usageService; - _stripeBillingClient = stripeBillingClient; - _mailer = mailer; - _messagePublisher = messagePublisher; - _options = options; - } - - // Mapping implementations - protected override Organization MapToModel(NewOrganization newModel) => _mapper.MapToOrganization(newModel); - protected override ViewOrganization MapToViewModel(Organization model) => _mapper.MapToViewOrganization(model); - protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewOrganizations(models); - - /// - /// Get all - /// - /// A filter that controls what data is returned from the server. - /// If no mode is set then a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned. - [HttpGet] - public async Task>> GetAllAsync(string? filter = null, string? mode = null) - { - var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray()); - if (organizations.Count == 0) - return Ok(EmptyModels); - - var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - organizations = String.IsNullOrWhiteSpace(filter) - ? organizations - : (await _repository.GetByFilterAsync(sf, filter, null, o => o.PageLimit(1000))).Documents; - var viewOrganizations = MapToViewModels(organizations); - await AfterResultMapAsync(viewOrganizations); - - if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return Ok(await PopulateOrganizationStatsAsync(viewOrganizations)); - - return Ok(viewOrganizations); - } - - [HttpGet("~/" + API_PREFIX + "/admin/organizations")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task>> GetForAdminsAsync(string? criteria = null, bool? paid = null, bool? suspended = null, string? mode = null, int page = 1, int limit = 10, OrganizationSortBy sort = OrganizationSortBy.Newest) - { - page = GetPage(page); - limit = GetLimit(limit); - var organizations = await _repository.GetByCriteriaAsync(criteria, o => o.PageNumber(page).PageLimit(limit), sort, paid, suspended); - var viewOrganizations = MapToViewModels(organizations.Documents); - await AfterResultMapAsync(viewOrganizations); - - if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return OkWithResourceLinks(await PopulateOrganizationStatsAsync(viewOrganizations), organizations.HasMore, page, organizations.Total); - - return OkWithResourceLinks(viewOrganizations, organizations.HasMore, page, organizations.Total); - } - - [HttpGet("~/" + API_PREFIX + "/admin/organizations/stats")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task> PlanStatsAsync() - { - return Ok(await _repository.GetBillingPlanStatsAsync()); - } - - /// - /// Get by id - /// - /// The identifier of the organization. - /// If no mode is set then the a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned. - /// The organization could not be found. - [HttpGet("{id:objectid}", Name = "GetOrganizationById")] - public async Task> GetAsync(string id, string? mode = null) - { - var organization = await GetModelAsync(id); - if (organization is null) - return NotFound(); - - var viewOrganization = MapToViewModel(organization); - await AfterResultMapAsync([viewOrganization]); - - if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return Ok(await PopulateOrganizationStatsAsync(viewOrganization)); - - return Ok(viewOrganization); - } - - /// - /// Create - /// - /// The organization. - /// An error occurred while creating the organization. - /// The organization already exists. - [HttpPost] - [Consumes("application/json")] - [ProducesResponseType(StatusCodes.Status201Created)] - public Task> PostAsync(NewOrganization organization) - { - return PostImplAsync(organization); - } - - /// - /// Update - /// - /// The identifier of the organization. - /// The changes - /// An error occurred while updating the organization. - /// The organization could not be found. - [HttpPatch] - [HttpPut] - [Consumes("application/json")] - [Route("{id:objectid}")] - public Task> PatchAsync(string id, Delta changes) - { - return PatchImplAsync(id, changes); - } - - /// - /// Upload icon - /// - /// The identifier of the organization. - /// The organization icon image file. - /// The cancellation token. - /// The organization could not be found. - /// The image file is invalid. - [HttpPost("{id:objectid}/icon")] - [Consumes("multipart/form-data")] - [MultipartFileUpload] - [RequestSizeLimit(ProfileImageStorage.MaxRequestBodySize)] - [RequestFormLimits(MultipartBodyLengthLimit = ProfileImageStorage.MaxRequestBodySize)] - public async Task> UploadIconAsync(string id, [FromForm] IFormFile? file, CancellationToken cancellationToken = default) - { - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - var image = await ProfileImageStorage.SaveAsync(_fileStorage, file, "organizations", organization.Id, ModelState, cancellationToken); - if (image is null) - return ValidationProblem(ModelState); - - string? oldIconFileName = organization.IconFileName; - organization.IconFileName = image.FileName; - try - { - await _repository.SaveAsync(organization, o => o.Cache()); - } - catch - { - await ProfileImageStorage.TryDeleteAsync(_fileStorage, image.FileName, "organizations", organization.Id, CancellationToken.None); - throw; - } - - await ProfileImageStorage.DeleteAsync(_fileStorage, oldIconFileName, "organizations", organization.Id, cancellationToken); - - return await OkModelAsync(organization); - } - - /// - /// Remove icon - /// - /// The identifier of the organization. - /// The cancellation token. - /// The organization could not be found. - [HttpDelete("{id:objectid}/icon")] - public async Task> DeleteIconAsync(string id, CancellationToken cancellationToken = default) - { - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - string? oldIconFileName = organization.IconFileName; - organization.IconFileName = null; - await _repository.SaveAsync(organization, o => o.Cache()); - await ProfileImageStorage.DeleteAsync(_fileStorage, oldIconFileName, "organizations", organization.Id, cancellationToken); - - return await OkModelAsync(organization); - } - - /// - /// Get icon - /// - /// The identifier of the organization. - /// The icon file name. - /// The cancellation token. - /// The icon could not be found. - [AllowAnonymous] - [HttpGet("{id:objectid}/icon/{fileName}", Name = "GetOrganizationIcon")] - [ResponseCache(Duration = 31536000, Location = ResponseCacheLocation.Any)] - public async Task GetIconAsync(string id, string fileName, CancellationToken cancellationToken = default) - { - if (!ProfileImageStorage.TryGetContentType(fileName, out string contentType)) - return NotFound(); - - var stream = await ProfileImageStorage.GetFileStreamAsync(_fileStorage, fileName, "organizations", id, cancellationToken); - return stream is null ? NotFound() : File(stream, contentType); - } - - /// - /// Remove - /// - /// A comma-delimited list of organization identifiers. - /// Accepted - /// One or more validation errors occurred. - /// One or more organizations were not found. - /// An error occurred while deleting one or more organizations. - [HttpDelete] - [Route("{ids:objectids}")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task> DeleteAsync(string ids) - { - return DeleteImplAsync(ids.FromDelimitedString()); - } - - protected override async Task> DeleteModelsAsync(ICollection organizations) - { - var user = CurrentUser; - foreach (var organization in organizations) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Organization(organization.Id).Tag("Delete").Identity(user.EmailAddress).Property("User", user).SetHttpContext(HttpContext)); - _logger.UserDeletingOrganization(user.Id, organization.Name, organization.Id); - await _organizationService.SoftDeleteOrganizationAsync(organization, user.Id); - } - - return []; - } - - /// - /// Get invoice - /// - /// The identifier of the invoice. - /// The invoice was not found. - [HttpGet] - [Route("invoice/{id:minlength(10)}")] - public async Task> GetInvoiceAsync(string id) - { - if (!_options.StripeOptions.EnableBilling) - return NotFound(); - - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Invoice").Identity(CurrentUser.EmailAddress) - .Property("User", CurrentUser).SetHttpContext(HttpContext)); - - if (!id.StartsWith("in_")) - id = "in_" + id; - - Stripe.Invoice? stripeInvoice = null; - try - { - stripeInvoice = await _stripeBillingClient.GetInvoiceAsync(id); - } - catch (StripeException ex) - { - _logger.LogCritical(ex, "Error getting invoice ({InvoiceId}): {Message}", id, ex.Message); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Unexpected error getting invoice ({InvoiceId}): {Message}", id, ex.Message); - } - - if (String.IsNullOrEmpty(stripeInvoice?.CustomerId)) - return NotFound(); - - var organization = await _repository.GetByStripeCustomerIdAsync(stripeInvoice.CustomerId); - if (organization is null || !CanAccessOrganization(organization.Id)) - return NotFound(); - - var invoice = new Invoice - { - Id = stripeInvoice.Id.Substring(3), - OrganizationId = organization.Id, - OrganizationName = organization.Name, - Date = stripeInvoice.Created, - Paid = String.Equals(stripeInvoice.Status, "paid", StringComparison.OrdinalIgnoreCase), - Total = stripeInvoice.Total / 100.0m - }; - - foreach (var line in stripeInvoice.Lines.Data) - { - var item = new InvoiceLineItem { Amount = line.Amount / 100.0m, Description = line.Description }; - - var priceId = line.Pricing?.PriceDetails?.PriceId; - if (!String.IsNullOrEmpty(priceId)) - { - var billingPlan = _billingManager.GetBillingPlan(priceId); - if (billingPlan is null) - _logger.LogWarning("Billing plan not found for price {PriceId} on invoice {InvoiceId}", priceId, id); - - string planName = billingPlan?.Name ?? priceId; - string interval = priceId.EndsWith("_YEARLY", StringComparison.OrdinalIgnoreCase) ? "year" : "month"; - item.Description = $"Exceptionless - {planName} Plan ({line.Amount / 100.0m:c}/{interval})"; - } - - var periodStart = line.Period.Start >= DateTime.MinValue ? line.Period.Start : stripeInvoice.PeriodStart; - var periodEnd = line.Period.End >= DateTime.MinValue ? line.Period.End : stripeInvoice.PeriodEnd; - item.Date = $"{periodStart.ToShortDateString()} - {periodEnd.ToShortDateString()}"; - invoice.Items.Add(item); - } - - var coupon = stripeInvoice.Discounts?.FirstOrDefault(d => d.Deleted is not true)?.Source?.Coupon; - if (coupon is not null) - { - if (coupon.AmountOff.HasValue) - { - decimal discountAmount = coupon.AmountOff.GetValueOrDefault() / 100.0m; - string description = $"{coupon.Id} ({discountAmount:C} off)"; - invoice.Items.Add(new InvoiceLineItem { Description = description, Amount = discountAmount }); - } - else - { - decimal discountAmount = (stripeInvoice.Subtotal / 100.0m) * (coupon.PercentOff.GetValueOrDefault() / 100.0m); - string description = $"{coupon.Id} ({coupon.PercentOff.GetValueOrDefault()}% off)"; - invoice.Items.Add(new InvoiceLineItem { Description = description, Amount = discountAmount }); - } - } - - return Ok(invoice); - } - - /// - /// Get invoices - /// - /// The identifier of the organization. - /// A cursor for use in pagination. before is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with obj_bar, your subsequent call can include before=obj_bar in order to fetch the previous page of the list. - /// A cursor for use in pagination. after is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with obj_foo, your subsequent call can include after=obj_foo in order to fetch the next page of the list. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The organization was not found. - [HttpGet] - [Route("{id:objectid}/invoices")] - public async Task>> GetInvoicesAsync(string id, string? before = null, string? after = null, int limit = 12) - { - if (!_options.StripeOptions.EnableBilling) - return NotFound(); - - var organization = await GetModelAsync(id); - if (organization is null) - return NotFound(); - - if (String.IsNullOrWhiteSpace(organization.StripeCustomerId)) - return Ok(new List()); - - if (!String.IsNullOrEmpty(before) && !before.StartsWith("in_")) - before = "in_" + before; - - if (!String.IsNullOrEmpty(after) && !after.StartsWith("in_")) - after = "in_" + after; - - var invoiceOptions = new InvoiceListOptions { Customer = organization.StripeCustomerId, Limit = limit + 1, EndingBefore = before, StartingAfter = after }; - var invoices = _mapper.MapToInvoiceGridModels(await _stripeBillingClient.ListInvoicesAsync(invoiceOptions)); - return OkWithResourceLinks(invoices.Take(limit).ToList(), invoices.Count > limit); - } - - /// - /// Get plans - /// - /// - /// Gets available plans for a specific organization. - /// - /// The identifier of the organization. - /// The organization was not found. - [HttpGet] - [Route("{id:objectid}/plans")] - public async Task>> GetPlansAsync(string id) - { - var organization = await GetModelAsync(id); - if (organization is null) - return NotFound(); - - var plans = Request.IsGlobalAdmin() - ? _plans.Plans.ToList() - : _plans.Plans.Where(p => !p.IsHidden || String.Equals(p.Id, organization.PlanId, StringComparison.OrdinalIgnoreCase)).ToList(); - - var currentPlan = new BillingPlan - { - Id = organization.PlanId, - Name = organization.PlanName, - Description = organization.PlanDescription, - IsHidden = false, - Price = organization.BillingPrice, - MaxProjects = organization.MaxProjects, - MaxUsers = organization.MaxUsers, - RetentionDays = organization.RetentionDays, - MaxEventsPerMonth = organization.MaxEventsPerMonth, - HasPremiumFeatures = organization.HasPremiumFeatures - }; - - int idx = plans.FindIndex(p => String.Equals(p.Id, organization.PlanId, StringComparison.OrdinalIgnoreCase)); - if (idx >= 0) - plans[idx] = currentPlan; - else - plans.Add(currentPlan); - - return Ok(plans); - } - - /// - /// Change plan - /// - /// - /// Upgrades or downgrades the organization's plan. - /// Accepts parameters via JSON body (preferred) or query string (legacy). - /// - /// The identifier of the organization. - /// The plan change request (JSON body). - /// Legacy query parameter: the plan identifier. - /// Legacy query parameter: the Stripe token. - /// Legacy query parameter: last four digits of the card. - /// Legacy query parameter: the coupon identifier. - /// The organization was not found. - [HttpPost] - [Route("{id:objectid}/change-plan")] - public async Task> ChangePlanAsync( - string id, - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ChangePlanRequest? model = null, - [FromQuery] string? planId = null, - [FromQuery] string? stripeToken = null, - [FromQuery] string? last4 = null, - [FromQuery] string? couponId = null) - { - // Support legacy clients that send query parameters instead of a JSON body - model ??= new ChangePlanRequest { PlanId = planId ?? String.Empty }; - if (String.IsNullOrEmpty(model.PlanId) && !String.IsNullOrEmpty(planId)) - model.PlanId = planId; - if (String.IsNullOrEmpty(model.StripeToken) && !String.IsNullOrEmpty(stripeToken)) - model.StripeToken = stripeToken; - if (String.IsNullOrEmpty(model.Last4) && !String.IsNullOrEmpty(last4)) - model.Last4 = last4; - if (String.IsNullOrEmpty(model.CouponId) && !String.IsNullOrEmpty(couponId)) - model.CouponId = couponId; - - if (String.IsNullOrEmpty(id) || !CanAccessOrganization(id)) - return NotFound(); - - using var _ = _logger.BeginScope(new ExceptionlessState().Tag("Change Plan").Organization(id) - .Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)); - - if (!_options.StripeOptions.EnableBilling) - return NotFound(); - - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - var plan = _billingManager.GetBillingPlan(model.PlanId); - if (plan is null) - { - _logger.LogWarning("Plan {PlanId} not found for organization {OrganizationId}", model.PlanId, id); - ModelState.AddModelError("general", "Invalid plan. Please select a valid plan."); - return ValidationProblem(ModelState); - } - - if (plan.IsHidden && !String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning("Hidden plan {PlanId} is not selectable for organization {OrganizationId}", model.PlanId, id); - ModelState.AddModelError("general", "Invalid plan. Please select a valid plan."); - return ValidationProblem(ModelState); - } - - if (String.Equals(organization.PlanId, plan.Id) && String.Equals(_plans.FreePlan.Id, plan.Id)) - return Ok(ChangePlanResult.SuccessWithMessage("Your plan was not changed as you were already on the free plan.")); - - // Only see if they can downgrade a plan if the plans are different. - if (!String.Equals(organization.PlanId, plan.Id)) - { - var result = await _billingManager.CanDownGradeAsync(organization, plan, CurrentUser); - if (!result.Success) - return Ok(result); - } - - bool isPaymentMethod = model.StripeToken?.StartsWith("pm_", StringComparison.Ordinal) is true; - - try - { - // If they are on a paid plan and then downgrade to a free plan then cancel their stripe subscription. - // NOTE: organization.PlanId still reflects the OLD plan here; it is updated at the end - // of this block by _billingManager.ApplyBillingPlan(organization, plan, CurrentUser). - if (!String.Equals(organization.PlanId, _plans.FreePlan.Id) && String.Equals(plan.Id, _plans.FreePlan.Id)) - { - if (!String.IsNullOrEmpty(organization.StripeCustomerId)) - { - var subs = await _stripeBillingClient.ListSubscriptionsAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); - foreach (var sub in subs.Where(s => !s.CanceledAt.HasValue)) - await _stripeBillingClient.CancelSubscriptionAsync(sub.Id, new SubscriptionCancelOptions()); - } - - organization.BillingStatus = BillingStatus.Trialing; - organization.RemoveSuspension(); - } - // New customer: create a Stripe customer and subscription from the provided payment token. - else if (String.IsNullOrEmpty(organization.StripeCustomerId)) - { - if (String.IsNullOrEmpty(model.StripeToken)) - return Ok(ChangePlanResult.FailWithMessage("Billing information was not set.")); - - organization.SubscribeDate = _timeProvider.GetUtcNow().UtcDateTime; - - var createCustomer = new CustomerCreateOptions - { - Description = organization.Name, - Email = CurrentUser.EmailAddress - }; - - if (isPaymentMethod) - { - createCustomer.PaymentMethod = model.StripeToken; - createCustomer.InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = model.StripeToken - }; - } - else - { - createCustomer.Source = model.StripeToken; - } - - var customer = await _stripeBillingClient.CreateCustomerAsync(createCustomer); - - // Persist the Stripe customer ID immediately so a retry won't create a duplicate customer - organization.StripeCustomerId = customer.Id; - organization.CardLast4 = model.Last4; - await _repository.SaveAsync(organization, o => o.Cache()); - - // Create the Stripe subscription for the selected plan, attach payment method and coupon if provided. - var subscriptionOptions = new SubscriptionCreateOptions - { - Customer = customer.Id, - Items = [new SubscriptionItemOptions { Price = model.PlanId }] - }; - - if (isPaymentMethod) - subscriptionOptions.DefaultPaymentMethod = model.StripeToken; - - if (!String.IsNullOrWhiteSpace(model.CouponId)) - subscriptionOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; - - await _stripeBillingClient.CreateSubscriptionAsync(subscriptionOptions); - - organization.BillingStatus = BillingStatus.Active; - organization.RemoveSuspension(); - } - // Existing customer: update (or create) their Stripe subscription and optionally swap payment method. - else - { - var update = new SubscriptionUpdateOptions { Items = [] }; - var create = new SubscriptionCreateOptions { Customer = organization.StripeCustomerId, Items = [] }; - bool cardUpdated = false; - - var customerUpdateOptions = new CustomerUpdateOptions { Description = organization.Name }; - if (!Request.IsGlobalAdmin()) - customerUpdateOptions.Email = CurrentUser.EmailAddress; - - var listSubscriptionsTask = _stripeBillingClient.ListSubscriptionsAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); - - if (!String.IsNullOrEmpty(model.StripeToken)) - { - if (isPaymentMethod) - { - await _stripeBillingClient.AttachPaymentMethodAsync(model.StripeToken, new PaymentMethodAttachOptions - { - Customer = organization.StripeCustomerId - }); - customerUpdateOptions.InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = model.StripeToken - }; - } - else - { - customerUpdateOptions.Source = model.StripeToken; - } - cardUpdated = true; - } - - await Task.WhenAll( - _stripeBillingClient.UpdateCustomerAsync(organization.StripeCustomerId, customerUpdateOptions), - listSubscriptionsTask - ); - - var subscriptionList = await listSubscriptionsTask; - var subscription = subscriptionList.FirstOrDefault(s => !s.CanceledAt.HasValue); - if (subscription is not null && subscription.Items.Data.Count > 0) - { - update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = model.PlanId }); - if (!String.IsNullOrWhiteSpace(model.CouponId)) - update.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; - await _stripeBillingClient.UpdateSubscriptionAsync(subscription.Id, update); - } - else if (subscription is not null) - { - _logger.LogWarning("Subscription {SubscriptionId} has no items for organization {OrganizationId}, adding new item", subscription.Id, id); - update.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); - if (!String.IsNullOrWhiteSpace(model.CouponId)) - update.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; - await _stripeBillingClient.UpdateSubscriptionAsync(subscription.Id, update); - } - else - { - create.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); - if (!String.IsNullOrWhiteSpace(model.CouponId)) - create.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; - await _stripeBillingClient.CreateSubscriptionAsync(create); - } - - if (cardUpdated) - organization.CardLast4 = model.Last4; - - if (organization.SubscribeDate is null || organization.SubscribeDate == DateTime.MinValue) - organization.SubscribeDate = _timeProvider.GetUtcNow().UtcDateTime; - - organization.BillingStatus = BillingStatus.Active; - organization.RemoveSuspension(); - } - - _billingManager.ApplyBillingPlan(organization, plan, CurrentUser); - await _repository.SaveAsync(organization, o => o.Cache().Originals()); - await _messagePublisher.PublishAsync(new PlanChanged { OrganizationId = organization.Id }); - } - catch (StripeException ex) - { - _logger.LogCritical(ex, "Error occurred update billing plan: {Message}", ex.Message); - return Ok(ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again or contact support.")); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "An unexpected error occurred while trying to update your billing plan: {Message}", ex.Message); - return Ok(ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again.")); - } - - return Ok(new ChangePlanResult { Success = true }); - } - - /// - /// Add user - /// - /// The identifier of the organization. - /// The email address of the user you wish to add to your organization. - /// The organization was not found. - /// Please upgrade your plan to add an additional user. - [HttpPost] - [Route("{id:objectid}/users/{email:minlength(1)}")] - public async Task> AddUserAsync(string id, string email) - { - if (String.IsNullOrEmpty(id) || !CanAccessOrganization(id) || String.IsNullOrEmpty(email)) - return NotFound(); - - var organization = await GetModelAsync(id); - if (organization is null) - return NotFound(); - - if (!await _billingManager.CanAddUserAsync(organization)) - return PlanLimitReached("Please upgrade your plan to add an additional user."); - - var user = await _userRepository.GetByEmailAddressAsync(email); - if (user is not null) - { - if (!user.OrganizationIds.Contains(organization.Id)) - { - user.OrganizationIds.Add(organization.Id); - await _userRepository.SaveAsync(user, o => o.Cache()); - await _messagePublisher.PublishAsync(new UserMembershipChanged - { - ChangeType = ChangeType.Added, - UserId = user.Id, - OrganizationId = organization.Id - }); - } - - await _mailer.SendOrganizationAddedAsync(CurrentUser, organization, user); - } - else - { - var invite = organization.Invites.FirstOrDefault(i => String.Equals(i.EmailAddress, email, StringComparison.OrdinalIgnoreCase)); - if (invite is null) - { - invite = new Invite - { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = _timeProvider.GetUtcNow().UtcDateTime - }; - organization.Invites.Add(invite); - await _repository.SaveAsync(organization, o => o.Cache()); - } - - await _mailer.SendOrganizationInviteAsync(CurrentUser, organization, invite); - } - - return Ok(new User { EmailAddress = email }); - } - - /// - /// Remove user - /// - /// The identifier of the organization. - /// The email address of the user you wish to remove from your organization. - /// The error occurred while removing the user from your organization - /// The organization was not found. - [HttpDelete] - [Route("{id:objectid}/users/{email:minlength(1)}")] - public async Task RemoveUserAsync(string id, string email) - { - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - var user = await _userRepository.GetByEmailAddressAsync(email); - if (user is null || !user.OrganizationIds.Contains(id)) - { - var invite = organization.Invites.FirstOrDefault(i => String.Equals(i.EmailAddress, email, StringComparison.OrdinalIgnoreCase)); - if (invite is null) - return Ok(); - - organization.Invites.Remove(invite); - await _repository.SaveAsync(organization, o => o.Cache()); - } - else - { - if (!user.OrganizationIds.Contains(organization.Id)) - return BadRequest(); - - var organizationUsers = await _userRepository.GetByOrganizationIdAsync(organization.Id); - if (organizationUsers.Total is 1) - return BadRequest("An organization must contain at least one user."); - - await _organizationService.CleanupProjectNotificationSettingsAsync(organization, [user.Id]); - await _organizationService.RemoveUserSavedViewsAsync(organization.Id, user.Id); - - user.OrganizationIds.Remove(organization.Id); - await _userRepository.SaveAsync(user, o => o.Cache()); - await _messagePublisher.PublishAsync(new UserMembershipChanged - { - ChangeType = ChangeType.Removed, - UserId = user.Id, - OrganizationId = organization.Id - }); - } - - return Ok(); - } - - [HttpPost] - [Route("{id:objectid}/suspend")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task SuspendAsync(string id, SuspensionCode code, string? notes = null) - { - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - organization.IsSuspended = true; - organization.SuspensionDate = _timeProvider.GetUtcNow().UtcDateTime; - organization.SuspendedByUserId = CurrentUser.Id; - organization.SuspensionCode = code; - organization.SuspensionNotes = notes; - await _repository.SaveAsync(organization, o => o.Cache().Originals()); - - return Ok(); - } - - [HttpDelete] - [Route("{id:objectid}/suspend")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task UnsuspendAsync(string id) - { - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - organization.IsSuspended = false; - organization.SuspensionDate = null; - organization.SuspendedByUserId = null; - organization.SuspensionCode = null; - organization.SuspensionNotes = null; - await _repository.SaveAsync(organization, o => o.Cache().Originals()); - - return Ok(); - } - - /// - /// Add custom data - /// - /// The identifier of the organization. - /// The key name of the data object. - /// Any string value. - /// The organization was not found. - [HttpPost] - [Consumes("application/json")] - [Route("{id:objectid}/data/{key:minlength(1)}")] - public async Task PostDataAsync(string id, string key, ValueFromBody value) - { - if (String.IsNullOrWhiteSpace(key) || String.IsNullOrWhiteSpace(value?.Value) || key.StartsWith('-')) - return BadRequest(); - - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - organization.Data ??= new DataDictionary(); - organization.Data[key.Trim()] = value.Value.Trim(); - await _repository.SaveAsync(organization, o => o.Cache()); - - return Ok(); - } - - /// - /// Remove custom data - /// - /// The identifier of the organization. - /// The key name of the data object. - /// The organization was not found. - [HttpDelete] - [Route("{id:objectid}/data/{key:minlength(1)}")] - public async Task DeleteDataAsync(string id, string key) - { - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - if (organization.Data is not null && organization.Data.Remove(key)) - await _repository.SaveAsync(organization, o => o.Cache()); - - return Ok(); - } - - /// - /// Enable a feature flag - /// - /// The identifier of the organization. - /// The feature flag identifier. - /// The feature flag was enabled. - /// The organization was not found. - [HttpPost] - [Route("{id:objectid}/features/{feature:minlength(1)}")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task SetFeatureAsync(string id, string feature) - { - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - var normalizedFeature = feature.Trim().ToLowerInvariant(); - if (String.IsNullOrEmpty(normalizedFeature)) - return BadRequest("Invalid feature flag."); - - organization.Features.Add(normalizedFeature); - await _repository.SaveAsync(organization, o => o.Cache()); - - return Ok(); - } - - /// - /// Disable a feature flag - /// - /// The identifier of the organization. - /// The feature flag identifier. - /// The feature flag was disabled. - /// The organization was not found. - [HttpDelete] - [Route("{id:objectid}/features/{feature:minlength(1)}")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task RemoveFeatureAsync(string id, string feature) - { - var organization = await GetModelAsync(id, false); - if (organization is null) - return NotFound(); - - var normalizedFeature = feature.Trim().ToLowerInvariant(); - if (String.IsNullOrEmpty(normalizedFeature)) - return BadRequest("Invalid feature flag."); - - if (organization.Features.Remove(normalizedFeature)) - await _repository.SaveAsync(organization, o => o.Cache()); - - return Ok(); - } - - /// - /// Check for unique name - /// - /// The organization name to check. - /// The organization name is available. - /// The organization name is not available. - [HttpGet] - [Route("check-name")] - public async Task IsNameAvailableAsync(string name) - { - if (await IsOrganizationNameAvailableInternalAsync(name)) - return StatusCode(StatusCodes.Status204NoContent); - - return StatusCode(StatusCodes.Status201Created); - } - - private async Task IsOrganizationNameAvailableInternalAsync(string name) - { - if (String.IsNullOrWhiteSpace(name)) - return false; - - string decodedName = Uri.UnescapeDataString(name).Trim().ToLowerInvariant(); - var results = await _repository.GetByIdsAsync(GetAssociatedOrganizationIds().ToArray(), o => o.Cache()); - return !results.Any(o => String.Equals(o.Name.Trim().ToLowerInvariant(), decodedName, StringComparison.OrdinalIgnoreCase)); - } - - protected override async Task CanAddAsync(Organization value) - { - if (String.IsNullOrEmpty(value.Name)) - return PermissionResult.DenyWithMessage("Organization name is required."); - - if (!await IsOrganizationNameAvailableInternalAsync(value.Name)) - return PermissionResult.DenyWithMessage("A organization with this name already exists."); - - if (!await _billingManager.CanAddOrganizationAsync(CurrentUser)) - return PermissionResult.DenyWithPlanLimitReached("Please upgrade your plan to add an additional organization."); - - return await base.CanAddAsync(value); - } - - protected override async Task AddModelAsync(Organization value) - { - var user = CurrentUser; - var plan = !_options.StripeOptions.EnableBilling || user.Roles.Contains(AuthorizationRoles.GlobalAdmin) - ? _plans.UnlimitedPlan - : _plans.FreePlan; - _billingManager.ApplyBillingPlan(value, plan, user); - - var organization = await base.AddModelAsync(value); - - user.OrganizationIds.Add(organization.Id); - await _userRepository.SaveAsync(user, o => o.Cache()); - await _messagePublisher.PublishAsync(new UserMembershipChanged - { - UserId = user.Id, - OrganizationId = organization.Id, - ChangeType = ChangeType.Added - }); - - return organization; - } - - protected override async Task CanUpdateAsync(Organization original, Delta changes) - { - var changed = changes.GetEntity(); - if (!await IsOrganizationNameAvailableInternalAsync(changed.Name)) - return PermissionResult.DenyWithMessage("A organization with this name already exists."); - - return await base.CanUpdateAsync(original, changes); - } - - protected override async Task CanDeleteAsync(Organization value) - { - if (!String.IsNullOrEmpty(value.StripeCustomerId) && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) - return PermissionResult.DenyWithMessage("An organization cannot be deleted if it has a subscription.", value.Id); - - var organizationProjects = await _projectRepository.GetByOrganizationIdAsync(value.Id); - var projects = organizationProjects.Documents.ToList(); - if (!User.IsInRole(AuthorizationRoles.GlobalAdmin) && projects.Count > 0) - return PermissionResult.DenyWithMessage("An organization cannot be deleted if it contains any projects.", value.Id); - - return await base.CanDeleteAsync(value); - } - - protected override async Task AfterResultMapAsync(ICollection models) - { - await base.AfterResultMapAsync(models); - - var viewOrganizations = models.OfType().ToList(); - foreach (var viewOrganization in viewOrganizations) - { - viewOrganization.IconUrl = GetOrganizationIconUrl(viewOrganization.Id, viewOrganization.IconUrl); - - var realTimeUsage = await _usageService.GetUsageAsync(viewOrganization.Id); - - // ensure 12 months of usage - viewOrganization.EnsureUsage(_timeProvider); - viewOrganization.TrimUsage(_timeProvider); - - var currentUsage = viewOrganization.GetCurrentUsage(_timeProvider); - currentUsage.Limit = realTimeUsage.CurrentUsage.Limit; - currentUsage.Total = realTimeUsage.CurrentUsage.Total; - currentUsage.Blocked = realTimeUsage.CurrentUsage.Blocked; - currentUsage.Discarded = realTimeUsage.CurrentUsage.Discarded; - currentUsage.TooBig = realTimeUsage.CurrentUsage.TooBig; - currentUsage.Deleted = realTimeUsage.CurrentUsage.Deleted; - - var currentHourUsage = viewOrganization.GetCurrentHourlyUsage(_timeProvider); - currentHourUsage.Total = realTimeUsage.CurrentHourUsage.Total; - currentHourUsage.Blocked = realTimeUsage.CurrentHourUsage.Blocked; - currentHourUsage.Discarded = realTimeUsage.CurrentHourUsage.Discarded; - currentHourUsage.TooBig = realTimeUsage.CurrentHourUsage.TooBig; - currentHourUsage.Deleted = realTimeUsage.CurrentHourUsage.Deleted; - - viewOrganization.IsThrottled = realTimeUsage.IsThrottled; - viewOrganization.IsOverRequestLimit = await OrganizationExtensions.IsOverRequestLimitAsync(viewOrganization.Id, _cacheClient, _options.ApiThrottleLimit, _timeProvider); - } - } - - private async Task PopulateOrganizationStatsAsync(ViewOrganization organization) - { - return (await PopulateOrganizationStatsAsync([organization])).Single(); - } - - private async Task> PopulateOrganizationStatsAsync(List viewOrganizations) - { - if (viewOrganizations.Count <= 0) - return viewOrganizations; - - int maximumRetentionDays = _options.MaximumRetentionDays; - var organizations = viewOrganizations.Select(o => new Organization { Id = o.Id, CreatedUtc = o.CreatedUtc, RetentionDays = o.RetentionDays }).ToList(); - var sf = new AppFilter(organizations); - var systemFilter = new RepositoryQuery().AppFilter(sf).DateRange(organizations.GetRetentionUtcCutoff(maximumRetentionDays, _timeProvider), _timeProvider.GetUtcNow().UtcDateTime, (PersistentEvent e) => e.Date).Index(organizations.GetRetentionUtcCutoff(maximumRetentionDays, _timeProvider), _timeProvider.GetUtcNow().UtcDateTime); - var result = await _eventRepository.CountAsync(q => q - .SystemFilter(systemFilter) - .AggregationsExpression($"terms:(organization_id~{viewOrganizations.Count} cardinality:stack_id)") - .EnforceEventStackFilter(false)); - - foreach (var organization in viewOrganizations) - { - var organizationStats = result.Aggregations.Terms("terms_organization_id")?.Buckets.FirstOrDefault(t => t.Key == organization.Id); - organization.EventCount = organizationStats?.Total ?? 0; - organization.StackCount = (long?)organizationStats?.Aggregations.Cardinality("cardinality_stack_id")?.Value ?? 0; - organization.ProjectCount = await _projectRepository.GetCountByOrganizationIdAsync(organization.Id); - } - - return viewOrganizations; - } - - private string? GetOrganizationIconUrl(string id, string? fileName) - { - if (String.IsNullOrWhiteSpace(fileName)) - return null; - - return Url.RouteUrl("GetOrganizationIcon", new { id, fileName }) ?? $"/api/v2/organizations/{id}/icon/{fileName}"; - } -} diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs deleted file mode 100644 index 9a9bc65c1e..0000000000 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ /dev/null @@ -1,868 +0,0 @@ -using Exceptionless.Core; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Billing; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Exceptionless.Core.Models.WorkItems; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Core.Repositories; -using Exceptionless.Core.Repositories.Queries; -using Exceptionless.Core.Services; -using Exceptionless.Core.Utility; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Mapping; -using Exceptionless.Web.Models; -using Exceptionless.Web.Utility; -using Foundatio.Jobs; -using Foundatio.Queues; -using Foundatio.Repositories; -using Foundatio.Repositories.Models; -using Foundatio.Serializer; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using DataDictionary = Exceptionless.Core.Models.DataDictionary; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX + "/projects")] -[Authorize(Policy = AuthorizationRoles.ClientPolicy)] -public class ProjectController : RepositoryApiController -{ - private readonly IOrganizationRepository _organizationRepository; - private readonly IProjectRepository _projectRepository; - private readonly IStackRepository _stackRepository; - private readonly IEventRepository _eventRepository; - private readonly ITokenRepository _tokenRepository; - private readonly IQueue _workItemQueue; - private readonly BillingManager _billingManager; - private readonly SlackService _slackService; - private readonly ITextSerializer _serializer; - private readonly AppOptions _options; - private readonly UsageService _usageService; - private readonly SampleDataService _sampleDataService; - - public ProjectController( - IOrganizationRepository organizationRepository, - IProjectRepository projectRepository, - IStackRepository stackRepository, - IEventRepository eventRepository, - ITokenRepository tokenRepository, - IQueue workItemQueue, - BillingManager billingManager, - SlackService slackService, - SampleDataService sampleDataService, - ApiMapper mapper, - IAppQueryValidator validator, - ITextSerializer serializer, - AppOptions options, - UsageService usageService, - TimeProvider timeProvider, - ILoggerFactory loggerFactory - ) : base(projectRepository, mapper, validator, timeProvider, loggerFactory) - { - _organizationRepository = organizationRepository; - _projectRepository = projectRepository; - _stackRepository = stackRepository; - _eventRepository = eventRepository; - _tokenRepository = tokenRepository; - _workItemQueue = workItemQueue; - _billingManager = billingManager; - _slackService = slackService; - _serializer = serializer; - _sampleDataService = sampleDataService; - _options = options; - _usageService = usageService; - } - - // Mapping implementations - protected override Project MapToModel(NewProject newModel) => _mapper.MapToProject(newModel); - protected override ViewProject MapToViewModel(Project model) => _mapper.MapToViewProject(model); - protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewProjects(models); - - /// - /// Get all - /// - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned. - [HttpGet] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetAllAsync(string? filter = null, string? sort = null, int page = 1, int limit = 10, string? mode = null) - { - var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); - if (organizations.Count == 0) - return Ok(EmptyModels); - - page = GetPage(page); - limit = GetLimit(limit, 1000); - - var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - var projects = await _repository.GetByFilterAsync(sf, filter, sort, o => o.PageNumber(page).PageLimit(limit)); - var viewProjects = MapToViewModels(projects.Documents); - await AfterResultMapAsync(viewProjects); - - if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return OkWithResourceLinks(await PopulateProjectStatsAsync(viewProjects), projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); - - return OkWithResourceLinks(viewProjects, projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); - } - - /// - /// Get all - /// - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date. - /// The identifier of the organization. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned. - /// The organization could not be found. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/projects")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByOrganizationAsync(string organizationId, string? filter = null, string? sort = null, int page = 1, int limit = 10, string? mode = null) - { - var organization = await GetOrganizationAsync(organizationId); - if (organization is null) - return NotFound(); - - page = GetPage(page); - limit = GetLimit(limit, 1000); - var sf = new AppFilter(organization); - var projects = await _repository.GetByFilterAsync(sf, filter, sort, o => o.PageNumber(page).PageLimit(limit)); - var viewProjects = MapToViewModels(projects.Documents); - await AfterResultMapAsync(viewProjects); - - if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return OkWithResourceLinks(await PopulateProjectStatsAsync(viewProjects), projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); - - return OkWithResourceLinks(viewProjects, projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); - } - - /// - /// Get by id - /// - /// The identifier of the project. - /// If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned. - /// The project could not be found. - [HttpGet("{id:objectid}", Name = "GetProjectById")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task> GetAsync(string id, string? mode = null) - { - var project = await GetModelAsync(id); - if (project is null) - return NotFound(); - - var viewProject = MapToViewModel(project); - await AfterResultMapAsync([viewProject]); - - if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return Ok(await PopulateProjectStatsAsync(viewProject)); - - return Ok(viewProject); - } - - /// - /// Create - /// - /// The project. - /// An error occurred while creating the project. - /// The project already exists. - [HttpPost] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status201Created)] - public Task> PostAsync(NewProject project) - { - return PostImplAsync(project); - } - - /// - /// Update - /// - /// The identifier of the project. - /// The changes - /// An error occurred while updating the project. - /// The project could not be found. - [HttpPatch("{id:objectid}")] - [HttpPut("{id:objectid}")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public Task> PatchAsync(string id, Delta changes) - { - return PatchImplAsync(id, changes); - } - - /// - /// Remove - /// - /// A comma-delimited list of project identifiers. - /// Accepted - /// One or more validation errors occurred. - /// One or more projects were not found. - /// An error occurred while deleting one or more projects. - [HttpDelete("{ids:objectids}")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task> DeleteAsync(string ids) - { - return DeleteImplAsync(ids.FromDelimitedString()); - } - - protected override async Task> DeleteModelsAsync(ICollection projects) - { - var user = CurrentUser; - foreach (var project in projects) - { - using var _ = _logger.BeginScope(new ExceptionlessState().Organization(project.OrganizationId).Project(project.Id).Tag("Delete").Identity(user.EmailAddress).Property("User", user).SetHttpContext(HttpContext)); - _logger.UserDeletingProject(user.Id, project.Name); - - await _tokenRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); - } - - return await base.DeleteModelsAsync(projects); - } - - [Obsolete("Use /api/v2/projects/config instead")] - [HttpGet("~/api/v1/project/config")] - public Task> GetV1ConfigAsync(int? v = null) - { - return GetConfigAsync(null, v); - } - - /// - /// Get configuration settings - /// - /// The client configuration version. - /// The client configuration version is the current version. - /// The project could not be found. - [HttpGet("config")] - public Task> GetV2ConfigAsync(int? v = null) - { - return GetConfigAsync(null, v); - } - - /// - /// Get configuration settings - /// - /// The identifier of the project. - /// The client configuration version. - /// The client configuration version is the current version. - /// The project could not be found. - [HttpGet("{id:objectid}/config")] - public async Task> GetConfigAsync(string? id = null, int? v = null) - { - if (String.IsNullOrEmpty(id)) - id = User.GetProjectId(); - - var project = await _repository.GetConfigAsync(id); - if (project is null) - return NotFound(); - - if (!CanAccessOrganization(project.OrganizationId)) - return NotFound(); - - if (v.HasValue && v == project.Configuration.Version) - return StatusCode(StatusCodes.Status304NotModified); - - return Ok(project.Configuration); - } - - /// - /// Add configuration value - /// - /// The identifier of the project. - /// The key name of the configuration object. - /// The configuration value. - /// Invalid configuration value. - /// The project could not be found. - [HttpPost("{id:objectid}/config")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task SetConfigAsync(string id, string key, ValueFromBody value) - { - if (String.IsNullOrWhiteSpace(key) || String.IsNullOrWhiteSpace(value?.Value)) - return BadRequest(); - - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - project.Configuration.Settings[key.Trim()] = value.Value.Trim(); - project.Configuration.IncrementVersion(); - await _repository.SaveAsync(project, o => o.Cache()); - - return Ok(); - } - - /// - /// Remove configuration value - /// - /// The identifier of the project. - /// The key name of the configuration object. - /// Invalid key value. - /// The project could not be found. - [HttpDelete("{id:objectid}/config")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task DeleteConfigAsync(string id, string key) - { - if (String.IsNullOrWhiteSpace(key)) - return BadRequest(); - - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - if (project.Configuration.Settings.Remove(key.Trim())) - { - project.Configuration.IncrementVersion(); - await _repository.SaveAsync(project, o => o.Cache()); - } - - return Ok(); - } - - /// - /// Generate sample project data - /// - /// The identifier of the project. - /// Accepted - /// The project could not be found. - [HttpPost("{id:objectid}/sample-data")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public async Task> GenerateSampleDataAsync(string id) - { - var project = await GetModelAsync(id); - if (project is null) - return NotFound(); - - string workItemId = await _sampleDataService.EnqueueSampleEventsAsync(project.OrganizationId, project.Id); - return WorkInProgress([workItemId]); - } - - /// - /// Reset project data - /// - /// The identifier of the project. - /// Accepted - /// The project could not be found. - [HttpGet("{id:objectid}/reset-data")] - [HttpPost("{id:objectid}/reset-data")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public async Task> ResetDataAsync(string id) - { - var project = await GetModelAsync(id); - if (project is null) - return NotFound(); - - string workItemId = await _workItemQueue.EnqueueAsync(new ResetProjectDataWorkItem - { - OrganizationId = project.OrganizationId, - ProjectId = project.Id - }); - - return WorkInProgress([workItemId]); - } - - [HttpGet("{id:objectid}/notifications")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task>> GetNotificationSettingsAsync(string id) - { - var project = await GetModelAsync(id); - if (project is null) - return NotFound(); - - return Ok(project.NotificationSettings); - } - - /// - /// Get user notification settings - /// - /// The identifier of the project. - /// The identifier of the user. - /// The project could not be found. - [HttpGet("~/" + API_PREFIX + "/users/{userId:objectid}/projects/{id:objectid}/notifications")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task> GetNotificationSettingsAsync(string id, string userId) - { - var project = await GetModelAsync(id); - if (project is null) - return NotFound(); - - if (!Request.IsGlobalAdmin() && !String.Equals(CurrentUser.Id, userId)) - return NotFound(); - - return Ok(project.NotificationSettings.TryGetValue(userId, out var settings) ? settings : new NotificationSettings()); - } - - - /// - /// Get an integrations notification settings - /// - /// The identifier of the project. - /// The identifier of the integration. - /// The project or integration could not be found. - [ApiExplorerSettings(IgnoreApi = true)] - [HttpGet("{id:objectid}/{integration:minlength(1)}/notifications")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task> GetIntegrationNotificationSettingsAsync(string id, string integration) - { - var project = await GetModelAsync(id); - if (project is null) - return NotFound(); - - if (!String.Equals(Project.NotificationIntegrations.Slack, integration)) - return NotFound(); - - return Ok(project.NotificationSettings.TryGetValue(Project.NotificationIntegrations.Slack, out var settings) ? settings : new NotificationSettings()); - } - - /// - /// Set user notification settings - /// - /// The identifier of the project. - /// The identifier of the user. - /// The notification settings. - /// The project could not be found. - [HttpPut("~/" + API_PREFIX + "/users/{userId:objectid}/projects/{id:objectid}/notifications")] - [HttpPost("~/" + API_PREFIX + "/users/{userId:objectid}/projects/{id:objectid}/notifications")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task SetNotificationSettingsAsync(string id, string userId, NotificationSettings? settings) - { - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - if (!Request.IsGlobalAdmin() && !String.Equals(CurrentUser.Id, userId)) - return NotFound(); - - if (settings is null) - project.NotificationSettings.Remove(userId); - else - project.NotificationSettings[userId] = settings; - - await _repository.SaveAsync(project, o => o.Cache()); - return Ok(); - } - - /// - /// Set an integrations notification settings - /// - /// The identifier of the project. - /// The identifier of the integration. - /// The notification settings. - /// The project or integration could not be found. - /// Please upgrade your plan to enable integrations. - [HttpPut("{id:objectid}/{integration:minlength(1)}/notifications")] - [HttpPost("{id:objectid}/{integration:minlength(1)}/notifications")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task SetIntegrationNotificationSettingsAsync(string id, string integration, NotificationSettings? settings) - { - if (!String.Equals(Project.NotificationIntegrations.Slack, integration)) - return NotFound(); - - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - var organization = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); - if (organization is null) - return NotFound(); - - if (!organization.HasPremiumFeatures) - return PlanLimitReached($"Please upgrade your plan to enable {integration} integration."); - - if (settings is null) - project.NotificationSettings.Remove(integration); - else - project.NotificationSettings[integration] = settings; - - await _repository.SaveAsync(project, o => o.Cache()); - return Ok(); - } - - /// - /// Remove user notification settings - /// - /// The identifier of the project. - /// The identifier of the user. - /// The project could not be found. - [HttpDelete("~/" + API_PREFIX + "/users/{userId:objectid}/projects/{id:objectid}/notifications")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task DeleteNotificationSettingsAsync(string id, string userId) - { - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - if (!Request.IsGlobalAdmin() && !String.Equals(CurrentUser.Id, userId)) - return NotFound(); - - if (project.NotificationSettings.Remove(userId)) - { - await _repository.SaveAsync(project, o => o.Cache()); - } - - return Ok(); - } - - /// - /// Promote tab - /// - /// The identifier of the project. - /// The tab name. - /// Invalid tab name. - /// The project could not be found. - [HttpPut("{id:objectid}/promotedtabs")] - [HttpPost("{id:objectid}/promotedtabs")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task PromoteTabAsync(string id, string name) - { - if (String.IsNullOrWhiteSpace(name)) - return BadRequest(); - - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - string normalizedName = name.Trim(); - project.PromotedTabs ??= []; - if (!project.PromotedTabs.Contains(normalizedName, StringComparer.Ordinal)) - { - project.PromotedTabs.Add(normalizedName); - await _repository.SaveAsync(project, o => o.Cache()); - } - - return Ok(); - } - - /// - /// Demote tab - /// - /// The identifier of the project. - /// The tab name. - /// Invalid tab name. - /// The project could not be found. - [HttpDelete("{id:objectid}/promotedtabs")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task DemoteTabAsync(string id, string name) - { - if (String.IsNullOrWhiteSpace(name)) - return BadRequest(); - - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - if (project.PromotedTabs is not null && project.PromotedTabs.Remove(name.Trim())) - await _repository.SaveAsync(project, o => o.Cache()); - - return Ok(); - } - - /// - /// Check for unique name - /// - /// The project name to check. - /// If set the check name will be scoped to a specific organization. - /// The project name is available. - /// The project name is not available. - [HttpGet("check-name")] - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/projects/check-name")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status201Created)] - public async Task IsNameAvailableAsync(string name, string? organizationId = null) - { - if (await IsProjectNameAvailableInternalAsync(organizationId, name)) - return StatusCode(StatusCodes.Status204NoContent); - - return StatusCode(StatusCodes.Status201Created); - } - - private async Task IsProjectNameAvailableInternalAsync(string? organizationId, string name) - { - if (String.IsNullOrWhiteSpace(name)) - return false; - - var organizationIds = IsInOrganization(organizationId) ? [organizationId] : GetAssociatedOrganizationIds(); - var projects = await _repository.GetByOrganizationIdsAsync(organizationIds); - - string decodedName = Uri.UnescapeDataString(name).Trim().ToLowerInvariant(); - return !projects.Documents.Any(p => String.Equals(p.Name.Trim().ToLowerInvariant(), decodedName, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Add custom data - /// - /// The identifier of the project. - /// The key name of the data object. - /// Any string value. - /// Invalid key or value. - /// The project could not be found. - [HttpPost("{id:objectid}/data")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task PostDataAsync(string id, string key, ValueFromBody value) - { - if (String.IsNullOrWhiteSpace(key) || String.IsNullOrWhiteSpace(value?.Value) || key.StartsWith('-')) - return BadRequest(); - - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - project.Data ??= new DataDictionary(); - project.Data[key.Trim()] = value.Value.Trim(); - await _repository.SaveAsync(project, o => o.Cache()); - - return Ok(); - } - - /// - /// Remove custom data - /// - /// The identifier of the project. - /// The key name of the data object. - /// Invalid key or value. - /// The project could not be found. - [HttpDelete("{id:objectid}/data")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task DeleteDataAsync(string id, string key) - { - if (String.IsNullOrWhiteSpace(key) || key.StartsWith('-')) - return BadRequest(); - - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - if (project.Data is not null && project.Data.Remove(key.Trim())) - await _repository.SaveAsync(project, o => o.Cache()); - - return Ok(); - } - - /// - /// Adds slack integration to the project - /// - /// The identifier of the project. - /// The oauth code that must be exchanged for an auth token.D - /// Invalid code or error contacting slack. - /// The project could not be found. - [ApiExplorerSettings(IgnoreApi = true)] - [HttpPost("{id:objectid}/slack")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task AddSlackAsync(string id, string code) - { - if (String.IsNullOrWhiteSpace(code)) - return BadRequest(); - - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - using var _ = _logger.BeginScope(new ExceptionlessState().Organization(project.OrganizationId).Project(project.Id).Property("Code", code).Tag("Slack").Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)); - - if (project.Data is not null && project.Data.ContainsKey(Project.KnownDataKeys.SlackToken)) - return StatusCode(StatusCodes.Status304NotModified); - - SlackToken? token; - try - { - token = await _slackService.GetAccessTokenAsync(code); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting slack access token: {Message}", ex.Message); - throw; - } - - project.AddDefaultNotificationSettings(Project.NotificationIntegrations.Slack); - - project.Data ??= new DataDictionary(); - project.Data[Project.KnownDataKeys.SlackToken] = token; - await _repository.SaveAsync(project, o => o.Cache()); - - return Ok(); - } - - /// - /// Remove custom data - /// - /// The identifier of the project. - /// The project could not be found. - [HttpDelete("{id:objectid}/slack")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task RemoveSlackAsync(string id) - { - var project = await GetModelAsync(id, false); - if (project is null) - return NotFound(); - - var token = project.GetSlackToken(_serializer, _logger); - using var _ = _logger.BeginScope(new ExceptionlessState().Property("Token", token).Tag("Slack").Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)); - - if (token is not null) - { - await _slackService.RevokeAccessTokenAsync(token.AccessToken); - } - - bool shouldSave = project.NotificationSettings.Remove(Project.NotificationIntegrations.Slack); - if (project.Data is not null && project.Data.Remove(Project.KnownDataKeys.SlackToken)) - shouldSave = true; - - if (shouldSave) - await _repository.SaveAsync(project, o => o.Cache()); - - return Ok(); - } - - protected override async Task AfterResultMapAsync(ICollection models) - { - await base.AfterResultMapAsync(models); - - // TODO: We can optimize this by normalizing the project model to include the organization name. - var viewProjects = models.OfType().ToList(); - var organizations = await _organizationRepository.GetByIdsAsync(viewProjects.Select(p => p.OrganizationId).ToArray(), o => o.Cache()); - foreach (var viewProject in viewProjects) - { - if (!viewProject.IsConfigured.HasValue) - { - viewProject.IsConfigured = true; - await _workItemQueue.EnqueueAsync(new SetProjectIsConfiguredWorkItem - { - ProjectId = viewProject.Id - }); - } - - var organization = organizations.SingleOrDefault(o => o.Id == viewProject.OrganizationId); - if (organization is null) - continue; - - viewProject.OrganizationName = organization.Name; - viewProject.HasPremiumFeatures = organization.HasPremiumFeatures; - - var realTimeUsage = await _usageService.GetUsageAsync(organization.Id, viewProject.Id); - viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), _timeProvider); - viewProject.TrimUsage(_timeProvider); - - var currentUsage = viewProject.GetCurrentUsage(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), _timeProvider); - currentUsage.Limit = realTimeUsage.CurrentUsage.Limit; - currentUsage.Total = realTimeUsage.CurrentUsage.Total; - currentUsage.Blocked = realTimeUsage.CurrentUsage.Blocked; - currentUsage.Discarded = realTimeUsage.CurrentUsage.Discarded; - currentUsage.TooBig = realTimeUsage.CurrentUsage.TooBig; - currentUsage.Deleted = realTimeUsage.CurrentUsage.Deleted; - - var currentHourUsage = viewProject.GetCurrentHourlyUsage(_timeProvider); - currentHourUsage.Total = realTimeUsage.CurrentHourUsage.Total; - currentHourUsage.Blocked = realTimeUsage.CurrentHourUsage.Blocked; - currentHourUsage.Discarded = realTimeUsage.CurrentHourUsage.Discarded; - currentHourUsage.TooBig = realTimeUsage.CurrentHourUsage.TooBig; - currentHourUsage.Deleted = realTimeUsage.CurrentHourUsage.Deleted; - } - } - - protected override async Task CanAddAsync(Project value) - { - if (String.IsNullOrEmpty(value.Name)) - return PermissionResult.DenyWithMessage("Project name is required."); - - if (!await IsProjectNameAvailableInternalAsync(value.OrganizationId, value.Name)) - return PermissionResult.DenyWithMessage("A project with this name already exists."); - - if (!await _billingManager.CanAddProjectAsync(value)) - return PermissionResult.DenyWithPlanLimitReached("Please upgrade your plan to add additional projects."); - - return await base.CanAddAsync(value); - } - - protected override Task AddModelAsync(Project value) - { - value.PromotedTabs = NormalizePromotedTabs(value.PromotedTabs); - value.IsConfigured = false; - value.NextSummaryEndOfDayTicks = _timeProvider.GetUtcNow().UtcDateTime.Date.AddDays(1).AddHours(1).Ticks; - value.AddDefaultNotificationSettings(CurrentUser.Id); - value.SetDefaultUserAgentBotPatterns(); - value.Configuration.IncrementVersion(); - - return base.AddModelAsync(value); - } - - protected override async Task CanUpdateAsync(Project original, Delta changes) - { - var changed = changes.GetEntity(); - if (changes.ContainsChangedProperty(p => p.Name) && !await IsProjectNameAvailableInternalAsync(original.OrganizationId, changed.Name)) - return PermissionResult.DenyWithMessage("A project with this name already exists."); - - return await base.CanUpdateAsync(original, changes); - } - - protected override Task UpdateModelAsync(Project original, Delta changes) - { - changes.Patch(original); - - if (changes.ContainsChangedProperty(p => p.PromotedTabs!)) - original.PromotedTabs = NormalizePromotedTabs(original.PromotedTabs); - - return _repository.SaveAsync(original, o => o.Cache()); - } - - private static List NormalizePromotedTabs(IEnumerable? promotedTabs) - { - if (promotedTabs is null) - return []; - - return promotedTabs - .Where(tab => !String.IsNullOrWhiteSpace(tab)) - .Select(tab => tab.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(); - } - - private Task GetOrganizationAsync(string organizationId, bool useCache = true) - { - if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) - return Task.FromResult(null); - - return _organizationRepository.GetByIdAsync(organizationId, o => o.Cache(useCache)); - } - - private async Task PopulateProjectStatsAsync(ViewProject project) - { - return (await PopulateProjectStatsAsync([project])).Single(); - } - - private async Task> PopulateProjectStatsAsync(List viewProjects) - { - if (viewProjects.Count <= 0) - return viewProjects; - - int maximumRetentionDays = _options.MaximumRetentionDays; - var organizations = await _organizationRepository.GetByIdsAsync(viewProjects.Select(p => p.OrganizationId).ToArray(), o => o.Cache()); - var projects = viewProjects.Select(p => new Project { Id = p.Id, CreatedUtc = p.CreatedUtc, OrganizationId = p.OrganizationId }).ToList(); - var sf = new AppFilter(projects, organizations); - var systemFilter = new RepositoryQuery().AppFilter(sf).DateRange(organizations.GetRetentionUtcCutoff(maximumRetentionDays, _timeProvider), _timeProvider.GetUtcNow().UtcDateTime, (PersistentEvent e) => e.Date).Index(organizations.GetRetentionUtcCutoff(maximumRetentionDays, _timeProvider), _timeProvider.GetUtcNow().UtcDateTime); - var result = await _eventRepository.CountAsync(q => q - .SystemFilter(systemFilter) - .AggregationsExpression($"terms:(project_id~{viewProjects.Count} cardinality:stack_id)") - .EnforceEventStackFilter(false)); - foreach (var project in viewProjects) - { - var term = result.Aggregations.Terms("terms_project_id")?.Buckets.FirstOrDefault(t => t.Key == project.Id); - project.EventCount = term?.Total ?? 0; - project.StackCount = (long)(term?.Aggregations.Cardinality("cardinality_stack_id")?.Value ?? 0); - } - - return viewProjects; - } -} diff --git a/src/Exceptionless.Web/Controllers/StackController.cs b/src/Exceptionless.Web/Controllers/StackController.cs deleted file mode 100644 index eea8603e3e..0000000000 --- a/src/Exceptionless.Web/Controllers/StackController.cs +++ /dev/null @@ -1,673 +0,0 @@ -using System.Text.Json; -using Exceptionless.Core; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Exceptionless.Core.Plugins.Formatting; -using Exceptionless.Core.Plugins.WebHook; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Core.Queues.Models; -using Exceptionless.Core.Repositories; -using Exceptionless.Core.Repositories.Configuration; -using Exceptionless.Core.Repositories.Queries; -using Exceptionless.Core.Utility; -using Exceptionless.DateTimeExtensions; -using Exceptionless.Web.Mapping; -using Exceptionless.Web.Models; -using Foundatio.Caching; -using Foundatio.Queues; -using Foundatio.Repositories; -using Foundatio.Repositories.Extensions; -using Foundatio.Repositories.Models; -using McSherry.SemanticVersioning; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX + "/stacks")] -[Authorize(Policy = AuthorizationRoles.ClientPolicy)] -public class StackController : RepositoryApiController -{ - private readonly IOrganizationRepository _organizationRepository; - private readonly IProjectRepository _projectRepository; - private readonly IStackRepository _stackRepository; - private readonly IEventRepository _eventRepository; - private readonly IWebHookRepository _webHookRepository; - private readonly SemanticVersionParser _semanticVersionParser; - private readonly WebHookDataPluginManager _webHookDataPluginManager; - private readonly ICacheClient _cache; - private readonly IQueue _webHookNotificationQueue; - private readonly FormattingPluginManager _formattingPluginManager; - private readonly AppOptions _options; - - public StackController( - IStackRepository stackRepository, - IOrganizationRepository organizationRepository, - IProjectRepository projectRepository, - IEventRepository eventRepository, - IWebHookRepository webHookRepository, - WebHookDataPluginManager webHookDataPluginManager, - IQueue webHookNotificationQueue, - ICacheClient cacheClient, - FormattingPluginManager formattingPluginManager, - SemanticVersionParser semanticVersionParser, - ApiMapper mapper, - StackQueryValidator validator, - AppOptions options, - TimeProvider timeProvider, - ILoggerFactory loggerFactory - ) : base(stackRepository, mapper, validator, timeProvider, loggerFactory) - { - _stackRepository = stackRepository; - _organizationRepository = organizationRepository; - _projectRepository = projectRepository; - _eventRepository = eventRepository; - _webHookRepository = webHookRepository; - _webHookDataPluginManager = webHookDataPluginManager; - _webHookNotificationQueue = webHookNotificationQueue; - _cache = cacheClient; - _formattingPluginManager = formattingPluginManager; - _semanticVersionParser = semanticVersionParser; - _options = options; - - AllowedDateFields.AddRange([StackIndex.Alias.FirstOccurrence, StackIndex.Alias.LastOccurrence]); - DefaultDateField = StackIndex.Alias.LastOccurrence; - } - - // Mapping implementations - Stack uses itself as view model (no mapping needed) - protected override Stack MapToModel(Stack newModel) => newModel; - protected override Stack MapToViewModel(Stack model) => model; - protected override List MapToViewModels(IEnumerable models) => models.ToList(); - - /// - /// Get by id - /// - /// The identifier of the stack. - /// The time offset in minutes that controls what data is returned based on the `time` filter. This is used for time zone support. - /// The stack could not be found. - [HttpGet("{id:objectid}", Name = "GetStackById")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task> GetAsync(string id, string? offset = null) - { - var stack = await GetModelAsync(id); - if (stack is null) - return NotFound(); - - return Ok(stack.ApplyOffset(GetOffset(offset))); - } - - /// - /// Mark fixed - /// - /// A comma-delimited list of stack identifiers. - /// A version number that the stack was fixed in. - /// The stacks were marked as fixed. - /// One or more stacks could not be found. - [HttpPost("{ids:objectids}/mark-fixed")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task MarkFixedAsync(string ids, string? version = null) - { - SemanticVersion? semanticVersion = null; - - if (!String.IsNullOrEmpty(version)) - { - semanticVersion = _semanticVersionParser.Parse(version); - if (semanticVersion is null) - return BadRequest("Invalid semantic version"); - } - - var stacks = await GetModelsAsync(ids.FromDelimitedString(), false); - if (stacks.Count is 0) - return NotFound(); - - foreach (var stack in stacks) - stack.MarkFixed(semanticVersion, _timeProvider); - - await _stackRepository.SaveAsync(stacks); - - return Ok(); - } - - /// - /// This controller action is called by zapier to mark the stack as fixed. - /// - [HttpPost("~/api/v1/stack/markfixed")] - [HttpPost("mark-fixed")] - [Consumes("application/json")] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task MarkFixedAsync(JsonDocument data) - { - string? id = null; - if (data.RootElement.TryGetProperty("ErrorStack", out var errorStackProp)) - id = errorStackProp.GetString(); - - if (data.RootElement.TryGetProperty("Stack", out var stackProp)) - id = stackProp.GetString(); - - if (String.IsNullOrEmpty(id)) - return NotFound(); - - if (id.StartsWith("http")) - id = id.Substring(id.LastIndexOf('/') + 1); - - return await MarkFixedAsync(id); - } - - /// - /// Mark the selected stacks as snoozed - /// - /// A comma-delimited list of stack identifiers. - /// A time that the stack should be snoozed until. - /// The stacks were snoozed. - /// One or more stacks could not be found. - [HttpPost("{ids:objectids}/mark-snoozed")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task SnoozeAsync(string ids, DateTime snoozeUntilUtc) - { - if (snoozeUntilUtc < _timeProvider.GetUtcNow().UtcDateTime.AddMinutes(5)) - return BadRequest("Must snooze for at least 5 minutes."); - - var stacks = await GetModelsAsync(ids.FromDelimitedString(), false); - if (stacks.Count is 0) - return NotFound(); - - foreach (var stack in stacks) - { - stack.Status = StackStatus.Snoozed; - stack.SnoozeUntilUtc = snoozeUntilUtc; - stack.FixedInVersion = null; - stack.DateFixed = null; - } - - await _stackRepository.SaveAsync(stacks); - - return Ok(); - } - - /// - /// Add reference link - /// - /// The identifier of the stack. - /// The reference link. - /// Invalid reference link. - /// The stack could not be found. - [HttpPost("{id:objectid}/add-link")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task AddLinkAsync(string id, ValueFromBody url) - { - if (String.IsNullOrWhiteSpace(url?.Value)) - return BadRequest(); - - var stack = await GetModelAsync(id, false); - if (stack is null) - return NotFound(); - - if (!stack.References.Contains(url.Value.Trim())) - { - stack.References.Add(url.Value.Trim()); - await _stackRepository.SaveAsync(stack); - } - - return Ok(); - } - - /// - /// This controller action is called by zapier to add a reference link to a stack. - /// - [HttpPost("~/api/v1/stack/addlink")] - [HttpPost("add-link")] - [Consumes("application/json")] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task AddLinkAsync(JsonDocument data) - { - string? id = null; - if (data.RootElement.TryGetProperty("ErrorStack", out var errorStackProp)) - id = errorStackProp.GetString(); - - if (data.RootElement.TryGetProperty("Stack", out var stackProp)) - id = stackProp.GetString(); - - if (String.IsNullOrEmpty(id)) - return NotFound(); - - if (id.StartsWith("http")) - id = id.Substring(id.LastIndexOf('/') + 1); - - string? url = data.RootElement.TryGetProperty("Link", out var linkProp) ? linkProp.GetString() : null; - return await AddLinkAsync(id, new ValueFromBody(url)); - } - - /// - /// Remove reference link - /// - /// The identifier of the stack. - /// The reference link. - /// The reference link was removed. - /// Invalid reference link. - /// The stack could not be found. - [HttpPost("{id:objectid}/remove-link")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task RemoveLinkAsync(string id, ValueFromBody url) - { - if (String.IsNullOrWhiteSpace(url?.Value)) - return BadRequest(); - - var stack = await GetModelAsync(id, false); - if (stack is null) - return NotFound(); - - if (stack.References.Contains(url.Value.Trim())) - { - stack.References.Remove(url.Value.Trim()); - await _stackRepository.SaveAsync(stack); - } - - return StatusCode(StatusCodes.Status204NoContent); - } - - /// - /// Mark future occurrences as critical - /// - /// A comma-delimited list of stack identifiers. - /// One or more stacks could not be found. - [HttpPost("{ids:objectids}/mark-critical")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task MarkCriticalAsync(string ids) - { - var stacks = await GetModelsAsync(ids.FromDelimitedString(), false); - if (stacks.Count is 0) - return NotFound(); - - stacks = stacks.Where(s => !s.OccurrencesAreCritical).ToList(); - if (stacks.Count > 0) - { - foreach (var stack in stacks) - stack.OccurrencesAreCritical = true; - - await _stackRepository.SaveAsync(stacks); - } - - return Ok(); - } - - /// - /// Mark future occurrences as not critical - /// - /// A comma-delimited list of stack identifiers. - /// The stacks were marked as not critical. - /// One or more stacks could not be found. - [HttpDelete("{ids:objectids}/mark-critical")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task MarkNotCriticalAsync(string ids) - { - var stacks = await GetModelsAsync(ids.FromDelimitedString(), false); - if (stacks.Count is 0) - return NotFound(); - - stacks = stacks.Where(s => s.OccurrencesAreCritical).ToList(); - if (stacks.Count > 0) - { - foreach (var stack in stacks) - stack.OccurrencesAreCritical = false; - - await _stackRepository.SaveAsync(stacks); - } - - return StatusCode(StatusCodes.Status204NoContent); - } - - /// - /// Change stack status - /// - /// A comma-delimited list of stack identifiers. - /// The status that the stack should be changed to. - /// One or more stacks could not be found. - [HttpPost("{ids:objectids}/change-status")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task ChangeStatusAsync(string ids, StackStatus status) - { - if (status is StackStatus.Regressed or StackStatus.Snoozed) - return BadRequest("Can't set stack status to regressed or snoozed."); - - var stacks = await GetModelsAsync(ids.FromDelimitedString(), false); - if (stacks.Count is 0) - return NotFound(); - - stacks = stacks.Where(s => s.Status != status).ToList(); - if (stacks.Count > 0) - { - foreach (var stack in stacks) - { - stack.Status = status; - if (status == StackStatus.Fixed) - { - stack.DateFixed = _timeProvider.GetUtcNow().UtcDateTime; - } - else - { - stack.DateFixed = null; - stack.FixedInVersion = null; - } - - stack.SnoozeUntilUtc = null; - } - - await _stackRepository.SaveAsync(stacks); - } - - return Ok(); - } - - /// - /// Promote to external service - /// - /// The identifier of the stack. - /// The stack could not be found. - /// Promote to External is a premium feature used to promote an error stack to an external system. - /// No promoted web hooks are configured for this project. - [HttpPost("{id:objectid}/promote")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task PromoteAsync(string id) - { - if (String.IsNullOrEmpty(id)) - return NotFound(); - - var stack = await _stackRepository.GetByIdAsync(id); - if (stack is null || !CanAccessOrganization(stack.OrganizationId)) - return NotFound(); - - var organization = await GetOrganizationAsync(stack.OrganizationId); - if (organization is null) - return NotFound(); - - if (!organization.HasPremiumFeatures) - return PlanLimitReached("Promote to External is a premium feature used to promote an error stack to an external system. Please upgrade your plan to enable this feature."); - - var promotedProjectHooks = (await _webHookRepository.GetByProjectIdAsync(stack.ProjectId)).Documents.Where(p => p.EventTypes.Contains(WebHook.KnownEventTypes.StackPromoted)).ToList(); - if (promotedProjectHooks.Count is 0) - return NotImplemented("No promoted web hooks are configured for this project. Please add a promoted web hook to use this feature."); - - using var _ = _logger.BeginScope(new ExceptionlessState() - .Organization(stack.OrganizationId) - .Project(stack.ProjectId) - .Tag("Promote") - .Identity(CurrentUser.EmailAddress) - .Property("User", CurrentUser) - .SetHttpContext(HttpContext)); - - var project = await GetProjectAsync(stack.ProjectId); - if (project is null) - return NotFound(); - - foreach (var hook in promotedProjectHooks) - { - if (!hook.IsEnabled) - { - _logger.LogWarning("Unable to promote to disabled WebHook Id={WebHookId}, Url={WebHookUrl}", hook.Id, hook.Url); - continue; - } - - var context = new WebHookDataContext(hook, organization, project, stack, null, stack.TotalOccurrences == 1, stack.Status == StackStatus.Regressed); - object? data = await _webHookDataPluginManager.CreateFromStackAsync(context); - if (data is null) - { - _logger.LogWarning("Unable to promote to WebHook with null payload Id={WebHookId}, Url={WebHookUrl}", hook.Id, hook.Url); - continue; - } - - await _webHookNotificationQueue.EnqueueAsync(new WebHookNotification - { - OrganizationId = stack.OrganizationId, - ProjectId = stack.ProjectId, - WebHookId = hook.Id, - Url = hook.Url, - Type = WebHookType.General, - Data = data - }); - } - - return Ok(); - } - - /// - /// Remove - /// - /// A comma-delimited list of stack identifiers. - /// Accepted - /// One or more validation errors occurred. - /// One or more stacks were not found. - /// An error occurred while deleting one or more stacks. - [HttpDelete("{ids:objectids}")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task> DeleteAsync(string ids) - { - return DeleteImplAsync(ids.FromDelimitedString()); - } - - /// - /// Get all - /// - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// Invalid filter. - [HttpGet] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetAllAsync(string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int page = 1, int limit = 10) - { - var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); - if (organizations.All(o => o.IsSuspended)) - return Ok(EmptyModels); - - var ti = GetTimeInfo(time, offset, organizations.GetRetentionUtcCutoff(_options.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit); - } - - private async Task>> GetInternalAsync(AppFilter sf, TimeInfo ti, string? filter = null, string? sort = null, string? mode = null, int page = 1, int limit = 10) - { - page = GetPage(page); - limit = GetLimit(limit); - int skip = GetSkip(page, limit); - if (skip > MAXIMUM_SKIP) - return Ok(EmptyModels); - - var pr = await _validator.ValidateQueryAsync(filter); - if (!pr.IsValid) - return BadRequest(pr.Message); - - sf.UsesPremiumFeatures = pr.UsesPremiumFeatures; - - try - { - var results = await _repository.FindAsync(q => q.AppFilter(ShouldApplySystemFilter(sf, filter) ? sf : null).FilterExpression(filter).SortExpression(sort).DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field), o => o.PageNumber(page).PageLimit(limit)); - - var stacks = results.Documents.Select(s => s.ApplyOffset(ti.Offset)).ToList(); - if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "summary", StringComparison.OrdinalIgnoreCase)) - return OkWithResourceLinks(await GetStackSummariesAsync(stacks, sf, ti), results.HasMore && !NextPageExceedsSkipLimit(page, limit), page); - - return OkWithResourceLinks(stacks, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page); - } - catch (ApplicationException ex) - { - using (_logger.BeginScope(new ExceptionlessState().Property("Search Filter", new { SystemFilter = sf, UserFilter = filter, Time = ti, Page = page, Limit = limit }).Tag("Search").Identity(CurrentUser?.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext))) - _logger.LogError(ex, "An error has occurred. Please check your search filter"); - - throw; - } - } - - /// - /// Get by organization - /// - /// The identifier of the organization. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// Invalid filter. - /// The organization could not be found. - /// Unable to view stack occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/stacks")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByOrganizationAsync(string? organizationId = null, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int page = 1, int limit = 10) - { - var organization = await GetOrganizationAsync(organizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view stack occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(_options.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(organization); - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit); - } - - /// - /// Get by project - /// - /// The identifier of the project. - /// A filter that controls what data is returned from the server. - /// Controls the sort order that the data is returned in. In this example -date returns the results descending by date. - /// The time filter that limits the data being returned to a specific date range. - /// The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support. - /// If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// Invalid filter. - /// The organization could not be found. - /// Unable to view stack occurrences for the suspended organization. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/stacks")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByProjectAsync(string? projectId = null, string? filter = null, string? sort = null, string? time = null, string? offset = null, string? mode = null, int page = 1, int limit = 10) - { - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - var organization = await GetOrganizationAsync(project.OrganizationId); - if (organization is null) - return NotFound(); - - if (organization.IsSuspended) - return PlanLimitReached("Unable to view stack occurrences for the suspended organization."); - - var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(project, _options.MaximumRetentionDays, _timeProvider)); - var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit); - } - - private Task GetOrganizationAsync(string? organizationId, bool useCache = true) - { - if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) - return Task.FromResult(null); - - return _organizationRepository.GetByIdAsync(organizationId, o => o.Cache(useCache)); - } - - private async Task GetProjectAsync(string? projectId, bool useCache = true) - { - if (String.IsNullOrEmpty(projectId)) - return null; - - var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache(useCache)); - if (project is null || !CanAccessOrganization(project.OrganizationId)) - return null; - - return project; - } - - private async Task> GetStackSummariesAsync(ICollection stacks, AppFilter eventSystemFilter, TimeInfo ti) - { - if (stacks.Count == 0) - return new List(); - - var systemFilter = new RepositoryQuery().AppFilter(eventSystemFilter).DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, (PersistentEvent e) => e.Date).Index(ti.Range.UtcStart, ti.Range.UtcEnd); - var stackTerms = await _eventRepository.CountAsync(q => q.SystemFilter(systemFilter).Stack(stacks.Select(r => r.Id)).AggregationsExpression($"terms:(stack_id~{stacks.Count} cardinality:user sum:count~1 min:date max:date)")); - var buckets = stackTerms.Aggregations.Terms("terms_stack_id")?.Buckets ?? []; - return await GetStackSummariesAsync(stacks, buckets, eventSystemFilter, ti); - } - - private async Task> GetStackSummariesAsync(ICollection stacks, IReadOnlyCollection> stackTerms, AppFilter sf, TimeInfo ti) - { - if (stacks.Count == 0) - return new List(0); - - var totalUsers = await GetUserCountByProjectIdsAsync(stacks, sf, ti.Range.UtcStart, ti.Range.UtcEnd); - return stacks.Join(stackTerms, s => s.Id, tk => tk.Key, (stack, term) => - { - var data = _formattingPluginManager.GetStackSummaryData(stack); - var summary = new StackSummaryModel - { - Id = data.Id, - TemplateKey = data.TemplateKey, - Data = data.Data, - Title = stack.Title, - Status = stack.Status, - FirstOccurrence = term.Aggregations.Min("min_date")?.Value ?? stack.FirstOccurrence, - LastOccurrence = term.Aggregations.Max("max_date")?.Value ?? stack.LastOccurrence, - Total = (long)(term.Aggregations.Sum("sum_count")?.Value ?? term.Total.GetValueOrDefault()), - - Users = term.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0, - TotalUsers = totalUsers.GetOrDefault(stack.ProjectId) - }; - - return summary; - }).ToList(); - } - - private async Task> GetUserCountByProjectIdsAsync(ICollection stacks, AppFilter sf, DateTime utcStart, DateTime utcEnd) - { - var scopedCacheClient = new ScopedCacheClient(_cache, $"Project:user-count:{utcStart.Floor(TimeSpan.FromMinutes(15)).Ticks}-{utcEnd.Floor(TimeSpan.FromMinutes(15)).Ticks}"); - var projectIds = stacks.Select(s => s.ProjectId).Distinct().ToList(); - var cachedTotals = await scopedCacheClient.GetAllAsync(projectIds); - - var totals = cachedTotals.Where(kvp => kvp.Value.HasValue).ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value); - if (totals.Count == projectIds.Count) - return totals; - - var systemFilter = new RepositoryQuery().AppFilter(sf).DateRange(utcStart, utcEnd, (PersistentEvent e) => e.Date).Index(utcStart, utcEnd); - var projects = cachedTotals - .Where(kvp => !kvp.Value.HasValue && stacks.Contains(s => s.ProjectId == kvp.Key)) - .Select(kvp => new Project { Id = kvp.Key, OrganizationId = stacks.First(s => s.ProjectId == kvp.Key).OrganizationId }) - .ToList(); - var countResult = await _eventRepository.CountAsync(q => q.SystemFilter(systemFilter).FilterExpression(projects.BuildFilter()).AggregationsExpression("terms:(project_id cardinality:user)")); - - // Cache all projects that have more than 10 users for 5 minutes. - var projectTerms = countResult.Aggregations.Terms("terms_project_id")?.Buckets ?? []; - var aggregations = projectTerms.ToDictionary(t => t.Key, t => t.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0); - await scopedCacheClient.SetAllAsync(aggregations.Where(t => t.Value >= 10).ToDictionary(k => k.Key, v => v.Value), TimeSpan.FromMinutes(5)); - totals.AddRange(aggregations); - - return totals; - } - - protected override Task> DeleteModelsAsync(ICollection stacks) - { - var user = CurrentUser; - foreach (var projectStacks in stacks.GroupBy(ev => ev.ProjectId)) - { - var stack = projectStacks.First(); - using var _ = _logger.BeginScope(new ExceptionlessState().Organization(stack.OrganizationId).Project(stack.ProjectId).Tag("Delete").Identity(user.EmailAddress).Property("User", user).SetHttpContext(HttpContext)); - _logger.LogInformation("User {User} deleted {RemovedCount} stacks in project ({ProjectId})", user.Id, projectStacks.Count(), stack.ProjectId); - } - - return base.DeleteModelsAsync(stacks); - } -} diff --git a/src/Exceptionless.Web/Controllers/StatusController.cs b/src/Exceptionless.Web/Controllers/StatusController.cs deleted file mode 100644 index ee65118d8d..0000000000 --- a/src/Exceptionless.Web/Controllers/StatusController.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Exceptionless.Core; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Messaging.Models; -using Exceptionless.Core.Queues.Models; -using Exceptionless.Core.Services; -using Exceptionless.Web.Models; -using Foundatio.Queues; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX)] -[ApiExplorerSettings(IgnoreApi = true)] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class StatusController : ExceptionlessApiController -{ - private readonly NotificationService _notificationService; - private readonly IQueue _eventQueue; - private readonly IQueue _mailQueue; - private readonly IQueue _notificationQueue; - private readonly IQueue _webHooksQueue; - private readonly IQueue _userDescriptionQueue; - private readonly AppOptions _appOptions; - - public StatusController( - NotificationService notificationService, - IQueue eventQueue, - IQueue mailQueue, - IQueue notificationQueue, - IQueue webHooksQueue, - IQueue userDescriptionQueue, - AppOptions appOptions, - TimeProvider timeProvider) : base(timeProvider) - { - _notificationService = notificationService; - _eventQueue = eventQueue; - _mailQueue = mailQueue; - _notificationQueue = notificationQueue; - _webHooksQueue = webHooksQueue; - _userDescriptionQueue = userDescriptionQueue; - _appOptions = appOptions; - } - - /// - /// Get the info of the API - /// - [AllowAnonymous] - [HttpGet("about")] - public IActionResult IndexAsync() - { - return Ok(new - { - _appOptions.InformationalVersion, - AppMode = _appOptions.AppMode.ToString(), - _appOptions.AppScope, - Environment.MachineName - }); - } - - [HttpGet("queue-stats")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task QueueStatsAsync() - { - var eventQueueStats = await _eventQueue.GetQueueStatsAsync(); - var mailQueueStats = await _mailQueue.GetQueueStatsAsync(); - var userDescriptionQueueStats = await _userDescriptionQueue.GetQueueStatsAsync(); - var notificationQueueStats = await _notificationQueue.GetQueueStatsAsync(); - var webHooksQueueStats = await _webHooksQueue.GetQueueStatsAsync(); - - return Ok(new - { - EventPosts = new - { - Active = eventQueueStats.Enqueued, - eventQueueStats.Deadletter, - eventQueueStats.Working - }, - MailMessages = new - { - Active = mailQueueStats.Enqueued, - mailQueueStats.Deadletter, - mailQueueStats.Working - }, - UserDescriptions = new - { - Active = userDescriptionQueueStats.Enqueued, - userDescriptionQueueStats.Deadletter, - userDescriptionQueueStats.Working - }, - Notifications = new - { - Active = notificationQueueStats.Enqueued, - notificationQueueStats.Deadletter, - notificationQueueStats.Working - }, - WebHooks = new - { - Active = webHooksQueueStats.Enqueued, - webHooksQueueStats.Deadletter, - webHooksQueueStats.Working - } - }); - } - - [HttpPost("notifications/release")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task> PostReleaseNotificationAsync(ValueFromBody message, bool critical = false) - { - var notification = await _notificationService.SendReleaseNotificationAsync(message.Value, critical); - - return Ok(notification); - } - - /// - /// Returns the current system notification messages. - /// - [HttpGet("notifications/system")] - public async Task> GetSystemNotificationAsync() - { - var notification = await _notificationService.GetSystemNotificationAsync(); - if (notification is null) - return Ok(); - - return Ok(notification); - } - - [HttpPost("notifications/system")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task> PostSystemNotificationAsync(SetSystemNotificationRequest request, bool publish = true) - { - if (String.IsNullOrWhiteSpace(request.Message)) - return NotFound(); - - var notification = await _notificationService.SetSystemNotificationAsync(request.Message, request.Level, request.Target, publish); - - return Ok(notification); - } - - [HttpDelete("notifications/system")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task RemoveSystemNotificationAsync(bool publish = true) - { - await _notificationService.ClearSystemNotificationAsync(publish); - - return Ok(); - } -} - -public record SetSystemNotificationRequest -{ - public string? Message { get; set; } - public SystemNotificationLevel Level { get; set; } = SystemNotificationLevel.Info; - public SystemNotificationTarget Target { get; set; } = SystemNotificationTarget.Both; -} diff --git a/src/Exceptionless.Web/Controllers/StripeController.cs b/src/Exceptionless.Web/Controllers/StripeController.cs deleted file mode 100644 index 502c3e7a47..0000000000 --- a/src/Exceptionless.Web/Controllers/StripeController.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Exceptionless.Core.Billing; -using Exceptionless.Core.Configuration; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Stripe; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX + "/stripe")] -[ApiExplorerSettings(IgnoreApi = true)] -[Authorize] -public class StripeController : ExceptionlessApiController -{ - private readonly StripeEventHandler _stripeEventHandler; - private readonly StripeOptions _stripeOptions; - private readonly ILogger _logger; - - public StripeController(StripeEventHandler stripeEventHandler, StripeOptions stripeOptions, - TimeProvider timeProvider, - ILogger logger) : base(timeProvider) - { - _stripeEventHandler = stripeEventHandler; - _stripeOptions = stripeOptions; - _logger = logger; - } - - [AllowAnonymous] - [HttpPost] - [Consumes("application/json")] - public async Task PostAsync() - { - string json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); - using (_logger.BeginScope(new ExceptionlessState().SetHttpContext(HttpContext).Property("event", json))) - { - if (String.IsNullOrEmpty(json)) - { - _logger.LogWarning("Unable to get json of incoming event"); - return BadRequest(); - } - - Event stripeEvent; - try - { - stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], _stripeOptions.StripeWebHookSigningSecret, throwOnApiVersionMismatch: false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Unable to parse incoming event with {Signature}: {Message}", Request.Headers["Stripe-Signature"], ex.Message); - return BadRequest(); - } - - if (stripeEvent is null) - { - _logger.LogWarning("Null stripe event"); - return BadRequest(); - } - - await _stripeEventHandler.HandleEventAsync(stripeEvent); - return Ok(); - } - } -} diff --git a/src/Exceptionless.Web/Controllers/TokenController.cs b/src/Exceptionless.Web/Controllers/TokenController.cs deleted file mode 100644 index d5bc931d0d..0000000000 --- a/src/Exceptionless.Web/Controllers/TokenController.cs +++ /dev/null @@ -1,388 +0,0 @@ -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Core.Repositories; -using Exceptionless.Web.Controllers; -using Exceptionless.Web.Mapping; -using Exceptionless.Web.Models; -using Exceptionless.Web.Utility; -using Foundatio.Repositories; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace Exceptionless.App.Controllers.API; - -[Route(API_PREFIX + "/tokens")] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class TokenController : RepositoryApiController -{ - private readonly IProjectRepository _projectRepository; - - public TokenController( - ITokenRepository repository, - IProjectRepository projectRepository, - ApiMapper mapper, - IAppQueryValidator validator, - TimeProvider timeProvider, - ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) - { - _projectRepository = projectRepository; - } - - // Mapping implementations - protected override Token MapToModel(NewToken newModel) => _mapper.MapToToken(newModel); - protected override ViewToken MapToViewModel(Token model) => _mapper.MapToViewToken(model); - protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewTokens(models); - - /// - /// Get by organization - /// - /// The identifier of the organization. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The organization could not be found. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/tokens")] - public async Task>> GetByOrganizationAsync(string organizationId, int page = 1, int limit = 10) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) - return NotFound(); - - page = GetPage(page); - limit = GetLimit(limit); - var tokens = await _repository.GetByTypeAndOrganizationIdAsync(TokenType.Access, organizationId, o => o.PageNumber(page).PageLimit(limit)); - var viewTokens = MapToViewModels(tokens.Documents); - await AfterResultMapAsync(viewTokens); - return OkWithResourceLinks(viewTokens, tokens.HasMore && !NextPageExceedsSkipLimit(page, limit), page, tokens.Total); - } - - /// - /// Get by project - /// - /// The identifier of the project. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The project could not be found. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/tokens")] - public async Task>> GetByProjectAsync(string projectId, int page = 1, int limit = 10) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - page = GetPage(page); - limit = GetLimit(limit); - var tokens = await _repository.GetByTypeAndProjectIdAsync(TokenType.Access, projectId, o => o.PageNumber(page).PageLimit(limit)); - var viewTokens = MapToViewModels(tokens.Documents); - await AfterResultMapAsync(viewTokens); - return OkWithResourceLinks(viewTokens, tokens.HasMore && !NextPageExceedsSkipLimit(page, limit), page, tokens.Total); - } - - /// - /// Get a projects default token - /// - /// The identifier of the project. - /// The project could not be found. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/tokens/default")] - public async Task> GetDefaultTokenAsync(string projectId) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - var defaultTokenResults = await _repository.GetByTypeAndProjectIdAsync(TokenType.Access, projectId, o => o.PageLimit(1)); - var token = defaultTokenResults.Documents.FirstOrDefault(); - if (token is not null) - return await OkModelAsync(token); - - return await PostImplAsync(new NewToken { OrganizationId = project.OrganizationId, ProjectId = projectId }); - } - - /// - /// Get by id - /// - /// The identifier of the token. - /// The token could not be found. - [HttpGet("{id:token}", Name = "GetTokenById")] - public async Task> GetAsync(string id) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - return await GetByIdImplAsync(id); - } - - /// - /// Create - /// - /// - /// To create a new token, you must specify an organization_id. There are three valid scopes: client, user and admin. - /// - /// The token. - /// An error occurred while creating the token. - /// The token already exists. - [HttpPost] - [Consumes("application/json")] - [ProducesResponseType(StatusCodes.Status201Created)] - public async Task> PostAsync(NewToken token) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - return await PostImplAsync(token); - } - - /// - /// Create for project - /// - /// - /// This is a helper action that makes it easier to create a token for a specific project. - /// You may also specify a scope when creating a token. There are three valid scopes: client, user and admin. - /// - /// The identifier of the project. - /// The token. - /// An error occurred while creating the token. - /// The project could not be found. - /// The token already exists. - [HttpPost("~/" + API_PREFIX + "/projects/{projectId:objectid}/tokens")] - [Consumes("application/json")] - [ProducesResponseType(StatusCodes.Status201Created)] - public async Task> PostByProjectAsync(string projectId, NewToken? token = null) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - if (token is null) - token = new NewToken(); - - token.OrganizationId = project.OrganizationId; - token.ProjectId = projectId; - return await PostImplAsync(token); - } - - /// - /// Create for organization - /// - /// - /// This is a helper action that makes it easier to create a token for a specific organization. - /// You may also specify a scope when creating a token. There are three valid scopes: client, user and admin. - /// - /// The identifier of the organization. - /// The token. - /// An error occurred while creating the token. - /// The token already exists. - [HttpPost("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/tokens")] - [Consumes("application/json")] - [ProducesResponseType(StatusCodes.Status201Created)] - public async Task> PostByOrganizationAsync(string organizationId, NewToken? token = null) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - if (token is null) - token = new NewToken(); - - if (!IsInOrganization(organizationId)) - return BadRequest(); - - token.OrganizationId = organizationId; - return await PostImplAsync(token); - } - - /// - /// Update - /// - /// The identifier of the token. - /// The changes - /// An error occurred while updating the token. - /// The token could not be found. - [HttpPatch("{id:tokens}")] - [HttpPut("{id:tokens}")] - [Consumes("application/json")] - public async Task> PatchAsync(string id, Delta changes) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - return await PatchImplAsync(id, changes); - } - - /// - /// Remove - /// - /// A comma-delimited list of token identifiers. - /// Accepted - /// One or more validation errors occurred. - /// One or more tokens were not found. - /// An error occurred while deleting one or more tokens. - [HttpDelete("{ids:tokens}")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public async Task> DeleteAsync(string ids) - { - if (User.IsTokenAuthType()) - return Forbidden(); - - return await DeleteImplAsync(ids.FromDelimitedString()); - } - - protected override async Task GetModelAsync(string id, bool useCache = true) - { - if (String.IsNullOrEmpty(id)) - return null; - - var model = await _repository.GetByIdAsync(id, o => o.Cache(useCache)); - if (model is null) - return null; - - if (!String.IsNullOrEmpty(model.OrganizationId) && !IsInOrganization(model.OrganizationId)) - return null; - - if (!User.IsInRole(AuthorizationRoles.GlobalAdmin) && !String.IsNullOrEmpty(model.UserId) && model.UserId != CurrentUser.Id) - return null; - - if (model.Type != TokenType.Access) - return null; - - if (!String.IsNullOrEmpty(model.ProjectId) && !await IsInProjectAsync(model.ProjectId)) - return null; - - return model; - } - - protected override async Task CanAddAsync(Token value) - { - // We only allow users to create organization scoped tokens. - if (String.IsNullOrEmpty(value.OrganizationId)) - return PermissionResult.Deny; - - bool hasUserRole = User.IsInRole(AuthorizationRoles.User); - bool hasGlobalAdminRole = User.IsInRole(AuthorizationRoles.GlobalAdmin); - if (!hasGlobalAdminRole && !String.IsNullOrEmpty(value.UserId) && value.UserId != CurrentUser.Id) - return PermissionResult.Deny; - - if (!String.IsNullOrEmpty(value.ProjectId) && !String.IsNullOrEmpty(value.UserId)) - return PermissionResult.DenyWithMessage("Token can't be associated to both user and project."); - - foreach (string scope in value.Scopes.ToList()) - { - string lowerCaseScoped = scope.ToLowerInvariant(); - if (!String.Equals(scope, lowerCaseScoped)) - { - value.Scopes.Remove(scope); - value.Scopes.Add(lowerCaseScoped); - } - - if (!AuthorizationRoles.AllScopes.Contains(lowerCaseScoped)) - { - ModelState.AddModelError(m => m.Scopes, "Invalid token scope requested."); - return PermissionResult.DenyWithValidationProblem(); - } - } - - if (value.Scopes.Count == 0) - value.Scopes.Add(AuthorizationRoles.Client); - - if (value.Scopes.Contains(AuthorizationRoles.Client) && !hasUserRole) - { - ModelState.AddModelError(m => m.Scopes, "Invalid token scope requested."); - return PermissionResult.DenyWithValidationProblem(); - } - - if (value.Scopes.Contains(AuthorizationRoles.User) && !hasUserRole) - { - ModelState.AddModelError(m => m.Scopes, "Invalid token scope requested."); - return PermissionResult.DenyWithValidationProblem(); - } - - if (value.Scopes.Contains(AuthorizationRoles.GlobalAdmin) && !hasGlobalAdminRole) - { - ModelState.AddModelError(m => m.Scopes, "Invalid token scope requested."); - return PermissionResult.DenyWithValidationProblem(); - } - - if (!String.IsNullOrEmpty(value.ProjectId)) - { - var project = await GetProjectAsync(value.ProjectId); - if (project is null) - { - ModelState.AddModelError(m => m.ProjectId, "Please specify a valid project id."); - return PermissionResult.DenyWithValidationProblem(); - } - - value.OrganizationId = project.OrganizationId; - value.DefaultProjectId = null; - } - - if (!String.IsNullOrEmpty(value.DefaultProjectId)) - { - var project = await GetProjectAsync(value.DefaultProjectId); - if (project is null) - { - ModelState.AddModelError(m => m.DefaultProjectId, "Please specify a valid default project id."); - return PermissionResult.DenyWithValidationProblem(); - } - } - - return await base.CanAddAsync(value); - } - - protected override Task AddModelAsync(Token value) - { - value.Id = StringExtensions.GetNewToken(); - value.CreatedUtc = value.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; - value.Type = TokenType.Access; - value.CreatedBy = CurrentUser.Id; - - // add implied scopes - if (value.Scopes.Contains(AuthorizationRoles.GlobalAdmin)) - value.Scopes.Add(AuthorizationRoles.User); - - if (value.Scopes.Contains(AuthorizationRoles.User)) - value.Scopes.Add(AuthorizationRoles.Client); - - return base.AddModelAsync(value); - } - - protected override async Task CanDeleteAsync(Token value) - { - if (!User.IsInRole(AuthorizationRoles.GlobalAdmin) && !String.IsNullOrEmpty(value.UserId) && value.UserId != CurrentUser.Id) - return PermissionResult.DenyWithMessage("Can only delete tokens created by you."); - - if (!String.IsNullOrEmpty(value.ProjectId) && !await IsInProjectAsync(value.ProjectId)) - return PermissionResult.DenyWithNotFound(value.Id); - - return await base.CanDeleteAsync(value); - } - - private async Task GetProjectAsync(string projectId, bool useCache = true) - { - if (String.IsNullOrEmpty(projectId)) - return null; - - var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache(useCache)); - if (project is null || !CanAccessOrganization(project.OrganizationId)) - return null; - - return project; - } - - private async Task IsInProjectAsync(string projectId) - { - var project = await GetProjectAsync(projectId); - return project is not null; - } -} diff --git a/src/Exceptionless.Web/Controllers/UserController.cs b/src/Exceptionless.Web/Controllers/UserController.cs deleted file mode 100644 index 9721e8346e..0000000000 --- a/src/Exceptionless.Web/Controllers/UserController.cs +++ /dev/null @@ -1,493 +0,0 @@ -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Configuration; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Mail; -using Exceptionless.Core.Models; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Core.Repositories; -using Exceptionless.DateTimeExtensions; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Mapping; -using Exceptionless.Web.Models; -using Exceptionless.Web.Utility; -using Exceptionless.Web.Utility.OpenApi; -using Foundatio.Caching; -using Foundatio.Repositories; -using Foundatio.Storage; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace Exceptionless.Web.Controllers; - -[Route(API_PREFIX + "/users")] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class UserController : RepositoryApiController -{ - private readonly IOrganizationRepository _organizationRepository; - private readonly ITokenRepository _tokenRepository; - private readonly ICacheClient _cache; - private readonly IFileStorage _fileStorage; - private readonly IMailer _mailer; - private readonly IntercomOptions _intercomOptions; - - public UserController( - IUserRepository userRepository, IFileStorage fileStorage, - IOrganizationRepository organizationRepository, ITokenRepository tokenRepository, ICacheClient cacheClient, IMailer mailer, - ApiMapper mapper, IAppQueryValidator validator, IntercomOptions intercomOptions, - TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(userRepository, mapper, validator, timeProvider, loggerFactory) - { - _organizationRepository = organizationRepository; - _tokenRepository = tokenRepository; - _cache = new ScopedCacheClient(cacheClient, "User"); - _fileStorage = fileStorage; - _mailer = mailer; - _intercomOptions = intercomOptions; - } - - // Mapping implementations - User uses ViewUser as both TViewModel and TNewModel (no NewUser type) - protected override User MapToModel(ViewUser newModel) => throw new NotSupportedException("Users cannot be created via API mapping."); - protected override ViewUser MapToViewModel(User model) => _mapper.MapToViewUser(model); - protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewUsers(models); - - /// - /// Get current user - /// - /// The current user could not be found. - [HttpGet("me")] - public async Task> GetCurrentUserAsync() - { - var currentUser = await GetModelAsync(CurrentUser.Id); - if (currentUser is null) - return NotFound(); - - return Ok(MapToViewCurrentUser(currentUser)); - } - - /// - /// Get by id - /// - /// The identifier of the user. - /// The user could not be found. - [HttpGet("{id:objectid}", Name = "GetUserById")] - public Task> GetAsync(string id) - { - return GetByIdImplAsync(id); - } - - /// - /// Get by organization - /// - /// The identifier of the organization. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The organization could not be found. - [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/users")] - public async Task>> GetByOrganizationAsync(string organizationId, int page = 1, int limit = 10) - { - if (!CanAccessOrganization(organizationId)) - return NotFound(); - - var organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); - if (organization is null) - return NotFound(); - - page = GetPage(page); - limit = GetLimit(limit); - int skip = GetSkip(page, limit); - if (skip > MAXIMUM_SKIP) - return Ok(Enumerable.Empty()); - - var results = await _repository.GetByOrganizationIdAsync(organizationId, o => o.PageLimit(MAXIMUM_SKIP)); - var users = MapToViewModels(results.Documents); - await AfterResultMapAsync(users); - if (!Request.IsGlobalAdmin()) - users.ForEach(u => u.Roles.Remove(AuthorizationRoles.GlobalAdmin)); - - if (organization.Invites.Count > 0) - { - users.AddRange(organization.Invites.Select(i => new ViewUser - { - EmailAddress = i.EmailAddress, - IsInvite = true - })); - } - - long total = results.Total + organization.Invites.Count; - var pagedUsers = users.Skip(skip).Take(limit).ToList(); - return OkWithResourceLinks(pagedUsers, total > GetSkip(page + 1, limit), page, total); - } - - /// - /// Update - /// - /// The identifier of the user. - /// The changes - /// An error occurred while updating the user. - /// The user could not be found. - [HttpPatch("{id:objectid}")] - [HttpPut("{id:objectid}")] - [Consumes("application/json")] - public Task> PatchAsync(string id, Delta changes) - { - return PatchImplAsync(id, changes); - } - - /// - /// Upload avatar - /// - /// The identifier of the user. - /// The avatar image file. - /// The cancellation token. - /// The user could not be found. - /// The image file is invalid. - [HttpPost("{id:objectid}/avatar")] - [Consumes("multipart/form-data")] - [MultipartFileUpload] - [RequestSizeLimit(ProfileImageStorage.MaxRequestBodySize)] - [RequestFormLimits(MultipartBodyLengthLimit = ProfileImageStorage.MaxRequestBodySize)] - public async Task> UploadAvatarAsync(string id, [FromForm] IFormFile? file, CancellationToken cancellationToken = default) - { - var user = await GetModelAsync(id, false); - if (user is null) - return NotFound(); - - var image = await ProfileImageStorage.SaveAsync(_fileStorage, file, "users", user.Id, ModelState, cancellationToken); - if (image is null) - return ValidationProblem(ModelState); - - string? oldAvatarFileName = user.AvatarFileName; - user.AvatarFileName = image.FileName; - try - { - await _repository.SaveAsync(user, o => o.Cache()); - } - catch - { - await ProfileImageStorage.TryDeleteAsync(_fileStorage, image.FileName, "users", user.Id, CancellationToken.None); - throw; - } - - await ProfileImageStorage.DeleteAsync(_fileStorage, oldAvatarFileName, "users", user.Id, cancellationToken); - - return await OkModelAsync(user); - } - - /// - /// Remove avatar - /// - /// The identifier of the user. - /// The cancellation token. - /// The user could not be found. - [HttpDelete("{id:objectid}/avatar")] - public async Task> DeleteAvatarAsync(string id, CancellationToken cancellationToken = default) - { - var user = await GetModelAsync(id, false); - if (user is null) - return NotFound(); - - string? oldAvatarFileName = user.AvatarFileName; - user.AvatarFileName = null; - await _repository.SaveAsync(user, o => o.Cache()); - await ProfileImageStorage.DeleteAsync(_fileStorage, oldAvatarFileName, "users", user.Id, cancellationToken); - - return await OkModelAsync(user); - } - - /// - /// Get avatar - /// - /// The identifier of the user. - /// The avatar file name. - /// The cancellation token. - /// The avatar could not be found. - [AllowAnonymous] - [HttpGet("{id:objectid}/avatar/{fileName}", Name = "GetUserAvatar")] - [ResponseCache(Duration = 31536000, Location = ResponseCacheLocation.Any)] - public async Task GetAvatarAsync(string id, string fileName, CancellationToken cancellationToken = default) - { - if (!ProfileImageStorage.TryGetContentType(fileName, out string contentType)) - return NotFound(); - - var stream = await ProfileImageStorage.GetFileStreamAsync(_fileStorage, fileName, "users", id, cancellationToken); - return stream is null ? NotFound() : File(stream, contentType); - } - - /// - /// Delete current user - /// - /// The current user could not be found. - [HttpDelete("me")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task> DeleteCurrentUserAsync() - { - string[] userIds = !String.IsNullOrEmpty(CurrentUser.Id) ? [CurrentUser.Id] : []; - return DeleteImplAsync(userIds); - } - - /// - /// Remove - /// - /// A comma-delimited list of user identifiers. - /// Accepted - /// One or more validation errors occurred. - /// One or more users were not found. - /// An error occurred while deleting one or more users. - [HttpDelete("{ids:objectids}")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task> DeleteAsync(string ids) - { - return DeleteImplAsync(ids.FromDelimitedString()); - } - - /// - /// Update email address - /// - /// The identifier of the user. - /// The new email address. - /// An error occurred while updating the users email address. - /// Validation error - /// Update email address rate limit reached. - [HttpPost("{id:objectid}/email-address/{email:minlength(1)}")] - public async Task> UpdateEmailAddressAsync(string id, string email) - { - var user = await GetModelAsync(id, false); - if (user is null) - return NotFound(); - - using var _ = _logger.BeginScope(new ExceptionlessState().Property("User", user).SetHttpContext(HttpContext)); - - email = email.Trim().ToLowerInvariant(); - if (String.Equals(CurrentUser.EmailAddress, email, StringComparison.InvariantCultureIgnoreCase)) - return Ok(new UpdateEmailAddressResult { IsVerified = user.IsEmailAddressVerified }); - - // Only allow 3 email address updates per hour period by a single user. - string updateEmailAddressAttemptsCacheKey = $"{CurrentUser.Id}:attempts"; - long attempts = await _cache.IncrementAsync(updateEmailAddressAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); - if (attempts > 3) - return TooManyRequests("Unable to update email address. Please try later."); - - if (!await IsEmailAddressAvailableInternalAsync(email)) - { - ModelState.AddModelError(m => m.EmailAddress, "A user already exists with this email address."); - return ValidationProblem(ModelState); - } - - user.ResetPasswordResetToken(); - user.EmailAddress = email; - user.IsEmailAddressVerified = user.OAuthAccounts.Any(oa => String.Equals(oa.EmailAddress(), email, StringComparison.InvariantCultureIgnoreCase)); - if (user.IsEmailAddressVerified) - user.MarkEmailAddressVerified(); - else - user.ResetVerifyEmailAddressTokenAndExpiration(_timeProvider); - - try - { - await _repository.SaveAsync(user, o => o.Cache()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating user Email Address: {Message}", ex.Message); - throw; - } - - if (!user.IsEmailAddressVerified) - await ResendVerificationEmailAsync(id); - - // TODO: We may want to send email to old email addresses as well. - return Ok(new UpdateEmailAddressResult { IsVerified = user.IsEmailAddressVerified }); - } - - /// - /// Verify email address - /// - /// The token identifier. - /// The user could not be found. - /// Verify Email Address Token has expired. - [HttpGet("verify-email-address/{token:token}")] - public async Task VerifyAsync(string token) - { - var user = await _repository.GetByVerifyEmailAddressTokenAsync(token); - if (user is null) - { - // The user may already be logged in and verified. - if (CurrentUser.IsEmailAddressVerified) - return Ok(); - - return NotFound(); - } - - if (!user.HasValidVerifyEmailAddressTokenExpiration(_timeProvider)) - { - ModelState.AddModelError(m => m.VerifyEmailAddressTokenExpiration, "Verify Email Address Token has expired."); - return ValidationProblem(ModelState); - } - - user.MarkEmailAddressVerified(); - await _repository.SaveAsync(user, o => o.Cache()); - - return Ok(); - } - - /// - /// Resend verification email - /// - /// The identifier of the user. - /// The user verification email has been sent. - /// The user could not be found. - [HttpGet("{id:objectid}/resend-verification-email")] - public async Task ResendVerificationEmailAsync(string id) - { - var user = await GetModelAsync(id, false); - if (user is null) - return NotFound(); - - if (!user.IsEmailAddressVerified) - { - user.ResetVerifyEmailAddressTokenAndExpiration(_timeProvider); - await _repository.SaveAsync(user, o => o.Cache()); - await _mailer.SendUserEmailVerifyAsync(user); - } - - return Ok(); - } - - [HttpPost("unverify-email-address")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - [Consumes("text/plain")] - public async Task UnverifyEmailAddressAsync() - { - using var reader = new StreamReader(HttpContext.Request.Body); - string[] emailAddresses = (await reader.ReadToEndAsync()).SplitAndTrim([',']); - - foreach (string emailAddress in emailAddresses) - { - var user = await _repository.GetByEmailAddressAsync(emailAddress); - if (user is null) - { - _logger.LogWarning("Unable to mark user with email address {EmailAddress} as unverified: User not Found", emailAddress); - continue; - } - - user.ResetVerifyEmailAddressTokenAndExpiration(_timeProvider); - await _repository.SaveAsync(user, o => o.Cache()); - _logger.LogInformation("User {UserId} with email address {EmailAddress} is now unverified", user.Id, emailAddress); - } - - return Ok(); - } - - [HttpPost("{id:objectid}/admin-role")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task AddAdminRoleAsync(string id) - { - var user = await GetModelAsync(id, false); - if (user is null) - return NotFound(); - - if (!user.Roles.Contains(AuthorizationRoles.GlobalAdmin)) - { - user.Roles.Add(AuthorizationRoles.GlobalAdmin); - await _repository.SaveAsync(user, o => o.Cache()); - } - - return Ok(); - } - - [HttpDelete("{id:objectid}/admin-role")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task DeleteAdminRoleAsync(string id) - { - var user = await GetModelAsync(id, false); - if (user is null) - return NotFound(); - - if (user.Roles.Remove(AuthorizationRoles.GlobalAdmin)) - { - await _repository.SaveAsync(user, o => o.Cache()); - } - - return StatusCode(StatusCodes.Status204NoContent); - } - - private async Task IsEmailAddressAvailableInternalAsync(string email) - { - if (String.IsNullOrWhiteSpace(email)) - return false; - - email = email.Trim().ToLowerInvariant(); - if (String.Equals(CurrentUser.EmailAddress, email, StringComparison.InvariantCultureIgnoreCase)) - return true; - - return await _repository.GetByEmailAddressAsync(email) is null; - } - - protected override async Task> OkModelAsync(User model) - { - if (String.Equals(CurrentUser.Id, model.Id)) - return Ok(MapToViewCurrentUser(model)); - - return await base.OkModelAsync(model); - } - - protected override async Task AfterResultMapAsync(ICollection models) - { - await base.AfterResultMapAsync(models); - - foreach (var user in models.OfType()) - user.AvatarUrl = GetUserAvatarUrl(user.Id, user.AvatarUrl); - } - - protected override async Task GetModelAsync(string id, bool useCache = true) - { - if (Request.IsGlobalAdmin() || String.Equals(CurrentUser.Id, id)) - return await base.GetModelAsync(id, useCache); - - return null; - } - - protected override Task> GetModelsAsync(string[] ids, bool useCache = true) - { - if (Request.IsGlobalAdmin()) - return base.GetModelsAsync(ids, useCache); - - return base.GetModelsAsync(ids.Where(id => String.Equals(CurrentUser.Id, id)).ToArray(), useCache); - } - - protected override async Task CanDeleteAsync(User value) - { - if (value.OrganizationIds.Count > 0) - return PermissionResult.DenyWithMessage("Please delete or leave any organizations before deleting your account."); - - if (!User.IsInRole(AuthorizationRoles.GlobalAdmin) && value.Id != CurrentUser.Id) - return PermissionResult.Deny; - - return await base.CanDeleteAsync(value); - } - - protected override async Task> DeleteModelsAsync(ICollection values) - { - foreach (var user in values) - { - long removed = await _tokenRepository.RemoveAllByUserIdAsync(user.Id); - _logger.RemovedTokens(removed, user.Id); - } - - return await base.DeleteModelsAsync(values); - } - - private ViewCurrentUser MapToViewCurrentUser(User user) - => new(user, _intercomOptions) { AvatarUrl = GetUserAvatarUrl(user.Id, user.AvatarFileName) }; - - private string? GetUserAvatarUrl(string id, string? fileName) - { - if (String.IsNullOrWhiteSpace(fileName)) - return null; - - return Url.RouteUrl("GetUserAvatar", new { id, fileName }) ?? $"/api/v2/users/{id}/avatar/{fileName}"; - } -} diff --git a/src/Exceptionless.Web/Controllers/UtilityController.cs b/src/Exceptionless.Web/Controllers/UtilityController.cs deleted file mode 100644 index 4f1cba85a8..0000000000 --- a/src/Exceptionless.Web/Controllers/UtilityController.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Queries.Validation; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Exceptionless.Web.Controllers; - -[ApiExplorerSettings(IgnoreApi = true)] -[Route(API_PREFIX)] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class UtilityController : ExceptionlessApiController -{ - private readonly PersistentEventQueryValidator _eventQueryValidator; - private readonly StackQueryValidator _stackQueryValidator; - - public UtilityController(PersistentEventQueryValidator eventQueryValidator, StackQueryValidator stackQueryValidator, TimeProvider timeProvider) : base(timeProvider) - { - _eventQueryValidator = eventQueryValidator; - _stackQueryValidator = stackQueryValidator; - } - - /// - /// Validate search query - /// - /// - /// Validate a search query to ensure that it can successfully be searched by the api - /// - /// The query you wish to validate. - [HttpGet("search/validate")] - public async Task> ValidateAsync(string query) - { - try - { - var eventResults = await _eventQueryValidator.ValidateQueryAsync(query); - var stackResults = await _stackQueryValidator.ValidateQueryAsync(query); - return Ok(new AppQueryValidator.QueryProcessResult - { - IsValid = eventResults.IsValid || stackResults.IsValid, - UsesPremiumFeatures = eventResults.UsesPremiumFeatures && stackResults.UsesPremiumFeatures, - Message = eventResults.Message ?? stackResults.Message - }); - } - catch (Exception) - { - return Ok(new AppQueryValidator.QueryProcessResult - { - IsValid = false, - Message = $"Error parsing query: \"{query}\"" - }); - } - } -} diff --git a/src/Exceptionless.Web/Controllers/WebHookController.cs b/src/Exceptionless.Web/Controllers/WebHookController.cs deleted file mode 100644 index f80d49b0a4..0000000000 --- a/src/Exceptionless.Web/Controllers/WebHookController.cs +++ /dev/null @@ -1,291 +0,0 @@ -using System.Text.Json; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Billing; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Exceptionless.Core.Queries.Validation; -using Exceptionless.Core.Repositories; -using Exceptionless.Web.Controllers; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Mapping; -using Exceptionless.Web.Models; -using Foundatio.Repositories; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Exceptionless.App.Controllers.API; - -[Route(API_PREFIX + "/webhooks")] -[Authorize(Policy = AuthorizationRoles.ClientPolicy)] -public class WebHookController : RepositoryApiController -{ - private readonly IProjectRepository _projectRepository; - private readonly BillingManager _billingManager; - - public WebHookController(IWebHookRepository repository, IProjectRepository projectRepository, BillingManager billingManager, ApiMapper mapper, IAppQueryValidator validator, - TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) - { - _projectRepository = projectRepository; - _billingManager = billingManager; - } - - // Mapping implementations - protected override WebHook MapToModel(NewWebHook newModel) => _mapper.MapToWebHook(newModel); - protected override WebHook MapToViewModel(WebHook model) => model; - protected override List MapToViewModels(IEnumerable models) => models.ToList(); - - /// - /// Get by project - /// - /// The identifier of the project. - /// The page parameter is used for pagination. This value must be greater than 0. - /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The project could not be found. - [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/webhooks")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByProjectAsync(string projectId, int page = 1, int limit = 10) - { - var project = await GetProjectAsync(projectId); - if (project is null) - return NotFound(); - - page = GetPage(page); - limit = GetLimit(limit); - var results = await _repository.GetByProjectIdAsync(projectId, o => o.PageNumber(page).PageLimit(limit)); - return OkWithResourceLinks(results.Documents.ToArray(), results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); - } - - /// - /// Get by id - /// - /// The identifier of the web hook. - /// The web hook could not be found. - [HttpGet("{id:objectid}", Name = "GetWebHookById")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public Task> GetAsync(string id) - { - return GetByIdImplAsync(id); - } - - /// - /// Create - /// - /// The web hook. - /// An error occurred while creating the web hook. - /// The web hook already exists. - [HttpPost] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status201Created)] - public Task> PostAsync(NewWebHook webhook) - { - return PostImplAsync(webhook); - } - - /// - /// Remove - /// - /// A comma-delimited list of web hook identifiers. - /// Accepted - /// One or more validation errors occurred. - /// One or more web hooks were not found. - /// An error occurred while deleting one or more web hooks. - [HttpDelete("{ids:objectids}")] - [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public Task> DeleteAsync(string ids) - { - return DeleteImplAsync(ids.FromDelimitedString()); - } - - /// - /// This controller action is called by zapier to create a hook subscription. - /// - [HttpPost("subscribe")] - [HttpPost("~/api/v{apiVersion:int=2}/webhooks/subscribe")] - [HttpPost("~/api/v1/projecthook/subscribe")] - [Consumes("application/json")] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task> SubscribeAsync(JsonDocument data, int apiVersion = 1) - { - string? eventType = data.RootElement.TryGetProperty("event", out var eventProp) ? eventProp.GetString() : null; - string? url = data.RootElement.TryGetProperty("target_url", out var urlProp) ? urlProp.GetString() : null; - if (String.IsNullOrEmpty(eventType) || String.IsNullOrEmpty(url)) - return BadRequest(); - - string? projectId = User.GetProjectId(); - if (projectId is null) - return BadRequest(); - - string? organizationId = Request.GetDefaultOrganizationId(); - if (organizationId is null) - return BadRequest(); - - var webHook = new NewWebHook - { - OrganizationId = organizationId, - ProjectId = projectId, - EventTypes = [eventType], - Url = url, - Version = new Version(apiVersion >= 0 ? apiVersion : 0, 0) - }; - - if (!webHook.Url.StartsWith("https://hooks.zapier.com")) - return NotFound(); - - return await PostImplAsync(webHook); - } - - /// - /// This controller action is called by zapier to remove a hook subscription. - /// - [AllowAnonymous] - [HttpPost("unsubscribe")] - [HttpPost("~/api/v1/projecthook/unsubscribe")] - [Consumes("application/json")] - [ApiExplorerSettings(IgnoreApi = true)] - public async Task UnsubscribeAsync(JsonDocument data) - { - string? targetUrl = data.RootElement.TryGetProperty("target_url", out var urlProp) ? urlProp.GetString() : null; - - // don't let this anon method delete non-zapier hooks - if (targetUrl is null || !targetUrl.StartsWith("https://hooks.zapier.com")) - return NotFound(); - - var results = await _repository.GetByUrlAsync(targetUrl); - if (results.Documents.Count > 0) - { - string organizationId = results.Documents.First().OrganizationId; - if (results.Documents.Any(h => h.OrganizationId != organizationId)) - throw new ArgumentException("All OrganizationIds must be the same."); - - _logger.RemovingZapierUrls(results.Documents.Count, targetUrl); - await _repository.RemoveAsync(results.Documents); - } - - return Ok(); - } - - /// - /// This controller action is called by zapier to test auth. - /// - [HttpGet("test")] - [HttpPost("test")] - [HttpGet("~/api/v1/projecthook/test")] - [HttpPost("~/api/v1/projecthook/test")] - [ApiExplorerSettings(IgnoreApi = true)] - public IActionResult Test() - { - return Ok(new[] { - new { id = 1, Message = "Test message 1." }, - new { id = 2, Message = "Test message 2." } - }); - } - - protected override async Task GetModelAsync(string id, bool useCache = true) - { - if (String.IsNullOrEmpty(id)) - return null; - - var webHook = await _repository.GetByIdAsync(id, o => o.Cache(useCache)); - if (webHook is null) - return null; - - if (!String.IsNullOrEmpty(webHook.OrganizationId) && !IsInOrganization(webHook.OrganizationId)) - return null; - - if (!String.IsNullOrEmpty(webHook.ProjectId) && !await IsInProjectAsync(webHook.ProjectId)) - return null; - - return webHook; - } - - protected override async Task> GetModelsAsync(string[] ids, bool useCache = true) - { - if (ids is null || ids.Length == 0) - return EmptyModels; - - var webHooks = await _repository.GetByIdsAsync(ids, o => o.Cache(useCache)); - if (webHooks.Count == 0) - return EmptyModels; - - var results = new List(); - foreach (var webHook in webHooks) - { - if ((!String.IsNullOrEmpty(webHook.OrganizationId) && IsInOrganization(webHook.OrganizationId)) - || (!String.IsNullOrEmpty(webHook.ProjectId) && (await IsInProjectAsync(webHook.ProjectId)))) - results.Add(webHook); - } - - return results; - } - - protected override async Task CanAddAsync(WebHook value) - { - if (String.IsNullOrEmpty(value.Url) || value.EventTypes.Length == 0) - return PermissionResult.Deny; - - if (String.IsNullOrEmpty(value.ProjectId) && String.IsNullOrEmpty(value.OrganizationId)) - return PermissionResult.Deny; - - if (!String.IsNullOrEmpty(value.OrganizationId) && !IsInOrganization(value.OrganizationId)) - return PermissionResult.DenyWithMessage("Invalid organization id specified."); - - Project? project = null; - if (!String.IsNullOrEmpty(value.ProjectId)) - { - project = await GetProjectAsync(value.ProjectId); - if (project is null) - return PermissionResult.DenyWithMessage("Invalid project id specified."); - - value.OrganizationId = project.OrganizationId; - } - - if (!await _billingManager.HasPremiumFeaturesAsync(project is not null ? project.OrganizationId : value.OrganizationId)) - return PermissionResult.DenyWithPlanLimitReached("Please upgrade your plan to add integrations."); - - return PermissionResult.Allow; - } - - protected override Task AddModelAsync(WebHook value) - { - if (!IsValidWebHookVersion(value.Version)) - value.Version = WebHook.KnownVersions.Version2; - - return base.AddModelAsync(value); - } - - protected override async Task CanDeleteAsync(WebHook value) - { - if (!String.IsNullOrEmpty(value.ProjectId) && !await IsInProjectAsync(value.ProjectId)) - return PermissionResult.DenyWithNotFound(value.Id); - - if (!String.IsNullOrEmpty(value.OrganizationId) && !IsInOrganization(value.OrganizationId)) - return PermissionResult.DenyWithNotFound(value.Id); - - return PermissionResult.Allow; - } - - private async Task GetProjectAsync(string projectId, bool useCache = true) - { - if (String.IsNullOrEmpty(projectId)) - return null; - - var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache(useCache)); - if (project is null || !CanAccessOrganization(project.OrganizationId)) - return null; - - return project; - } - - private async Task IsInProjectAsync(string projectId) - { - var project = await GetProjectAsync(projectId); - return project is not null; - } - - private bool IsValidWebHookVersion(string version) - { - return String.Equals(version, WebHook.KnownVersions.Version1) || String.Equals(version, WebHook.KnownVersions.Version2); - } -} diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index fd5b364b43..44830a85cd 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -5,6 +5,7 @@ $(DefaultItemExcludes);$(SpaRoot)node_modules\**;$(AngularSpaRoot)node_modules\**; false + false @@ -16,6 +17,9 @@ + + + @@ -41,8 +45,7 @@ - + @@ -55,8 +58,7 @@ - + @@ -65,8 +67,7 @@ - + wwwroot\next\%(RecursiveDir)%(FileName)%(Extension) Always true @@ -74,8 +75,7 @@ - + wwwroot\%(RecursiveDir)%(FileName)%(Extension) Always true diff --git a/src/Exceptionless.Web/Extensions/DeltaExtensions.cs b/src/Exceptionless.Web/Extensions/DeltaExtensions.cs deleted file mode 100644 index b0ea5bec9d..0000000000 --- a/src/Exceptionless.Web/Extensions/DeltaExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Linq.Expressions; -using Exceptionless.Web.Utility; - -namespace Exceptionless.Web.Extensions; - -public static class DeltaExtensions -{ - public static bool ContainsChangedProperty(this Delta value, Expression> action) where T : class, new() - { - if (!value.GetChangedPropertyNames().Any()) - return false; - - var expression = action.Body as MemberExpression ?? ((UnaryExpression)action.Body).Operand as MemberExpression; - return expression is not null && value.GetChangedPropertyNames().Contains(expression.Member.Name); - } -} diff --git a/src/Exceptionless.Web/Extensions/HttpExtensions.cs b/src/Exceptionless.Web/Extensions/HttpExtensions.cs index ee6d12f242..a53cfe1fc2 100644 --- a/src/Exceptionless.Web/Extensions/HttpExtensions.cs +++ b/src/Exceptionless.Web/Extensions/HttpExtensions.cs @@ -5,11 +5,20 @@ using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; +using Exceptionless.Web.Utility; +using Microsoft.Net.Http.Headers; namespace Exceptionless.Web.Extensions; public static class HttpExtensions { + public static string? GetClientUserAgent(this HttpRequest request) + { + if (request.Headers.TryGetValue(Headers.Client, out var values) && values.Count > 0) + return values; + return request.Headers[HeaderNames.UserAgent].ToString(); + } + public static User GetUser(this HttpRequest request) { if (request.HttpContext.Items.TryGetAndReturn("User") is User user) diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index db9dd79749..25fe7a2d97 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -1,25 +1,360 @@ using System.Diagnostics; +using System.Security.Claims; using Exceptionless.Core; +using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; +using Exceptionless.Core.Serialization; +using Exceptionless.Core.Validation; using Exceptionless.Insulation.Configuration; +using Exceptionless.Web.Api; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Hubs; +using Exceptionless.Web.Security; +using Exceptionless.Web.Utility; +using Exceptionless.Web.Utility.Handlers; +using Exceptionless.Web.Utility.OpenApi; +using Foundatio.Extensions.Hosting.Startup; +using Foundatio.Mediator; +using Foundatio.Repositories.Exceptions; +using Joonasw.AspNetCore.SecurityHeaders; +using Joonasw.AspNetCore.SecurityHeaders.Csp; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Net.Http.Headers; using OpenTelemetry; +using Scalar.AspNetCore; using Serilog; using Serilog.Events; using Serilog.Sinks.Exceptionless; namespace Exceptionless.Web; -public class Program +public partial class Program { public static async Task Main(string[] args) { try { - await CreateHostBuilder(args).Build().RunAsync(); + Console.Title = "Exceptionless Web"; + + var builder = WebApplication.CreateBuilder(args); + string? environment = Environment.GetEnvironmentVariable("EX_AppMode"); + if (String.IsNullOrWhiteSpace(environment)) + environment = builder.Environment.EnvironmentName; + if (String.IsNullOrWhiteSpace(environment)) + environment = Environments.Production; + + builder.Host.UseEnvironment(environment); + builder.Configuration.Sources.Clear(); + builder.Configuration + .AddYamlFile("appsettings.yml", optional: true, reloadOnChange: true) + .AddYamlFile($"appsettings.{environment}.yml", optional: true, reloadOnChange: true) + .AddYamlFile("appsettings.Local.yml", optional: true, reloadOnChange: true); + + // When running inside WebApplicationFactory, AppContext.BaseDirectory differs from + // the content root and may contain test-specific configuration overrides. + string appBaseDir = Path.GetFullPath(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)); + string contentRoot = Path.GetFullPath(builder.Environment.ContentRootPath.TrimEnd(Path.DirectorySeparatorChar)); + if (!appBaseDir.Equals(contentRoot, StringComparison.OrdinalIgnoreCase)) + { + builder.Configuration.AddYamlFile( + new Microsoft.Extensions.FileProviders.PhysicalFileProvider(appBaseDir), + "appsettings.yml", optional: true, reloadOnChange: false); + } + + builder.Configuration + .AddCustomEnvironmentVariables() + .AddCommandLine(args); + + var configuration = (IConfigurationRoot)builder.Configuration; + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateBootstrapLogger() + .ForContext(); + + var options = AppOptions.ReadFromConfiguration(configuration); + options.QueueOptions.MetricsPollingEnabled = options.RunJobsInProcess; + + var apmConfig = new ApmConfig(configuration, "web", options.InformationalVersion, options.CacheOptions.Provider == "redis"); + + Log.Information("Bootstrapping Exceptionless Web in {AppMode} mode ({InformationalVersion}) on {MachineName} with options {@Options}", environment, options.InformationalVersion, Environment.MachineName, options); + + SetClientEnvironmentVariablesInDevelopmentMode(options); + + builder.Logging.ClearProviders(); + + builder.Host + .UseSerilog((ctx, sp, c) => + { + c.ReadFrom.Configuration(ctx.Configuration); + c.ReadFrom.Services(sp); + c.Enrich.WithMachineName(); + + if (!String.IsNullOrEmpty(options.ExceptionlessApiKey)) + c.WriteTo.Sink(new ExceptionlessSink(), LogEventLevel.Information); + }, writeToProviders: true) + .AddApm(apmConfig); + + builder.WebHost.ConfigureKestrel(c => + { + c.AddServerHeader = false; + + if (options.MaximumEventPostSize > 0) + c.Limits.MaxRequestBodySize = options.MaximumEventPostSize; + }); + + builder.Services.AddSingleton(configuration); + builder.Services.AddSingleton(apmConfig); + builder.Services.AddAppOptions(options); + builder.Services.AddHttpContextAccessor(); + + builder.Services.AddCors(b => b.AddPolicy("AllowAny", p => p + .AllowAnyHeader() + .AllowAnyMethod() + .SetIsOriginAllowed(isOriginAllowed: _ => true) + .AllowCredentials() + .SetPreflightMaxAge(TimeSpan.FromMinutes(5)) + .WithExposedHeaders("ETag", Headers.LegacyConfigurationVersion, Headers.ConfigurationVersion, HeaderNames.Link, Headers.RateLimit, Headers.RateLimitRemaining, Headers.ResultCount))); + + builder.Services.Configure(o => + { + o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + o.RequireHeaderSymmetry = false; + o.KnownIPNetworks.Clear(); + o.KnownProxies.Clear(); + }); + + builder.Services.ConfigureHttpJsonOptions(o => + { + o.SerializerOptions.ConfigureExceptionlessApiDefaults(); + }); + + builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = CustomizeProblemDetails); + builder.Services.AddExceptionHandler(); + + builder.Services.AddAuthentication(ApiKeyAuthenticationOptions.ApiKeySchema).AddApiKeyAuthentication(); + builder.Services.AddAuthorization(o => + { + o.DefaultPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); + o.AddPolicy(AuthorizationRoles.ClientPolicy, policy => policy.RequireClaim(ClaimTypes.Role, AuthorizationRoles.Client)); + o.AddPolicy(AuthorizationRoles.UserPolicy, policy => policy.RequireClaim(ClaimTypes.Role, AuthorizationRoles.User)); + o.AddPolicy(AuthorizationRoles.GlobalAdminPolicy, policy => policy.RequireClaim(ClaimTypes.Role, AuthorizationRoles.GlobalAdmin)); + }); + + builder.Services.AddRouting(r => + { + r.LowercaseUrls = true; + r.ConstraintMap.Add("identifier", typeof(IdentifierRouteConstraint)); + r.ConstraintMap.Add("identifiers", typeof(IdentifiersRouteConstraint)); + r.ConstraintMap.Add("objectid", typeof(ObjectIdRouteConstraint)); + r.ConstraintMap.Add("objectids", typeof(ObjectIdsRouteConstraint)); + r.ConstraintMap.Add("token", typeof(TokenRouteConstraint)); + r.ConstraintMap.Add("tokens", typeof(TokensRouteConstraint)); + }); + + builder.Services.AddOpenApi(o => + { + o.CreateSchemaReferenceId = SchemaReferenceIdHelper.CreateSchemaReferenceId; + o.AddDocumentTransformer(); + o.AddDocumentTransformer(); + o.AddDocumentTransformer(); + o.AddOperationTransformer(); + o.AddOperationTransformer(); + o.AddOperationTransformer(); + o.AddOperationTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + }); + + builder.Services.AddSingleton, ApiResultMapper>(); + builder.Services.AddMediator() + .ConfigureResultMapping(resultMapping => resultMapping + .MapStatus(ResultStatus.BadRequest, ApiResultMapper.MapBadRequest) + .MapStatus(ResultStatus.Invalid, ApiResultMapper.MapValidation) + .MapStatus(ResultStatus.NotFound, ApiResultMapper.MapNotFound) + .MapStatus(ResultStatus.Unauthorized, ApiResultMapper.MapUnauthorized) + .MapStatus(ResultStatus.Forbidden, ApiResultMapper.MapForbidden) + .MapStatus(ResultStatus.Conflict, ApiResultMapper.MapConflict) + .MapStatus(ResultStatus.Error, ApiResultMapper.MapError) + .MapStatus(ResultStatus.CriticalError, ApiResultMapper.MapCriticalError) + .MapStatus(ResultStatus.Unavailable, ApiResultMapper.MapUnavailable)); + Bootstrapper.RegisterServices(builder.Services, options, Log.Logger.ToLoggerFactory()); + builder.Services.AddSingleton(_ => new ThrottlingOptions + { + MaxRequestsForUserIdentifierFunc = _ => options.ApiThrottleLimit, + Period = TimeSpan.FromMinutes(15) + }); + + var app = builder.Build(); + + Core.Bootstrapper.LogConfiguration(app.Services, options, app.Services.GetRequiredService>()); + + app.UseExceptionHandler(new ExceptionHandlerOptions + { + StatusCodeSelector = ex => ex switch + { + UnauthorizedAccessException => StatusCodes.Status401Unauthorized, + MiniValidatorException => StatusCodes.Status422UnprocessableEntity, + BadHttpRequestException badRequest => badRequest.StatusCode, + ApplicationException applicationException when applicationException.Message.Contains("version_conflict") => StatusCodes.Status409Conflict, + VersionConflictDocumentException => StatusCodes.Status409Conflict, + NotImplementedException => StatusCodes.Status501NotImplemented, + _ => StatusCodes.Status500InternalServerError + } + }); + app.UseStatusCodePages(); + + app.UseHealthChecks("/health", new HealthCheckOptions + { + Predicate = hcr => hcr.Tags.Contains("Critical") || (options.RunJobsInProcess && hcr.Tags.Contains("AllJobs")) + }); + + List readyTags = ["Critical"]; + if (!options.EventSubmissionDisabled) + readyTags.Add("Storage"); + app.UseReadyHealthChecks(readyTags.ToArray()); + app.UseWaitForStartupActionsBeforeServingRequests(); + + if (!String.IsNullOrEmpty(options.ExceptionlessApiKey) && !String.IsNullOrEmpty(options.ExceptionlessServerUrl)) + app.UseExceptionless(ExceptionlessClient.Default); + + app.Use(async (context, next) => + { + if (options.AppMode != AppMode.Development && !context.Request.IsLocal()) + context.Response.Headers.StrictTransportSecurity = "max-age=31536000; includeSubDomains"; + + context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + context.Response.Headers.XContentTypeOptions = "nosniff"; + context.Response.Headers.XFrameOptions = "DENY"; + context.Response.Headers.XXSSProtection = "1; mode=block"; + context.Response.Headers.Remove("X-Powered-By"); + + await next(); + }); + + var serverAddressesFeature = app.Services.GetRequiredService().Features.Get(); + bool ssl = options.AppMode != AppMode.Development && serverAddressesFeature is not null && serverAddressesFeature.Addresses.Any(a => a.StartsWith("https://")); + + if (ssl) + app.UseHttpsRedirection(); + + app.UseCsp(csp => + { + csp.AllowFonts.FromSelf() + .From("https://fonts.gstatic.com") + .From("https://www.gravatar.com") + .From("https://fonts.intercomcdn.com") + .From("https://cdn.jsdelivr.net"); + csp.AllowImages.FromSelf() + .From("data:") + .From("https://q.stripe.com") + .From("https://js.intercomcdn.com") + .From("https://downloads.intercomcdn.com") + .From("https://uploads.intercomcdn.com") + .From("https://static.intercomassets.com") + .From("https://user-images.githubusercontent.com") + .From("https://www.gravatar.com") + .From("http://www.gravatar.com"); + csp.AllowScripts.FromSelf() + .AllowUnsafeInline() + .AllowUnsafeEval() + .From("https://js.stripe.com") + .From("https://widget.intercom.io") + .From("https://js.intercomcdn.com") + .From("https://cdn.jsdelivr.net"); + csp.AllowStyles.FromSelf() + .AllowUnsafeInline() + .From("https://fonts.googleapis.com") + .From("https://cdn.jsdelivr.net"); + csp.AllowConnections.ToSelf() + .To("https://collector.exceptionless.io") + .To("https://config.exceptionless.io") + .To("https://heartbeat.exceptionless.io") + .To("https://api-iam.intercom.io/") + .To("wss://nexus-websocket-a.intercom.io"); + + csp.OnSendingHeader = new Func(context => + { + context.ShouldNotSend = context.HttpContext.Request.Path.StartsWithSegments("/api"); + return Task.CompletedTask; + }); + }); + + app.UseSerilogRequestLogging(o => + { + o.EnrichDiagnosticContext = (context, httpContext) => + { + if (Activity.Current?.Id is not null) + context.Set("ActivityId", Activity.Current.Id); + }; + o.MessageTemplate = "{ActivityId} HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; + o.GetLevel = (context, duration, ex) => + { + if (ex is not null || context.Response.StatusCode > 499) + return LogEventLevel.Error; + + if (context.Response.StatusCode > 399) + return LogEventLevel.Information; + + if (duration < 1000 || context.Request.Path.StartsWithSegments("/api/v2/push")) + return LogEventLevel.Debug; + + return LogEventLevel.Information; + }; + }); + + app.UseStaticFiles(); + app.UseDefaultFiles(); + app.UseFileServer(); + app.UseRouting(); + app.UseCors("AllowAny"); + app.UseHttpMethodOverride(); + app.UseForwardedHeaders(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseMiddleware(); + app.UseMiddleware(); + + if (options.ApiThrottleLimit < Int32.MaxValue) + app.UseMiddleware(); + + app.UseMiddleware(); + + if (options.EnableWebSockets) + { + app.UseWebSockets(); + app.UseMiddleware(); + } + + app.MapOpenApi("/docs/v2/openapi.json"); + app.MapScalarApiReference("/docs", o => + { + o.WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") + .AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true) + .AddPreferredSecuritySchemes("Bearer"); + }); + app.MapApiEndpoints(); + app.MapFallback("{**slug:nonfile}", CreateRequestDelegate(app, "/index.html")); + + await app.RunAsync(); return 0; } - catch (Exception ex) + catch (Exception ex) when (ex is not HostAbortedException) { Log.Fatal(ex, "Job host terminated unexpectedly"); return 1; @@ -34,78 +369,48 @@ public static async Task Main(string[] args) } } - public static IHostBuilder CreateHostBuilder(string[] args) + private static void CustomizeProblemDetails(ProblemDetailsContext ctx) { - string? environment = Environment.GetEnvironmentVariable("EX_AppMode"); - if (String.IsNullOrWhiteSpace(environment)) - environment = "Production"; - - var config = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddYamlFile("appsettings.yml", optional: true, reloadOnChange: true) - .AddYamlFile($"appsettings.{environment}.yml", optional: true, reloadOnChange: true) - .AddYamlFile($"appsettings.Local.yml", optional: true, reloadOnChange: true) - .AddCustomEnvironmentVariables() - .AddCommandLine(args) - .Build(); - - return CreateHostBuilder(config, environment); - } - - public static IHostBuilder CreateHostBuilder(IConfigurationRoot config, string environment) - { - Console.Title = "Exceptionless Web"; + ctx.ProblemDetails.Instance = $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}"; + if (ctx.HttpContext.Items.TryGetValue("reference-id", out object? refId) && refId is string referenceId) + ctx.ProblemDetails.Extensions.Add("reference-id", referenceId); - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(config) - .CreateBootstrapLogger() - .ForContext(); + if (ctx.HttpContext.Items.TryGetValue("errors", out object? value) && value is Dictionary errors) + ctx.ProblemDetails.Extensions.Add("errors", errors); - var options = AppOptions.ReadFromConfiguration(config); - // only poll the queue metrics if this process is going to host the jobs - options.QueueOptions.MetricsPollingEnabled = options.RunJobsInProcess; + if (ctx.ProblemDetails is ValidationProblemDetails validationProblem) + { + validationProblem.Errors = validationProblem.Errors + .ToDictionary( + error => error.Key.ToLowerUnderscoredWords(), + error => error.Value + ); + } + } - var apmConfig = new ApmConfig(config, "web", options.InformationalVersion, options.CacheOptions.Provider == "redis"); + private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) + { + var app = endpoints.CreateApplicationBuilder(); + var apiPathSegment = new PathString("/api"); + var docsPathSegment = new PathString("/docs"); + var nextPathSegment = new PathString("/next"); + app.Use(next => context => + { + bool isApiRequest = context.Request.Path.StartsWithSegments(apiPathSegment); + bool isDocsRequest = context.Request.Path.StartsWithSegments(docsPathSegment); + bool isNextRequest = context.Request.Path.StartsWithSegments(nextPathSegment); - Log.Information("Bootstrapping Exceptionless Web in {AppMode} mode ({InformationalVersion}) on {MachineName} with options {@Options}", environment, options.InformationalVersion, Environment.MachineName, options); + if (!isApiRequest && !isDocsRequest && !isNextRequest) + context.Request.Path = "/" + filePath; + else if (!isApiRequest && !isDocsRequest) + context.Request.Path = "/next/" + filePath; - SetClientEnvironmentVariablesInDevelopmentMode(options); + context.SetEndpoint(null); + return next(context); + }); - var builder = Host.CreateDefaultBuilder() - .UseEnvironment(environment) - .ConfigureLogging(b => b.ClearProviders()) // clears .net providers since we are telling serilog to write to providers we only want it to be the otel provider - .UseSerilog((ctx, sp, c) => - { - c.ReadFrom.Configuration(ctx.Configuration); - c.ReadFrom.Services(sp); - c.Enrich.WithMachineName(); - - if (!String.IsNullOrEmpty(options.ExceptionlessApiKey)) - c.WriteTo.Sink(new ExceptionlessSink(), LogEventLevel.Information); - }, writeToProviders: true) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder - .UseConfiguration(config) - .ConfigureKestrel(c => - { - c.AddServerHeader = false; - - if (options.MaximumEventPostSize > 0) - c.Limits.MaxRequestBodySize = options.MaximumEventPostSize; - }) - .UseStartup(); - }) - .ConfigureServices((ctx, services) => - { - services.AddSingleton(config); - services.AddSingleton(apmConfig); - services.AddAppOptions(options); - services.AddHttpContextAccessor(); - }) - .AddApm(apmConfig); - - return builder; + app.UseStaticFiles(); + return app.Build(); } private static void SetClientEnvironmentVariablesInDevelopmentMode(AppOptions options) @@ -137,3 +442,7 @@ private static void SetClientEnvironmentVariablesInDevelopmentMode(AppOptions op } } } + +public partial class Program +{ +} diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs deleted file mode 100644 index 269646ba89..0000000000 --- a/src/Exceptionless.Web/Startup.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System.Diagnostics; -using System.Security.Claims; -using System.Text.Json; -using Exceptionless.Core; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Serialization; -using Exceptionless.Core.Validation; -using Exceptionless.Web.Extensions; -using Exceptionless.Web.Hubs; -using Exceptionless.Web.Security; -using Exceptionless.Web.Utility; -using Exceptionless.Web.Utility.Handlers; -using Exceptionless.Web.Utility.OpenApi; -using Foundatio.Extensions.Hosting.Startup; -using Foundatio.Repositories.Exceptions; -using Joonasw.AspNetCore.SecurityHeaders; -using Joonasw.AspNetCore.SecurityHeaders.Csp; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; -using Microsoft.Net.Http.Headers; -using Scalar.AspNetCore; -using Serilog; -using Serilog.Events; - -namespace Exceptionless.Web; - -public class Startup -{ - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - public void ConfigureServices(IServiceCollection services) - { - services.AddCors(b => b.AddPolicy("AllowAny", p => p - .AllowAnyHeader() - .AllowAnyMethod() - .SetIsOriginAllowed(isOriginAllowed: _ => true) - .AllowCredentials() - .SetPreflightMaxAge(TimeSpan.FromMinutes(5)) - .WithExposedHeaders("ETag", Headers.LegacyConfigurationVersion, Headers.ConfigurationVersion, HeaderNames.Link, Headers.RateLimit, Headers.RateLimitRemaining, Headers.ResultCount))); - - services.Configure(o => - { - o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - o.RequireHeaderSymmetry = false; - o.KnownIPNetworks.Clear(); - o.KnownProxies.Clear(); - }); - - services.AddControllers(o => - { - o.ModelBinderProviders.Insert(0, new CustomAttributesModelBinderProvider()); - o.ModelMetadataDetailsProviders.Add(new SystemTextJsonValidationMetadataProvider(JsonNamingPolicy.SnakeCaseLower)); - o.InputFormatters.Insert(0, new RawRequestBodyFormatter()); - }) - .AddJsonOptions(o => - { - o.JsonSerializerOptions.ConfigureExceptionlessApiDefaults(); - o.JsonSerializerOptions.Converters.Add(new DeltaJsonConverterFactory()); - }); - - // Have to add this to get the open api json file to be snake case. - services.ConfigureHttpJsonOptions(o => - { - o.SerializerOptions.ConfigureExceptionlessApiDefaults(); - o.SerializerOptions.Converters.Add(new DeltaJsonConverterFactory()); - }); - - services.AddProblemDetails(o => o.CustomizeProblemDetails = CustomizeProblemDetails); - services.AddExceptionHandler(); - services.AddAutoValidation(); - - services.AddAuthentication(ApiKeyAuthenticationOptions.ApiKeySchema).AddApiKeyAuthentication(); - services.AddAuthorization(o => - { - o.DefaultPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); - o.AddPolicy(AuthorizationRoles.ClientPolicy, policy => policy.RequireClaim(ClaimTypes.Role, AuthorizationRoles.Client)); - o.AddPolicy(AuthorizationRoles.UserPolicy, policy => policy.RequireClaim(ClaimTypes.Role, AuthorizationRoles.User)); - o.AddPolicy(AuthorizationRoles.GlobalAdminPolicy, policy => policy.RequireClaim(ClaimTypes.Role, AuthorizationRoles.GlobalAdmin)); - }); - - services.AddRouting(r => - { - r.LowercaseUrls = true; - r.ConstraintMap.Add("identifier", typeof(IdentifierRouteConstraint)); - r.ConstraintMap.Add("identifiers", typeof(IdentifiersRouteConstraint)); - r.ConstraintMap.Add("objectid", typeof(ObjectIdRouteConstraint)); - r.ConstraintMap.Add("objectids", typeof(ObjectIdsRouteConstraint)); - r.ConstraintMap.Add("token", typeof(TokenRouteConstraint)); - r.ConstraintMap.Add("tokens", typeof(TokensRouteConstraint)); - }); - - services.AddOpenApi(o => - { - // Customize schema names to match legacy SwashBuckle naming for backwards compatibility - o.CreateSchemaReferenceId = SchemaReferenceIdHelper.CreateSchemaReferenceId; - - // Document transformers (run on entire document) - o.AddDocumentTransformer(); - o.AddDocumentTransformer(); - o.AddDocumentTransformer(); - - // Operation transformers (run on each operation) - o.AddOperationTransformer(); - o.AddOperationTransformer(); - o.AddOperationTransformer(); - - // Schema transformers (run on each schema) - alphabetical order - o.AddSchemaTransformer(); - o.AddSchemaTransformer(); - o.AddSchemaTransformer(); - o.AddSchemaTransformer(); - o.AddSchemaTransformer(); - o.AddSchemaTransformer(); - o.AddSchemaTransformer(); - o.AddSchemaTransformer(); - }); - - var appOptions = AppOptions.ReadFromConfiguration(Configuration); - Bootstrapper.RegisterServices(services, appOptions, Log.Logger.ToLoggerFactory()); - services.AddSingleton(s => - { - return new ThrottlingOptions - { - MaxRequestsForUserIdentifierFunc = userIdentifier => appOptions.ApiThrottleLimit, - Period = TimeSpan.FromMinutes(15) - }; - }); - } - - private void CustomizeProblemDetails(ProblemDetailsContext ctx) - { - ctx.ProblemDetails.Instance = $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}"; - - if (ctx.HttpContext.Items.TryGetValue("reference-id", out object? refId) && refId is string referenceId) - { - ctx.ProblemDetails.Extensions.Add("reference-id", referenceId); - } - - if (ctx.HttpContext.Items.TryGetValue("errors", out object? value) && value is Dictionary errors) - { - ctx.ProblemDetails.Extensions.Add("errors", errors); - } - - if (ctx.ProblemDetails is ValidationProblemDetails validationProblem) - { - // MVC validation keys are CLR property names; normalize them to the API's snake_case contract. - // NOTE: the key could be wrong for things like ExternalAuthInfo where the keys are camel case. - validationProblem.Errors = validationProblem.Errors - .ToDictionary( - error => error.Key.ToLowerUnderscoredWords(), - error => error.Value - ); - } - - } - - public void Configure(IApplicationBuilder app) - { - var options = app.ApplicationServices.GetRequiredService(); - Core.Bootstrapper.LogConfiguration(app.ApplicationServices, options, Log.Logger.ToLoggerFactory().CreateLogger()); - - app.UseExceptionHandler(new ExceptionHandlerOptions - { - StatusCodeSelector = ex => ex switch - { - UnauthorizedAccessException => StatusCodes.Status401Unauthorized, - MiniValidatorException => StatusCodes.Status422UnprocessableEntity, - ApplicationException applicationException when applicationException.Message.Contains("version_conflict") => StatusCodes.Status409Conflict, - VersionConflictDocumentException => StatusCodes.Status409Conflict, - NotImplementedException => StatusCodes.Status501NotImplemented, - _ => StatusCodes.Status500InternalServerError - } - }); - app.UseStatusCodePages(); - - app.UseHealthChecks("/health", new HealthCheckOptions - { - Predicate = hcr => hcr.Tags.Contains("Critical") || (options.RunJobsInProcess && hcr.Tags.Contains("AllJobs")) - }); - - List readyTags = ["Critical"]; - if (!options.EventSubmissionDisabled) - readyTags.Add("Storage"); - app.UseReadyHealthChecks(readyTags.ToArray()); - app.UseWaitForStartupActionsBeforeServingRequests(); - - if (!String.IsNullOrEmpty(options.ExceptionlessApiKey) && !String.IsNullOrEmpty(options.ExceptionlessServerUrl)) - app.UseExceptionless(ExceptionlessClient.Default); - - app.Use(async (context, next) => - { - if (options.AppMode != AppMode.Development && context.Request.IsLocal() == false) - context.Response.Headers.StrictTransportSecurity = "max-age=31536000; includeSubDomains"; - - context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; - context.Response.Headers.XContentTypeOptions = "nosniff"; - context.Response.Headers.XFrameOptions = "DENY"; - context.Response.Headers.XXSSProtection = "1; mode=block"; - context.Response.Headers.Remove("X-Powered-By"); - - await next(); - }); - - var serverAddressesFeature = app.ServerFeatures.Get(); - bool ssl = options.AppMode != AppMode.Development && serverAddressesFeature is not null && serverAddressesFeature.Addresses.Any(a => a.StartsWith("https://")); - - if (ssl) - app.UseHttpsRedirection(); - - app.UseCsp(csp => - { - csp.AllowFonts.FromSelf() - .From("https://fonts.gstatic.com") - .From("https://www.gravatar.com") - .From("https://fonts.intercomcdn.com") - .From("https://cdn.jsdelivr.net"); - csp.AllowImages.FromSelf() - .From("data:") - .From("https://q.stripe.com") - .From("https://js.intercomcdn.com") - .From("https://downloads.intercomcdn.com") - .From("https://uploads.intercomcdn.com") - .From("https://static.intercomassets.com") - .From("https://user-images.githubusercontent.com") - .From("https://www.gravatar.com") - .From("http://www.gravatar.com"); - csp.AllowScripts.FromSelf() - .AllowUnsafeInline() - .AllowUnsafeEval() - .From("https://js.stripe.com") - .From("https://widget.intercom.io") - .From("https://js.intercomcdn.com") - .From("https://cdn.jsdelivr.net"); - csp.AllowStyles.FromSelf() - .AllowUnsafeInline() - .From("https://fonts.googleapis.com") - .From("https://cdn.jsdelivr.net"); - csp.AllowConnections.ToSelf() - .To("https://collector.exceptionless.io") - .To("https://config.exceptionless.io") - .To("https://heartbeat.exceptionless.io") - .To("https://api-iam.intercom.io/") - .To("wss://nexus-websocket-a.intercom.io"); - - csp.OnSendingHeader = new Func(context => - { - context.ShouldNotSend = context.HttpContext.Request.Path.StartsWithSegments("/api"); - return Task.CompletedTask; - }); - }); - - app.UseSerilogRequestLogging(o => - { - o.EnrichDiagnosticContext = (context, httpContext) => - { - if (Activity.Current?.Id is not null) - context.Set("ActivityId", Activity.Current.Id); - }; - o.MessageTemplate = "{ActivityId} HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; - o.GetLevel = (context, duration, ex) => - { - if (ex is not null || context.Response.StatusCode > 499) - return LogEventLevel.Error; - - if (context.Response.StatusCode > 399) - return LogEventLevel.Information; - - if (duration < 1000 || context.Request.Path.StartsWithSegments("/api/v2/push")) - return LogEventLevel.Debug; - - return LogEventLevel.Information; - }; - }); - - app.UseStaticFiles(); - app.UseDefaultFiles(); - app.UseFileServer(); - app.UseRouting(); - app.UseCors("AllowAny"); - app.UseHttpMethodOverride(); - app.UseForwardedHeaders(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseMiddleware(); - app.UseMiddleware(); - - if (options.ApiThrottleLimit < Int32.MaxValue) - { - // Throttle api calls to X every 15 minutes by IP address. - app.UseMiddleware(); - } - - // Reject event posts in organizations over their max event limits. - app.UseMiddleware(); - - if (options.EnableWebSockets) - { - app.UseWebSockets(); - app.UseMiddleware(); - } - - app.UseEndpoints(endpoints => - { - endpoints.MapOpenApi("/docs/v2/openapi.json"); - endpoints.MapScalarApiReference("/docs", o => - { - o.WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") - .AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true) - .AddPreferredSecuritySchemes("Bearer"); - }); - - endpoints.MapControllers(); - endpoints.MapFallback("{**slug:nonfile}", CreateRequestDelegate(endpoints, "/index.html")); - }); - } - - private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) - { - var app = endpoints.CreateApplicationBuilder(); - var apiPathSegment = new PathString("/api"); - var docsPathSegment = new PathString("/docs"); - var nextPathSegment = new PathString("/next"); - app.Use(next => context => - { - bool isApiRequest = context.Request.Path.StartsWithSegments(apiPathSegment); - bool isDocsRequest = context.Request.Path.StartsWithSegments(docsPathSegment); - bool isNextRequest = context.Request.Path.StartsWithSegments(nextPathSegment); - - if (!isApiRequest && !isDocsRequest && !isNextRequest) - context.Request.Path = "/" + filePath; - else if (!isApiRequest && !isDocsRequest) - context.Request.Path = "/next/" + filePath; - - // Set endpoint to null so the static files middleware will handle the request. - context.SetEndpoint(null); - - return next(context); - }); - - app.UseStaticFiles(); - return app.Build(); - } -} diff --git a/src/Exceptionless.Web/Utility/Delta/Delta.cs b/src/Exceptionless.Web/Utility/Delta/Delta.cs deleted file mode 100644 index 6fed3be62c..0000000000 --- a/src/Exceptionless.Web/Utility/Delta/Delta.cs +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. - -using System.Collections.Concurrent; -using System.Dynamic; -using System.Text.Json; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Reflection; - -namespace Exceptionless.Web.Utility; - -/// -/// A class the tracks changes (i.e. the Delta) for a particular . -/// -/// TEntityType is the base type of entity this delta tracks changes for. -public class Delta : DynamicObject /*, IDelta */ where TEntityType : class -{ - // cache property accessors for this type and all its derived types. - private static readonly ConcurrentDictionary> _propertyCache = new(); - - private Dictionary _propertiesThatExist = null!; - private readonly Dictionary _unknownProperties = new(); - private HashSet _changedProperties = null!; - private TEntityType _entity = null!; - private Type _entityType = null!; - private readonly JsonSerializerOptions? _options; - - /// - /// Initializes a new instance of . - /// - public Delta() : this(typeof(TEntityType)) - { - } - - /// - /// Initializes a new instance of . - /// - public Delta(Type entityType, JsonSerializerOptions? options = null) - { - _options = options; - Initialize(entityType); - } - - /// - /// The actual type of the entity for which the changes are tracked. - /// - internal Type EntityType => _entityType; - - /// - /// Clears the Delta and resets the underlying Entity. - /// - public void Clear() - { - Initialize(_entityType); - } - - /// - /// Attempts to set the Property called to the specified. - /// - /// Only properties that exist on can be set. - /// If there is a type mismatch the request will fail. - /// - /// - /// The name of the Property - /// The new value of the Property - /// The target entity to set the value on - /// True if successful - public bool TrySetPropertyValue(string name, object? value, TEntityType? target = null) - { - ArgumentNullException.ThrowIfNull(name); - - if (!_propertiesThatExist.TryGetValue(name, out var cacheHit)) - return false; - - if (value is null && !IsNullable(cacheHit.MemberType)) - return false; - - if (value is not null) - { - if (value is JsonElement jsonElement) - { - try - { - value = _options is not null - ? JsonSerializer.Deserialize(jsonElement.GetRawText(), cacheHit.MemberType, _options) - : JsonSerializer.Deserialize(jsonElement.GetRawText(), cacheHit.MemberType); - } - catch (Exception) - { - return false; - } - } - else - { - bool isGuid = cacheHit.MemberType == typeof(Guid) && value is string; - bool isEnum = cacheHit.MemberType.IsEnum && value is long and <= Int32.MaxValue; - bool isInt32 = cacheHit.MemberType == typeof(int) && value is long and <= Int32.MaxValue; - - if (!cacheHit.MemberType.IsPrimitive && !isGuid && !isEnum && !cacheHit.MemberType.IsInstanceOfType(value)) - return false; - - if (isGuid) - value = new Guid((string)value); - if (isInt32) - value = (int)(long)value; - if (isEnum) - value = Enum.Parse(cacheHit.MemberType, value.ToString() ?? throw new InvalidOperationException()); - } - } - - //.Setter.Invoke(_entity, new object[] { value }); - cacheHit.SetValue(target ?? _entity, value); - _changedProperties.Add(name); - return true; - } - - /// - /// Attempts to get the value of the Property called from the underlying Entity. - /// - /// Only properties that exist on can be retrieved. - /// Both modified and unmodified properties can be retrieved. - /// - /// - /// The name of the Property - /// The value of the Property - /// The target entity to get the value from - /// True if the Property was found - public bool TryGetPropertyValue(string name, out object? value, TEntityType? target = null) - { - ArgumentNullException.ThrowIfNull(name); - - if (_propertiesThatExist.TryGetValue(name, out var cacheHit)) - { - value = cacheHit.GetValue(target ?? _entity); - return true; - } - - value = null; - return false; - } - - /// - /// Attempts to get the of the Property called from the underlying Entity. - /// - /// Only properties that exist on can be retrieved. - /// Both modified and unmodified properties can be retrieved. - /// - /// - /// The name of the Property - /// The type of the Property - /// Returns true if the Property was found and false if not. - public bool TryGetPropertyType(string name, out Type? type) - { - ArgumentNullException.ThrowIfNull(name); - - if (_propertiesThatExist.TryGetValue(name, out var value)) - { - type = value.MemberType; - return true; - } - - type = null; - return false; - } - - /// - /// A dictionary of values that were set on the delta that don't exist in TEntityType. - /// - public IDictionary UnknownProperties => _unknownProperties; - - /// - /// Overrides the DynamicObject TrySetMember method, so that only the properties - /// of can be set. - /// - public override bool TrySetMember(SetMemberBinder binder, object? value) - { - ArgumentNullException.ThrowIfNull(binder); - - // add properties that don't exist to the unknown properties collect - if (!_propertiesThatExist.ContainsKey(binder.Name)) - { - _unknownProperties[binder.Name] = value; - return true; - } - - return TrySetPropertyValue(binder.Name, value); - } - - /// - /// Overrides the DynamicObject TryGetMember method, so that only the properties - /// of can be got. - /// - public override bool TryGetMember(GetMemberBinder binder, out object? result) - { - ArgumentNullException.ThrowIfNull(binder); - - return TryGetPropertyValue(binder.Name, out result); - } - - /// - /// Returns the instance - /// that holds all the changes (and original values) being tracked by this Delta. - /// - public TEntityType GetEntity() - { - return _entity; - } - - /// - /// Returns the Properties that have been modified through this Delta as an - /// enumeration of Property Names - /// - public IEnumerable GetChangedPropertyNames() - { - return _changedProperties; - } - - /// - /// Returns the Properties that have been modified from their original values through this Delta as an - /// enumeration of Property Names - /// - public IEnumerable GetChangedPropertyNames(TEntityType? original) - { - if (original is null) - return _changedProperties; - - var changedPropertyNames = new HashSet(); - - foreach (string propertyName in _changedProperties) - { - if (!TryGetPropertyValue(propertyName, out object? originalValue, original)) - changedPropertyNames.Add(propertyName); - - if (!TryGetPropertyValue(propertyName, out object? newValue)) - continue; - - if (originalValue is null && newValue is null) - continue; - - if (newValue is null || !newValue.Equals(originalValue)) - changedPropertyNames.Add(propertyName); - } - - return changedPropertyNames; - } - - /// - /// Returns the Properties that have not been modified through this Delta as an - /// enumeration of Property Names - /// - public IEnumerable GetUnchangedPropertyNames() - { - return _propertiesThatExist.Keys.Except(GetChangedPropertyNames()); - } - - /// - /// Copies any changed property values that match up from the underlying entity (accessible via ) - /// to the entity. - /// - /// The target entity to be updated. - public void CopyChangedValues(object target) - { - ArgumentNullException.ThrowIfNull(target); - - var targetType = target.GetType(); - if (!_propertyCache.ContainsKey(targetType)) - CachePropertyAccessors(targetType); - - var propertiesToCopy = GetChangedPropertyNames().Select(s => _propertiesThatExist[s]).ToArray(); - - foreach (var sourceProperty in propertiesToCopy) - { - object? value = sourceProperty.GetValue(_entity); - if (!_propertyCache[targetType].TryGetValue(sourceProperty.Name, out var targetAccessor)) - continue; - - if (!targetAccessor.MemberType.IsInstanceOfType(value)) - continue; - - targetAccessor.SetValue(target, value); - } - } - - /// - /// Copies the unchanged property values from the underlying entity (accessible via ) - /// to the entity. - /// - /// The entity to be updated. - public void CopyUnchangedValues(object target) - { - ArgumentNullException.ThrowIfNull(target); - - var targetType = target.GetType(); - if (!_propertyCache.ContainsKey(targetType)) - CachePropertyAccessors(targetType); - - var propertiesToCopy = GetUnchangedPropertyNames().Select(s => _propertiesThatExist[s]).ToArray(); - - foreach (var sourceProperty in propertiesToCopy) - { - object? value = sourceProperty.GetValue(_entity); - if (!_propertyCache[targetType].TryGetValue(sourceProperty.Name, out var targetAccessor)) - continue; - - if (!targetAccessor.MemberType.IsInstanceOfType(value)) - continue; - - targetAccessor.SetValue(target, value); - } - } - - /// - /// Overwrites the entity with the changes tracked by this Delta. - /// The semantics of this operation are equivalent to a HTTP PATCH operation, hence the name. - /// - /// The entity to be updated. - public void Patch(object target) - { - CopyChangedValues(target); - } - - /// - /// Overwrites the entity with the values stored in this Delta. - /// The semantics of this operation are equivalent to a HTTP PUT operation, hence the name. - /// - /// The entity to be updated. - public void Put(object target) - { - CopyChangedValues(target); - CopyUnchangedValues(target); - } - - private void Initialize(Type entityType) - { - ArgumentNullException.ThrowIfNull(entityType); - - if (!typeof(TEntityType).IsAssignableFrom(entityType)) - throw new InvalidOperationException("Delta Entity Type Not Assignable"); - - _entity = Activator.CreateInstance(entityType) as TEntityType ?? throw new InvalidOperationException(); - _changedProperties = new HashSet(); - _entityType = entityType; - CachePropertyAccessors(entityType); - _propertiesThatExist = _propertyCache[entityType]; - } - - private static void CachePropertyAccessors(Type type) - { - _propertyCache.GetOrAdd(type, t => - { - var properties = t.GetProperties() - .Where(p => p.GetSetMethod() is not null && p.GetGetMethod() is not null) - .Select(LateBinder.GetPropertyAccessor).ToList(); - - var items = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var p in properties) - { - items[p.Name] = p; - items[p.Name.ToLowerUnderscoredWords()] = p; - } - - return items; - }); - } - - public static bool IsNullable(Type type) - { - return !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; - } -} diff --git a/src/Exceptionless.Web/Utility/Delta/DeltaJsonConverter.cs b/src/Exceptionless.Web/Utility/Delta/DeltaJsonConverter.cs deleted file mode 100644 index 2e71eb7fc5..0000000000 --- a/src/Exceptionless.Web/Utility/Delta/DeltaJsonConverter.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Exceptionless.Web.Utility; - -/// -/// JsonConverterFactory for Delta<T> types to support System.Text.Json deserialization. -/// -public class DeltaJsonConverterFactory : JsonConverterFactory -{ - public override bool CanConvert(Type typeToConvert) - { - if (!typeToConvert.IsGenericType) - { - return false; - } - - return typeToConvert.GetGenericTypeDefinition() == typeof(Delta<>); - } - - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) - { - var entityType = typeToConvert.GetGenericArguments()[0]; - var converterType = typeof(DeltaJsonConverter<>).MakeGenericType(entityType); - - return (JsonConverter?)Activator.CreateInstance(converterType, options); - } -} - -/// -/// JsonConverter for Delta<T> that reads JSON properties and sets them on the Delta instance. -/// -public class DeltaJsonConverter : JsonConverter> where TEntityType : class -{ - private readonly JsonSerializerOptions _options; - private readonly Dictionary _propertyNameMap; - - public DeltaJsonConverter(JsonSerializerOptions options) - { - // Create a copy without the factory to avoid infinite recursion if a Delta property - // ever appears inside another Delta model. - _options = new JsonSerializerOptions(options); - for (int i = _options.Converters.Count - 1; i >= 0; i--) - { - if (_options.Converters[i] is DeltaJsonConverterFactory) - { - _options.Converters.RemoveAt(i); - break; - } - } - - // Build a mapping from JSON property names (snake_case) to C# property names (PascalCase) - _propertyNameMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - var entityType = typeof(TEntityType); - foreach (var prop in entityType.GetProperties()) - { - // [JsonPropertyName] takes precedence over the naming policy - var jsonPropertyNameAttr = prop.GetCustomAttribute(); - var jsonName = jsonPropertyNameAttr?.Name ?? options.PropertyNamingPolicy?.ConvertName(prop.Name) ?? prop.Name; - _propertyNameMap[jsonName] = prop.Name; - } - } - - public override Delta? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Null) - { - return null; - } - - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException("Expected StartObject token"); - } - - var delta = new Delta(typeof(TEntityType), _options); - - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndObject) - { - break; - } - - if (reader.TokenType != JsonTokenType.PropertyName) - { - throw new JsonException("Expected PropertyName token"); - } - - var jsonPropertyName = reader.GetString(); - if (jsonPropertyName is null) - { - throw new JsonException("Property name is null"); - } - - reader.Read(); - - // Convert JSON property name (snake_case) to C# property name (PascalCase) - var propertyName = _propertyNameMap.TryGetValue(jsonPropertyName, out var mapped) - ? mapped - : jsonPropertyName; - - // Try to get the property type from Delta - if (delta.TryGetPropertyType(propertyName, out var propertyType) && propertyType is not null) - { - var value = JsonSerializer.Deserialize(ref reader, propertyType, _options); - delta.TrySetPropertyValue(propertyName, value); - } - else - { - // Unknown property - read and store as JsonElement - var element = JsonSerializer.Deserialize(ref reader, _options); - delta.UnknownProperties[jsonPropertyName] = element; - } - } - - return delta; - } - - public override void Write(Utf8JsonWriter writer, Delta value, JsonSerializerOptions options) - { - writer.WriteStartObject(); - - foreach (var (propertyName, propertyValue) in value.GetChangedPropertyNames() - .Select(name => (Name: name, HasValue: value.TryGetPropertyValue(name, out var val), Value: val)) - .Where(x => x.HasValue) - .Select(x => (x.Name, x.Value))) - { - // Convert property name to snake_case if needed - var jsonPropertyName = options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName; - writer.WritePropertyName(jsonPropertyName); - JsonSerializer.Serialize(writer, propertyValue, _options); - } - - foreach (var kvp in value.UnknownProperties) - { - var jsonPropertyName = options.PropertyNamingPolicy?.ConvertName(kvp.Key) ?? kvp.Key; - writer.WritePropertyName(jsonPropertyName); - JsonSerializer.Serialize(writer, kvp.Value, _options); - } - - writer.WriteEndObject(); - } -} diff --git a/src/Exceptionless.Web/Utility/Handlers/ExceptionToProblemDetailsHandler.cs b/src/Exceptionless.Web/Utility/Handlers/ExceptionToProblemDetailsHandler.cs index 749fa5d062..23866641ea 100644 --- a/src/Exceptionless.Web/Utility/Handlers/ExceptionToProblemDetailsHandler.cs +++ b/src/Exceptionless.Web/Utility/Handlers/ExceptionToProblemDetailsHandler.cs @@ -28,6 +28,13 @@ public ValueTask TryHandleAsync(HttpContext httpContext, Exception excepti error => error.Value )); } + else if (exception is BadHttpRequestException badRequestException) + { + httpContext.Items.Add("errors", new Dictionary + { + [""] = [badRequestException.Message] + }); + } return ValueTask.FromResult(false); } diff --git a/src/Exceptionless.Web/Utility/OpenApi/DataAnnotationHelper.cs b/src/Exceptionless.Web/Utility/OpenApi/DataAnnotationHelper.cs index 0c14258be9..43d1879f85 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/DataAnnotationHelper.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/DataAnnotationHelper.cs @@ -26,7 +26,7 @@ namespace Exceptionless.Web.Utility.OpenApi; /// To add support for additional annotations, add them here and they will automatically apply to: /// /// Regular class/record properties via DataAnnotationsSchemaTransformer -/// Delta<T> PATCH models via DeltaSchemaTransformer +/// JsonPatchDocument<T> PATCH models via JsonPatchDocumentSchemaTransformer /// /// /// diff --git a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs deleted file mode 100644 index 34f000b90a..0000000000 --- a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using Exceptionless.Core.Extensions; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi; - -namespace Exceptionless.Web.Utility.OpenApi; - -/// -/// Schema transformer that populates Delta<T> schemas with the properties from T. -/// All properties are optional to represent PATCH semantics (partial updates). -/// -public class DeltaSchemaTransformer : IOpenApiSchemaTransformer -{ - private static readonly NullabilityInfoContext NullabilityContext = new(); - - public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) - { - var type = context.JsonTypeInfo.Type; - - // Check if this is a Delta type - if (!IsDeltaType(type)) - return Task.CompletedTask; - - // Get the inner type T from Delta - var innerType = type.GetGenericArguments().FirstOrDefault(); - if (innerType is null) - return Task.CompletedTask; - - // Set the type to object - schema.Type = JsonSchemaType.Object; - - // Add properties from the inner type - schema.Properties ??= new Dictionary(); - - foreach (var property in innerType.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanRead && p.CanWrite)) - { - bool isNullable = IsPropertyNullable(property); - var propertySchema = CreateSchemaForType(property.PropertyType, isNullable); - - // Apply data annotations from the inner type's property - DataAnnotationHelper.ApplyToSchema(propertySchema, property); - ApplyArrayAnnotations(propertySchema, property); - - string propertyName = property.Name.ToLowerUnderscoredWords(); - schema.Properties[propertyName] = propertySchema; - } - - // Ensure no required array - all properties are optional for PATCH - schema.Required = null; - - return Task.CompletedTask; - } - - private static bool IsDeltaType(Type type) - { - return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Delta<>); - } - - private static bool IsPropertyNullable(PropertyInfo property) - { - // Check for Nullable value types - if (Nullable.GetUnderlyingType(property.PropertyType) is not null) - return true; - - // Check for nullable reference types using NullabilityInfo - try - { - var nullabilityInfo = NullabilityContext.Create(property); - return nullabilityInfo.WriteState == NullabilityState.Nullable; - } - catch - { - // If we can't determine nullability, assume not nullable - return false; - } - } - - private static OpenApiSchema CreateSchemaForType(Type type, bool isNullable) - { - var schema = new OpenApiSchema(); - JsonSchemaType schemaType = default; - - // Handle nullable value types (int?, DateTime?, etc.) - var underlyingType = Nullable.GetUnderlyingType(type); - if (underlyingType is not null) - { - type = underlyingType; - isNullable = true; - } - - // Add null type if nullable - if (isNullable) - { - schemaType |= JsonSchemaType.Null; - } - - if (type == typeof(string)) - { - schemaType |= JsonSchemaType.String; - } - else if (type == typeof(bool)) - { - schemaType |= JsonSchemaType.Boolean; - } - else if (type == typeof(int) || type == typeof(long) || type == typeof(short) || type == typeof(byte)) - { - schemaType |= JsonSchemaType.Integer; - } - else if (type == typeof(double) || type == typeof(float) || type == typeof(decimal)) - { - schemaType |= JsonSchemaType.Number; - } - else if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) - { - schemaType |= JsonSchemaType.String; - schema.Format = "date-time"; - } - else if (type == typeof(Guid)) - { - schemaType |= JsonSchemaType.String; - schema.Format = "uuid"; - } - else if (type.IsEnum) - { - schemaType |= JsonSchemaType.String; - } - else if (type.IsGenericType && type.GetInterfaces().Concat([type]).Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>))) - { - schemaType |= JsonSchemaType.Object; - var valueType = type.GetGenericArguments().ElementAtOrDefault(1); - if (valueType is not null) - { - schema.AdditionalProperties = CreateSchemaForType(valueType, false); - } - } - else if (TryGetEnumerableElementType(type, out var elementType)) - { - schemaType |= JsonSchemaType.Array; - schema.Items = CreateSchemaForType(elementType, false); - } - else - { - schemaType = JsonSchemaType.Object; - } - - schema.Type = schemaType; - return schema; - } - - private static void ApplyArrayAnnotations(OpenApiSchema schema, PropertyInfo property) - { - if (!schema.Type.HasValue || (schema.Type.Value & JsonSchemaType.Array) != JsonSchemaType.Array) - { - return; - } - - var maxLength = property.GetCustomAttribute(); - if (maxLength is { Length: > -1 }) - { - schema.MaxItems = maxLength.Length; - } - } - - private static bool TryGetEnumerableElementType(Type type, out Type elementType) - { - if (type.IsArray) - { - elementType = type.GetElementType() ?? typeof(object); - return true; - } - - if (type == typeof(string) || !typeof(System.Collections.IEnumerable).IsAssignableFrom(type)) - { - elementType = typeof(object); - return false; - } - - var enumerableType = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>) - ? type - : type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); - - elementType = enumerableType?.GetGenericArguments()[0] ?? typeof(object); - return true; - } -} diff --git a/src/Exceptionless.Web/Utility/OpenApi/EndpointDocumentationOperationTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/EndpointDocumentationOperationTransformer.cs new file mode 100644 index 0000000000..2980aeffd7 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/EndpointDocumentationOperationTransformer.cs @@ -0,0 +1,137 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Defines an additional parameter to inject into the OpenAPI operation. +/// Used for parameters that are read from HttpContext rather than method signatures. +/// +public sealed record AdditionalParameterDefinition( + string Name, + string In, // "query" or "header" + string? Description = null, + bool Required = false, + string Type = "string", + string? Format = null +); + +/// +/// Metadata record that holds API documentation for an endpoint's parameters and responses. +/// Applied via .WithMetadata() on endpoint definitions. +/// +public sealed record EndpointDocumentation +{ + public string? RequestBodyDescription { get; init; } + public Dictionary ParameterDescriptions { get; init; } = new(); + public Dictionary ResponseDescriptions { get; init; } = new(); + public List AdditionalParameters { get; init; } = new(); +} + +/// +/// Operation transformer that reads EndpointDocumentation metadata +/// and applies parameter/response descriptions to the OpenAPI operation. +/// +public class EndpointDocumentationOperationTransformer : IOpenApiOperationTransformer +{ + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var documentation = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .FirstOrDefault(); + + if (documentation is null) + return Task.CompletedTask; + + // Inject additional parameters that don't already exist + if (documentation.AdditionalParameters.Count > 0) + { + operation.Parameters ??= []; + + foreach (var additionalParam in documentation.AdditionalParameters) + { + // Skip if parameter already exists + var location = additionalParam.In == "header" ? ParameterLocation.Header : ParameterLocation.Query; + if (operation.Parameters.Any(p => string.Equals(p.Name, additionalParam.Name, StringComparison.OrdinalIgnoreCase) && p.In == location)) + continue; + + OpenApiSchema schema; + + if (additionalParam.Type == "array") + { + // Array type — items are key-value pairs from query string + var itemSchema = new OpenApiSchema { Type = JsonSchemaType.Object }; + itemSchema.Required = new HashSet { "key", "value" }; + itemSchema.Properties = new Dictionary + { + ["key"] = new OpenApiSchema { Type = JsonSchemaType.Null | JsonSchemaType.String }, + ["value"] = new OpenApiSchema { Type = JsonSchemaType.Array, Items = new OpenApiSchema { Type = JsonSchemaType.String } } + }; + schema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = itemSchema + }; + } + else + { + schema = new OpenApiSchema(); + schema.Type = additionalParam.Type switch + { + "integer" => JsonSchemaType.Integer, + "number" => JsonSchemaType.Number, + "boolean" => JsonSchemaType.Boolean, + _ => JsonSchemaType.String, + }; + if (additionalParam.Format is not null) + schema.Format = additionalParam.Format; + } + + var param = new OpenApiParameter + { + Name = additionalParam.Name, + In = location, + Required = additionalParam.Required, + Schema = schema + }; + + if (additionalParam.Description is not null) + param.Description = additionalParam.Description; + + operation.Parameters.Add(param); + } + } + + // Apply parameter descriptions + if (operation.Parameters is not null) + { + foreach (var param in operation.Parameters) + { + if (param.Name is not null && documentation.ParameterDescriptions.TryGetValue(param.Name, out var description)) + { + param.Description = description; + } + } + } + + // Apply response descriptions + if (operation.Responses is not null) + { + foreach (var (code, desc) in documentation.ResponseDescriptions) + { + if (operation.Responses.TryGetValue(code, out var response)) + { + response.Description = desc; + } + } + } + + // Apply request body description + if (documentation.RequestBodyDescription is not null && operation.RequestBody is not null) + { + operation.RequestBody.Description = documentation.RequestBodyDescription; + } + + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/JsonPatchDocumentSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/JsonPatchDocumentSchemaTransformer.cs new file mode 100644 index 0000000000..d52f6debdd --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/JsonPatchDocumentSchemaTransformer.cs @@ -0,0 +1,59 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Schema transformer that replaces auto-generated schemas for JsonPatchDocument<T> with the +/// standard RFC 6902 JSON Patch array schema: an array of operation objects with op, path, value, and from. +/// +public class JsonPatchDocumentSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (!IsJsonPatchDocumentType(context.JsonTypeInfo.Type)) + return Task.CompletedTask; + + // RFC 6902: JSON Patch is an array of operations + schema.Type = JsonSchemaType.Array; + schema.Properties?.Clear(); + + schema.Items = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "op", "path" }, + Properties = new Dictionary + { + ["op"] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Enum = [JsonValue.Create("replace"), JsonValue.Create("test")], + Description = "The operation to perform (only 'replace' and 'test' are supported)." + }, + ["path"] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "A JSON Pointer (RFC 6901) to the target property, using snake_case naming (e.g., '/full_name')." + }, + ["value"] = new OpenApiSchema + { + Description = "The value to use for the operation." + }, + ["from"] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "A JSON Pointer to the source property (only used with 'move' and 'copy' operations)." + } + } + }; + + return Task.CompletedTask; + } + + private static bool IsJsonPatchDocumentType(Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>); + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/RequestBodyContentOperationTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/RequestBodyContentOperationTransformer.cs index 15db2876ba..80c8978564 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/RequestBodyContentOperationTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/RequestBodyContentOperationTransformer.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; @@ -14,6 +15,31 @@ public class RequestBodyContentOperationTransformer : IOpenApiOperationTransform { public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { + var requestBodySchema = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .FirstOrDefault(); + if (requestBodySchema is not null) + { + var document = context.Document!; + document.Components ??= new OpenApiComponents(); + document.Components.Schemas ??= new Dictionary(); + document.Components.Schemas.TryAdd(requestBodySchema.SchemaReferenceId, CreateJsonPatchSchema()); + + operation.RequestBody = new OpenApiRequestBody + { + Required = true, + Content = new Dictionary + { + [requestBodySchema.ContentType] = new() + { + Schema = new OpenApiSchemaReference(requestBodySchema.SchemaReferenceId, document) + } + } + }; + + return Task.CompletedTask; + } + var methodInfo = context.Description.ActionDescriptor.EndpointMetadata .OfType() .FirstOrDefault(); @@ -82,6 +108,42 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + + private static OpenApiSchema CreateJsonPatchSchema() + { + return new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "op", "path" }, + Properties = new Dictionary + { + ["op"] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Enum = [JsonValue.Create("replace"), JsonValue.Create("test")], + Description = "The operation to perform (only 'replace' and 'test' are supported)." + }, + ["path"] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "A JSON Pointer (RFC 6901) to the target property, using snake_case naming (e.g., '/full_name')." + }, + ["value"] = new OpenApiSchema + { + Description = "The value to use for the operation." + }, + ["from"] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "A JSON Pointer to the source property (only used with 'move' and 'copy' operations)." + } + } + } + }; + } } /// @@ -105,3 +167,23 @@ public MultipartFileUploadAttribute(string fileParameterName = "file") FileParameterName = fileParameterName; } } + +[AttributeUsage(AttributeTargets.Method)] +public class JsonPatchRequestBodyAttribute : RequestBodySchemaAttribute +{ + public JsonPatchRequestBodyAttribute() : base($"{typeof(T).Name}JsonPatchDocument", "application/json-patch+json") + { + } +} + +public abstract class RequestBodySchemaAttribute : Attribute +{ + protected RequestBodySchemaAttribute(string schemaReferenceId, string contentType) + { + SchemaReferenceId = schemaReferenceId; + ContentType = contentType; + } + + public string SchemaReferenceId { get; } + public string ContentType { get; } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/SchemaReferenceIdHelper.cs b/src/Exceptionless.Web/Utility/OpenApi/SchemaReferenceIdHelper.cs index a72e1fe507..a4fef8f566 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/SchemaReferenceIdHelper.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/SchemaReferenceIdHelper.cs @@ -17,12 +17,11 @@ public static class SchemaReferenceIdHelper { var type = typeInfo.Type; - // Delta -> T (e.g., Delta -> UpdateToken) - // Delta is used for PATCH operations; the schema name should match the inner type - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Delta<>)) + // JsonPatchDocument -> {T}JsonPatchDocument (e.g., JsonPatchDocument -> UpdateTokenJsonPatchDocument) + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument<>)) { var innerType = type.GetGenericArguments()[0]; - return innerType.Name; + return $"{innerType.Name}JsonPatchDocument"; } // ValueFromBody -> {T}ValueFromBody (e.g., ValueFromBody -> StringValueFromBody) diff --git a/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs index 1e50184f17..7064d7509a 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactory.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs @@ -2,15 +2,24 @@ using System.Net; using Aspire.Hosting; using Aspire.Hosting.Testing; +using Exceptionless.Core; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Utility; using Exceptionless.Insulation.Configuration; -using Exceptionless.Web; +using Foundatio.Resilience; +using Foundatio.Serializer; +using Foundatio.Storage; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Xunit; namespace Exceptionless.Tests; -public class AppWebHostFactory : WebApplicationFactory, IAsyncLifetime +public class AppWebHostFactory : WebApplicationFactory, IAsyncLifetime { private const string SharedElasticsearchUrl = "http://localhost:9200"; private static int s_counter = -1; @@ -74,22 +83,88 @@ private static async Task WaitForElasticsearchAsync(Uri elasticsearchUri) protected override void ConfigureWebHost(IWebHostBuilder builder) { + builder.UseEnvironment(Environments.Development); + builder.UseDefaultServiceProvider(options => + { + // Disable ValidateOnBuild because the service graph uses lambda factories + // (queues, caching, Elasticsearch config) that resolve dependencies at runtime + // through IServiceProvider, which cannot be statically validated at build time. + options.ValidateOnBuild = false; + options.ValidateScopes = true; + }); builder.UseSolutionRelativeContentRoot("src/Exceptionless.Web", "*.slnx"); + builder.ConfigureAppConfiguration((_, config) => + { + config.SetBasePath(AppContext.BaseDirectory) + .AddYamlFile("appsettings.yml", optional: false, reloadOnChange: false) + .AddInMemoryCollection(new Dictionary + { + ["AppScope"] = AppScope, + ["ConnectionStrings:Elasticsearch"] = SharedElasticsearchUrl + }); + }); + + // In the minimal hosting model, Program.Main reads AppOptions BEFORE Build() applies + // ConfigureAppConfiguration overrides. Re-register AppOptions from the final configuration + // so the per-instance AppScope (test, test-1, test-2) is used correctly. + builder.ConfigureTestServices(services => + { + services.AddSingleton(sp => + { + var config = (IConfigurationRoot)sp.GetRequiredService(); + var opts = AppOptions.ReadFromConfiguration(config); + opts.QueueOptions.MetricsPollingEnabled = opts.RunJobsInProcess; + return opts; + }); + services.AddSingleton(sp => sp.GetRequiredService().CacheOptions); + services.AddSingleton(sp => sp.GetRequiredService().MessageBusOptions); + services.AddSingleton(sp => sp.GetRequiredService().QueueOptions); + services.AddSingleton(sp => sp.GetRequiredService().StorageOptions); + services.AddSingleton(sp => sp.GetRequiredService().EmailOptions); + services.AddSingleton(sp => sp.GetRequiredService().ElasticsearchOptions); + services.AddSingleton(sp => sp.GetRequiredService().IntercomOptions); + services.AddSingleton(sp => sp.GetRequiredService().SlackOptions); + services.AddSingleton(sp => sp.GetRequiredService().StripeOptions); + services.AddSingleton(sp => sp.GetRequiredService().AuthOptions); + + // Storage is registered before ConfigureAppConfiguration's AppScope override is applied. + // Recreate it from the final test AppOptions so parallel test factories don't delete each + // other's queued event payloads while ResetDataAsync clears scoped storage. + services.ReplaceSingleton(CreateScopedFileStorage); + }); } - protected override IHostBuilder CreateHostBuilder() + private static IFileStorage CreateScopedFileStorage(IServiceProvider serviceProvider) { - var config = new ConfigurationBuilder() - .SetBasePath(AppContext.BaseDirectory) - .AddYamlFile("appsettings.yml", optional: false, reloadOnChange: false) - .AddInMemoryCollection(new Dictionary + var options = serviceProvider.GetRequiredService().StorageOptions; + IFileStorage storage; + + if (String.Equals(options.Provider, "folder", StringComparison.OrdinalIgnoreCase)) + { + string path = options.Data.GetString("path", "|DataDirectory|\\storage"); + storage = new FolderFileStorage(new FolderFileStorageOptions { - ["AppScope"] = AppScope, - ["ConnectionStrings:Elasticsearch"] = SharedElasticsearchUrl - }) - .Build(); + Folder = PathHelper.ExpandPath(path), + Serializer = serviceProvider.GetRequiredService(), + TimeProvider = serviceProvider.GetRequiredService(), + ResiliencePolicyProvider = serviceProvider.GetRequiredService(), + LoggerFactory = serviceProvider.GetRequiredService() + }); + } + else + { + storage = new InMemoryFileStorage(new InMemoryFileStorageOptions + { + Serializer = serviceProvider.GetRequiredService(), + TimeProvider = serviceProvider.GetRequiredService(), + ResiliencePolicyProvider = serviceProvider.GetRequiredService(), + LoggerFactory = serviceProvider.GetRequiredService() + }); + } - return Web.Program.CreateHostBuilder(config, Environments.Development); + return !String.IsNullOrWhiteSpace(options.Scope) + ? new ScopedFileStorage(storage, options.Scope) + : storage; } public override ValueTask DisposeAsync() diff --git a/tests/Exceptionless.Tests/AppWebHostFactoryTests.cs b/tests/Exceptionless.Tests/AppWebHostFactoryTests.cs new file mode 100644 index 0000000000..57c3e36469 --- /dev/null +++ b/tests/Exceptionless.Tests/AppWebHostFactoryTests.cs @@ -0,0 +1,30 @@ +using System.Text; +using Foundatio.Storage; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Exceptionless.Tests; + +[Collection("EventQueue")] +public sealed class AppWebHostFactoryTests +{ + [Fact] + public async Task ConfigureWebHost_MultipleFactories_IsolatesFileStorageByAppScope() + { + await using var firstFactory = new AppWebHostFactory(); + await firstFactory.InitializeAsync(); + var firstStorage = firstFactory.Services.GetRequiredService(); + + const string path = "scope-isolation/payload.txt"; + await using (var stream = new MemoryStream(Encoding.UTF8.GetBytes("payload"))) + await firstStorage.SaveFileAsync(path, stream, TestContext.Current.CancellationToken); + + await using var secondFactory = new AppWebHostFactory(); + await secondFactory.InitializeAsync(); + var secondStorage = secondFactory.Services.GetRequiredService(); + + await secondStorage.DeleteFilesAsync(await secondStorage.GetFileListAsync(cancellationToken: TestContext.Current.CancellationToken)); + + Assert.True(await firstStorage.ExistsAsync(path)); + } +} diff --git a/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs b/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs index 9777c4c00f..4cac4e5b86 100644 --- a/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs @@ -15,6 +15,7 @@ namespace Exceptionless.Tests.Controllers; +[Collection("EventQueue")] public class AdminControllerTests : IntegrationTestsBase { private readonly WorkItemJob _workItemJob; diff --git a/tests/Exceptionless.Tests/Controllers/ControllerManifestTests.cs b/tests/Exceptionless.Tests/Controllers/ControllerManifestTests.cs index 176af58b8b..fa8a773570 100644 --- a/tests/Exceptionless.Tests/Controllers/ControllerManifestTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ControllerManifestTests.cs @@ -1,7 +1,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; -using Exceptionless.Web.Controllers; +using Exceptionless.Web; using Foundatio.Xunit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,216 +13,14 @@ namespace Exceptionless.Tests.Controllers; public sealed class ControllerManifestTests(ITestOutputHelper output) : TestWithLoggingBase(output) { [Fact] - public async Task GetControllerManifest_AllEndpoints_ReturnsExpectedBaseline() + public void NoMvcControllersRemain() { - // Arrange - string baselinePath = Path.Join(AppContext.BaseDirectory, "Controllers", "Data", "controller-manifest.json"); - - // Act - string actualJson = BuildManifestJson(); - - // Set UPDATE_SNAPSHOTS=true to regenerate the baseline file. - if (String.Equals(Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS"), "true", StringComparison.OrdinalIgnoreCase)) - { - // Write to the source tree so the change produces a real git diff. - string sourcePath = Path.GetFullPath(Path.Join(AppContext.BaseDirectory, "..", "..", "..", "Controllers", "Data", "controller-manifest.json")); - await File.WriteAllTextAsync(sourcePath, actualJson, TestContext.Current.CancellationToken); - - return; - } - - // Assert - string expectedJson = (await File.ReadAllTextAsync(baselinePath, TestContext.Current.CancellationToken)).Replace("\r\n", "\n"); - actualJson = actualJson.Replace("\r\n", "\n"); - Assert.Equal(expectedJson, actualJson); - } - - internal static string BuildManifestJson() - { - var manifest = GetEndpoints() - .OrderBy(endpoint => endpoint.Route, StringComparer.Ordinal) - .ThenBy(endpoint => endpoint.HttpMethod, StringComparer.Ordinal) - .ThenBy(endpoint => endpoint.Controller, StringComparer.Ordinal) - .ThenBy(endpoint => endpoint.Action, StringComparer.Ordinal) - .ToArray(); - - return JsonSerializer.Serialize(manifest, new JsonSerializerOptions - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true - }); - } - - private static IEnumerable GetEndpoints() - { - var controllerTypes = typeof(AuthController).Assembly.GetTypes() + // After the Minimal API migration, no MVC controllers should remain. + var controllerTypes = typeof(Exceptionless.Web.Program).Assembly.GetTypes() .Where(type => !type.IsAbstract) .Where(type => typeof(ControllerBase).IsAssignableFrom(type)) - .Where(type => type.Namespace is not null - && (type.Namespace.StartsWith("Exceptionless.Web.Controllers", StringComparison.Ordinal) - || type.Namespace.StartsWith("Exceptionless.App.Controllers", StringComparison.Ordinal))) - .OrderBy(type => type.FullName, StringComparer.Ordinal); - - foreach (var controllerType in controllerTypes) - { - var controllerRoutes = controllerType.GetCustomAttributes(true) - .Select(attribute => attribute.Template) - .DefaultIfEmpty(null) - .ToArray(); - var controllerAttributes = controllerType.GetCustomAttributes(true).ToArray(); - - foreach (var method in controllerType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) - .Where(method => !method.IsSpecialName) - .Where(method => !method.GetCustomAttributes(true).Any()) - .OrderBy(method => method.Name, StringComparer.Ordinal)) - { - var httpAttributes = method.GetCustomAttributes(true).ToArray(); - if (httpAttributes.Length == 0) - continue; - - var methodRouteAttributes = method.GetCustomAttributes(true) - .OfType() - .Where(attribute => attribute.GetType() == typeof(RouteAttribute)) - .ToArray(); - var methodAttributes = method.GetCustomAttributes(true).ToArray(); - - foreach (var controllerRoute in controllerRoutes) - { - foreach (var httpAttribute in httpAttributes) - { - var routeTemplates = ResolveMethodRouteTemplates(httpAttribute, methodRouteAttributes); - string? routeName = httpAttribute.Name ?? methodRouteAttributes.FirstOrDefault()?.Name; - - foreach (var httpMethod in httpAttribute.HttpMethods.OrderBy(value => value, StringComparer.Ordinal)) - { - foreach (var routeTemplate in routeTemplates) - { - yield return new ControllerEndpointManifest - { - Controller = controllerType.Name, - Action = method.Name, - HttpMethod = httpMethod, - Route = CombineRouteTemplates(controllerRoute, routeTemplate), - Name = routeName, - Authorization = GetAuthorizationAttributes(controllerAttributes, methodAttributes), - Consumes = GetContentTypes(controllerAttributes, methodAttributes), - Produces = GetContentTypes(controllerAttributes, methodAttributes), - Obsolete = methodAttributes.OfType().Select(attribute => attribute.Message).FirstOrDefault() - ?? controllerAttributes.OfType().Select(attribute => attribute.Message).FirstOrDefault(), - ExcludeFromDescription = IsExcludedFromDescription(controllerAttributes, methodAttributes) - }; - } - } - } - } - } - } - } - - private static string[] ResolveMethodRouteTemplates(HttpMethodAttribute httpAttribute, RouteAttribute[] methodRouteAttributes) - { - if (!String.IsNullOrEmpty(httpAttribute.Template)) - return [httpAttribute.Template]; - - if (methodRouteAttributes.Length > 0) - return methodRouteAttributes.Select(attribute => attribute.Template ?? String.Empty).ToArray(); - - return [String.Empty]; - } - - private static string CombineRouteTemplates(string? controllerTemplate, string? methodTemplate) - { - if (IsAbsoluteTemplate(methodTemplate)) - return NormalizeRoute(methodTemplate!); - - if (String.IsNullOrEmpty(controllerTemplate)) - return NormalizeRoute(methodTemplate ?? String.Empty); - - if (String.IsNullOrEmpty(methodTemplate)) - return NormalizeRoute(controllerTemplate); - - return NormalizeRoute($"{controllerTemplate.TrimEnd('/')}/{methodTemplate.TrimStart('/')}"); - } - - private static bool IsAbsoluteTemplate(string? template) - { - return !String.IsNullOrEmpty(template) && (template.StartsWith("~/", StringComparison.Ordinal) || template.StartsWith("/", StringComparison.Ordinal)); - } - - private static string NormalizeRoute(string route) - { - route = route.Trim(); - if (route.StartsWith("~/", StringComparison.Ordinal)) - route = route[1..]; - else if (!route.StartsWith("/", StringComparison.Ordinal)) - route = "/" + route; - - if (route.Length > 1) - route = route.TrimEnd('/'); - - return route; - } - - private static string[] GetAuthorizationAttributes(object[] controllerAttributes, object[] methodAttributes) - { - return controllerAttributes.Concat(methodAttributes) - .Where(attribute => attribute is AuthorizeAttribute or AllowAnonymousAttribute) - .Select(DescribeAuthorizationAttribute) - .Distinct(StringComparer.Ordinal) - .OrderBy(value => value, StringComparer.Ordinal) - .ToArray(); - } - - private static string DescribeAuthorizationAttribute(object attribute) - { - if (attribute is AllowAnonymousAttribute) - return nameof(AllowAnonymousAttribute).Replace("Attribute", String.Empty, StringComparison.Ordinal); - - var authorize = (AuthorizeAttribute)attribute; - var segments = new List(); - if (!String.IsNullOrWhiteSpace(authorize.Policy)) - segments.Add($"Policy={authorize.Policy}"); - if (!String.IsNullOrWhiteSpace(authorize.Roles)) - segments.Add($"Roles={authorize.Roles}"); - if (!String.IsNullOrWhiteSpace(authorize.AuthenticationSchemes)) - segments.Add($"AuthenticationSchemes={authorize.AuthenticationSchemes}"); - - return segments.Count == 0 ? "Authorize" : $"Authorize({String.Join(", ", segments)})"; - } - - private static string[] GetContentTypes(object[] controllerAttributes, object[] methodAttributes) where TAttribute : Attribute - { - return controllerAttributes.Concat(methodAttributes) - .OfType() - .SelectMany(attribute => attribute switch - { - ConsumesAttribute consumes => consumes.ContentTypes, - ProducesAttribute produces => produces.ContentTypes, - _ => [] - }) - .Distinct(StringComparer.Ordinal) - .OrderBy(value => value, StringComparer.Ordinal) .ToArray(); - } - - private static bool IsExcludedFromDescription(object[] controllerAttributes, object[] methodAttributes) - { - return controllerAttributes.Concat(methodAttributes).Any(attribute => - attribute.GetType().Name == "ExcludeFromDescriptionAttribute" - || attribute is ApiExplorerSettingsAttribute { IgnoreApi: true }); - } - private sealed record ControllerEndpointManifest - { - public required string Controller { get; init; } - public required string Action { get; init; } - public required string HttpMethod { get; init; } - public required string Route { get; init; } - public string? Name { get; init; } - public required string[] Authorization { get; init; } - public required string[] Consumes { get; init; } - public required string[] Produces { get; init; } - public string? Obsolete { get; init; } - public bool ExcludeFromDescription { get; init; } + Assert.Empty(controllerTypes); } } diff --git a/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json b/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json deleted file mode 100644 index 888f741f89..0000000000 --- a/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json +++ /dev/null @@ -1,3129 +0,0 @@ -[ - { - "Controller": "EventController", - "Action": "LegacyPostAsync", - "HttpMethod": "POST", - "Route": "/api/v1/error", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json", - "text/plain" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use POST /api/v2/events", - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "LegacyPatchAsync", - "HttpMethod": "PATCH", - "Route": "/api/v1/error/{id:objectid}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use PATCH /api/v2/events", - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "PostV1Async", - "HttpMethod": "POST", - "Route": "/api/v1/events", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json", - "text/plain" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use POST /api/v2/events", - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSubmitEventV1Async", - "HttpMethod": "GET", - "Route": "/api/v1/events/submit", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use GET /api/v2/events/submit", - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSubmitEventV1Async", - "HttpMethod": "GET", - "Route": "/api/v1/events/submit/{type:minlength(1)}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use GET /api/v2/events/submit", - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GetV1ConfigAsync", - "HttpMethod": "GET", - "Route": "/api/v1/project/config", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use /api/v2/projects/config instead", - "ExcludeFromDescription": false - }, - { - "Controller": "WebHookController", - "Action": "SubscribeAsync", - "HttpMethod": "POST", - "Route": "/api/v1/projecthook/subscribe", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "WebHookController", - "Action": "Test", - "HttpMethod": "GET", - "Route": "/api/v1/projecthook/test", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "WebHookController", - "Action": "Test", - "HttpMethod": "POST", - "Route": "/api/v1/projecthook/test", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "WebHookController", - "Action": "UnsubscribeAsync", - "HttpMethod": "POST", - "Route": "/api/v1/projecthook/unsubscribe", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "EventController", - "Action": "PostV1Async", - "HttpMethod": "POST", - "Route": "/api/v1/projects/{projectId:objectid}/events", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json", - "text/plain" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use POST /api/v2/events", - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSubmitEventV1Async", - "HttpMethod": "GET", - "Route": "/api/v1/projects/{projectId:objectid}/events/submit", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use GET /api/v2/events/submit", - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSubmitEventV1Async", - "HttpMethod": "GET", - "Route": "/api/v1/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "Obsolete": "Use GET /api/v2/events/submit", - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "AddLinkAsync", - "HttpMethod": "POST", - "Route": "/api/v1/stack/addlink", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "StackController", - "Action": "MarkFixedAsync", - "HttpMethod": "POST", - "Route": "/api/v1/stack/markfixed", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "StatusController", - "Action": "IndexAsync", - "HttpMethod": "GET", - "Route": "/api/v2/about", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "Assemblies", - "HttpMethod": "GET", - "Route": "/api/v2/admin/assemblies", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "ChangePlanAsync", - "HttpMethod": "POST", - "Route": "/api/v2/admin/change-plan", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "EchoRequest", - "HttpMethod": "GET", - "Route": "/api/v2/admin/echo", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "GetElasticsearchInfoAsync", - "HttpMethod": "GET", - "Route": "/api/v2/admin/elasticsearch", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "GetElasticsearchSnapshotsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/admin/elasticsearch/snapshots", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "GenerateSampleEventsAsync", - "HttpMethod": "POST", - "Route": "/api/v2/admin/generate-sample-events", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "RunJobAsync", - "HttpMethod": "GET", - "Route": "/api/v2/admin/maintenance/{name:minlength(1)}", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "GetMigrationsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/admin/migrations", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "OrganizationController", - "Action": "GetForAdminsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/admin/organizations", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "OrganizationController", - "Action": "PlanStatsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/admin/organizations/stats", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "RequeueAsync", - "HttpMethod": "GET", - "Route": "/api/v2/admin/requeue", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "SetBonusAsync", - "HttpMethod": "POST", - "Route": "/api/v2/admin/set-bonus", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "SettingsRequest", - "HttpMethod": "GET", - "Route": "/api/v2/admin/settings", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AdminController", - "Action": "GetStatsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/admin/stats", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AuthController", - "Action": "CancelResetPasswordAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/cancel-reset-password/{token:minlength(1)}", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "ChangePasswordAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/change-password", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "IsEmailAddressAvailableAsync", - "HttpMethod": "GET", - "Route": "/api/v2/auth/check-email-address/{email:minlength(1)}", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "AuthController", - "Action": "FacebookAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/facebook", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "ForgotPasswordAsync", - "HttpMethod": "GET", - "Route": "/api/v2/auth/forgot-password/{email:minlength(1)}", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "GitHubAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/github", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "GoogleAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/google", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "GetIntercomTokenAsync", - "HttpMethod": "GET", - "Route": "/api/v2/auth/intercom", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "LiveAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/live", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "LoginAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/login", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "LogoutAsync", - "HttpMethod": "GET", - "Route": "/api/v2/auth/logout", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "ResetPasswordAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/reset-password", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "SignupAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/signup", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "AuthController", - "Action": "RemoveExternalLoginAsync", - "HttpMethod": "POST", - "Route": "/api/v2/auth/unlink/{providerName:minlength(1)}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetAllAsync", - "HttpMethod": "GET", - "Route": "/api/v2/events", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "PostV2Async", - "HttpMethod": "POST", - "Route": "/api/v2/events", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json", - "text/plain" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetByReferenceIdAsync", - "HttpMethod": "GET", - "Route": "/api/v2/events/by-ref/{referenceId:identifier}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "SetUserDescriptionAsync", - "HttpMethod": "POST", - "Route": "/api/v2/events/by-ref/{referenceId:identifier}/user-description", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetCountAsync", - "HttpMethod": "GET", - "Route": "/api/v2/events/count", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "RecordHeartbeatAsync", - "HttpMethod": "GET", - "Route": "/api/v2/events/session/heartbeat", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSessionsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/events/sessions", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetBySessionIdAsync", - "HttpMethod": "GET", - "Route": "/api/v2/events/sessions/{sessionId:identifier}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSubmitEventV2Async", - "HttpMethod": "GET", - "Route": "/api/v2/events/submit", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSubmitEventByTypeV2Async", - "HttpMethod": "GET", - "Route": "/api/v2/events/submit/{type:minlength(1)}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetAsync", - "HttpMethod": "GET", - "Route": "/api/v2/events/{id:objectid}", - "Name": "GetPersistentEventById", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "DeleteAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/events/{ids:objectids}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StatusController", - "Action": "PostReleaseNotificationAsync", - "HttpMethod": "POST", - "Route": "/api/v2/notifications/release", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "StatusController", - "Action": "RemoveSystemNotificationAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/notifications/system", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "StatusController", - "Action": "GetSystemNotificationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/notifications/system", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "StatusController", - "Action": "PostSystemNotificationAsync", - "HttpMethod": "POST", - "Route": "/api/v2/notifications/system", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "OrganizationController", - "Action": "GetAllAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "PostAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "IsNameAvailableAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/check-name", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "GetInvoiceAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/invoice/{id:minlength(10)}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "GetAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{id:objectid}", - "Name": "GetOrganizationById", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "PatchAsync", - "HttpMethod": "PATCH", - "Route": "/api/v2/organizations/{id:objectid}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "PatchAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/organizations/{id:objectid}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "ChangePlanAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{id:objectid}/change-plan", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "DeleteDataAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/organizations/{id:objectid}/data/{key:minlength(1)}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "PostDataAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{id:objectid}/data/{key:minlength(1)}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "RemoveFeatureAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/organizations/{id:objectid}/features/{feature:minlength(1)}", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "OrganizationController", - "Action": "SetFeatureAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{id:objectid}/features/{feature:minlength(1)}", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "OrganizationController", - "Action": "DeleteIconAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/organizations/{id:objectid}/icon", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "UploadIconAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{id:objectid}/icon", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "multipart/form-data" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "GetIconAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{id:objectid}/icon/{fileName}", - "Name": "GetOrganizationIcon", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "GetInvoicesAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{id:objectid}/invoices", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "GetPlansAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{id:objectid}/plans", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "UnsuspendAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/organizations/{id:objectid}/suspend", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "OrganizationController", - "Action": "SuspendAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{id:objectid}/suspend", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "OrganizationController", - "Action": "RemoveUserAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/organizations/{id:objectid}/users/{email:minlength(1)}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "AddUserAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{id:objectid}/users/{email:minlength(1)}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "OrganizationController", - "Action": "DeleteAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/organizations/{ids:objectids}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetByOrganizationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/events", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetCountByOrganizationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/events/count", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSessionByOrganizationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/events/sessions", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GetByOrganizationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/projects", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "IsNameAvailableAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/projects/check-name", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "GetByOrganizationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "PostAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "ExportOrganizationSavedViewsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views/export", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "PostPredefinedAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views/predefined", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "GetByViewAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views/{viewType}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "GetByOrganizationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/stacks", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "GetByOrganizationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/tokens", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "PostByOrganizationAsync", - "HttpMethod": "POST", - "Route": "/api/v2/organizations/{organizationId:objectid}/tokens", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "GetByOrganizationAsync", - "HttpMethod": "GET", - "Route": "/api/v2/organizations/{organizationId:objectid}/users", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GetAllAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "PostAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "IsNameAvailableAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/check-name", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GetV2ConfigAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/config", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GetAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{id:objectid}", - "Name": "GetProjectById", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "PatchAsync", - "HttpMethod": "PATCH", - "Route": "/api/v2/projects/{id:objectid}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "PatchAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/projects/{id:objectid}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "DeleteConfigAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/projects/{id:objectid}/config", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GetConfigAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{id:objectid}/config", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "SetConfigAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{id:objectid}/config", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "DeleteDataAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/projects/{id:objectid}/data", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "PostDataAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{id:objectid}/data", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GetNotificationSettingsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{id:objectid}/notifications", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=GlobalAdminPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "ProjectController", - "Action": "DemoteTabAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/projects/{id:objectid}/promotedtabs", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "PromoteTabAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{id:objectid}/promotedtabs", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "PromoteTabAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/projects/{id:objectid}/promotedtabs", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "ResetDataAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{id:objectid}/reset-data", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "ResetDataAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{id:objectid}/reset-data", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GenerateSampleDataAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{id:objectid}/sample-data", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "RemoveSlackAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/projects/{id:objectid}/slack", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "ProjectController", - "Action": "AddSlackAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{id:objectid}/slack", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "ProjectController", - "Action": "GetIntegrationNotificationSettingsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "ProjectController", - "Action": "SetIntegrationNotificationSettingsAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "SetIntegrationNotificationSettingsAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "DeleteAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/projects/{ids:objectids}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetByProjectAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/events", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "PostByProjectV2Async", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{projectId:objectid}/events", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json", - "text/plain" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetByReferenceIdAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "SetUserDescriptionAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}/user-description", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetCountByProjectAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/events/count", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSessionByProjectAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/events/sessions", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetBySessionIdAndProjectAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/events/sessions/{sessionId:identifier}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSubmitEventByProjectV2Async", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/events/submit", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetSubmitEventByProjectV2Async", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "GetByProjectAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/stacks", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "GetByProjectAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/tokens", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "PostByProjectAsync", - "HttpMethod": "POST", - "Route": "/api/v2/projects/{projectId:objectid}/tokens", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "GetDefaultTokenAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/tokens/default", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "WebHookController", - "Action": "GetByProjectAsync", - "HttpMethod": "GET", - "Route": "/api/v2/projects/{projectId:objectid}/webhooks", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StatusController", - "Action": "QueueStatsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/queue-stats", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "SavedViewController", - "Action": "GetPredefinedAsync", - "HttpMethod": "GET", - "Route": "/api/v2/saved-views/predefined", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "PutPredefinedAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/saved-views/predefined", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "GetAsync", - "HttpMethod": "GET", - "Route": "/api/v2/saved-views/{id:objectid}", - "Name": "GetSavedViewById", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "PatchAsync", - "HttpMethod": "PATCH", - "Route": "/api/v2/saved-views/{id:objectid}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "PatchAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/saved-views/{id:objectid}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "DeletePredefinedSavedViewAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/saved-views/{id:objectid}/predefined", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "PostPredefinedSavedViewAsync", - "HttpMethod": "POST", - "Route": "/api/v2/saved-views/{id:objectid}/predefined", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "SavedViewController", - "Action": "DeleteAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/saved-views/{ids:objectids}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UtilityController", - "Action": "ValidateAsync", - "HttpMethod": "GET", - "Route": "/api/v2/search/validate", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "StackController", - "Action": "GetAllAsync", - "HttpMethod": "GET", - "Route": "/api/v2/stacks", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "AddLinkAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/add-link", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "StackController", - "Action": "MarkFixedAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/mark-fixed", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "StackController", - "Action": "GetAsync", - "HttpMethod": "GET", - "Route": "/api/v2/stacks/{id:objectid}", - "Name": "GetStackById", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "AddLinkAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/{id:objectid}/add-link", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "PromoteAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/{id:objectid}/promote", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "RemoveLinkAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/{id:objectid}/remove-link", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "DeleteAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/stacks/{ids:objectids}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "ChangeStatusAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/{ids:objectids}/change-status", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "MarkNotCriticalAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/stacks/{ids:objectids}/mark-critical", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "MarkCriticalAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/{ids:objectids}/mark-critical", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "MarkFixedAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/{ids:objectids}/mark-fixed", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StackController", - "Action": "SnoozeAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stacks/{ids:objectids}/mark-snoozed", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "EventController", - "Action": "GetByStackAsync", - "HttpMethod": "GET", - "Route": "/api/v2/stacks/{stackId:objectid}/events", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "StripeController", - "Action": "PostAsync", - "HttpMethod": "POST", - "Route": "/api/v2/stripe", - "Authorization": [ - "AllowAnonymous", - "Authorize" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "TokenController", - "Action": "PostAsync", - "HttpMethod": "POST", - "Route": "/api/v2/tokens", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "PatchAsync", - "HttpMethod": "PATCH", - "Route": "/api/v2/tokens/{id:tokens}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "PatchAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/tokens/{id:tokens}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "GetAsync", - "HttpMethod": "GET", - "Route": "/api/v2/tokens/{id:token}", - "Name": "GetTokenById", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "TokenController", - "Action": "DeleteAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/tokens/{ids:tokens}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "DeleteCurrentUserAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/users/me", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "GetCurrentUserAsync", - "HttpMethod": "GET", - "Route": "/api/v2/users/me", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "UnverifyEmailAddressAsync", - "HttpMethod": "POST", - "Route": "/api/v2/users/unverify-email-address", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "text/plain" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "UserController", - "Action": "VerifyAsync", - "HttpMethod": "GET", - "Route": "/api/v2/users/verify-email-address/{token:token}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "GetAsync", - "HttpMethod": "GET", - "Route": "/api/v2/users/{id:objectid}", - "Name": "GetUserById", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "PatchAsync", - "HttpMethod": "PATCH", - "Route": "/api/v2/users/{id:objectid}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "PatchAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/users/{id:objectid}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "DeleteAdminRoleAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/users/{id:objectid}/admin-role", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "UserController", - "Action": "AddAdminRoleAsync", - "HttpMethod": "POST", - "Route": "/api/v2/users/{id:objectid}/admin-role", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "UserController", - "Action": "DeleteAvatarAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/users/{id:objectid}/avatar", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "UploadAvatarAsync", - "HttpMethod": "POST", - "Route": "/api/v2/users/{id:objectid}/avatar", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "multipart/form-data" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "GetAvatarAsync", - "HttpMethod": "GET", - "Route": "/api/v2/users/{id:objectid}/avatar/{fileName}", - "Name": "GetUserAvatar", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "UpdateEmailAddressAsync", - "HttpMethod": "POST", - "Route": "/api/v2/users/{id:objectid}/email-address/{email:minlength(1)}", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "ResendVerificationEmailAsync", - "HttpMethod": "GET", - "Route": "/api/v2/users/{id:objectid}/resend-verification-email", - "Authorization": [ - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "UserController", - "Action": "DeleteAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/users/{ids:objectids}", - "Authorization": [ - "Authorize(Policy=GlobalAdminPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "DeleteNotificationSettingsAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "GetNotificationSettingsAsync", - "HttpMethod": "GET", - "Route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "SetNotificationSettingsAsync", - "HttpMethod": "POST", - "Route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "ProjectController", - "Action": "SetNotificationSettingsAsync", - "HttpMethod": "PUT", - "Route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "WebHookController", - "Action": "PostAsync", - "HttpMethod": "POST", - "Route": "/api/v2/webhooks", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "WebHookController", - "Action": "SubscribeAsync", - "HttpMethod": "POST", - "Route": "/api/v2/webhooks/subscribe", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "WebHookController", - "Action": "Test", - "HttpMethod": "GET", - "Route": "/api/v2/webhooks/test", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "WebHookController", - "Action": "Test", - "HttpMethod": "POST", - "Route": "/api/v2/webhooks/test", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "WebHookController", - "Action": "UnsubscribeAsync", - "HttpMethod": "POST", - "Route": "/api/v2/webhooks/unsubscribe", - "Authorization": [ - "AllowAnonymous", - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - }, - { - "Controller": "WebHookController", - "Action": "GetAsync", - "HttpMethod": "GET", - "Route": "/api/v2/webhooks/{id:objectid}", - "Name": "GetWebHookById", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "WebHookController", - "Action": "DeleteAsync", - "HttpMethod": "DELETE", - "Route": "/api/v2/webhooks/{ids:objectids}", - "Authorization": [ - "Authorize(Policy=ClientPolicy)", - "Authorize(Policy=UserPolicy)" - ], - "Consumes": [], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": false - }, - { - "Controller": "WebHookController", - "Action": "SubscribeAsync", - "HttpMethod": "POST", - "Route": "/api/v{apiVersion:int=2}/webhooks/subscribe", - "Authorization": [ - "Authorize(Policy=ClientPolicy)" - ], - "Consumes": [ - "application/json" - ], - "Produces": [ - "application/json", - "application/problem\u002Bjson" - ], - "ExcludeFromDescription": true - } -] \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Controllers/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Controllers/Data/endpoint-manifest.json new file mode 100644 index 0000000000..d57bcbe3ec --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/Data/endpoint-manifest.json @@ -0,0 +1,2729 @@ +[ + { + "method": "POST", + "route": "/api/v1/error", + "displayName": "HTTP: POST api/v1/error", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PATCH", + "route": "/api/v1/error/{id:objectid}", + "displayName": "HTTP: PATCH api/v1/error/{id:objectid}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v1/events", + "displayName": "HTTP: POST api/v1/events", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v1/events/submit", + "displayName": "HTTP: GET api/v1/events/submit", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v1/events/submit/{type:minlength(1)}", + "displayName": "HTTP: GET api/v1/events/submit/{type:minlength(1)}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v1/project/config", + "displayName": "HTTP: GET api/v1/project/config", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v1/projecthook/subscribe", + "displayName": "HTTP: POST api/v1/projecthook/subscribe", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v1/projecthook/test", + "displayName": "HTTP: GET api/v1/projecthook/test", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v1/projecthook/test", + "displayName": "HTTP: POST api/v1/projecthook/test", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v1/projecthook/unsubscribe", + "displayName": "HTTP: POST api/v1/projecthook/unsubscribe", + "tags": [], + "allowAnonymous": true, + "authorizationPolicies": [], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v1/projects/{projectId:objectid}/events", + "displayName": "HTTP: POST api/v1/projects/{projectId:objectid}/events", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v1/projects/{projectId:objectid}/events/submit", + "displayName": "HTTP: GET api/v1/projects/{projectId:objectid}/events/submit", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v1/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", + "displayName": "HTTP: GET api/v1/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v1/stack/addlink", + "displayName": "HTTP: POST api/v1/stack/addlink", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v1/stack/markfixed", + "displayName": "HTTP: POST api/v1/stack/markfixed", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/about", + "displayName": "HTTP: GET /api/v2/about", + "tags": [], + "allowAnonymous": true, + "authorizationPolicies": [], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/assemblies", + "displayName": "HTTP: GET /api/v2/admin/assemblies", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/admin/change-plan", + "displayName": "HTTP: POST api/v2/admin/change-plan", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/echo", + "displayName": "HTTP: GET api/v2/admin/echo", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/elasticsearch", + "displayName": "HTTP: GET /api/v2/admin/elasticsearch", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/elasticsearch/snapshots", + "displayName": "HTTP: GET /api/v2/admin/elasticsearch/snapshots", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/admin/generate-sample-events", + "displayName": "HTTP: POST /api/v2/admin/generate-sample-events", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/maintenance/{name:minlength(1)}", + "displayName": "HTTP: GET /api/v2/admin/maintenance/{name:minlength(1)}", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/migrations", + "displayName": "HTTP: GET /api/v2/admin/migrations", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/organizations", + "displayName": "HTTP: GET api/v2/admin/organizations", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/organizations/stats", + "displayName": "HTTP: GET api/v2/admin/organizations/stats", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/requeue", + "displayName": "HTTP: GET /api/v2/admin/requeue", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/admin/set-bonus", + "displayName": "HTTP: POST api/v2/admin/set-bonus", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/settings", + "displayName": "HTTP: GET /api/v2/admin/settings", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/stats", + "displayName": "HTTP: GET /api/v2/admin/stats", + "tags": [ + "Admin" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/cancel-reset-password/{token:minlength(1)}", + "displayName": "HTTP: POST api/v2/auth/cancel-reset-password/{token:minlength(1)}", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/change-password", + "displayName": "HTTP: POST api/v2/auth/change-password", + "tags": [ + "Auth" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/auth/check-email-address/{email:minlength(1)}", + "displayName": "HTTP: GET api/v2/auth/check-email-address/{email:minlength(1)}", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/facebook", + "displayName": "HTTP: POST api/v2/auth/facebook", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/auth/forgot-password/{email:minlength(1)}", + "displayName": "HTTP: GET api/v2/auth/forgot-password/{email:minlength(1)}", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/github", + "displayName": "HTTP: POST api/v2/auth/github", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/google", + "displayName": "HTTP: POST api/v2/auth/google", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/auth/intercom", + "displayName": "HTTP: GET api/v2/auth/intercom", + "tags": [ + "Auth" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/live", + "displayName": "HTTP: POST api/v2/auth/live", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/login", + "displayName": "HTTP: POST api/v2/auth/login", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/auth/logout", + "displayName": "HTTP: GET api/v2/auth/logout", + "tags": [ + "Auth" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/reset-password", + "displayName": "HTTP: POST api/v2/auth/reset-password", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/signup", + "displayName": "HTTP: POST api/v2/auth/signup", + "tags": [ + "Auth" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/auth/unlink/{providerName:minlength(1)}", + "displayName": "HTTP: POST api/v2/auth/unlink/{providerName:minlength(1)}", + "tags": [ + "Auth" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events", + "displayName": "HTTP: GET api/v2/events", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/events", + "displayName": "HTTP: POST api/v2/events", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events/by-ref/{referenceId:identifier}", + "displayName": "HTTP: GET api/v2/events/by-ref/{referenceId:identifier}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/events/by-ref/{referenceId:identifier}/user-description", + "displayName": "HTTP: POST api/v2/events/by-ref/{referenceId:identifier}/user-description", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events/count", + "displayName": "HTTP: GET api/v2/events/count", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events/session/heartbeat", + "displayName": "HTTP: GET api/v2/events/session/heartbeat", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events/sessions", + "displayName": "HTTP: GET api/v2/events/sessions", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events/sessions/{sessionId:identifier}", + "displayName": "HTTP: GET api/v2/events/sessions/{sessionId:identifier}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events/submit", + "displayName": "HTTP: GET api/v2/events/submit", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events/submit/{type:minlength(1)}", + "displayName": "HTTP: GET api/v2/events/submit/{type:minlength(1)}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/events/{id:objectid}", + "displayName": "HTTP: GET api/v2/events/{id:objectid}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/events/{ids:objectids}", + "displayName": "HTTP: DELETE api/v2/events/{ids:objectids}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/notifications/release", + "displayName": "HTTP: POST api/v2/notifications/release", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/notifications/system", + "displayName": "HTTP: DELETE api/v2/notifications/system", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/notifications/system", + "displayName": "HTTP: GET api/v2/notifications/system", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/notifications/system", + "displayName": "HTTP: POST api/v2/notifications/system", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations", + "displayName": "HTTP: GET api/v2/organizations", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations", + "displayName": "HTTP: POST api/v2/organizations", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/check-name", + "displayName": "HTTP: GET api/v2/organizations/check-name", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/invoice/{id:minlength(10)}", + "displayName": "HTTP: GET api/v2/organizations/invoice/{id:minlength(10)}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{id:objectid}", + "displayName": "HTTP: GET api/v2/organizations/{id:objectid}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PATCH", + "route": "/api/v2/organizations/{id:objectid}", + "displayName": "HTTP: PATCH api/v2/organizations/{id:objectid}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/organizations/{id:objectid}", + "displayName": "HTTP: PUT api/v2/organizations/{id:objectid}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{id:objectid}/change-plan", + "displayName": "HTTP: POST api/v2/organizations/{id:objectid}/change-plan", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/organizations/{id:objectid}/data/{key:minlength(1)}", + "displayName": "HTTP: DELETE api/v2/organizations/{id:objectid}/data/{key:minlength(1)}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{id:objectid}/data/{key:minlength(1)}", + "displayName": "HTTP: POST api/v2/organizations/{id:objectid}/data/{key:minlength(1)}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/organizations/{id:objectid}/features/{feature:minlength(1)}", + "displayName": "HTTP: DELETE api/v2/organizations/{id:objectid}/features/{feature:minlength(1)}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{id:objectid}/features/{feature:minlength(1)}", + "displayName": "HTTP: POST api/v2/organizations/{id:objectid}/features/{feature:minlength(1)}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/organizations/{id:objectid}/icon", + "displayName": "HTTP: DELETE api/v2/organizations/{id:objectid}/icon =\u003E DeleteIconAsync", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{id:objectid}/icon", + "displayName": "HTTP: POST api/v2/organizations/{id:objectid}/icon =\u003E UploadIconAsync", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{id:objectid}/icon/{fileName}", + "displayName": "HTTP: GET api/v2/organizations/{id:objectid}/icon/{fileName} =\u003E GetIconAsync", + "tags": [ + "Organization" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{id:objectid}/invoices", + "displayName": "HTTP: GET api/v2/organizations/{id:objectid}/invoices", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{id:objectid}/plans", + "displayName": "HTTP: GET api/v2/organizations/{id:objectid}/plans", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/organizations/{id:objectid}/suspend", + "displayName": "HTTP: DELETE api/v2/organizations/{id:objectid}/suspend", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{id:objectid}/suspend", + "displayName": "HTTP: POST api/v2/organizations/{id:objectid}/suspend", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/organizations/{id:objectid}/users/{email:minlength(1)}", + "displayName": "HTTP: DELETE api/v2/organizations/{id:objectid}/users/{email:minlength(1)}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{id:objectid}/users/{email:minlength(1)}", + "displayName": "HTTP: POST api/v2/organizations/{id:objectid}/users/{email:minlength(1)}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/organizations/{ids:objectids}", + "displayName": "HTTP: DELETE api/v2/organizations/{ids:objectids}", + "tags": [ + "Organization" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/events", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/events", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/events/count", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/events/count", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/events/sessions", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/events/sessions", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/projects", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/projects", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/projects/check-name", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/projects/check-name", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/saved-views", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/saved-views", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{organizationId:objectid}/saved-views", + "displayName": "HTTP: POST api/v2/organizations/{organizationId:objectid}/saved-views", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/saved-views/export", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/saved-views/export", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{organizationId:objectid}/saved-views/predefined", + "displayName": "HTTP: POST api/v2/organizations/{organizationId:objectid}/saved-views/predefined", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/saved-views/{viewType}", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/saved-views/{viewType}", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/stacks", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/stacks", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/tokens", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/tokens", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/organizations/{organizationId:objectid}/tokens", + "displayName": "HTTP: POST api/v2/organizations/{organizationId:objectid}/tokens", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/organizations/{organizationId:objectid}/users", + "displayName": "HTTP: GET api/v2/organizations/{organizationId:objectid}/users", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects", + "displayName": "HTTP: GET api/v2/projects", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects", + "displayName": "HTTP: POST api/v2/projects", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/check-name", + "displayName": "HTTP: GET api/v2/projects/check-name", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/config", + "displayName": "HTTP: GET api/v2/projects/config", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{id:objectid}", + "displayName": "HTTP: GET api/v2/projects/{id:objectid}", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PATCH", + "route": "/api/v2/projects/{id:objectid}", + "displayName": "HTTP: PATCH api/v2/projects/{id:objectid}", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/projects/{id:objectid}", + "displayName": "HTTP: PUT api/v2/projects/{id:objectid}", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/projects/{id:objectid}/config", + "displayName": "HTTP: DELETE api/v2/projects/{id:objectid}/config", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{id:objectid}/config", + "displayName": "HTTP: GET api/v2/projects/{id:objectid}/config", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{id:objectid}/config", + "displayName": "HTTP: POST api/v2/projects/{id:objectid}/config", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/projects/{id:objectid}/data", + "displayName": "HTTP: DELETE api/v2/projects/{id:objectid}/data", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{id:objectid}/data", + "displayName": "HTTP: POST api/v2/projects/{id:objectid}/data", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{id:objectid}/notifications", + "displayName": "HTTP: GET api/v2/projects/{id:objectid}/notifications", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/projects/{id:objectid}/promotedtabs", + "displayName": "HTTP: DELETE api/v2/projects/{id:objectid}/promotedtabs", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{id:objectid}/promotedtabs", + "displayName": "HTTP: POST api/v2/projects/{id:objectid}/promotedtabs", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/projects/{id:objectid}/promotedtabs", + "displayName": "HTTP: PUT api/v2/projects/{id:objectid}/promotedtabs", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{id:objectid}/reset-data", + "displayName": "HTTP: GET api/v2/projects/{id:objectid}/reset-data", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{id:objectid}/reset-data", + "displayName": "HTTP: POST api/v2/projects/{id:objectid}/reset-data", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{id:objectid}/sample-data", + "displayName": "HTTP: POST api/v2/projects/{id:objectid}/sample-data", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/projects/{id:objectid}/slack", + "displayName": "HTTP: DELETE api/v2/projects/{id:objectid}/slack", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{id:objectid}/slack", + "displayName": "HTTP: POST api/v2/projects/{id:objectid}/slack", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "displayName": "HTTP: GET api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "displayName": "HTTP: POST api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "displayName": "HTTP: PUT api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/projects/{ids:objectids}", + "displayName": "HTTP: DELETE api/v2/projects/{ids:objectids}", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/events", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/events", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{projectId:objectid}/events", + "displayName": "HTTP: POST api/v2/projects/{projectId:objectid}/events", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}/user-description", + "displayName": "HTTP: POST api/v2/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}/user-description", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/events/count", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/events/count", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/events/sessions", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/events/sessions", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/events/sessions/{sessionId:identifier}", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/events/sessions/{sessionId:identifier}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/events/submit", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/events/submit", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/stacks", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/stacks", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/tokens", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/tokens", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{projectId:objectid}/tokens", + "displayName": "HTTP: POST api/v2/projects/{projectId:objectid}/tokens", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/tokens/default", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/tokens/default", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{projectId:objectid}/webhooks", + "displayName": "HTTP: GET api/v2/projects/{projectId:objectid}/webhooks", + "tags": [ + "WebHook" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/queue-stats", + "displayName": "HTTP: GET /api/v2/queue-stats", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/saved-views/predefined", + "displayName": "HTTP: GET api/v2/saved-views/predefined", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/saved-views/predefined", + "displayName": "HTTP: PUT api/v2/saved-views/predefined", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/saved-views/{id:objectid}", + "displayName": "HTTP: GET api/v2/saved-views/{id:objectid}", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PATCH", + "route": "/api/v2/saved-views/{id:objectid}", + "displayName": "HTTP: PATCH api/v2/saved-views/{id:objectid}", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/saved-views/{id:objectid}", + "displayName": "HTTP: PUT api/v2/saved-views/{id:objectid}", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/saved-views/{id:objectid}/predefined", + "displayName": "HTTP: DELETE api/v2/saved-views/{id:objectid}/predefined", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/saved-views/{id:objectid}/predefined", + "displayName": "HTTP: POST api/v2/saved-views/{id:objectid}/predefined", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/saved-views/{ids:objectids}", + "displayName": "HTTP: DELETE api/v2/saved-views/{ids:objectids}", + "tags": [ + "SavedView" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/search/validate", + "displayName": "HTTP: GET api/v2/search/validate", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/stacks", + "displayName": "HTTP: GET api/v2/stacks", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/add-link", + "displayName": "HTTP: POST api/v2/stacks/add-link", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/mark-fixed", + "displayName": "HTTP: POST api/v2/stacks/mark-fixed", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/stacks/{id:objectid}", + "displayName": "HTTP: GET api/v2/stacks/{id:objectid}", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/{id:objectid}/add-link", + "displayName": "HTTP: POST api/v2/stacks/{id:objectid}/add-link", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/{id:objectid}/promote", + "displayName": "HTTP: POST api/v2/stacks/{id:objectid}/promote", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/{id:objectid}/remove-link", + "displayName": "HTTP: POST api/v2/stacks/{id:objectid}/remove-link", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/stacks/{ids:objectids}", + "displayName": "HTTP: DELETE api/v2/stacks/{ids:objectids}", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/{ids:objectids}/change-status", + "displayName": "HTTP: POST api/v2/stacks/{ids:objectids}/change-status", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/stacks/{ids:objectids}/mark-critical", + "displayName": "HTTP: DELETE api/v2/stacks/{ids:objectids}/mark-critical", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/{ids:objectids}/mark-critical", + "displayName": "HTTP: POST api/v2/stacks/{ids:objectids}/mark-critical", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/{ids:objectids}/mark-fixed", + "displayName": "HTTP: POST api/v2/stacks/{ids:objectids}/mark-fixed", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stacks/{ids:objectids}/mark-snoozed", + "displayName": "HTTP: POST api/v2/stacks/{ids:objectids}/mark-snoozed", + "tags": [ + "Stack" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/stacks/{stackId:objectid}/events", + "displayName": "HTTP: GET api/v2/stacks/{stackId:objectid}/events", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/stripe", + "displayName": "HTTP: POST api/v2/stripe", + "tags": [], + "allowAnonymous": true, + "authorizationPolicies": [], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/tokens", + "displayName": "HTTP: POST api/v2/tokens", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PATCH", + "route": "/api/v2/tokens/{id:tokens}", + "displayName": "HTTP: PATCH api/v2/tokens/{id:tokens}", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/tokens/{id:tokens}", + "displayName": "HTTP: PUT api/v2/tokens/{id:tokens}", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/tokens/{id:token}", + "displayName": "HTTP: GET api/v2/tokens/{id:token}", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/tokens/{ids:tokens}", + "displayName": "HTTP: DELETE api/v2/tokens/{ids:tokens}", + "tags": [ + "Token" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/users/me", + "displayName": "HTTP: DELETE api/v2/users/me", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/users/me", + "displayName": "HTTP: GET api/v2/users/me", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/users/unverify-email-address", + "displayName": "HTTP: POST api/v2/users/unverify-email-address", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/users/verify-email-address/{token:token}", + "displayName": "HTTP: GET api/v2/users/verify-email-address/{token:token}", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/users/{id:objectid}", + "displayName": "HTTP: GET api/v2/users/{id:objectid}", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PATCH", + "route": "/api/v2/users/{id:objectid}", + "displayName": "HTTP: PATCH api/v2/users/{id:objectid}", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/users/{id:objectid}", + "displayName": "HTTP: PUT api/v2/users/{id:objectid}", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/users/{id:objectid}/admin-role", + "displayName": "HTTP: DELETE api/v2/users/{id:objectid}/admin-role", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/users/{id:objectid}/admin-role", + "displayName": "HTTP: POST api/v2/users/{id:objectid}/admin-role", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/users/{id:objectid}/avatar", + "displayName": "HTTP: DELETE api/v2/users/{id:objectid}/avatar =\u003E DeleteAvatarAsync", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/users/{id:objectid}/avatar", + "displayName": "HTTP: POST api/v2/users/{id:objectid}/avatar =\u003E UploadAvatarAsync", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/users/{id:objectid}/avatar/{fileName}", + "displayName": "HTTP: GET api/v2/users/{id:objectid}/avatar/{fileName} =\u003E GetAvatarAsync", + "tags": [ + "User" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/users/{id:objectid}/email-address/{email:minlength(1)}", + "displayName": "HTTP: POST api/v2/users/{id:objectid}/email-address/{email:minlength(1)}", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/users/{id:objectid}/resend-verification-email", + "displayName": "HTTP: GET api/v2/users/{id:objectid}/resend-verification-email", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/users/{ids:objectids}", + "displayName": "HTTP: DELETE api/v2/users/{ids:objectids}", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "displayName": "HTTP: DELETE api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "displayName": "HTTP: GET api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "displayName": "HTTP: POST api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "displayName": "HTTP: PUT api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/webhooks", + "displayName": "HTTP: POST api/v2/webhooks", + "tags": [ + "WebHook" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/webhooks/subscribe", + "displayName": "HTTP: POST api/v2/webhooks/subscribe", + "tags": [ + "WebHook" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/webhooks/test", + "displayName": "HTTP: GET api/v2/webhooks/test", + "tags": [ + "WebHook" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/webhooks/test", + "displayName": "HTTP: POST api/v2/webhooks/test", + "tags": [ + "WebHook" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/webhooks/unsubscribe", + "displayName": "HTTP: POST api/v2/webhooks/unsubscribe", + "tags": [ + "WebHook" + ], + "allowAnonymous": true, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/webhooks/{id:objectid}", + "displayName": "HTTP: GET api/v2/webhooks/{id:objectid}", + "tags": [ + "WebHook" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/webhooks/{ids:objectids}", + "displayName": "HTTP: DELETE api/v2/webhooks/{ids:objectids}", + "tags": [ + "WebHook" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy", + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v{apiVersion:int}/webhooks/subscribe", + "displayName": "HTTP: POST api/v{apiVersion:int}/webhooks/subscribe", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + } +] \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index aecd0d6058..6bffe3e84e 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -20,41 +20,18 @@ } ], "paths": { - "/api/v2/organizations/{organizationId}/saved-views": { + "/api/v1/project/config": { "get": { "tags": [ - "SavedView" + "Project" ], - "summary": "Get by organization", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "The identifier of the organization.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "limit", + "name": "v", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { "type": "integer", - "format": "int32", - "default": 25 + "format": "int32" } } ], @@ -64,29 +41,36 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewSavedView" - } + "$ref": "#/components/schemas/ClientConfiguration" } } } }, + "304": { + "description": "Not Modified" + }, "404": { - "description": "The organization could not be found." + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "post": { + } + }, + "/api/v1/error/{id}": { + "patch": { "tags": [ - "SavedView" + "Event" ], - "summary": "Create", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -95,254 +79,1538 @@ } ], "requestBody": { - "description": "The saved view.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NewSavedView" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewSavedView" + "$ref": "#/components/schemas/JsonElement" } } }, "required": true }, "responses": { - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewSavedView" - } - } - } - }, - "400": { - "description": "An error occurred while creating the saved view." - }, - "409": { - "description": "The saved view already exists." + "200": { + "description": "OK" } } } }, - "/api/v2/organizations/{organizationId}/saved-views/{viewType}": { + "/api/v1/events/submit": { "get": { "tags": [ - "SavedView" + "Event" ], - "summary": "Get by organization and view", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "viewType", - "in": "path", - "description": "The dashboard view type (events, stacks, stream).", - "required": true, + "name": "message", + "in": "query", + "description": "The event message.", "schema": { "type": "string" } }, { - "name": "page", + "name": "reference", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { - "type": "integer", - "format": "int32", - "default": 1 + "type": "string" } }, { - "name": "limit", + "name": "date", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "description": "The number of duplicated events.", "schema": { "type": "integer", - "format": "int32", - "default": 25 + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewSavedView" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { - "description": "The organization could not be found." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/saved-views/{id}": { + "/api/v1/events/submit/{type}": { "get": { "tags": [ - "SavedView" + "Event" ], - "summary": "Get by id", - "operationId": "GetSavedViewById", "parameters": [ { - "name": "id", + "name": "type", "in": "path", - "description": "The identifier of the saved view.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "minLength": 1, "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewSavedView" - } - } - } }, - "404": { - "description": "The saved view could not be found." - } - } - }, - "patch": { - "tags": [ - "SavedView" - ], - "summary": "Update", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the saved view.", - "required": true, + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSavedView" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateSavedView" - } + }, + { + "name": "message", + "in": "query", + "description": "The event message.", + "schema": { + "type": "string" } }, - "required": true - }, + { + "name": "reference", + "in": "query", + "description": "An optional identifier to be used for referencing this event instance at a later time.", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "description": "The number of duplicated events.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/projects/{projectId}/events/submit": { + "get": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" + } + }, + { + "name": "message", + "in": "query", + "description": "The event message.", + "schema": { + "type": "string" + } + }, + { + "name": "reference", + "in": "query", + "description": "An optional identifier to be used for referencing this event instance at a later time.", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "description": "The number of duplicated events.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/projects/{projectId}/events/submit/{type}": { + "get": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" + } + }, + { + "name": "message", + "in": "query", + "description": "The event message.", + "schema": { + "type": "string" + } + }, + { + "name": "reference", + "in": "query", + "description": "An optional identifier to be used for referencing this event instance at a later time.", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "description": "The number of duplicated events.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/error": { + "post": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "202": { + "description": "Accepted" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/events": { + "post": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/projects/{projectId}/events": { + "post": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Login", + "description": "Log in with your email address and password to generate a token scoped with your users roles.\n\n\u0060\u0060\u0060{ \u0022email\u0022: \u0022noreply@exceptionless.io\u0022, \u0022password\u0022: \u0022exceptionless\u0022 }\u0060\u0060\u0060\n\nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\nor append it onto the query string: ?access_token=MY_TOKEN\n\nPlease note that you can also use this token on the documentation site by placing it in the\nheaders api_key input box.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Login" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/Login" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User Authentication Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "401": { + "description": "Login failed", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/intercom": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Get the current user\u0027s Intercom messenger token.", + "responses": { + "200": { + "description": "Intercom messenger token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "401": { + "description": "User not logged in", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Intercom is not enabled.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/logout": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Logout the current user and remove the current access token", + "responses": { + "200": { + "description": "User successfully logged-out" + }, + "401": { + "description": "User not logged in", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Current action is not supported with user access token", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/signup": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Sign up", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Signup" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/Signup" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User Authentication Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "401": { + "description": "Sign-up failed", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Account Creation is currently disabled", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/github": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Sign in with GitHub", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User Authentication Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "403": { + "description": "Account Creation is currently disabled", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/google": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Sign in with Google", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User Authentication Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "403": { + "description": "Account Creation is currently disabled", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/facebook": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Sign in with Facebook", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User Authentication Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "403": { + "description": "Account Creation is currently disabled", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/live": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Sign in with Microsoft", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "OK", + "description": "User Authentication Token", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "403": { + "description": "Account Creation is currently disabled", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/unlink/{providerName}": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Removes an external login provider from the account", + "parameters": [ + { + "name": "providerName", + "in": "path", + "description": "The provider name.", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "description": "The provider user id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User Authentication Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" } } } }, "400": { - "description": "An error occurred while updating the saved view." + "description": "Invalid provider name.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/change-password": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Change password", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordModel" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordModel" + } + } }, - "404": { - "description": "The saved view could not be found." + "required": true + }, + "responses": { + "200": { + "description": "User Authentication Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "put": { + } + }, + "/api/v2/auth/forgot-password/{email}": { + "get": { "tags": [ - "SavedView" + "Auth" ], - "summary": "Update", + "summary": "Forgot password", "parameters": [ { - "name": "id", + "name": "email", "in": "path", - "description": "The identifier of the saved view.", + "description": "The email address.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "minLength": 1, "type": "string" } } ], + "responses": { + "200": { + "description": "Forgot password email was sent." + }, + "400": { + "description": "Invalid email address.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/reset-password": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Reset password", "requestBody": { - "description": "The changes", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSavedView" + "$ref": "#/components/schemas/ResetPasswordModel" } }, - "application/*+json": { + "application/*\u002Bjson": { "schema": { - "$ref": "#/components/schemas/UpdateSavedView" + "$ref": "#/components/schemas/ResetPasswordModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password reset email was sent." + }, + "422": { + "description": "Invalid reset password model.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/auth/cancel-reset-password/{token}": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Cancel reset password", + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The password reset token.", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Password reset email was cancelled." + }, + "400": { + "description": "Invalid password reset token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } - }, - "required": true - }, + } + } + } + }, + "/api/v2/organizations/{organizationId}/tokens": { + "get": { + "tags": [ + "Token" + ], + "summary": "Get by organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The organization could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "400": { - "description": "An error occurred while updating the saved view." - }, - "404": { - "description": "The saved view could not be found." } } - } - }, - "/api/v2/organizations/{organizationId}/saved-views/predefined": { + }, "post": { "tags": [ - "SavedView" + "Token" ], - "summary": "Create or update predefined saved views", + "summary": "Create for organization", + "description": "This is a helper action that makes it easier to create a token for a specific organization. You may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", "parameters": [ { "name": "organizationId", @@ -355,109 +1623,60 @@ } } ], - "responses": { - "200": { - "description": "The predefined saved views were created or updated.", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewSavedView" + "requestBody": { + "description": "The token.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NewToken" } - } + ] } } - }, - "404": { - "description": "The organization could not be found." } - } - } - }, - "/api/v2/saved-views/predefined": { - "get": { - "tags": [ - "SavedView" - ], - "summary": "Get global predefined saved views as seed JSON", + }, "responses": { - "200": { - "description": "The current predefined saved views.", + "201": { + "description": "Created", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" - } + "$ref": "#/components/schemas/ViewToken" } } } - } - } - }, - "put": { - "tags": [ - "SavedView" - ], - "summary": "Replace all predefined saved views with the provided definitions", - "requestBody": { - "description": "The full set of predefined saved view definitions.", - "content": { - "text/plain": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" - } - } - }, - "application/octet-stream": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" - } - } - }, - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" - } - } - }, - "text/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + }, + "400": { + "description": "An error occurred while creating the token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } - }, - "application/*+json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "required": true - }, - "responses": { - "200": { - "description": "The predefined saved views were replaced.", + "409": { + "description": "The token already exists.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" - } + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -465,55 +1684,71 @@ } } }, - "/api/v2/organizations/{organizationId}/saved-views/export": { + "/api/v2/projects/{projectId}/tokens": { "get": { "tags": [ - "SavedView" + "Token" ], - "summary": "Get an organization's saved views exported as predefined definitions", + "summary": "Get by project", "parameters": [ { - "name": "organizationId", + "name": "projectId", "in": "path", - "description": "The identifier of the organization to export from.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } } ], "responses": { "200": { - "description": "The organization's saved views as predefined definitions.", + "description": "OK" + }, + "404": { + "description": "The project could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" - } + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The organization could not be found." } } - } - }, - "/api/v2/saved-views/{id}/predefined": { + }, "post": { "tags": [ - "SavedView" + "Token" ], - "summary": "Save a saved view as a global predefined saved view", + "summary": "Create for project", + "description": "This is a helper action that makes it easier to create a token for a specific project. You may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", - "description": "The identifier of the saved view to promote.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -521,132 +1756,126 @@ } } ], + "requestBody": { + "description": "The token.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NewToken" + } + ] + } + } + } + }, "responses": { - "200": { - "description": "The predefined saved view was created or updated.", + "201": { + "description": "Created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$ref": "#/components/schemas/ViewToken" } } } }, - "404": { - "description": "The saved view could not be found." - } - } - }, - "delete": { - "tags": [ - "SavedView" - ], - "summary": "Delete a global predefined saved view", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the saved view whose predefined saved view should be deleted.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", + "400": { + "description": "An error occurred while creating the token.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "204": { - "description": "The predefined saved view was deleted." - }, "404": { - "description": "The saved view could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "The token already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/saved-views/{ids}": { - "delete": { + "/api/v2/projects/{projectId}/tokens/default": { + "get": { "tags": [ - "SavedView" + "Token" ], - "summary": "Remove", + "summary": "Get a projects default token", "parameters": [ { - "name": "ids", + "name": "projectId", "in": "path", - "description": "A comma-delimited list of saved view identifiers.", + "description": "The identifier of the project.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ViewToken" } } } }, - "400": { - "description": "One or more validation errors occurred." - }, "404": { - "description": "One or more saved views were not found." - }, - "500": { - "description": "An error occurred while deleting one or more saved views." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{organizationId}/tokens": { + "/api/v2/tokens/{id}": { "get": { "tags": [ "Token" ], - "summary": "Get by organization", + "summary": "Get by id", + "operationId": "GetTokenById", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the token.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{24,40}$", "type": "string" } - }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 - } } ], "responses": { @@ -655,69 +1884,54 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewToken" - } + "$ref": "#/components/schemas/ViewToken" } } } }, "404": { - "description": "The organization could not be found." + "description": "The token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, - "post": { + "patch": { "tags": [ "Token" ], - "summary": "Create for organization", - "description": "This is a helper action that makes it easier to create a token for a specific organization.\r\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", + "summary": "Update", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the token.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", "type": "string" } } ], "requestBody": { - "description": "The token.", + "description": "The changes", "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] - } - }, - "application/*+json": { + "application/json-patch\u002Bjson": { "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] + "$ref": "#/components/schemas/UpdateTokenJsonPatchDocument" } } - } + }, + "required": true }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "content": { "application/json": { "schema": { @@ -727,147 +1941,209 @@ } }, "400": { - "description": "An error occurred while creating the token." + "description": "An error occurred while updating the token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "409": { - "description": "The token already exists." + "404": { + "description": "The token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/projects/{projectId}/tokens": { - "get": { + }, + "put": { "tags": [ "Token" ], - "summary": "Get by project", + "summary": "Update", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the token.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", "type": "string" } - }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 - } } ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateTokenJsonPatchDocument" + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewToken" - } + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "400": { + "description": "An error occurred while updating the token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { - "description": "The project could not be found." + "description": "The token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, + } + }, + "/api/v2/tokens": { "post": { "tags": [ "Token" ], - "summary": "Create for project", - "description": "This is a helper action that makes it easier to create a token for a specific project.\r\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", - "parameters": [ - { - "name": "projectId", - "in": "path", - "description": "The identifier of the project.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - } - ], + "summary": "Create", + "description": "To create a new token, you must specify an organization_id. There are three valid scopes: client, user and admin.", "requestBody": { "description": "The token.", "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] + "$ref": "#/components/schemas/NewToken" } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "400": { + "description": "An error occurred while creating the token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "The token already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } } - }, + } + } + }, + "/api/v2/tokens/{ids}": { + "delete": { + "tags": [ + "Token" + ], + "summary": "Remove", + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "A comma-delimited list of token identifiers.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", + "type": "string" + } + } + ], "responses": { - "201": { - "description": "Created", + "202": { + "description": "Accepted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, "400": { - "description": "An error occurred while creating the token." + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "The project could not be found." + "description": "One or more tokens were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "409": { - "description": "The token already exists." + "500": { + "description": "An error occurred while deleting one or more tokens.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/tokens/default": { + "/api/v2/projects/{projectId}/webhooks": { "get": { "tags": [ - "Token" + "WebHook" ], - "summary": "Get a projects default token", + "summary": "Get by project", "parameters": [ { "name": "projectId", @@ -878,40 +2154,60 @@ "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The project could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The project could not be found." } } } }, - "/api/v2/tokens/{id}": { + "/api/v2/webhooks/{id}": { "get": { "tags": [ - "Token" + "WebHook" ], "summary": "Get by id", - "operationId": "GetTokenById", + "operationId": "GetWebHookById", "parameters": [ { "name": "id", "in": "path", - "description": "The identifier of the token.", + "description": "The identifier of the web hook.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{24,40}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } @@ -922,222 +2218,299 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/WebHook" } } } }, "404": { - "description": "The token could not be found." + "description": "The web hook could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "patch": { + } + }, + "/api/v2/webhooks": { + "post": { "tags": [ - "Token" - ], - "summary": "Update", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the token.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", - "type": "string" - } - } + "WebHook" ], + "summary": "Create", "requestBody": { - "description": "The changes", + "description": "The web hook.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateToken" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateToken" + "$ref": "#/components/schemas/NewWebHook" } } }, "required": true }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/WebHook" } } } }, "400": { - "description": "An error occurred while updating the token." + "description": "An error occurred while creating the web hook.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "404": { - "description": "The token could not be found." + "409": { + "description": "The web hook already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "put": { + } + }, + "/api/v2/webhooks/{ids}": { + "delete": { "tags": [ - "Token" + "WebHook" ], - "summary": "Update", + "summary": "Remove", "parameters": [ { - "name": "id", + "name": "ids", "in": "path", - "description": "The identifier of the token.", + "description": "A comma-delimited list of web hook identifiers.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } } ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateToken" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateToken" - } - } - }, - "required": true - }, "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, "400": { - "description": "An error occurred while updating the token." + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "The token could not be found." + "description": "One or more web hooks were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "An error occurred while deleting one or more web hooks.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/tokens": { - "post": { + "/api/v2/organizations/{organizationId}/saved-views": { + "get": { "tags": [ - "Token" + "SavedView" ], - "summary": "Create", - "description": "To create a new token, you must specify an organization_id. There are three valid scopes: client, user and admin.", - "requestBody": { - "description": "The token.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewToken" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewToken" - } + "summary": "Get by organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } }, - "required": true - }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } } } } }, - "400": { - "description": "An error occurred while creating the token." - }, - "409": { - "description": "The token already exists." + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/tokens/{ids}": { - "delete": { + }, + "post": { "tags": [ - "Token" + "SavedView" ], - "summary": "Remove", + "summary": "Create", "parameters": [ { - "name": "ids", + "name": "organizationId", "in": "path", - "description": "A comma-delimited list of token identifiers.", + "description": "The identifier of the organization.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], + "requestBody": { + "description": "The saved view.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewSavedView" + } + } + }, + "required": true + }, "responses": { - "202": { - "description": "Accepted", + "201": { + "description": "Created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ViewSavedView" } } } }, "400": { - "description": "One or more validation errors occurred." + "description": "An error occurred while creating the saved view.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "404": { - "description": "One or more tokens were not found." + "409": { + "description": "The saved view already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "500": { - "description": "An error occurred while deleting one or more tokens." + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/webhooks": { + "/api/v2/organizations/{organizationId}/saved-views/{viewType}": { "get": { "tags": [ - "WebHook" + "SavedView" ], - "summary": "Get by project", + "summary": "Get by organization and view", "parameters": [ { - "name": "projectId", + "name": "organizationId", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, + { + "name": "viewType", + "in": "path", + "description": "The dashboard view type (events, issues, stream).", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "page", "in": "query", @@ -1155,7 +2528,7 @@ "schema": { "type": "integer", "format": "int32", - "default": 10 + "default": 25 } } ], @@ -1167,30 +2540,37 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/WebHook" + "$ref": "#/components/schemas/ViewSavedView" } } } } }, "404": { - "description": "The project could not be found." + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/webhooks/{id}": { + "/api/v2/saved-views/{id}": { "get": { "tags": [ - "WebHook" + "SavedView" ], "summary": "Get by id", - "operationId": "GetWebHookById", + "operationId": "GetSavedViewById", "parameters": [ { "name": "id", "in": "path", - "description": "The identifier of the web hook.", + "description": "The identifier of the saved view.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -1204,250 +2584,267 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebHook" + "$ref": "#/components/schemas/ViewSavedView" } } } }, "404": { - "description": "The web hook could not be found." + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/webhooks": { - "post": { + }, + "patch": { "tags": [ - "WebHook" + "SavedView" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } ], - "summary": "Create", "requestBody": { - "description": "The web hook.", + "description": "The changes", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWebHook" - } - }, - "application/*+json": { + "application/json-patch\u002Bjson": { "schema": { - "$ref": "#/components/schemas/NewWebHook" + "$ref": "#/components/schemas/UpdateSavedViewJsonPatchDocument" } } }, "required": true }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebHook" + "$ref": "#/components/schemas/ViewSavedView" } } } }, "400": { - "description": "An error occurred while creating the web hook." + "description": "An error occurred while updating the saved view.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "409": { - "description": "The web hook already exists." + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/webhooks/{ids}": { - "delete": { + }, + "put": { "tags": [ - "WebHook" + "SavedView" ], - "summary": "Remove", + "summary": "Update", "parameters": [ { - "name": "ids", + "name": "id", "in": "path", - "description": "A comma-delimited list of web hook identifiers.", + "description": "The identifier of the saved view.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedViewJsonPatchDocument" + } + } + }, + "required": true + }, "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ViewSavedView" } } } }, "400": { - "description": "One or more validation errors occurred." + "description": "An error occurred while updating the saved view.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "One or more web hooks were not found." - }, - "500": { - "description": "An error occurred while deleting one or more web hooks." - } - } - } - }, - "/api/v2/auth/login": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Login", - "description": "Log in with your email address and password to generate a token scoped with your users roles.\r\n\r\n```{ \"email\": \"noreply@exceptionless.io\", \"password\": \"exceptionless\" }```\r\n\r\nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\r\nor append it onto the query string: ?access_token=MY_TOKEN\r\n\r\nPlease note that you can also use this token on the documentation site by placing it in the\r\nheaders api_key input box.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Login" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Login" + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "User Authentication Token", + "409": { + "description": "Conflict", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "401": { - "description": "Login failed" - }, "422": { - "description": "Validation error" + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/intercom": { - "get": { + "/api/v2/organizations/{organizationId}/saved-views/predefined": { + "post": { "tags": [ - "Auth" + "SavedView" + ], + "summary": "Create or update predefined saved views", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } ], - "summary": "Get the current user's Intercom messenger token.", "responses": { "200": { - "description": "Intercom messenger token", + "description": "The predefined saved views were created or updated.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } } } } }, - "401": { - "description": "User not logged in" - }, - "422": { - "description": "Intercom is not enabled." - } - } - } - }, - "/api/v2/auth/logout": { - "get": { - "tags": [ - "Auth" - ], - "summary": "Logout the current user and remove the current access token", - "responses": { - "200": { - "description": "User successfully logged-out", + "404": { + "description": "The organization could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, - "401": { - "description": "User not logged in" - }, - "403": { - "description": "Current action is not supported with user access token" } } } }, - "/api/v2/auth/signup": { - "post": { + "/api/v2/saved-views/predefined": { + "get": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Sign up", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Signup" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Signup" - } - } - }, - "required": true - }, + "summary": "Get global predefined saved views as seed JSON", "responses": { "200": { - "description": "User Authentication Token", + "description": "The current predefined saved views.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + } } } } - }, - "401": { - "description": "Sign-up failed" - }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" } } - } - }, - "/api/v2/auth/github": { - "post": { + }, + "put": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Sign in with GitHub", + "summary": "Replace all predefined saved views with the provided definitions", "requestBody": { + "description": "The full set of predefined saved view definitions.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" + "type": "array", + "items": { + "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + } } } }, @@ -1455,403 +2852,435 @@ }, "responses": { "200": { - "description": "User Authentication Token", + "description": "The predefined saved views were replaced.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + } } } } - }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" } } } }, - "/api/v2/auth/google": { - "post": { + "/api/v2/organizations/{organizationId}/saved-views/export": { + "get": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Sign in with Google", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } + "summary": "Get an organization\u0027s saved views exported as predefined definitions", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization to export from.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "User Authentication Token", + "description": "The organization\u0027s saved views as predefined definitions.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + } } } } }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/facebook": { + "/api/v2/saved-views/{id}/predefined": { "post": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Sign in with Facebook", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } + "summary": "Save a saved view as a global predefined saved view", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view to promote.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "User Authentication Token", + "description": "The predefined saved view was created or updated.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ViewSavedView" } } } }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" + "404": { + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/auth/live": { - "post": { + }, + "delete": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Sign in with Microsoft", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } + "summary": "Delete a global predefined saved view", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view whose predefined saved view should be deleted.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "User Authentication Token", + "description": "OK" + }, + "204": { + "description": "The predefined saved view was deleted." + }, + "404": { + "description": "The saved view could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" } } } }, - "/api/v2/auth/unlink/{providerName}": { - "post": { + "/api/v2/saved-views/{ids}": { + "delete": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Removes an external login provider from the account", + "summary": "Remove", "parameters": [ { - "name": "providerName", + "name": "ids", "in": "path", - "description": "The provider name.", + "description": "A comma-delimited list of saved view identifiers.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } } ], - "requestBody": { - "description": "The provider user id.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + } + }, + "400": { + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "User Authentication Token", + "404": { + "description": "One or more saved views were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "An error occurred while deleting one or more saved views.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "400": { - "description": "Invalid provider name." } } } }, - "/api/v2/auth/change-password": { - "post": { + "/api/v2/users/me": { + "get": { "tags": [ - "Auth" + "User" ], - "summary": "Change password", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordModel" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordModel" + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewCurrentUser" + } } } }, - "required": true - }, + "404": { + "description": "The current user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "User" + ], + "summary": "Delete current user", "responses": { - "200": { - "description": "User Authentication Token", + "202": { + "description": "Accepted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, - "422": { - "description": "Validation error" + "404": { + "description": "The current user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/forgot-password/{email}": { + "/api/v2/users/{id}": { "get": { "tags": [ - "Auth" + "User" ], - "summary": "Forgot password", + "summary": "Get by id", + "operationId": "GetUserById", "parameters": [ { - "name": "email", + "name": "id", "in": "path", - "description": "The email address.", + "description": "The identifier of the user.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { "200": { - "description": "Forgot password email was sent.", + "description": "OK", "content": { - "application/json": { } - } - }, - "400": { - "description": "Invalid email address." - } - } - } - }, - "/api/v2/auth/reset-password": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Reset password", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordModel" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordModel" + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewUser" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "Password reset email was sent.", + "404": { + "description": "The user could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, - "422": { - "description": "Invalid reset password model." } } - } - }, - "/api/v2/auth/cancel-reset-password/{token}": { - "post": { + }, + "patch": { "tags": [ - "Auth" + "User" ], - "summary": "Cancel reset password", + "summary": "Update", "parameters": [ { - "name": "token", + "name": "id", "in": "path", - "description": "The password reset token.", + "description": "The identifier of the user.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateUserJsonPatchDocument" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Password reset email was cancelled.", + "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewUser" + } + } } }, "400": { - "description": "Invalid password reset token." + "description": "An error occurred while updating the user.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/events/count": { - "get": { + }, + "put": { "tags": [ - "Event" + "User" ], - "summary": "Count", + "summary": "Update", "parameters": [ { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "aggregations", - "in": "query", - "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", - "schema": { - "type": "string" - } - }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "name": "id", + "in": "path", + "description": "The identifier of the user.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateUserJsonPatchDocument" + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CountResult" + "$ref": "#/components/schemas/ViewUser" } } } }, "400": { - "description": "Invalid filter." + "description": "An error occurred while updating the user.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{organizationId}/events/count": { + "/api/v2/organizations/{organizationId}/users": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Count by organization", + "summary": "Get by organization", "parameters": [ { "name": "organizationId", @@ -1864,43 +3293,23 @@ } }, { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "aggregations", - "in": "query", - "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", + "name": "page", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "string" + "type": "integer", + "format": "int32", + "default": 1 } }, { - "name": "mode", + "name": "limit", "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "string" + "type": "integer", + "format": "int32", + "default": 10 } } ], @@ -1910,71 +3319,106 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CountResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewUser" + } } } } }, - "400": { - "description": "Invalid filter." + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/count": { - "get": { + "/api/v2/users/{id}/avatar": { + "post": { "tags": [ - "Event" + "User" ], - "summary": "Count by project", + "summary": "Upload avatar", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the user.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "$ref": "#/components/schemas/IFormFile" + } + } + } } }, - { - "name": "aggregations", - "in": "query", - "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", - "schema": { - "type": "string" + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewUser" + } + } } }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", - "schema": { - "type": "string" + "422": { + "description": "The image file is invalid.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + }, + "delete": { + "tags": [ + "User" + ], + "summary": "Remove avatar", + "parameters": [ { - "name": "mode", - "in": "query", - "description": "If mode is set to stack_new, then additional filters will be added.", + "name": "id", + "in": "path", + "description": "The identifier of the user.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } @@ -1985,29 +3429,36 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CountResult" + "$ref": "#/components/schemas/ViewUser" } } } }, - "400": { - "description": "Invalid filter." + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/{id}": { + "/api/v2/users/{id}/avatar/{fileName}": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Get by id", - "operationId": "GetPersistentEventById", + "summary": "Get avatar", + "operationId": "GetUserAvatar", "parameters": [ { "name": "id", "in": "path", - "description": "The identifier of the event.", + "description": "The identifier of the user.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -2015,24 +3466,10 @@ } }, { - "name": "expected_stack_id", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "name": "fileName", + "in": "path", + "description": "The avatar file name.", + "required": true, "schema": { "type": "string" } @@ -2040,106 +3477,107 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The avatar could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "400": { - "description": "The event does not belong to the expected stack." - }, - "404": { - "description": "The event occurrence could not be found." - }, - "426": { - "description": "Unable to view event occurrence due to plan limits." } } } }, - "/api/v2/events": { - "get": { + "/api/v2/users/{ids}": { + "delete": { "tags": [ - "Event" + "User" ], - "summary": "Get all", + "summary": "Remove", "parameters": [ { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of user identifiers.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", - "schema": { - "type": "string" + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } } }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", - "schema": { - "type": "string" + "400": { + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + "404": { + "description": "One or more users were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "500": { + "description": "An error occurred while deleting one or more users.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/users/{id}/email-address/{email}": { + "post": { + "tags": [ + "User" + ], + "summary": "Update email address", + "parameters": [ { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the user.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "after", - "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "email", + "in": "path", + "description": "The new email address.", + "required": true, "schema": { + "minLength": 1, "type": "string" } } @@ -2150,88 +3588,141 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/UpdateEmailAddressResult" } } } }, "400": { - "description": "Invalid filter." + "description": "An error occurred while updating the users email address.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "Update email address rate limit reached.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "post": { + } + }, + "/api/v2/users/verify-email-address/{token}": { + "get": { "tags": [ - "Event" + "User" ], - "summary": "Submit event by POST", - "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\r\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\r\n object into the events data collection.\r\n\r\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\r\n\r\n Simple event:\r\n ```{ \"message\": \"Exceptionless is amazing!\" }```\r\n\r\n Simple log event with user identity:\r\n ```{\r\n \"type\": \"log\",\r\n \"message\": \"Exceptionless is amazing!\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\r\n}```\r\n\r\n Multiple events from string content:\r\n ```Exceptionless is amazing!\r\nExceptionless is really amazing!```\r\n\r\n Simple error:\r\n ```{\r\n \"type\": \"error\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@simple_error\": {\r\n \"message\": \"Simple Exception\",\r\n \"type\": \"System.Exception\",\r\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\r\n }\r\n}```", + "summary": "Verify email address", "parameters": [ { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "token", + "in": "path", + "description": "The token identifier.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}$", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "202": { - "description": "Accepted", + "422": { + "description": "Verify Email Address Token has expired.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, - "400": { - "description": "No project id specified and no default project was found." - }, - "404": { - "description": "No project was found." } } } }, - "/api/v2/organizations/{organizationId}/events": { + "/api/v2/users/{id}/resend-verification-email": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Get by organization", + "summary": "Resend verification email", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the user.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + } + ], + "responses": { + "200": { + "description": "The user verification email has been sent." }, + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects": { + "get": { + "tags": [ + "Project" + ], + "summary": "Get all", + "parameters": [ { "name": "filter", "in": "query", @@ -2243,31 +3734,7 @@ { "name": "sort", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", - "schema": { - "type": "string" - } - }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date.", "schema": { "type": "string" } @@ -2278,7 +3745,8 @@ "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 1 } }, { @@ -2292,17 +3760,9 @@ } }, { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", - "schema": { - "type": "string" - } - }, - { - "name": "after", + "name": "mode", "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "description": "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", "schema": { "type": "string" } @@ -2316,77 +3776,103 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ViewProject" } } } } + } + } + }, + "post": { + "tags": [ + "Project" + ], + "summary": "Create", + "requestBody": { + "description": "The project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewProject" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewProject" + } + } + } }, "400": { - "description": "Invalid filter." + "description": "An error occurred while creating the project.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "404": { - "description": "The organization could not be found." + "409": { + "description": "The project already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events": { + "/api/v2/organizations/{organizationId}/projects": { "get": { "tags": [ - "Event" + "Project" ], - "summary": "Get by project", + "summary": "Get all", "parameters": [ { - "name": "projectId", + "name": "organizationId", "in": "path", - "description": "The identifier of the project.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - }, - { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "offset", + "name": "filter", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "mode", + "name": "sort", "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date.", "schema": { "type": "string" } @@ -2397,7 +3883,8 @@ "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 1 } }, { @@ -2411,17 +3898,9 @@ } }, { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", - "schema": { - "type": "string" - } - }, - { - "name": "after", + "name": "mode", "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "description": "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", "schema": { "type": "string" } @@ -2435,32 +3914,35 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ViewProject" } } } } }, - "400": { - "description": "Invalid filter." - }, "404": { - "description": "The project could not be found." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "post": { + } + }, + "/api/v2/projects/{id}": { + "get": { "tags": [ - "Event" + "Project" ], - "summary": "Submit event by POST for a specific project", - "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\r\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\r\n object into the events data collection.\r\n\r\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\r\n\r\n Simple event:\r\n ```{ \"message\": \"Exceptionless is amazing!\" }```\r\n\r\n Simple log event with user identity:\r\n ```{\r\n \"type\": \"log\",\r\n \"message\": \"Exceptionless is amazing!\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\r\n}```\r\n\r\n Multiple events from string content:\r\n ```Exceptionless is amazing!\r\nExceptionless is really amazing!```\r\n\r\n Simple error:\r\n ```{\r\n \"type\": \"error\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@simple_error\": {\r\n \"message\": \"Simple Exception\",\r\n \"type\": \"System.Exception\",\r\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\r\n }\r\n}```", + "summary": "Get by id", + "operationId": "GetProjectById", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", "description": "The identifier of the project.", "required": true, @@ -2470,137 +3952,310 @@ } }, { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "mode", + "in": "query", + "description": "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewProject" + } + } + } + }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Project" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "requestBody": { + "description": "The changes", "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { + "application/json-patch\u002Bjson": { "schema": { - "type": "string", - "example": "" + "$ref": "#/components/schemas/UpdateProjectJsonPatchDocument" } } }, "required": true }, "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewProject" + } + } } }, "400": { - "description": "No project id specified and no default project was found." + "description": "An error occurred while updating the project.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "No project was found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/stacks/{stackId}/events": { - "get": { + }, + "put": { "tags": [ - "Event" + "Project" ], - "summary": "Get by stack", + "summary": "Update", "parameters": [ { - "name": "stackId", + "name": "id", "in": "path", - "description": "The identifier of the stack.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectJsonPatchDocument" + } + } }, - { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewProject" + } + } } }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" + "400": { + "description": "An error occurred while updating the project.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects/{ids}": { + "delete": { + "tags": [ + "Project" + ], + "summary": "Remove", + "parameters": [ { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of project identifiers.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", - "schema": { - "type": "string" + "400": { + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "One or more projects were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "500": { + "description": "An error occurred while deleting one or more projects.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects/config": { + "get": { + "tags": [ + "Project" + ], + "summary": "Get configuration settings", + "parameters": [ { - "name": "page", + "name": "v", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The client configuration version.", "schema": { "type": "integer", "format": "int32" } - }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientConfiguration" + } + } } }, + "304": { + "description": "The client configuration version is the current version." + }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects/{id}/config": { + "get": { + "tags": [ + "Project" + ], + "summary": "Get configuration settings", + "parameters": [ { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "after", + "name": "v", "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "description": "The client configuration version.", "schema": { - "type": "string" + "type": "integer", + "format": "int32" } } ], @@ -2610,90 +4265,110 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ClientConfiguration" } } } }, - "400": { - "description": "Invalid filter." + "304": { + "description": "The client configuration version is the current version." }, "404": { - "description": "The stack could not be found." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/events/by-ref/{referenceId}": { - "get": { + }, + "post": { "tags": [ - "Event" + "Project" ], - "summary": "Get by reference id", + "summary": "Add configuration value", "parameters": [ { - "name": "referenceId", + "name": "id", "in": "path", - "description": "An identifier used that references an event instance.", + "description": "The identifier of the project.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "offset", + "name": "key", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "The key name of the configuration object.", + "required": true, "schema": { "type": "string" } - }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", - "schema": { - "type": "string" + } + ], + "requestBody": { + "description": "The configuration value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } } }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" - } + "required": true + }, + "responses": { + "200": { + "description": "OK" }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "400": { + "description": "Invalid configuration value.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Project" + ], + "summary": "Remove configuration value", + "parameters": [ { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "after", + "name": "key", "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "description": "The key name of the configuration object.", + "required": true, "schema": { "type": "string" } @@ -2701,46 +4376,40 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid key value.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/by-ref/{referenceId}": { - "get": { + "/api/v2/projects/{id}/sample-data": { + "post": { "tags": [ - "Event" + "Project" ], - "summary": "Get by reference id", + "summary": "Generate sample project data", "parameters": [ { - "name": "referenceId", - "in": "path", - "description": "An identifier used that references an event instance.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", - "type": "string" - } - }, - { - "name": "projectId", + "name": "id", "in": "path", "description": "The identifier of the project.", "required": true, @@ -2748,220 +4417,293 @@ "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", - "schema": { - "type": "string" - } - }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } } }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/projects/{id}/reset-data": { + "get": { + "tags": [ + "Project" + ], + "summary": "Reset project data", + "parameters": [ { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "Project" + ], + "summary": "Reset project data", + "parameters": [ { - "name": "after", - "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, - "400": { - "description": "Invalid filter." - }, "404": { - "description": "The project could not be found." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/sessions/{sessionId}": { + "/api/v2/users/{userId}/projects/{id}/notifications": { "get": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions or events by a session id", + "summary": "Get user notification settings", "parameters": [ { - "name": "sessionId", + "name": "id", "in": "path", - "description": "An identifier that represents a session of events.", + "description": "The identifier of the project.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", + "name": "userId", + "in": "path", + "description": "The identifier of the user.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettings" + } + } } }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + }, + "put": { + "tags": [ + "Project" + ], + "summary": "Set user notification settings", + "parameters": [ { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "name": "userId", + "in": "path", + "description": "The identifier of the user.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + } + ], + "requestBody": { + "description": "The notification settings.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } } + } + }, + "responses": { + "200": { + "description": "OK" }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + }, + "post": { + "tags": [ + "Project" + ], + "summary": "Set user notification settings", + "parameters": [ { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "after", - "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "userId", + "in": "path", + "description": "The identifier of the user.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], + "requestBody": { + "description": "The notification settings.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } + } + } + }, "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The project could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "400": { - "description": "Invalid filter." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." } } - } - }, - "/api/v2/projects/{projectId}/events/sessions/{sessionId}": { - "get": { + }, + "delete": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of by a session id", + "summary": "Remove user notification settings", "parameters": [ { - "name": "sessionId", - "in": "path", - "description": "An identifier that represents a session of events.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", - "type": "string" - } - }, - { - "name": "projectId", + "name": "id", "in": "path", "description": "The identifier of the project.", "required": true, @@ -2971,185 +4713,249 @@ } }, { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", + "name": "userId", + "in": "path", + "description": "The identifier of the user.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/projects/{id}/{integration}/notifications": { + "put": { + "tags": [ + "Project" + ], + "summary": "Set an integrations notification settings", + "parameters": [ { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "name": "integration", + "in": "path", + "description": "The identifier of the integration.", + "required": true, "schema": { + "minLength": 1, "type": "string" } - }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", - "schema": { - "type": "string" + } + ], + "requestBody": { + "description": "The notification settings.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } } + } + }, + "responses": { + "200": { + "description": "OK" }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + "404": { + "description": "The project or integration could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "426": { + "description": "Please upgrade your plan to enable integrations.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + }, + "post": { + "tags": [ + "Project" + ], + "summary": "Set an integrations notification settings", + "parameters": [ { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "after", - "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "integration", + "in": "path", + "description": "The identifier of the integration.", + "required": true, "schema": { + "minLength": 1, "type": "string" } } ], + "requestBody": { + "description": "The notification settings.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } + } + } + }, "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The project or integration could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." - }, - "404": { - "description": "The project could not be found." - }, "426": { - "description": "Unable to view event occurrences for the suspended organization." + "description": "Please upgrade your plan to enable integrations.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/sessions": { - "get": { + "/api/v2/projects/{id}/promotedtabs": { + "put": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions", + "summary": "Promote tab", "parameters": [ { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "mode", + "name": "name", "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "The tab name.", + "required": true, "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + "400": { + "description": "Invalid tab name.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + }, + "post": { + "tags": [ + "Project" + ], + "summary": "Promote tab", + "parameters": [ { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "after", + "name": "name", "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "description": "The tab name.", + "required": true, "schema": { "type": "string" } @@ -3157,35 +4963,40 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid tab name.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/organizations/{organizationId}/events/sessions": { - "get": { + }, + "delete": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions", + "summary": "Demote tab", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -3193,116 +5004,122 @@ } }, { - "name": "filter", + "name": "name", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "description": "The tab name.", + "required": true, "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" + "400": { + "description": "Invalid tab name.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/projects/check-name": { + "get": { + "tags": [ + "Project" + ], + "summary": "Check for unique name", + "parameters": [ { - "name": "offset", + "name": "name", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "The project name to check.", + "required": true, "schema": { "type": "string" } }, { - "name": "mode", + "name": "organizationId", "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { "type": "string" } + } + ], + "responses": { + "201": { + "description": "The project name is available." }, + "204": { + "description": "The project name is not available." + } + } + } + }, + "/api/v2/organizations/{organizationId}/projects/check-name": { + "get": { + "tags": [ + "Project" + ], + "summary": "Check for unique name", + "parameters": [ { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 - } - }, - { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "organizationId", + "in": "path", + "description": "If set the check name will be scoped to a specific organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "after", + "name": "name", "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "description": "The project name to check.", + "required": true, "schema": { "type": "string" } } ], "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } - } - } - } - }, - "400": { - "description": "Invalid filter." - }, - "404": { - "description": "The project could not be found." + "201": { + "description": "The project name is available." }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "204": { + "description": "The project name is not available." } } } }, - "/api/v2/projects/{projectId}/events/sessions": { - "get": { + "/api/v2/projects/{id}/data": { + "post": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions", + "summary": "Add custom data", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", "description": "The identifier of the project.", "required": true, @@ -3312,76 +5129,124 @@ } }, { - "name": "filter", + "name": "key", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "description": "The key name of the data object.", + "required": true, "schema": { "type": "string" } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" + } + ], + "requestBody": { + "description": "Any string value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } } }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid key or value.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Project" + ], + "summary": "Remove custom data", + "parameters": [ { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "mode", + "name": "key", "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "The key name of the data object.", + "required": true, "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + "400": { + "description": "Invalid key or value.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/organizations": { + "get": { + "tags": [ + "Organization" + ], + "summary": "Get all", + "parameters": [ { - "name": "before", + "name": "filter", "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "after", + "name": "mode", "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "description": "If no mode is set then a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned.", "schema": { "type": "string" } @@ -3395,145 +5260,134 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ViewOrganization" } } } } - }, - "400": { - "description": "Invalid filter." - }, - "404": { - "description": "The project could not be found." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." } } - } - }, - "/api/v2/events/by-ref/{referenceId}/user-description": { + }, "post": { "tags": [ - "Event" - ], - "summary": "Set user description", - "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", - "parameters": [ - { - "name": "referenceId", - "in": "path", - "description": "An identifier used that references an event instance.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", - "type": "string" - } - } + "Organization" ], + "summary": "Create", "requestBody": { - "description": "The identifier of the project.", + "description": "The organization.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserDescription" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UserDescription" + "$ref": "#/components/schemas/NewOrganization" } } }, "required": true }, "responses": { - "202": { - "description": "Accepted", + "201": { + "description": "Created", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } } }, "400": { - "description": "Description must be specified." + "description": "An error occurred while creating the organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "404": { - "description": "The event occurrence with the specified reference id could not be found." + "409": { + "description": "The organization already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/by-ref/{referenceId}/user-description": { - "post": { + "/api/v2/organizations/{id}": { + "get": { "tags": [ - "Event" + "Organization" ], - "summary": "Set user description", - "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", + "summary": "Get by id", + "operationId": "GetOrganizationById", "parameters": [ { - "name": "referenceId", + "name": "id", "in": "path", - "description": "An identifier used that references an event instance.", + "description": "The identifier of the organization.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "projectId", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], - "requestBody": { - "description": "The user description.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDescription" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UserDescription" - } - } - }, - "required": true - }, "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } } }, - "400": { - "description": "Description must be specified." - }, "404": { - "description": "The event occurrence with the specified reference id could not be found." + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v1/error/{id}": { + }, "patch": { "tags": [ - "Event" + "Organization" ], + "summary": "Update", "parameters": [ { "name": "id", "in": "path", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -3542,15 +5396,11 @@ } ], "requestBody": { + "description": "The changes", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateEvent" - } - }, - "application/*+json": { + "application/json-patch\u002Bjson": { "schema": { - "$ref": "#/components/schemas/UpdateEvent" + "$ref": "#/components/schemas/NewOrganizationJsonPatchDocument" } } }, @@ -3560,186 +5410,236 @@ "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } + } + }, + "400": { + "description": "An error occurred while updating the organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true - } - }, - "/api/v2/events/session/heartbeat": { - "get": { + } + }, + "put": { "tags": [ - "Event" + "Organization" ], - "summary": "Submit heartbeat", + "summary": "Update", "parameters": [ { "name": "id", - "in": "query", - "description": "The session id or user id.", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "close", - "in": "query", - "description": "If true, the session will be closed.", - "schema": { - "type": "boolean", - "default": false - } } ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/NewOrganizationJsonPatchDocument" + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } } }, "400": { - "description": "No project id specified and no default project was found." - }, - "404": { - "description": "No project was found." - } - } - } - }, - "/api/v1/events/submit": { - "get": { - "tags": [ - "Event" - ], - "parameters": [ - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" + "description": "An error occurred while updating the organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "parameters", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } - } - ], - "responses": { - "200": { - "description": "OK", + }, + "422": { + "description": "Unprocessable Entity", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/events/submit/{type}": { - "get": { + "/api/v2/organizations/{id}/icon": { + "post": { "tags": [ - "Event" + "Organization" ], + "summary": "Upload icon", "parameters": [ { - "name": "type", + "name": "id", "in": "path", + "description": "The identifier of the organization.", "required": true, "schema": { - "minLength": 1, - "type": "string" - } - }, - { - "name": "userAgent", - "in": "header", - "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "parameters", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } - } } ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "$ref": "#/components/schemas/IFormFile" + } + } + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } + } + }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "The image file is invalid.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true - } - }, - "/api/v1/projects/{projectId}/events/submit": { - "get": { + } + }, + "delete": { "tags": [ - "Event" + "Organization" ], + "summary": "Remove icon", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" - } - }, - { - "name": "parameters", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } - } } ], "responses": { "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } + } + }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/projects/{projectId}/events/submit/{type}": { + "/api/v2/organizations/{id}/icon/{fileName}": { "get": { "tags": [ - "Event" + "Organization" ], + "summary": "Get icon", + "operationId": "GetOrganizationIcon", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -3747,294 +5647,177 @@ } }, { - "name": "type", + "name": "fileName", "in": "path", + "description": "The icon file name.", "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - }, - { - "name": "userAgent", - "in": "header", "schema": { "type": "string" } - }, - { - "name": "parameters", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } - } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The icon could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v2/events/submit": { - "get": { + "/api/v2/organizations/{ids}": { + "delete": { "tags": [ - "Event" + "Organization" ], - "summary": "Submit event by GET", - "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "summary": "Remove", "parameters": [ { - "name": "type", - "in": "query", - "description": "The event type (ie. error, log message, feature usage).", - "schema": { - "type": "string" - } - }, - { - "name": "source", - "in": "query", - "description": "The event source (ie. machine name, log name, feature name).", - "schema": { - "type": "string" - } - }, - { - "name": "message", - "in": "query", - "description": "The event message.", - "schema": { - "type": "string" - } - }, - { - "name": "reference", - "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", - "schema": { - "type": "string" - } - }, - { - "name": "date", - "in": "query", - "description": "The date that the event occurred on.", - "schema": { - "type": "string" - } - }, - { - "name": "count", - "in": "query", - "description": "The number of duplicated events.", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "value", - "in": "query", - "description": "The value of the event if any.", - "schema": { - "type": "number", - "format": "double" - } - }, - { - "name": "geo", - "in": "query", - "description": "The geo coordinates where the event happened.", - "schema": { - "type": "string" - } - }, - { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", - "schema": { - "type": "string" - } - }, - { - "name": "identity", - "in": "query", - "description": "The user's identity that the event happened to.", - "schema": { - "type": "string" - } - }, - { - "name": "identityname", - "in": "query", - "description": "The user's friendly name that the event happened to.", - "schema": { - "type": "string" - } - }, - { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", - "schema": { - "type": "string" - } - }, - { - "name": "parameters", - "in": "query", - "description": "Query string parameters that control what properties are set on the event", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of organization identifiers.", + "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } } }, "400": { - "description": "No project id specified and no default project was found." + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "No project was found." + "description": "One or more organizations were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "An error occurred while deleting one or more organizations.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/submit/{type}": { + "/api/v2/organizations/invoice/{id}": { "get": { "tags": [ - "Event" + "Organization" ], - "summary": "Submit event type by GET", - "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage event named build with a value of 10:\r\n```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10```\r\n\r\nLog event with message, geo and extended data\r\n```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "summary": "Get invoice", "parameters": [ { - "name": "type", + "name": "id", "in": "path", - "description": "The event type (ie. error, log message, feature usage).", + "description": "The identifier of the invoice.", "required": true, "schema": { - "minLength": 1, - "type": "string" - } - }, - { - "name": "source", - "in": "query", - "description": "The event source (ie. machine name, log name, feature name).", - "schema": { - "type": "string" - } - }, - { - "name": "message", - "in": "query", - "description": "The event message.", - "schema": { - "type": "string" - } - }, - { - "name": "reference", - "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", - "schema": { - "type": "string" - } - }, - { - "name": "date", - "in": "query", - "description": "The date that the event occurred on.", - "schema": { + "minLength": 10, "type": "string" } - }, - { - "name": "count", - "in": "query", - "description": "The number of duplicated events.", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "value", - "in": "query", - "description": "The value of the event if any.", - "schema": { - "type": "number", - "format": "double" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invoice" + } + } } }, - { - "name": "geo", - "in": "query", - "description": "The geo coordinates where the event happened.", - "schema": { - "type": "string" + "404": { + "description": "The invoice was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/organizations/{id}/invoices": { + "get": { + "tags": [ + "Organization" + ], + "summary": "Get invoices", + "parameters": [ { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "identity", + "name": "before", "in": "query", - "description": "The user's identity that the event happened to.", + "description": "A cursor for use in pagination. before is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with obj_bar, your subsequent call can include before=obj_bar in order to fetch the previous page of the list.", "schema": { "type": "string" } }, { - "name": "identityname", + "name": "after", "in": "query", - "description": "The user's friendly name that the event happened to.", - "schema": { - "type": "string" - } - }, - { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "description": "A cursor for use in pagination. after is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with obj_foo, your subsequent call can include after=obj_foo in order to fetch the next page of the list.", "schema": { "type": "string" } }, { - "name": "parameters", + "name": "limit", "in": "query", - "description": "Query string parameters that control what properties are set on the event", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "type": "integer", + "format": "int32", + "default": 12 } } ], @@ -4042,135 +5825,202 @@ "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoiceGridModel" + } + } + } } }, - "400": { - "description": "No project id specified and no default project was found." - }, "404": { - "description": "No project was found." + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/submit": { + "/api/v2/organizations/{id}/plans": { "get": { "tags": [ - "Event" + "Organization" ], - "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "summary": "Get plans", + "description": "Gets available plans for a specific organization.", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "source", - "in": "query", - "description": "The event source (ie. machine name, log name, feature name).", - "schema": { - "type": "string" - } - }, - { - "name": "message", - "in": "query", - "description": "The event message.", - "schema": { - "type": "string" - } - }, - { - "name": "reference", - "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", - "schema": { - "type": "string" - } - }, - { - "name": "date", - "in": "query", - "description": "The date that the event occurred on.", - "schema": { - "type": "string" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BillingPlan" + } + } + } } }, - { - "name": "count", - "in": "query", - "description": "The number of duplicated events.", - "schema": { - "type": "integer", - "format": "int32" + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/organizations/{id}/change-plan": { + "post": { + "tags": [ + "Organization" + ], + "summary": "Change plan", + "description": "Upgrades or downgrades the organization\u0027s plan. Accepts parameters via JSON body (preferred) or query string (legacy).", + "parameters": [ { - "name": "value", - "in": "query", - "description": "The value of the event if any.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { - "type": "number", - "format": "double" + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } }, { - "name": "geo", + "name": "planId", "in": "query", - "description": "The geo coordinates where the event happened.", + "description": "Legacy query parameter: the plan identifier.", "schema": { "type": "string" } }, { - "name": "tags", + "name": "stripeToken", "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", + "description": "Legacy query parameter: the Stripe token.", "schema": { "type": "string" } }, { - "name": "identity", + "name": "last4", "in": "query", - "description": "The user's identity that the event happened to.", + "description": "Legacy query parameter: last four digits of the card.", "schema": { "type": "string" } }, { - "name": "identityname", + "name": "couponId", "in": "query", - "description": "The user's friendly name that the event happened to.", + "description": "Legacy query parameter: the coupon identifier.", "schema": { "type": "string" } + } + ], + "requestBody": { + "description": "The plan change request (JSON body).", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePlanResult" + } + } + } + }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/{id}/users/{email}": { + "post": { + "tags": [ + "Organization" + ], + "summary": "Add user", + "parameters": [ { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "parameters", - "in": "query", - "description": "Query String parameters that control what properties are set on the event", + "name": "email", + "in": "path", + "description": "The email address of the user you wish to add to your organization.", + "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "minLength": 1, + "type": "string" } } ], @@ -4178,30 +6028,45 @@ "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } } }, - "400": { - "description": "No project id specified and no default project was found." - }, "404": { - "description": "No project was found." + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "426": { + "description": "Please upgrade your plan to add an additional user.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/projects/{projectId}/events/submit/{type}": { - "get": { + }, + "delete": { "tags": [ - "Event" + "Organization" ], - "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "summary": "Remove user", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -4209,279 +6074,604 @@ } }, { - "name": "type", + "name": "email", "in": "path", - "description": "The event type (ie. error, log message, feature usage).", + "description": "The email address of the user you wish to remove from your organization.", "required": true, "schema": { "minLength": 1, "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "source", - "in": "query", - "description": "The event source (ie. machine name, log name, feature name).", - "schema": { - "type": "string" + "400": { + "description": "The error occurred while removing the user from your organization", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/{id}/data/{key}": { + "post": { + "tags": [ + "Organization" + ], + "summary": "Add custom data", + "parameters": [ { - "name": "message", - "in": "query", - "description": "The event message.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "reference", - "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", + "name": "key", + "in": "path", + "description": "The key name of the data object.", + "required": true, "schema": { + "minLength": 1, "type": "string" } + } + ], + "requestBody": { + "description": "Any string value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Organization" + ], + "summary": "Remove custom data", + "parameters": [ { - "name": "date", - "in": "query", - "description": "The date that the event occurred on.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "count", - "in": "query", - "description": "The number of duplicated events.", + "name": "key", + "in": "path", + "description": "The key name of the data object.", + "required": true, "schema": { - "type": "integer", - "format": "int32" + "minLength": 1, + "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/check-name": { + "get": { + "tags": [ + "Organization" + ], + "summary": "Check for unique name", + "parameters": [ { - "name": "value", + "name": "name", "in": "query", - "description": "The value of the event if any.", + "description": "The organization name to check.", + "required": true, "schema": { - "type": "number", - "format": "double" + "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "201": { + "description": "The organization name is available." }, + "204": { + "description": "The organization name is not available." + } + } + } + }, + "/api/v2/stacks/{id}": { + "get": { + "tags": [ + "Stack" + ], + "summary": "Get by id", + "operationId": "GetStackById", + "parameters": [ { - "name": "geo", - "in": "query", - "description": "The geo coordinates where the event happened.", + "name": "id", + "in": "path", + "description": "The identifier of the stack.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "tags", + "name": "offset", "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", + "description": "The time offset in minutes that controls what data is returned based on the \u0060time\u0060 filter. This is used for time zone support.", "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stack" + } + } + } }, + "404": { + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/stacks/{ids}/mark-fixed": { + "post": { + "tags": [ + "Stack" + ], + "summary": "Mark fixed", + "parameters": [ { - "name": "identity", - "in": "query", - "description": "The user's identity that the event happened to.", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of stack identifiers.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "identityname", + "name": "version", "in": "query", - "description": "The user's friendly name that the event happened to.", + "description": "A version number that the stack was fixed in.", "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "The stacks were marked as fixed." }, + "404": { + "description": "One or more stacks could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/stacks/{ids}/mark-snoozed": { + "post": { + "tags": [ + "Stack" + ], + "summary": "Mark the selected stacks as snoozed", + "parameters": [ { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of stack identifiers.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "parameters", + "name": "snoozeUntilUtc", "in": "query", - "description": "Query String parameters that control what properties are set on the event", + "description": "A time that the stack should be snoozed until.", + "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "type": "string", + "format": "date-time" } } ], "responses": { "200": { - "description": "OK", + "description": "The stacks were snoozed." + }, + "404": { + "description": "One or more stacks could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/stacks/{id}/add-link": { + "post": { + "tags": [ + "Stack" + ], + "summary": "Add reference link", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the stack.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The reference link.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } } }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, "400": { - "description": "No project id specified and no default project was found." + "description": "Invalid reference link.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "No project was found." + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/error": { + "/api/v2/stacks/{id}/remove-link": { "post": { "tags": [ - "Event" + "Stack" ], + "summary": "Remove reference link", "parameters": [ { - "name": "userAgent", - "in": "header", + "name": "id", + "in": "path", + "description": "The identifier of the stack.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "requestBody": { + "description": "The reference link.", "content": { "application/json": { "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" + "$ref": "#/components/schemas/StringValueFromBody" } } }, "required": true }, + "responses": { + "204": { + "description": "The reference link was removed." + }, + "400": { + "description": "Invalid reference link.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/stacks/{ids}/mark-critical": { + "post": { + "tags": [ + "Stack" + ], + "summary": "Mark future occurrences as critical", + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "A comma-delimited list of stack identifiers.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "type": "string" + } + } + ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "One or more stacks could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Stack" + ], + "summary": "Mark future occurrences as not critical", + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "A comma-delimited list of stack identifiers.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "The stacks were marked as not critical." + }, + "404": { + "description": "One or more stacks could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/events": { + "/api/v2/stacks/{ids}/change-status": { "post": { "tags": [ - "Event" + "Stack" ], + "summary": "Change stack status", "parameters": [ { - "name": "userAgent", - "in": "header", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of stack identifiers.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } + }, + { + "name": "status", + "in": "query", + "description": "The status that the stack should be changed to.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StackStatus" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" - } - } - }, - "required": true - }, "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK" + }, + "404": { + "description": "One or more stacks could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/projects/{projectId}/events": { + "/api/v2/stacks/{id}/promote": { "post": { "tags": [ - "Event" + "Stack" ], + "summary": "Promote to external service", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", + "description": "The identifier of the stack.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "202": { - "description": "Accepted", + "426": { + "description": "Promote to External is a premium feature used to promote an error stack to an external system.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } + }, + "501": { + "description": "No promoted web hooks are configured for this project." } - }, - "deprecated": true + } } }, - "/api/v2/events/{ids}": { + "/api/v2/stacks/{ids}": { "delete": { "tags": [ - "Event" + "Stack" ], "summary": "Remove", "parameters": [ { "name": "ids", "in": "path", - "description": "A comma-delimited list of event identifiers.", + "description": "A comma-delimited list of stack identifiers.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", @@ -4501,21 +6691,42 @@ } }, "400": { - "description": "One or more validation errors occurred." + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "One or more event occurrences were not found." + "description": "One or more stacks were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "500": { - "description": "An error occurred while deleting one or more event occurrences." + "description": "An error occurred while deleting one or more stacks.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations": { + "/api/v2/stacks": { "get": { "tags": [ - "Organization" + "Stack" ], "summary": "Get all", "parameters": [ @@ -4528,228 +6739,84 @@ } }, { - "name": "mode", + "name": "sort", "in": "query", - "description": "If no mode is set then a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned.", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewOrganization" - } - } - } - } - } - } - }, - "post": { - "tags": [ - "Organization" - ], - "summary": "Create", - "requestBody": { - "description": "The organization.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewOrganization" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewOrganization" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewOrganization" - } - } - } }, - "400": { - "description": "An error occurred while creating the organization." - }, - "409": { - "description": "The organization already exists." - } - } - } - }, - "/api/v2/organizations/{id}": { - "get": { - "tags": [ - "Organization" - ], - "summary": "Get by id", - "operationId": "GetOrganizationById", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "mode", + "name": "offset", "in": "query", - "description": "If no mode is set then the a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned.", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewOrganization" - } - } - } }, - "404": { - "description": "The organization could not be found." - } - } - }, - "patch": { - "tags": [ - "Organization" - ], - "summary": "Update", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewOrganization" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewOrganization" - } - } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewOrganization" - } - } + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 } }, - "400": { - "description": "An error occurred while updating the organization." - }, - "404": { - "description": "The organization could not be found." - } - } - }, - "put": { - "tags": [ - "Organization" - ], - "summary": "Update", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" + "type": "integer", + "format": "int32", + "default": 10 } } ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewOrganization" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewOrganization" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewOrganization" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "400": { - "description": "An error occurred while updating the organization." - }, - "404": { - "description": "The organization could not be found." } } } }, - "/api/v2/organizations/{id}/icon": { - "post": { + "/api/v2/organizations/{organizationId}/stacks": { + "get": { "tags": [ - "Organization" + "Stack" ], - "summary": "Upload icon", + "summary": "Get by organization", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", "description": "The identifier of the organization.", "required": true, @@ -4757,93 +6824,116 @@ "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": [ - "file" - ], - "type": "object", - "properties": { - "file": { - "type": "string", - "description": "The image file to upload.", - "format": "binary" - } - } - } + }, + { + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", + "schema": { + "type": "string" } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewOrganization" - } - } + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" } }, - "404": { - "description": "The organization could not be found." + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } }, - "422": { - "description": "The image file is invalid." - } - } - }, - "delete": { - "tags": [ - "Organization" - ], - "summary": "Remove icon", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewOrganization" + "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { - "description": "The organization could not be found." + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "426": { + "description": "Unable to view stack occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{id}/icon/{fileName}": { + "/api/v2/projects/{projectId}/stacks": { "get": { "tags": [ - "Organization" + "Stack" ], - "summary": "Get icon", - "operationId": "GetOrganizationIcon", + "summary": "Get by project", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -4851,83 +6941,147 @@ } }, { - "name": "fileName", - "in": "path", - "description": "The icon file name.", - "required": true, + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" } }, - "404": { - "description": "The icon could not be found." - } - } - } - }, - "/api/v2/organizations/{ids}": { - "delete": { - "tags": [ - "Organization" - ], - "summary": "Remove", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of organization identifiers.", - "required": true, + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", - "type": "string" + "type": "integer", + "format": "int32", + "default": 10 } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "One or more validation errors occurred." - }, "404": { - "description": "One or more organizations were not found." + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "500": { - "description": "An error occurred while deleting one or more organizations." + "426": { + "description": "Unable to view stack occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/invoice/{id}": { + "/api/v2/events/count": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Get invoice", + "summary": "Count", "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the invoice.", - "required": true, + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", + "schema": { + "type": "string" + } + }, + { + "name": "aggregations", + "in": "query", + "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", + "schema": { + "type": "string" + } + }, + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "minLength": 10, "type": "string" } } @@ -4938,26 +7092,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Invoice" + "$ref": "#/components/schemas/CountResult" } } } }, - "404": { - "description": "The invoice was not found." + "400": { + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{id}/invoices": { + "/api/v2/organizations/{organizationId}/events/count": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Get invoices", + "summary": "Count by organization", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", "description": "The identifier of the organization.", "required": true, @@ -4967,67 +7128,42 @@ } }, { - "name": "before", + "name": "filter", "in": "query", - "description": "A cursor for use in pagination. before is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with obj_bar, your subsequent call can include before=obj_bar in order to fetch the previous page of the list.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "after", + "name": "aggregations", "in": "query", - "description": "A cursor for use in pagination. after is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with obj_foo, your subsequent call can include after=obj_foo in order to fetch the next page of the list.", + "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", "schema": { "type": "string" } }, { - "name": "limit", + "name": "time", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { - "type": "integer", - "format": "int32", - "default": 12 + "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InvoiceGridModel" - } - } - } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" } }, - "404": { - "description": "The organization was not found." - } - } - } - }, - "/api/v2/organizations/{id}/plans": { - "get": { - "tags": [ - "Organization" - ], - "summary": "Get plans", - "description": "Gets available plans for a specific organization.", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } @@ -5038,32 +7174,35 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BillingPlan" - } + "$ref": "#/components/schemas/CountResult" } } } }, - "404": { - "description": "The organization was not found." + "400": { + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{id}/change-plan": { - "post": { + "/api/v2/projects/{projectId}/events/count": { + "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Change plan", - "description": "Upgrades or downgrades the organization's plan.\r\nAccepts parameters via JSON body (preferred) or query string (legacy).", + "summary": "Count by project", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -5071,131 +7210,82 @@ } }, { - "name": "planId", - "in": "query", - "description": "Legacy query parameter: the plan identifier.", - "schema": { - "type": "string" - } - }, - { - "name": "stripeToken", + "name": "filter", "in": "query", - "description": "Legacy query parameter: the Stripe token.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "last4", + "name": "aggregations", "in": "query", - "description": "Legacy query parameter: last four digits of the card.", + "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", "schema": { "type": "string" } }, { - "name": "couponId", - "in": "query", - "description": "Legacy query parameter: the coupon identifier.", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "The plan change request (JSON body).", - "content": { - "text/plain": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } - }, - "application/octet-stream": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } - }, - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If mode is set to stack_new, then additional filters will be added.", + "schema": { + "type": "string" } } - }, + ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePlanResult" + "$ref": "#/components/schemas/CountResult" } } } }, - "404": { - "description": "The organization was not found." + "400": { + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{id}/users/{email}": { - "post": { + "/api/v2/events/{id}": { + "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Add user", + "summary": "Get by id", + "operationId": "GetPersistentEventById", "parameters": [ { "name": "id", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the event.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -5203,12 +7293,18 @@ } }, { - "name": "email", - "in": "path", - "description": "The email address of the user you wish to add to your organization.", - "required": true, + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "minLength": 1, "type": "string" } } @@ -5219,197 +7315,218 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/PersistentEvent" } } } }, "404": { - "description": "The organization was not found." + "description": "The event occurrence could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "426": { - "description": "Please upgrade your plan to add an additional user." + "description": "Unable to view event occurrence due to plan limits.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "delete": { + } + }, + "/api/v2/events": { + "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Remove user", + "summary": "Get all", "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "email", - "in": "path", - "description": "The email address of the user you wish to remove from your organization.", - "required": true, + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", "schema": { - "minLength": 1, "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } }, - "400": { - "description": "The error occurred while removing the user from your organization" + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } }, - "404": { - "description": "The organization was not found." - } - } - } - }, - "/api/v2/organizations/{id}/data/{key}": { - "post": { - "tags": [ - "Organization" - ], - "summary": "Add custom data", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "key", - "in": "path", - "description": "The key name of the data object.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "description": "Any string value.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 } }, - "404": { - "description": "The organization was not found." - } - } - }, - "delete": { - "tags": [ - "Organization" - ], - "summary": "Remove custom data", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "key", - "in": "path", - "description": "The key name of the data object.", - "required": true, + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "minLength": 1, "type": "string" } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "404": { - "description": "The organization was not found." + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/organizations/check-name": { - "get": { + }, + "post": { "tags": [ - "Organization" + "Event" ], - "summary": "Check for unique name", + "summary": "Submit event by POST", + "description": "You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON object into the events data collection.\n\nYou can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n\nSimple event:\n\u0060\u0060\u0060{ \u0022message\u0022: \u0022Exceptionless is amazing!\u0022 }\u0060\u0060\u0060\n\nSimple log event with user identity:\n\u0060\u0060\u0060{ \u0022type\u0022: \u0022log\u0022, \u0022message\u0022: \u0022Exceptionless is amazing!\u0022, \u0022date\u0022:\u00222030-01-01T12:00:00.0000000-05:00\u0022, \u0022@user\u0022:{ \u0022identity\u0022:\u0022123456789\u0022, \u0022name\u0022: \u0022Test User\u0022 } }\u0060\u0060\u0060\n\nSimple error:\n\u0060\u0060\u0060{ \u0022type\u0022: \u0022error\u0022, \u0022date\u0022:\u00222030-01-01T12:00:00.0000000-05:00\u0022, \u0022@simple_error\u0022: { \u0022message\u0022: \u0022Simple Exception\u0022, \u0022type\u0022: \u0022System.Exception\u0022, \u0022stack_trace\u0022: \u0022 at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\u0022 } }\u0060\u0060\u0060", "parameters": [ { - "name": "name", - "in": "query", - "description": "The organization name to check.", + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", "schema": { "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, - "201": { - "description": "The organization name is available." - }, - "204": { - "description": "The organization name is not available." } } } }, - "/api/v2/projects": { + "/api/v2/organizations/{organizationId}/events": { "get": { "tags": [ - "Project" + "Event" ], - "summary": "Get all", + "summary": "Get by organization", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, { "name": "filter", "in": "query", @@ -5421,7 +7538,31 @@ { "name": "sort", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date.", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" + } + }, + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { "type": "string" } @@ -5432,8 +7573,7 @@ "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { "type": "integer", - "format": "int32", - "default": 1 + "format": "int32" } }, { @@ -5447,9 +7587,17 @@ } }, { - "name": "mode", + "name": "before", "in": "query", - "description": "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "schema": { + "type": "string" + } + }, + { + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { "type": "string" } @@ -5457,72 +7605,52 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewProject" - } + "$ref": "#/components/schemas/ProblemDetails" } } } - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Create", - "requestBody": { - "description": "The project.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewProject" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewProject" + }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "201": { - "description": "Created", + "426": { + "description": "Unable to view event occurrences for the suspended organization.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewProject" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "400": { - "description": "An error occurred while creating the project." - }, - "409": { - "description": "The project already exists." } } } }, - "/api/v2/organizations/{organizationId}/projects": { + "/api/v2/projects/{projectId}/events": { "get": { "tags": [ - "Project" + "Event" ], - "summary": "Get all", + "summary": "Get by project", "parameters": [ { - "name": "organizationId", + "name": "projectId", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -5540,7 +7668,31 @@ { "name": "sort", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date.", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" + } + }, + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { "type": "string" } @@ -5551,8 +7703,7 @@ "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { "type": "integer", - "format": "int32", - "default": 1 + "format": "int32" } }, { @@ -5566,56 +7717,17 @@ } }, { - "name": "mode", + "name": "before", "in": "query", - "description": "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewProject" - } - } - } - } - }, - "404": { - "description": "The organization could not be found." - } - } - } - }, - "/api/v2/projects/{id}": { - "get": { - "tags": [ - "Project" - ], - "summary": "Get by id", - "operationId": "GetProjectById", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "mode", + "name": "after", "in": "query", - "description": "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { "type": "string" } @@ -5623,453 +7735,353 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewProject" + "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { - "description": "The project could not be found." - } - } - }, - "patch": { - "tags": [ - "Project" - ], - "summary": "Update", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - } - ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProject" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateProject" + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", + "426": { + "description": "Unable to view event occurrences for the suspended organization.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewProject" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "400": { - "description": "An error occurred while updating the project." - }, - "404": { - "description": "The project could not be found." } } }, - "put": { + "post": { "tags": [ - "Project" + "Event" ], - "summary": "Update", + "summary": "Submit event by POST for a specific project", + "description": "You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON object into the events data collection.\n\nYou can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n\nSimple event:\n\u0060\u0060\u0060{ \u0022message\u0022: \u0022Exceptionless is amazing!\u0022 }\u0060\u0060\u0060\n\nSimple log event with user identity:\n\u0060\u0060\u0060{ \u0022type\u0022: \u0022log\u0022, \u0022message\u0022: \u0022Exceptionless is amazing!\u0022, \u0022date\u0022:\u00222030-01-01T12:00:00.0000000-05:00\u0022, \u0022@user\u0022:{ \u0022identity\u0022:\u0022123456789\u0022, \u0022name\u0022: \u0022Test User\u0022 } }\u0060\u0060\u0060\n\nSimple error:\n\u0060\u0060\u0060{ \u0022type\u0022: \u0022error\u0022, \u0022date\u0022:\u00222030-01-01T12:00:00.0000000-05:00\u0022, \u0022@simple_error\u0022: { \u0022message\u0022: \u0022Simple Exception\u0022, \u0022type\u0022: \u0022System.Exception\u0022, \u0022stack_trace\u0022: \u0022 at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\u0022 } }\u0060\u0060\u0060", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", "description": "The identifier of the project.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { "type": "string" } } ], "requestBody": { - "description": "The changes", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateProject" + "type": "string" } }, - "application/*+json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/UpdateProject" + "type": "string" } } }, "required": true }, "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewProject" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "An error occurred while updating the project." - }, "404": { - "description": "The project could not be found." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{ids}": { - "delete": { + "/api/v2/stacks/{stackId}/events": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Remove", + "summary": "Get by stack", "parameters": [ { - "name": "ids", + "name": "stackId", "in": "path", - "description": "A comma-delimited list of project identifiers.", + "description": "The identifier of the stack.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" - } - } + }, + { + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", + "schema": { + "type": "string" } }, - "400": { - "description": "One or more validation errors occurred." + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" + } }, - "404": { - "description": "One or more projects were not found." + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } }, - "500": { - "description": "An error occurred while deleting one or more projects." - } - } - } - }, - "/api/v1/project/config": { - "get": { - "tags": [ - "Project" - ], - "parameters": [ { - "name": "v", + "name": "offset", "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "type": "integer", - "format": "int32" + "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClientConfiguration" - } - } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" } - } - }, - "deprecated": true - } - }, - "/api/v2/projects/config": { - "get": { - "tags": [ - "Project" - ], - "summary": "Get configuration settings", - "parameters": [ + }, { - "name": "v", + "name": "page", "in": "query", - "description": "The client configuration version.", + "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { "type": "integer", "format": "int32" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClientConfiguration" - } - } - } }, - "304": { - "description": "The client configuration version is the current version." + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } }, - "404": { - "description": "The project could not be found." - } - } - } - }, - "/api/v2/projects/{id}/config": { - "get": { - "tags": [ - "Project" - ], - "summary": "Get configuration settings", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "v", + "name": "after", "in": "query", - "description": "The client configuration version.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "type": "integer", - "format": "int32" + "type": "string" } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ClientConfiguration" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "304": { - "description": "The client configuration version is the current version." - }, "404": { - "description": "The project could not be found." + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "post": { + } + }, + "/api/v2/events/by-ref/{referenceId}": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Add configuration value", + "summary": "Get by reference id", "parameters": [ { - "name": "id", + "name": "referenceId", "in": "path", - "description": "The identifier of the project.", + "description": "An identifier used that references an event instance.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{8,100}$", "type": "string" } }, { - "name": "key", + "name": "offset", "in": "query", - "description": "The key name of the configuration object.", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { "type": "string" } - } - ], - "requestBody": { - "description": "The configuration value.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" } }, - "400": { - "description": "Invalid configuration value." + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" + } }, - "404": { - "description": "The project could not be found." - } - } - }, - "delete": { - "tags": [ - "Project" - ], - "summary": "Remove configuration value", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" + "type": "integer", + "format": "int32", + "default": 10 } }, { - "name": "key", + "name": "before", "in": "query", - "description": "The key name of the configuration object.", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } - }, - "400": { - "description": "Invalid key value." }, - "404": { - "description": "The project could not be found." - } - } - } - }, - "/api/v2/projects/{id}/sample-data": { - "post": { - "tags": [ - "Project" - ], - "summary": "Generate sample project data", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "404": { - "description": "The project could not be found." + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{id}/reset-data": { + "/api/v2/projects/{projectId}/events/by-ref/{referenceId}": { "get": { "tags": [ - "Project" + "Event" ], - "summary": "Reset project data", + "summary": "Get by reference id", "parameters": [ { - "name": "id", + "name": "referenceId", "in": "path", - "description": "The identifier of the project.", + "description": "An identifier used that references an event instance.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{8,100}$", "type": "string" } - } - ], - "responses": { - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" - } - } - } }, - "404": { - "description": "The project could not be found." - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Reset project data", - "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", "description": "The identifier of the project.", "required": true, @@ -6077,490 +8089,434 @@ "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" - } - } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" } }, - "404": { - "description": "The project could not be found." - } - } - } - }, - "/api/v2/users/{userId}/projects/{id}/notifications": { - "get": { - "tags": [ - "Project" - ], - "summary": "Get user notification settings", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "userId", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + }, + { + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "schema": { + "type": "string" + } + }, + { + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/NotificationSettings" + "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { - "description": "The project could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "put": { + } + }, + "/api/v2/events/sessions/{sessionId}": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Set user notification settings", + "summary": "Get a list of all sessions or events by a session id", "parameters": [ { - "name": "id", + "name": "sessionId", "in": "path", - "description": "The identifier of the project.", + "description": "An identifier that represents a session of events.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{8,100}$", "type": "string" } }, { - "name": "userId", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The notification settings.", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" } }, - "404": { - "description": "The project could not be found." - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Set user notification settings", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "userId", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The notification settings.", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" } }, - "404": { - "description": "The project could not be found." - } - } - }, - "delete": { - "tags": [ - "Project" - ], - "summary": "Remove user notification settings", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + }, + { + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "userId", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "404": { - "description": "The project could not be found." + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{id}/{integration}/notifications": { - "put": { + "/api/v2/projects/{projectId}/events/sessions/{sessionId}": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Set an integrations notification settings", + "summary": "Get a list of by a session id", "parameters": [ { - "name": "id", + "name": "sessionId", "in": "path", - "description": "The identifier of the project.", + "description": "An identifier that represents a session of events.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{8,100}$", "type": "string" } }, { - "name": "integration", + "name": "projectId", "in": "path", - "description": "The identifier of the integration.", + "description": "The identifier of the project.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The notification settings.", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } + }, + { + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", + "schema": { + "type": "string" } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" } }, - "404": { - "description": "The project or integration could not be found." + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } }, - "426": { - "description": "Please upgrade your plan to enable integrations." - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Set an integrations notification settings", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "integration", - "in": "path", - "description": "The identifier of the integration.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "description": "The notification settings.", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + }, + { + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "schema": { + "type": "string" + } + }, + { + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "schema": { + "type": "string" } } - }, + ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "The project or integration could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "426": { - "description": "Please upgrade your plan to enable integrations." + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{id}/promotedtabs": { - "put": { + "/api/v2/events/sessions": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Promote tab", + "summary": "Get a list of all sessions", "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "name", + "name": "sort", "in": "query", - "description": "The tab name.", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } }, - "400": { - "description": "Invalid tab name." + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } }, - "404": { - "description": "The project could not be found." - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Promote tab", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "name", + "name": "mode", "in": "query", - "description": "The tab name.", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" } }, - "400": { - "description": "Invalid tab name." + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } }, - "404": { - "description": "The project could not be found." - } - } - }, - "delete": { - "tags": [ - "Project" - ], - "summary": "Demote tab", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "name", + "name": "after", "in": "query", - "description": "The tab name.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { "type": "string" } @@ -6568,153 +8524,160 @@ ], "responses": { "200": { - "description": "OK", - "content": { - "application/json": { } - } + "description": "OK" }, "400": { - "description": "Invalid tab name." - }, - "404": { - "description": "The project could not be found." + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/check-name": { + "/api/v2/organizations/{organizationId}/events/sessions": { "get": { "tags": [ - "Project" + "Event" ], - "summary": "Check for unique name", + "summary": "Get a list of all sessions", "parameters": [ { - "name": "name", + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "filter", "in": "query", - "description": "The project name to check.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } - } - ], - "responses": { - "201": { - "description": "The project name is available.", - "content": { - "application/json": { } + }, + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" } }, - "204": { - "description": "The project name is not available." - } - } - } - }, - "/api/v2/organizations/{organizationId}/projects/check-name": { - "get": { - "tags": [ - "Project" - ], - "summary": "Check for unique name", - "parameters": [ { - "name": "name", + "name": "time", "in": "query", - "description": "The project name to check.", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { "type": "string" } }, { - "name": "organizationId", - "in": "path", - "description": "If set the check name will be scoped to a specific organization.", - "required": true, + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "201": { - "description": "The project name is available.", - "content": { - "application/json": { } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" } }, - "204": { - "description": "The project name is not available." - } - } - } - }, - "/api/v2/projects/{id}/data": { - "post": { - "tags": [ - "Project" - ], - "summary": "Add custom data", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + }, + { + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "key", + "name": "after", "in": "query", - "description": "The key name of the data object.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { "type": "string" } } ], - "requestBody": { - "description": "Any string value.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "OK", - "content": { - "application/json": { } - } + "description": "OK" }, "400": { - "description": "Invalid key or value." + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "The project could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "delete": { + } + }, + "/api/v2/projects/{projectId}/events/sessions": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Remove custom data", + "summary": "Get a list of all sessions", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", "description": "The identifier of the project.", "required": true, @@ -6724,214 +8687,204 @@ } }, { - "name": "key", + "name": "filter", "in": "query", - "description": "The key name of the data object.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } }, - "400": { - "description": "Invalid key or value." + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" + } }, - "404": { - "description": "The project could not be found." - } - } - } - }, - "/api/v2/stacks/{id}": { - "get": { - "tags": [ - "Stack" - ], - "summary": "Get by id", - "operationId": "GetStackById", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the stack.", - "required": true, + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { "name": "offset", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the `time` filter. This is used for time zone support.", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Stack" - } - } - } }, - "404": { - "description": "The stack could not be found." - } - } - } - }, - "/api/v2/stacks/{ids}/mark-fixed": { - "post": { - "tags": [ - "Stack" - ], - "summary": "Mark fixed", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "version", + "name": "page", "in": "query", - "description": "A version number that the stack was fixed in.", + "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "string" + "type": "integer", + "format": "int32" } - } - ], - "responses": { - "200": { - "description": "The stacks were marked as fixed.", - "content": { - "application/json": { } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 } }, - "404": { - "description": "One or more stacks could not be found." - } - } - } - }, - "/api/v2/stacks/{ids}/mark-snoozed": { - "post": { - "tags": [ - "Stack" - ], - "summary": "Mark the selected stacks as snoozed", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "snoozeUntilUtc", + "name": "after", "in": "query", - "description": "A time that the stack should be snoozed until.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "type": "string", - "format": "date-time" + "type": "string" } } ], "responses": { "200": { - "description": "The stacks were snoozed.", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "One or more stacks could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/stacks/{id}/add-link": { + "/api/v2/events/by-ref/{referenceId}/user-description": { "post": { "tags": [ - "Stack" + "Event" ], - "summary": "Add reference link", + "summary": "Set user description", + "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", "parameters": [ { - "name": "id", + "name": "referenceId", "in": "path", - "description": "The identifier of the stack.", + "description": "An identifier used that references an event instance.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{8,100}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "query", + "schema": { "type": "string" } } ], "requestBody": { - "description": "The reference link.", + "description": "The user description.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/UserDescription" } } }, "required": true }, "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } + "202": { + "description": "Accepted" }, "400": { - "description": "Invalid reference link." + "description": "Description must be specified.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "The stack could not be found." + "description": "The event occurrence with the specified reference id could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/stacks/{id}/remove-link": { + "/api/v2/projects/{projectId}/events/by-ref/{referenceId}/user-description": { "post": { "tags": [ - "Stack" + "Event" ], - "summary": "Remove reference link", + "summary": "Set user description", + "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", "parameters": [ { - "name": "id", + "name": "referenceId", "in": "path", - "description": "The identifier of the stack.", + "description": "An identifier used that references an event instance.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{8,100}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -6940,412 +8893,430 @@ } ], "requestBody": { - "description": "The reference link.", + "description": "The user description.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/UserDescription" } } }, "required": true }, "responses": { - "204": { - "description": "The reference link was removed.", - "content": { - "application/json": { } - } + "202": { + "description": "Accepted" }, "400": { - "description": "Invalid reference link." + "description": "Description must be specified.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "The stack could not be found." + "description": "The event occurrence with the specified reference id could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/stacks/{ids}/mark-critical": { - "post": { + "/api/v2/events/session/heartbeat": { + "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Mark future occurrences as critical", + "summary": "Submit heartbeat", "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "id", + "in": "query", + "description": "The session id or user id.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } + }, + { + "name": "close", + "in": "query", + "description": "If true, the session will be closed.", + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "One or more stacks could not be found." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "delete": { + } + }, + "/api/v2/events/submit": { + "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Mark future occurrences as not critical", + "summary": "Submit event by GET", + "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\n\nFeature usage named build with a duration of 10:\n\u0060\u0060\u0060/events/submit?access_token=YOUR_API_KEY\u0026type=usage\u0026source=build\u0026value=10\u0060\u0060\u0060\n\nLog with message, geo and extended data\n\u0060\u0060\u0060/events/submit?access_token=YOUR_API_KEY\u0026type=log\u0026message=Hello World\u0026source=server01\u0026geo=32.85,-96.9613\u0026randomproperty=true\u0060\u0060\u0060", "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "type", + "in": "query", + "description": "The event type (ie. error, log message, feature usage).", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } - } - ], - "responses": { - "204": { - "description": "The stacks were marked as not critical.", - "content": { - "application/json": { } + }, + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" } }, - "404": { - "description": "One or more stacks could not be found." - } - } - } - }, - "/api/v2/stacks/{ids}/change-status": { - "post": { - "tags": [ - "Stack" - ], - "summary": "Change stack status", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "message", + "in": "query", + "description": "The event message.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "status", + "name": "reference", "in": "query", - "description": "The status that the stack should be changed to.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { - "$ref": "#/components/schemas/StackStatus" + "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" } }, - "404": { - "description": "One or more stacks could not be found." - } - } - } - }, - "/api/v2/stacks/{id}/promote": { - "post": { - "tags": [ - "Stack" - ], - "summary": "Promote to external service", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the stack.", - "required": true, + "name": "count", + "in": "query", + "description": "The number of duplicated events.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" } }, - "404": { - "description": "The stack could not be found." + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } }, - "426": { - "description": "Promote to External is a premium feature used to promote an error stack to an external system." + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } }, - "501": { - "description": "No promoted web hooks are configured for this project." - } - } - } - }, - "/api/v2/stacks/{ids}": { - "delete": { - "tags": [ - "Stack" - ], - "summary": "Remove", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "One or more validation errors occurred." - }, "404": { - "description": "One or more stacks were not found." - }, - "500": { - "description": "An error occurred while deleting one or more stacks." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/stacks": { + "/api/v2/events/submit/{type}": { "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Get all", + "summary": "Submit event type by GET", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage event named build with a value of 10:\n\u0060\u0060\u0060/events/submit/usage?access_token=YOUR_API_KEY\u0026source=build\u0026value=10\u0060\u0060\u0060\n\nLog event with message, geo and extended data\n\u0060\u0060\u0060/events/submit/log?access_token=YOUR_API_KEY\u0026message=Hello World\u0026source=server01\u0026geo=32.85,-96.9613\u0026randomproperty=true\u0060\u0060\u0060", "parameters": [ { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", + "name": "type", + "in": "path", + "description": "The event type (ie. error, log message, feature usage).", + "required": true, "schema": { + "minLength": 1, "type": "string" } }, { - "name": "sort", + "name": "source", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "description": "The event source (ie. machine name, log name, feature name).", "schema": { "type": "string" } }, { - "name": "time", + "name": "message", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "The event message.", "schema": { "type": "string" } }, { - "name": "offset", + "name": "reference", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { "type": "string" } }, { - "name": "mode", + "name": "date", "in": "query", - "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "The date that the event occurred on.", "schema": { "type": "string" } }, { - "name": "page", + "name": "count", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The number of duplicated events.", "schema": { "type": "integer", - "format": "int32", - "default": 1 + "format": "int32" } }, { - "name": "limit", + "name": "value", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Stack" - } - } - } - } - }, - "400": { - "description": "Invalid filter." - } - } - } - }, - "/api/v2/organizations/{organizationId}/stacks": { - "get": { - "tags": [ - "Stack" - ], - "summary": "Get by organization", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "description": "The value of the event if any.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" + "type": "number", + "format": "double" } }, { - "name": "filter", + "name": "geo", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "description": "The geo coordinates where the event happened.", "schema": { "type": "string" } }, { - "name": "sort", + "name": "tags", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "description": "A list of tags used to categorize this event (comma separated).", "schema": { "type": "string" } }, { - "name": "time", + "name": "identity", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "The user\u0027s identity that the event happened to.", "schema": { "type": "string" } }, { - "name": "offset", + "name": "identityname", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "The user\u0027s friendly name that the event happened to.", "schema": { "type": "string" } }, { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", "schema": { "type": "string" } }, { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "limit", + "name": "parameters", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "Query string parameters that control what properties are set on the event", "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Stack" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." - }, "404": { - "description": "The organization could not be found." - }, - "426": { - "description": "Unable to view stack occurrences for the suspended organization." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/stacks": { + "/api/v2/projects/{projectId}/events/submit": { "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Get by project", + "summary": "Submit event type by GET for a specific project", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n\u0060\u0060\u0060/projects/{projectId}/events/submit?access_token=YOUR_API_KEY\u0026type=usage\u0026source=build\u0026value=10\u0060\u0060\u0060\n\nLog with message, geo and extended data\n\u0060\u0060\u0060/projects/{projectId}/events/submit?access_token=YOUR_API_KEY\u0026type=log\u0026message=Hello World\u0026source=server01\u0026geo=32.85,-96.9613\u0026randomproperty=true\u0060\u0060\u0060", "parameters": [ { "name": "projectId", @@ -7358,477 +9329,345 @@ } }, { - "name": "filter", + "name": "type", "in": "query", - "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "sort", + "name": "source", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "description": "The event source (ie. machine name, log name, feature name).", "schema": { "type": "string" } }, { - "name": "time", + "name": "message", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "The event message.", "schema": { "type": "string" } }, { - "name": "offset", + "name": "reference", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { "type": "string" } }, { - "name": "mode", + "name": "date", "in": "query", - "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "The date that the event occurred on.", "schema": { "type": "string" } }, { - "name": "page", + "name": "count", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The number of duplicated events.", "schema": { "type": "integer", - "format": "int32", - "default": 1 + "format": "int32" } }, { - "name": "limit", + "name": "value", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The value of the event if any.", "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "type": "number", + "format": "double" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Stack" - } - } - } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" } }, - "400": { - "description": "Invalid filter." + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } }, - "404": { - "description": "The organization could not be found." + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } }, - "426": { - "description": "Unable to view stack occurrences for the suspended organization." + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query String parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } } - } - } - }, - "/api/v2/users/me": { - "get": { - "tags": [ - "User" ], - "summary": "Get current user", "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewCurrentUser" + "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { - "description": "The current user could not be found." - } - } - }, - "delete": { - "tags": [ - "User" - ], - "summary": "Delete current user", - "responses": { - "202": { - "description": "Accepted", + "description": "No project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The current user could not be found." } } } }, - "/api/v2/users/{id}": { + "/api/v2/projects/{projectId}/events/submit/{type}": { "get": { "tags": [ - "User" + "Event" ], - "summary": "Get by id", - "operationId": "GetUserById", + "summary": "Submit event type by GET for a specific project", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n\u0060\u0060\u0060/projects/{projectId}/events/submit?access_token=YOUR_API_KEY\u0026type=usage\u0026source=build\u0026value=10\u0060\u0060\u0060\n\nLog with message, geo and extended data\n\u0060\u0060\u0060/projects/{projectId}/events/submit?access_token=YOUR_API_KEY\u0026type=log\u0026message=Hello World\u0026source=server01\u0026geo=32.85,-96.9613\u0026randomproperty=true\u0060\u0060\u0060", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", - "description": "The identifier of the user.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } - } }, - "404": { - "description": "The user could not be found." - } - } - }, - "patch": { - "tags": [ - "User" - ], - "summary": "Update", - "parameters": [ { - "name": "id", + "name": "type", "in": "path", - "description": "The identifier of the user.", + "description": "The event type (ie. error, log message, feature usage).", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateUser" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateUser" - } - } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" } }, - "400": { - "description": "An error occurred while updating the user." - }, - "404": { - "description": "The user could not be found." - } - } - }, - "put": { - "tags": [ - "User" - ], - "summary": "Update", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "message", + "in": "query", + "description": "The event message.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateUser" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateUser" - } - } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } + { + "name": "reference", + "in": "query", + "description": "An optional identifier to be used for referencing this event instance at a later time.", + "schema": { + "type": "string" } }, - "400": { - "description": "An error occurred while updating the user." - }, - "404": { - "description": "The user could not be found." - } - } - } - }, - "/api/v2/organizations/{organizationId}/users": { - "get": { - "tags": [ - "User" - ], - "summary": "Get by organization", - "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "page", + "name": "count", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The number of duplicated events.", "schema": { "type": "integer", - "format": "int32", - "default": 1 + "format": "int32" } }, { - "name": "limit", + "name": "value", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The value of the event if any.", "schema": { - "type": "integer", - "format": "int32", - "default": 10 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewUser" - } - } - } + "type": "number", + "format": "double" } }, - "404": { - "description": "The organization could not be found." - } - } - } - }, - "/api/v2/users/{id}/avatar": { - "post": { - "tags": [ - "User" - ], - "summary": "Upload avatar", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": [ - "file" - ], - "type": "object", - "properties": { - "file": { - "type": "string", - "description": "The image file to upload.", - "format": "binary" - } - } - } - } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" } }, - "404": { - "description": "The user could not be found." - }, - "422": { - "description": "The image file is invalid." - } - } - }, - "delete": { - "tags": [ - "User" - ], - "summary": "Remove avatar", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } - } }, - "404": { - "description": "The user could not be found." - } - } - } - }, - "/api/v2/users/{id}/avatar/{fileName}": { - "get": { - "tags": [ - "User" - ], - "summary": "Get avatar", - "operationId": "GetUserAvatar", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "fileName", - "in": "path", - "description": "The avatar file name.", - "required": true, + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", "schema": { "type": "string" } + }, + { + "name": "parameters", + "in": "query", + "description": "Query String parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "The avatar could not be found." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/users/{ids}": { + "/api/v2/events/{ids}": { "delete": { "tags": [ - "User" + "Event" ], "summary": "Remove", "parameters": [ { "name": "ids", "in": "path", - "description": "A comma-delimited list of user identifiers.", + "description": "A comma-delimited list of event identifiers.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", @@ -7848,129 +9687,34 @@ } }, "400": { - "description": "One or more validation errors occurred." - }, - "404": { - "description": "One or more users were not found." - }, - "500": { - "description": "An error occurred while deleting one or more users." - } - } - } - }, - "/api/v2/users/{id}/email-address/{email}": { - "post": { - "tags": [ - "User" - ], - "summary": "Update email address", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - }, - { - "name": "email", - "in": "path", - "description": "The new email address.", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", + "description": "One or more validation errors occurred.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/UpdateEmailAddressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "An error occurred while updating the users email address." - }, - "422": { - "description": "Validation error" - }, - "429": { - "description": "Update email address rate limit reached." - } - } - } - }, - "/api/v2/users/verify-email-address/{token}": { - "get": { - "tags": [ - "User" - ], - "summary": "Verify email address", - "parameters": [ - { - "name": "token", - "in": "path", - "description": "The token identifier.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d-]{24,40}$", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "One or more event occurrences were not found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "404": { - "description": "The user could not be found." - }, - "422": { - "description": "Verify Email Address Token has expired." - } - } - } - }, - "/api/v2/users/{id}/resend-verification-email": { - "get": { - "tags": [ - "User" - ], - "summary": "Resend verification email", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "The user verification email has been sent.", + "500": { + "description": "An error occurred while deleting one or more event occurrences.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, - "404": { - "description": "The user could not be found." } } } @@ -8196,12 +9940,16 @@ "properties": { "data": { "type": "object", - "additionalProperties": { }, + "additionalProperties": {}, "description": "Additional data associated with the aggregate." } }, "description": "Base interface for aggregation results. Concrete types include ValueAggregate, BucketAggregate, StatsAggregate, etc. See client-side type definitions for full type information." }, + "IFormFile": { + "type": "string", + "format": "binary" + }, "Invite": { "required": [ "token", @@ -8308,7 +10056,7 @@ } } }, - "JsonElement": { }, + "JsonElement": {}, "Login": { "required": [ "email", @@ -8317,8 +10065,7 @@ "type": "object", "properties": { "email": { - "type": "string", - "description": "The email address or domain username" + "type": "string" }, "password": { "maxLength": 100, @@ -8346,6 +10093,37 @@ } } }, + "NewOrganizationJsonPatchDocument": { + "type": "array", + "items": { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "replace", + "test" + ], + "type": "string", + "description": "The operation to perform (only \u0027replace\u0027 and \u0027test\u0027 are supported)." + }, + "path": { + "type": "string", + "description": "A JSON Pointer (RFC 6901) to the target property, using snake_case naming (e.g., \u0027/full_name\u0027)." + }, + "value": { + "description": "The value to use for the operation." + }, + "from": { + "type": "string", + "description": "A JSON Pointer to the source property (only used with \u0027move\u0027 and \u0027copy\u0027 operations)." + } + } + } + }, "NewProject": { "required": [ "organization_id", @@ -8418,7 +10196,7 @@ }, "slug": { "maxLength": 100, - "pattern": "^(?![a-f0-9]{24}$)[a-z0-9]+(?:-[a-z0-9]+)*$", + "pattern": "^(?![a-f0-9]{24}$)[a-z0-9]\u002B(?:-[a-z0-9]\u002B)*$", "type": [ "null", "string" @@ -8470,8 +10248,7 @@ "type": [ "null", "boolean" - ], - "description": "If true, the view will only be visible to the current user. Defaults to false." + ] } } }, @@ -8558,12 +10335,11 @@ } }, "version": { - "pattern": "^\\d+(\\.\\d+){1,3}$", + "pattern": "^\\d\u002B(\\.\\d\u002B){1,3}$", "type": [ "null", "string" - ], - "description": "The schema version that should be used." + ] } } }, @@ -8641,37 +10417,31 @@ "maxLength": 24, "minLength": 24, "pattern": "^[a-fA-F0-9]{24}$", - "type": "string", - "description": "Unique id that identifies an event." + "type": "string" }, "organization_id": { "maxLength": 24, "minLength": 24, "pattern": "^[a-fA-F0-9]{24}$", - "type": "string", - "description": "The organization that the event belongs to." + "type": "string" }, "project_id": { "maxLength": 24, "minLength": 24, "pattern": "^[a-fA-F0-9]{24}$", - "type": "string", - "description": "The project that the event belongs to." + "type": "string" }, "stack_id": { "maxLength": 24, "minLength": 24, "pattern": "^[a-fA-F0-9]{24}$", - "type": "string", - "description": "The stack that the event belongs to." + "type": "string" }, "is_first_occurrence": { - "type": "boolean", - "description": "Whether the event resulted in the creation of a new stack." + "type": "boolean" }, "created_utc": { "type": "string", - "description": "The date that the event was created in the system.", "format": "date-time" }, "idx": { @@ -8679,8 +10449,7 @@ "null", "object" ], - "additionalProperties": { }, - "description": "Used to store primitive data type custom data values for searching the event." + "additionalProperties": {} }, "type": { "maxLength": 100, @@ -8688,8 +10457,7 @@ "type": [ "null", "string" - ], - "description": "The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types.\r\nNullable in transit; the pipeline infers a default before save. Validated as required on repository save." + ] }, "source": { "maxLength": 2000, @@ -8697,12 +10465,10 @@ "type": [ "null", "string" - ], - "description": "The event source (ie. machine name, log name, feature name)." + ] }, "date": { "type": "string", - "description": "The date that the event occurred on.", "format": "date-time" }, "tags": { @@ -8713,8 +10479,7 @@ ], "items": { "type": "string" - }, - "description": "A list of tags used to categorize this event." + } }, "message": { "maxLength": 2000, @@ -8722,22 +10487,19 @@ "type": [ "null", "string" - ], - "description": "The event message." + ] }, "geo": { "type": [ "null", "string" - ], - "description": "The geo coordinates where the event happened." + ] }, "value": { "type": [ "null", "number" ], - "description": "The value of the event if any.", "format": "double" }, "count": { @@ -8745,7 +10507,6 @@ "null", "integer" ], - "description": "The number of duplicated events.", "format": "int32" }, "data": { @@ -8753,15 +10514,13 @@ "null", "object" ], - "additionalProperties": { }, - "description": "Optional data entries that contain additional information about this event." + "additionalProperties": {} }, "reference_id": { "type": [ "null", "string" - ], - "description": "An optional identifier to be used for referencing this event instance at a later time." + ] } } }, @@ -8814,34 +10573,70 @@ } ] }, - "columns": { + "columns": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "boolean" + } + }, + "columnOrder": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "showStats": { + "type": [ + "null", + "boolean" + ] + }, + "showChart": { + "type": [ + "null", + "boolean" + ] + } + } + }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": [ + "null", + "string" + ] + }, + "title": { "type": [ "null", - "object" - ], - "additionalProperties": { - "type": "boolean" - } + "string" + ] }, - "columnOrder": { + "status": { "type": [ "null", - "array" + "integer" ], - "items": { - "type": "string" - } + "format": "int32" }, - "showStats": { + "detail": { "type": [ "null", - "boolean" + "string" ] }, - "showChart": { + "instance": { "type": [ "null", - "boolean" + "string" ] } } @@ -8877,8 +10672,7 @@ "type": "string" }, "email": { - "type": "string", - "description": "The email address or domain username" + "type": "string" }, "password": { "maxLength": 100, @@ -8923,31 +10717,26 @@ "maxLength": 24, "minLength": 24, "pattern": "^[a-fA-F0-9]{24}$", - "type": "string", - "description": "Unique id that identifies a stack." + "type": "string" }, "organization_id": { "maxLength": 24, "minLength": 24, "pattern": "^[a-fA-F0-9]{24}$", - "type": "string", - "description": "The organization that the stack belongs to." + "type": "string" }, "project_id": { "maxLength": 24, "minLength": 24, "pattern": "^[a-fA-F0-9]{24}$", - "type": "string", - "description": "The project that the stack belongs to." + "type": "string" }, "type": { "maxLength": 100, "minLength": 1, - "type": "string", - "description": "The stack type (ie. error, log message, feature usage). Check KnownTypes for standard stack types." + "type": "string" }, "status": { - "description": "The stack status (ie. open, fixed, regressed,", "$ref": "#/components/schemas/StackStatus" }, "snooze_until_utc": { @@ -8955,85 +10744,71 @@ "null", "string" ], - "description": "The date that the stack should be snoozed until.", "format": "date-time" }, "signature_hash": { - "type": "string", - "description": "The signature used for stacking future occurrences." + "type": "string" }, "signature_info": { "type": "object", "additionalProperties": { "type": "string" - }, - "description": "The collection of information that went into creating the signature hash for the stack." + } }, "fixed_in_version": { "type": [ "null", "string" - ], - "description": "The version the stack was fixed in." + ] }, "date_fixed": { "type": [ "null", "string" ], - "description": "The date the stack was fixed.", "format": "date-time" }, "title": { "maxLength": 1000, "minLength": 0, - "type": "string", - "description": "The stack title." + "type": "string" }, "total_occurrences": { "type": "integer", - "description": "The total number of occurrences in the stack.", "format": "int32" }, "first_occurrence": { "type": "string", - "description": "The date of the 1st occurrence of this stack in UTC time.", "format": "date-time" }, "last_occurrence": { "type": "string", - "description": "The date of the last occurrence of this stack in UTC time.", "format": "date-time" }, "description": { "type": [ "null", "string" - ], - "description": "The stack description." + ] }, "occurrences_are_critical": { - "type": "boolean", - "description": "If true, all future occurrences will be marked as critical." + "type": "boolean" }, "references": { "type": "array", "items": { "type": "string" - }, - "description": "A list of references." + } }, "tags": { "uniqueItems": true, "type": "array", "items": { "type": "string" - }, - "description": "A list of tags used to categorize this stack." + } }, "duplicate_signature": { - "type": "string", - "description": "The signature used for finding duplicate stacks. (ProjectId, SignatureHash)" + "type": "string" }, "created_utc": { "type": "string", @@ -9070,27 +10845,6 @@ "Discarded" ] }, - "StringStringValuesKeyValuePair": { - "required": [ - "key", - "value" - ], - "type": "object", - "properties": { - "key": { - "type": [ - "null", - "string" - ] - }, - "value": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "StringValueFromBody": { "required": [ "value" @@ -9127,145 +10881,129 @@ } } }, - "UpdateEvent": { - "type": "object", - "properties": { - "email_address": { - "type": [ - "null", - "string" - ], - "format": "email" - }, - "description": { - "type": [ - "null", - "string" - ] - } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." - }, - "UpdateProject": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "delete_bot_data_enabled": { - "type": "boolean" - }, - "promoted_tabs": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" + "UpdateProjectJsonPatchDocument": { + "type": "array", + "items": { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "replace", + "test" + ], + "type": "string", + "description": "The operation to perform (only \u0027replace\u0027 and \u0027test\u0027 are supported)." + }, + "path": { + "type": "string", + "description": "A JSON Pointer (RFC 6901) to the target property, using snake_case naming (e.g., \u0027/full_name\u0027)." + }, + "value": { + "description": "The value to use for the operation." + }, + "from": { + "type": "string", + "description": "A JSON Pointer to the source property (only used with \u0027move\u0027 and \u0027copy\u0027 operations)." } } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, - "UpdateSavedView": { - "type": "object", - "properties": { - "name": { - "type": [ - "null", - "string" - ] - }, - "filter": { - "type": [ - "null", - "string" - ] - }, - "time": { - "type": [ - "null", - "string" - ] - }, - "sort": { - "type": [ - "null", - "string" - ] - }, - "slug": { - "type": [ - "null", - "string" - ] - }, - "filter_definitions": { - "type": [ - "null", - "string" - ] - }, - "columns": { - "type": [ - "null", - "object" - ], - "additionalProperties": { - "type": "boolean" - } - }, - "column_order": { - "maxItems": 50, - "type": [ - "null", - "array" - ], - "items": { - "type": "string" + "UpdateSavedViewJsonPatchDocument": { + "type": "array", + "items": { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "replace", + "test" + ], + "type": "string", + "description": "The operation to perform (only \u0027replace\u0027 and \u0027test\u0027 are supported)." + }, + "path": { + "type": "string", + "description": "A JSON Pointer (RFC 6901) to the target property, using snake_case naming (e.g., \u0027/full_name\u0027)." + }, + "value": { + "description": "The value to use for the operation." + }, + "from": { + "type": "string", + "description": "A JSON Pointer to the source property (only used with \u0027move\u0027 and \u0027copy\u0027 operations)." } - }, - "show_stats": { - "type": [ - "null", - "boolean" - ] - }, - "show_chart": { - "type": [ - "null", - "boolean" - ] } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, - "UpdateToken": { - "type": "object", - "properties": { - "is_disabled": { - "type": "boolean" - }, - "notes": { - "type": [ - "null", - "string" - ] + "UpdateTokenJsonPatchDocument": { + "type": "array", + "items": { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "replace", + "test" + ], + "type": "string", + "description": "The operation to perform (only \u0027replace\u0027 and \u0027test\u0027 are supported)." + }, + "path": { + "type": "string", + "description": "A JSON Pointer (RFC 6901) to the target property, using snake_case naming (e.g., \u0027/full_name\u0027)." + }, + "value": { + "description": "The value to use for the operation." + }, + "from": { + "type": "string", + "description": "A JSON Pointer to the source property (only used with \u0027move\u0027 and \u0027copy\u0027 operations)." + } } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, - "UpdateUser": { - "type": "object", - "properties": { - "full_name": { - "type": "string" - }, - "email_notifications_enabled": { - "type": "boolean" + "UpdateUserJsonPatchDocument": { + "type": "array", + "items": { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "replace", + "test" + ], + "type": "string", + "description": "The operation to perform (only \u0027replace\u0027 and \u0027test\u0027 are supported)." + }, + "path": { + "type": "string", + "description": "A JSON Pointer (RFC 6901) to the target property, using snake_case naming (e.g., \u0027/full_name\u0027)." + }, + "value": { + "description": "The value to use for the operation." + }, + "from": { + "type": "string", + "description": "A JSON Pointer to the source property (only used with \u0027move\u0027 and \u0027copy\u0027 operations)." + } } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, "UsageHourInfo": { "required": [ @@ -9368,16 +11106,14 @@ "maxLength": 24, "minLength": 24, "pattern": "^[a-fA-F0-9]{24}$", - "type": "string", - "description": "Unique id that identifies an user." + "type": "string" }, "organization_ids": { "uniqueItems": true, "type": "array", "items": { "type": "string" - }, - "description": "The organizations that the user has access to." + } }, "password": { "type": [ @@ -9408,8 +11144,7 @@ } }, "full_name": { - "type": "string", - "description": "Gets or sets the users Full Name." + "type": "string" }, "email_address": { "type": "string", @@ -9440,8 +11175,7 @@ "format": "date-time" }, "is_active": { - "type": "boolean", - "description": "Gets or sets the users active state." + "type": "boolean" }, "roles": { "uniqueItems": true, @@ -9481,8 +11215,7 @@ "null", "object" ], - "additionalProperties": { }, - "description": "Extended data entries for this user description." + "additionalProperties": {} } } }, @@ -9757,7 +11490,7 @@ "null", "object" ], - "additionalProperties": { } + "additionalProperties": {} }, "is_throttled": { "type": "boolean" @@ -9818,7 +11551,7 @@ "null", "object" ], - "additionalProperties": { } + "additionalProperties": {} }, "promoted_tabs": { "type": "array", @@ -10180,8 +11913,7 @@ "type": "boolean" }, "version": { - "type": "string", - "description": "The schema version that should be used." + "type": "string" }, "created_utc": { "type": "string", @@ -10212,12 +11944,12 @@ }, "Bearer": { "type": "http", - "description": "Authorization token. Example: \"Bearer {apikey}\"", + "description": "Authorization token. Example: \u0022Bearer {apikey}\u0022", "scheme": "bearer" }, "Token": { "type": "apiKey", - "description": "Authorization token. Example: \"Bearer {apikey}\"", + "description": "Authorization token. Example: \u0022Bearer {apikey}\u0022", "name": "access_token", "in": "query" } @@ -10225,31 +11957,31 @@ }, "tags": [ { - "name": "SavedView" + "name": "Project" }, { - "name": "Token" + "name": "Event" }, { - "name": "WebHook" + "name": "Auth" }, { - "name": "Auth" + "name": "Token" }, { - "name": "Event" + "name": "WebHook" }, { - "name": "Organization" + "name": "SavedView" }, { - "name": "Project" + "name": "User" }, { - "name": "Stack" + "name": "Organization" }, { - "name": "User" + "name": "Stack" } ] } \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Controllers/EndpointManifestTests.cs b/tests/Exceptionless.Tests/Controllers/EndpointManifestTests.cs new file mode 100644 index 0000000000..09ef6f27e8 --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/EndpointManifestTests.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +public sealed class EndpointManifestTests +{ + [Fact] + public Task MapApiEndpoints_DefaultServices_MatchesSnapshot() + { + // Arrange + using var app = MinimalApiTestApp.Create(); + + // Act + var manifest = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(dataSource => dataSource.Endpoints) + .OfType() + .SelectMany(CreateManifestEntries) + .OrderBy(endpoint => endpoint.Route, StringComparer.Ordinal) + .ThenBy(endpoint => endpoint.Method, StringComparer.Ordinal) + .ThenBy(endpoint => endpoint.DisplayName, StringComparer.Ordinal) + .ToArray(); + + string actualJson = SnapshotTestHelper.Serialize(manifest); + + // Assert + return SnapshotTestHelper.AssertMatchesJsonSnapshotAsync("endpoint-manifest.json", actualJson, TestContext.Current.CancellationToken); + } + + private static IEnumerable CreateManifestEntries(RouteEndpoint endpoint) + { + var authorizeData = endpoint.Metadata.GetOrderedMetadata(); + var tags = endpoint.Metadata.GetOrderedMetadata() + .SelectMany(metadata => metadata.Tags) + .Distinct(StringComparer.Ordinal) + .OrderBy(tag => tag, StringComparer.Ordinal) + .ToArray(); + var methods = endpoint.Metadata.GetMetadata()?.HttpMethods ?? ["ANY"]; + + foreach (string method in methods.OrderBy(value => value, StringComparer.Ordinal)) + { + yield return new EndpointManifestEntry + { + Method = method, + Route = NormalizeRoute(endpoint.RoutePattern.RawText), + DisplayName = endpoint.DisplayName ?? String.Empty, + Tags = tags, + AllowAnonymous = endpoint.Metadata.GetMetadata() is not null, + AuthorizationPolicies = authorizeData + .Select(data => data.Policy) + .Where(policy => !String.IsNullOrWhiteSpace(policy)) + .Select(policy => policy!) + .Distinct(StringComparer.Ordinal) + .OrderBy(policy => policy, StringComparer.Ordinal) + .ToArray(), + AuthorizationRoles = authorizeData + .SelectMany(data => SplitCsv(data.Roles)) + .Distinct(StringComparer.Ordinal) + .OrderBy(role => role, StringComparer.Ordinal) + .ToArray(), + AuthenticationSchemes = authorizeData + .SelectMany(data => SplitCsv(data.AuthenticationSchemes)) + .Distinct(StringComparer.Ordinal) + .OrderBy(scheme => scheme, StringComparer.Ordinal) + .ToArray() + }; + } + } + + private static IEnumerable SplitCsv(string? value) + { + if (String.IsNullOrWhiteSpace(value)) + return []; + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static string NormalizeRoute(string? route) + { + if (String.IsNullOrWhiteSpace(route)) + return "/"; + + return route.StartsWith('/') ? route : $"/{route}"; + } + + private sealed class EndpointManifestEntry + { + public required string Method { get; init; } + public required string Route { get; init; } + public required string DisplayName { get; init; } + public required string[] Tags { get; init; } = []; + public required bool AllowAnonymous { get; init; } + public required string[] AuthorizationPolicies { get; init; } = []; + public required string[] AuthorizationRoles { get; init; } = []; + public required string[] AuthenticationSchemes { get; init; } = []; + } +} diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index a103144c60..f848842b6c 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -19,6 +19,7 @@ using Exceptionless.Helpers; using Exceptionless.Tests.Extensions; using Exceptionless.Tests.Utility; +using Exceptionless.Web.Controllers; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Jobs; @@ -34,6 +35,7 @@ namespace Exceptionless.Tests.Controllers; +[Collection("EventQueue")] public partial class EventControllerTests : IntegrationTestsBase { private readonly JsonSerializerOptions _jsonSerializerOptions; @@ -167,6 +169,67 @@ await SendRequestAsync(r => r await _eventUserDescriptionQueue.DeleteQueueAsync(); } + [Fact] + public async Task DeleteAsync_WithMixedAccess_ReturnsModelActionResults() + { + // Arrange + var (_, events) = await CreateDataAsync(d => + { + d.Event().TestProject(); + d.Event().FreeProject(); + }); + + var testEvent = Assert.Single(events, e => String.Equals(e.OrganizationId, SampleDataService.TEST_ORG_ID, StringComparison.Ordinal)); + var freeEvent = Assert.Single(events, e => String.Equals(e.OrganizationId, SampleDataService.FREE_ORG_ID, StringComparison.Ordinal)); + + // Act + var response = await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPath($"events/{testEvent.Id},{freeEvent.Id}") + .StatusCodeShouldBeBadRequest()); + + var result = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken), _jsonSerializerOptions); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Success); + Assert.Contains(testEvent.Id, result.Success); + + var failure = Assert.Single(result.Failure); + Assert.False(failure.Allowed); + Assert.Equal(freeEvent.Id, failure.Id); + Assert.Equal(StatusCodes.Status404NotFound, failure.StatusCode); + + await RefreshDataAsync(); + Assert.Null(await _eventRepository.GetByIdAsync(testEvent.Id)); + Assert.NotNull(await _eventRepository.GetByIdAsync(freeEvent.Id)); + } + + [Fact] + public async Task GetByOrganizationAsync_SuspendedOrganization_ReturnsUpgradeRequired() + { + // Arrange + var userRepository = GetService(); + var user = await userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + + organization.IsSuspended = true; + organization.SuspensionCode = SuspensionCode.Billing; + organization.SuspensionDate = DateTime.UtcNow; + organization.SuspendedByUserId = user.Id; + await _organizationRepository.SaveAsync(organization, o => o.Originals().ImmediateConsistency().Cache()); + + // Act & Assert + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", organization.Id, "events") + .StatusCodeShouldBeUpgradeRequired()); + } + [Fact] public async Task GetSubmitEventAsync_WithOnlyIgnoredParameters_DoesNotEnqueueEvent() { diff --git a/tests/Exceptionless.Tests/Controllers/MinimalApiTestApp.cs b/tests/Exceptionless.Tests/Controllers/MinimalApiTestApp.cs new file mode 100644 index 0000000000..aa09cadeb5 --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/MinimalApiTestApp.cs @@ -0,0 +1,120 @@ +using System.Reflection; +using Exceptionless.Core.Serialization; +using Exceptionless.Web.Api; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Utility; +using Exceptionless.Web.Utility.OpenApi; +using Foundatio.Mediator; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Exceptionless.Tests.Controllers; + +internal static class MinimalApiTestApp +{ + public static WebApplication Create(bool useTestServer = false, bool includeOpenApi = false) + { + var builder = WebApplication.CreateBuilder(); + if (useTestServer) + builder.WebHost.UseTestServer(); + + builder.Services.AddAuthorization(); + builder.Services.AddAuthenticationCore(); + builder.Services.ConfigureHttpJsonOptions(o => + { + o.SerializerOptions.ConfigureExceptionlessApiDefaults(); + }); + builder.Services.AddRouting(options => + { + options.ConstraintMap.Add("identifier", typeof(IdentifierRouteConstraint)); + options.ConstraintMap.Add("identifiers", typeof(IdentifiersRouteConstraint)); + options.ConstraintMap.Add("objectid", typeof(ObjectIdRouteConstraint)); + options.ConstraintMap.Add("objectids", typeof(ObjectIdsRouteConstraint)); + options.ConstraintMap.Add("token", typeof(TokenRouteConstraint)); + options.ConstraintMap.Add("tokens", typeof(TokensRouteConstraint)); + }); + builder.Services.AddSingleton(); + builder.Services.AddSingleton, ApiResultMapper>(); + builder.Services.AddSingleton(_ => DispatchProxy.Create()); + + if (includeOpenApi) + { + builder.Services.AddOpenApi(options => + { + options.CreateSchemaReferenceId = SchemaReferenceIdHelper.CreateSchemaReferenceId; + options.AddDocumentTransformer(); + options.AddDocumentTransformer(); + options.AddDocumentTransformer(); + options.AddOperationTransformer(); + options.AddOperationTransformer(); + options.AddOperationTransformer(); + options.AddOperationTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + }); + } + + var app = builder.Build(); + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + + if (includeOpenApi) + app.MapOpenApi("/docs/v2/openapi.json"); + + app.MapApiEndpoints(); + return app; + } + + private sealed class PermissiveServiceProviderIsService : IServiceProviderIsService + { + public bool IsService(Type serviceType) + { + var underlyingType = Nullable.GetUnderlyingType(serviceType) ?? serviceType; + if (underlyingType == typeof(string) || underlyingType.IsPrimitive || underlyingType.IsEnum) + return false; + + return true; + } + } + + private sealed class NullMediatorProxy : DispatchProxy + { + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod is null) + return null; + + return GetDefaultValue(targetMethod.ReturnType); + } + + private static object? GetDefaultValue(Type type) + { + if (type == typeof(void)) + return null; + + if (type == typeof(Task)) + return Task.CompletedTask; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>)) + { + var resultType = type.GetGenericArguments()[0]; + var defaultValue = resultType.IsValueType ? Activator.CreateInstance(resultType) : null; + return typeof(Task) + .GetMethod(nameof(Task.FromResult))! + .MakeGenericMethod(resultType) + .Invoke(null, [defaultValue]); + } + + return type.IsValueType ? Activator.CreateInstance(type) : null; + } + } +} diff --git a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OpenApiSnapshotTests.cs similarity index 65% rename from tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs rename to tests/Exceptionless.Tests/Controllers/OpenApiSnapshotTests.cs index 09ee908ed1..ce2414b3c3 100644 --- a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OpenApiSnapshotTests.cs @@ -1,45 +1,29 @@ +using System.Net; using System.Text.Json; -using Exceptionless.Tests.Extensions; +using Microsoft.AspNetCore.TestHost; using Xunit; namespace Exceptionless.Tests.Controllers; -public class OpenApiControllerTests : IntegrationTestsBase +public sealed class OpenApiSnapshotTests { - public OpenApiControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) - { - } - [Fact] - public async Task GetOpenApiJson_Default_ReturnsExpectedBaseline() + public async Task GetOpenApiJson_Default_MatchesSnapshot() { - // Arrange - string baselinePath = Path.Combine(AppContext.BaseDirectory, "Controllers", "Data", "openapi.json"); - // Act - var response = await SendRequestAsync(r => r - .BaseUri(_server.BaseAddress) - .AppendPaths("docs", "v2", "openapi.json") - .StatusCodeShouldBeOk() - ); - - string actualJson = await response.Content.ReadAsStringAsync(TestCancellationToken); + string actualJson = await GetOpenApiJsonAsync(); // Set UPDATE_SNAPSHOTS=true to regenerate the baseline file. if (String.Equals(Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS"), "true", StringComparison.OrdinalIgnoreCase)) { // Write to the source tree so the change produces a real git diff. string sourcePath = Path.GetFullPath(Path.Join(AppContext.BaseDirectory, "..", "..", "..", "Controllers", "Data", "openapi.json")); - await File.WriteAllTextAsync(sourcePath, actualJson, TestCancellationToken); + await File.WriteAllTextAsync(sourcePath, actualJson, TestContext.Current.CancellationToken); return; } - // Assert - string expectedJson = NormalizeOpenApiJson(await File.ReadAllTextAsync(baselinePath, TestCancellationToken)); - actualJson = NormalizeOpenApiJson(actualJson); - - Assert.Equal(expectedJson, actualJson); + await SnapshotTestHelper.AssertMatchesJsonSnapshotAsync("openapi.json", actualJson, TestContext.Current.CancellationToken); } [Fact] @@ -66,6 +50,14 @@ public async Task GetOpenApiJson_ContainsExpectedRoutesOperationsAndResponses() Assert.True(userDescriptionPath.TryGetProperty("post", out var userDescriptionPost)); Assert.True(userDescriptionPost.TryGetProperty("requestBody", out _)); AssertResponseCodes(userDescriptionPost, "202"); + + Assert.True(paths.TryGetProperty("/api/v2/events", out var eventsPath)); + Assert.True(eventsPath.TryGetProperty("post", out var eventsPost)); + AssertRequestBodyContent(eventsPost, "application/json", "text/plain"); + + Assert.True(paths.TryGetProperty("/api/v2/projects/{projectId}/events", out var projectEventsPath)); + Assert.True(projectEventsPath.TryGetProperty("post", out var projectEventsPost)); + AssertRequestBodyContent(projectEventsPost, "application/json", "text/plain"); } [Fact] @@ -95,24 +87,25 @@ public async Task GetOpenApiJson_ContainsExpectedSchemasAndSecuritySchemes() Assert.Equal("access_token", token.GetProperty("name").GetString()); } - private static string NormalizeOpenApiJson(string json) + private static async Task GetOpenApiDocumentAsync() { - return json - .ReplaceLineEndings("\n") - .Replace("\\r\\n", "\\n") - .TrimEnd('\n'); + string json = await GetOpenApiJsonAsync(); + return JsonDocument.Parse(json); } - private async Task GetOpenApiDocumentAsync() + private static async Task GetOpenApiJsonAsync() { - var response = await SendRequestAsync(r => r - .BaseUri(_server.BaseAddress) - .AppendPaths("docs", "v2", "openapi.json") - .StatusCodeShouldBeOk() - ); + await using var app = MinimalApiTestApp.Create(useTestServer: true, includeOpenApi: true); + await app.StartAsync(TestContext.Current.CancellationToken); - string json = await response.Content.ReadAsStringAsync(TestCancellationToken); - return JsonDocument.Parse(json); + var client = app.GetTestClient(); + client.BaseAddress = new Uri("http://localhost"); + + using var response = await client.GetAsync("/docs/v2/openapi.json", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + string json = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + return SnapshotTestHelper.NormalizeJson(json); } private static void AssertResponseCodes(JsonElement operation, params string[] expectedStatusCodes) @@ -121,4 +114,12 @@ private static void AssertResponseCodes(JsonElement operation, params string[] e foreach (string statusCode in expectedStatusCodes) Assert.True(responses.TryGetProperty(statusCode, out _), $"Expected response status code '{statusCode}'."); } + + private static void AssertRequestBodyContent(JsonElement operation, params string[] expectedContentTypes) + { + Assert.True(operation.TryGetProperty("requestBody", out var requestBody), "Expected request body."); + var content = requestBody.GetProperty("content"); + foreach (string contentType in expectedContentTypes) + Assert.True(content.TryGetProperty(contentType, out _), $"Expected request body content type '{contentType}'."); + } } diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 599c6da2f6..dfbc6cee41 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -1218,7 +1219,7 @@ public async Task ChangePlanAsync_FreePlanCancelsActiveStripeSubscriptions() public async Task ChangePlanAsync_StripeBillingClientThrows_ReturnsFailure() { // Arrange - StripeBillingClient.CreateCustomerException = new InvalidOperationException("Stripe unavailable"); + StripeBillingClient.CreateCustomerException = new StripeException("Stripe unavailable"); // Act var result = await WithBillingEnabledAsync(() => @@ -1238,7 +1239,7 @@ public async Task ChangePlanAsync_StripeBillingClientThrows_ReturnsFailure() // Assert Assert.NotNull(result); Assert.False(result.Success); - Assert.Equal("An error occurred while changing plans. Please try again.", result.Message); + Assert.Equal("An error occurred while changing plans. Please try again or contact support.", result.Message); Assert.Empty(StripeBillingClient.CreatedSubscriptionOptions); } @@ -1247,7 +1248,7 @@ public async Task ChangePlanAsync_NewCustomerSubscriptionFails_PreservesStripeCu { // Arrange StripeBillingClient.CustomerToReturn = new Customer { Id = "cus_created" }; - StripeBillingClient.CreateSubscriptionException = new InvalidOperationException("Stripe unavailable"); + StripeBillingClient.CreateSubscriptionException = new StripeException("Stripe unavailable"); // Act var result = await WithBillingEnabledAsync(() => @@ -1267,7 +1268,7 @@ public async Task ChangePlanAsync_NewCustomerSubscriptionFails_PreservesStripeCu // Assert Assert.NotNull(result); Assert.False(result.Success); - Assert.Equal("An error occurred while changing plans. Please try again.", result.Message); + Assert.Equal("An error occurred while changing plans. Please try again or contact support.", result.Message); var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); Assert.NotNull(organization); @@ -1283,7 +1284,7 @@ public async Task ChangePlanAsync_ExistingCustomerSubscriptionFails_DoesNotPersi // Arrange await SetStripeCustomerIdAsync(SampleDataService.FREE_ORG_ID, "cus_existing"); StripeBillingClient.Subscriptions.Add(CreateStripeSubscription("sub_active", "si_active")); - StripeBillingClient.UpdateSubscriptionException = new InvalidOperationException("Stripe unavailable"); + StripeBillingClient.UpdateSubscriptionException = new StripeException("Stripe unavailable"); // Act var result = await WithBillingEnabledAsync(() => @@ -1303,7 +1304,7 @@ public async Task ChangePlanAsync_ExistingCustomerSubscriptionFails_DoesNotPersi // Assert Assert.NotNull(result); Assert.False(result.Success); - Assert.Equal("An error occurred while changing plans. Please try again.", result.Message); + Assert.Equal("An error occurred while changing plans. Please try again or contact support.", result.Message); Assert.NotEmpty(StripeBillingClient.UpdatedSubscriptions); var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); @@ -1449,7 +1450,7 @@ public async Task GetInvoiceAsync_StripeInvoice_ReturnsMappedInvoice() public Task GetInvoiceAsync_StripeBillingClientThrows_ReturnsNotFound() { // Arrange - StripeBillingClient.GetInvoiceException = new InvalidOperationException("Stripe unavailable"); + StripeBillingClient.GetInvoiceException = new StripeException("Stripe unavailable"); // Act & Assert return WithBillingEnabledAsync(() => @@ -1538,7 +1539,7 @@ public Task PatchAsync_AnonymousUser_ReturnsUnauthorized() return SendRequestAsync(r => r .Patch() .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) - .Content(new NewOrganization { Name = "Unauthorized Update" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("name", "Unauthorized Update"))), "application/json-patch+json") .StatusCodeShouldBeUnauthorized() ); } @@ -1556,7 +1557,7 @@ await SendRequestAsync(r => r .Patch() .AsTestOrganizationUser() .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) - .Content(new NewOrganization { Name = "" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("name", ""))), "application/json-patch+json") .StatusCodeShouldBeBadRequest() ); @@ -1574,7 +1575,7 @@ public Task PatchAsync_NonExistentOrganization_ReturnsNotFound() .Patch() .AsTestOrganizationUser() .AppendPaths("organizations", "000000000000000000000000") - .Content(new NewOrganization { Name = "Nope" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("name", "Nope"))), "application/json-patch+json") .StatusCodeShouldBeNotFound() ); } @@ -1591,7 +1592,7 @@ public async Task PatchAsync_UpdateName_ReturnsUpdatedOrganization() .Patch() .AsTestOrganizationUser() .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) - .Content(new NewOrganization { Name = "Updated Acme" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("name", "Updated Acme"))), "application/json-patch+json") .StatusCodeShouldBeOk() ); @@ -1618,7 +1619,7 @@ public async Task PatchAsync_EmptyJsonBody_ReturnsOriginalOrganizationUnchanged( .Patch() .AsTestOrganizationUser() .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) - .Content("{}", "application/json") + .Content("[]", "application/json-patch+json") .StatusCodeShouldBeOk() ); diff --git a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs index 68788a3c3d..2959f49305 100644 --- a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs @@ -5,6 +5,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; +using RequestExtensions = Exceptionless.Tests.Extensions.RequestExtensions; using Exceptionless.Tests.Utility; using Exceptionless.Web.Controllers; using Exceptionless.Web.Models; @@ -694,9 +695,13 @@ public async Task PatchAsync_WithNameOnlySnakeCasePayload_UpdatesNameAndPreserve /* language=json */ const string json = """ - { - "name": "Updated Name" - } + [ + { + "op": "replace", + "path": "/name", + "value": "Updated Name" + } + ] """; // Act @@ -704,7 +709,7 @@ public async Task PatchAsync_WithNameOnlySnakeCasePayload_UpdatesNameAndPreserve .AsTestOrganizationUser() .Patch() .AppendPaths("projects", project.Id) - .Content(json, "application/json") + .Content(json, "application/json-patch+json") .StatusCodeShouldBeOk() ); string responseJson = await response.Content.ReadAsStringAsync(TestCancellationToken); @@ -783,11 +788,7 @@ await SendRequestAsync(r => r .AsTestOrganizationUser() .Patch() .AppendPaths("projects", "000000000000000000000000") - .Content(new UpdateProject - { - Name = "Should Not Exist", - DeleteBotDataEnabled = false - }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("name", "Should Not Exist"), ("delete_bot_data_enabled", false))), "application/json-patch+json") .StatusCodeShouldBeNotFound() ); @@ -798,7 +799,7 @@ await SendRequestAsync(r => r } [Fact] - public async Task PatchAsync_WithExtraPayloadProperties_IgnoresReadOnlyFieldsAndUpdatesKnownFields() + public async Task PatchAsync_WithExtraPayloadProperties_RejectsUnknownPaths() { // Arrange var project = await SendRequestAsAsync(r => r @@ -818,26 +819,49 @@ public async Task PatchAsync_WithExtraPayloadProperties_IgnoresReadOnlyFieldsAnd var persistedBefore = await _projectRepository.GetByIdAsync(project.Id); Assert.NotNull(persistedBefore); - /* language=json */ - string json = $$""" - { - "id": "000000000000000000000000", - "organization_id": "{{SampleDataService.FREE_ORG_ID}}", - "organization_name": "Hijacked Org", - "created_utc": "2000-01-01T00:00:00Z", - "name": "Patched With Extras", - "delete_bot_data_enabled": false, - "has_premium_features": true, - "stack_count": 9999 - } - """; + // Act — immutable path /organization_id is rejected at validation time + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Patch() + .AppendPaths("projects", project.Id) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("organization_id", SampleDataService.FREE_ORG_ID))), "application/json-patch+json") + .StatusCodeShouldBeUnprocessableEntity() + ); - // Act + // Verify entity was NOT changed + var persistedAfter = await _projectRepository.GetByIdAsync(project.Id); + Assert.NotNull(persistedAfter); + Assert.Equal(SampleDataService.TEST_ORG_ID, persistedAfter.OrganizationId); + Assert.Equal(persistedBefore.Name, persistedAfter.Name); + } + + [Fact] + public async Task PatchAsync_WithValidFields_UpdatesKnownFieldsAndPreservesOthers() + { + // Arrange + var project = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPath("projects") + .Content(new NewProject + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Valid Fields Project", + DeleteBotDataEnabled = true + }) + .StatusCodeShouldBeCreated() + ); + Assert.NotNull(project); + + var persistedBefore = await _projectRepository.GetByIdAsync(project.Id); + Assert.NotNull(persistedBefore); + + // Act — only send valid fields var response = await SendRequestAsync(r => r .AsTestOrganizationUser() .Patch() .AppendPaths("projects", project.Id) - .Content(json, "application/json") + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("name", "Patched With Extras"), ("delete_bot_data_enabled", false))), "application/json-patch+json") .StatusCodeShouldBeOk() ); string responseJson = await response.Content.ReadAsStringAsync(TestCancellationToken); @@ -867,6 +891,50 @@ public async Task PatchAsync_WithExtraPayloadProperties_IgnoresReadOnlyFieldsAnd Assert.False(root.TryGetProperty("DeleteBotDataEnabled", out _), "Response must not drift back to PascalCase 'DeleteBotDataEnabled'."); } + [Fact] + public async Task PatchAsync_PathWithoutLeadingSlash_ReturnsUnprocessableEntity() + { + // Arrange + var project = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPath("projects") + .Content(new NewProject + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Original RFC Project", + DeleteBotDataEnabled = true + }) + .StatusCodeShouldBeCreated() + ); + Assert.NotNull(project); + + /* language=json */ + const string json = """ + [ + { + "op": "replace", + "path": "name", + "value": "Should Not Apply" + } + ] + """; + + // Act + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Patch() + .AppendPaths("projects", project.Id) + .Content(json, JsonPatchHelper.ContentType) + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Assert + var persisted = await _projectRepository.GetByIdAsync(project.Id); + Assert.NotNull(persisted); + Assert.Equal("Original RFC Project", persisted.Name); + } + [Fact] public async Task PostAsync_NewProject_MapsAllPropertiesToProject() { diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index 85462c4961..8cdb610b71 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -747,17 +747,13 @@ await SendRequestAsync(r => r .StatusCodeShouldBeOk() ); - var changes = new UpdateSavedView - { - Filter = "type:log level:warn", - FilterDefinitions = """[{"type":"type","value":["log"],"hidden":true},{"type":"level","value":["Warn"]}]""" - }; - await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", logs.Id) - .Content(changes) + .JsonPatchContent( + ("filter", "type:log level:warn"), + ("filter_definitions", """[{"type":"type","value":["log"],"hidden":true},{"type":"level","value":["Warn"]}]""")) .StatusCodeShouldBeOk() ); @@ -878,7 +874,7 @@ public async Task PatchAsync_UpdateName_UpdatesNameAndSetsUpdatedByUserId() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Name = "Updated Name" }) + .JsonPatchContent("name", "Updated Name") .StatusCodeShouldBeOk() ); @@ -901,7 +897,7 @@ public async Task PatchAsync_UpdateFilter_UpdatesFilterString() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Filter = "status:regressed" }) + .JsonPatchContent("filter", "status:regressed") .StatusCodeShouldBeOk() ); @@ -922,7 +918,7 @@ public async Task PatchAsync_UpdateTime_UpdatesTimeString() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Time = "[now-30D TO now]" }) + .JsonPatchContent("time", "[now-30D TO now]") .StatusCodeShouldBeOk() ); @@ -943,7 +939,7 @@ public async Task PatchAsync_UpdateSort_UpdatesSortString() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Sort = "-date" }) + .JsonPatchContent("sort", "-date") .StatusCodeShouldBeOk() ); @@ -959,7 +955,7 @@ public Task PatchAsync_NonExistentFilter_ReturnsNotFound() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", "000000000000000000000000") - .Content(new UpdateSavedView { Name = "Nope" }) + .JsonPatchContent("name", "Nope") .StatusCodeShouldBeNotFound() ); } @@ -1072,7 +1068,7 @@ public async Task PatchAsync_OrganizationWideFilterByOrganizationMember_Succeeds .Patch() .AsTestOrganizationUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Name = "Renamed by Organization User" }) + .JsonPatchContent("name", "Renamed by Organization User") .StatusCodeShouldBeOk() ); @@ -1320,7 +1316,7 @@ await SendRequestAsync(r => r .Patch() .AsTestOrganizationUser() .AppendPaths("saved-views", privateView.Id) - .Content(new UpdateSavedView { Name = "Hacked" }) + .JsonPatchContent("name", "Hacked") .StatusCodeShouldBeNotFound() ); @@ -1343,7 +1339,7 @@ public async Task PatchAsync_UpdateFilterDefinitions_PersistsJsonBlob() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { FilterDefinitions = filterDefs }) + .JsonPatchContent("filter_definitions", filterDefs) .StatusCodeShouldBeOk() ); @@ -1366,7 +1362,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Name = "existing patch name" }) + .JsonPatchContent("name", "existing patch name") .ExpectedStatus(HttpStatusCode.Conflict) ); } @@ -1385,7 +1381,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Slug = "existing-patch-url" }) + .JsonPatchContent("slug", "existing-patch-url") .ExpectedStatus(HttpStatusCode.Conflict) ); } @@ -1402,7 +1398,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Slug = "507f1f77bcf86cd799439011" }) + .JsonPatchContent("slug", "507f1f77bcf86cd799439011") .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1419,7 +1415,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { FilterDefinitions = "not valid json" }) + .JsonPatchContent("filter_definitions", "not valid json") .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1592,7 +1588,7 @@ public Task PatchAsync_AnonymousUser_ReturnsUnauthorized() .Patch() .AsAnonymousUser() .AppendPaths("saved-views", "000000000000000000000000") - .Content(new UpdateSavedView { Name = "Hacked" }) + .JsonPatchContent("name", "Hacked") .StatusCodeShouldBeUnauthorized() ); } @@ -1754,10 +1750,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView - { - Columns = new Dictionary { ["INVALID_COLUMN"] = true } - }) + .JsonPatchContent("columns", new Dictionary { ["INVALID_COLUMN"] = true }) .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1812,10 +1805,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView - { - ColumnOrder = ["summary", "summary"] - }) + .JsonPatchContent("column_order", new List { "summary", "summary" }) .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1832,7 +1822,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Name = new string('x', 101) }) + .JsonPatchContent("name", new string('x', 101)) .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1849,7 +1839,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Filter = new string('x', 2001) }) + .JsonPatchContent("filter", new string('x', 2001)) .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1866,7 +1856,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Sort = new string('x', 101) }) + .JsonPatchContent("sort", new string('x', 101)) .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1904,7 +1894,7 @@ await SendRequestAsync(r => r .Patch() .AsTestOrganizationUser() .AppendPaths("saved-views", privateView.Id) - .Content(new UpdateSavedView { Name = "Hijacked" }) + .JsonPatchContent("name", "Hijacked") .StatusCodeShouldBeNotFound() ); @@ -1952,7 +1942,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new { columns }) + .JsonPatchContent("columns", columns) .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1969,7 +1959,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new { name = " " }) + .JsonPatchContent("name", " ") .StatusCodeShouldBeUnprocessableEntity() ); } diff --git a/tests/Exceptionless.Tests/Controllers/SnapshotTestHelper.cs b/tests/Exceptionless.Tests/Controllers/SnapshotTestHelper.cs new file mode 100644 index 0000000000..fdfa0c3703 --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/SnapshotTestHelper.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +internal static class SnapshotTestHelper +{ + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + public static async Task AssertMatchesJsonSnapshotAsync(string snapshotFileName, string actualJson, CancellationToken cancellationToken = default) + { + string snapshotPath = GetControllersDataPath(snapshotFileName); + string normalizedActualJson = NormalizeLineEndings(NormalizeJson(actualJson)); + + if (ShouldUpdateSnapshots()) + await File.WriteAllTextAsync(snapshotPath, normalizedActualJson, cancellationToken); + + string expectedJson = NormalizeLineEndings(await File.ReadAllTextAsync(snapshotPath, cancellationToken)); + Assert.Equal(expectedJson, normalizedActualJson); + } + + public static string Serialize(object value) + { + return NormalizeLineEndings(JsonSerializer.Serialize(value, s_jsonSerializerOptions)); + } + + public static string NormalizeJson(string json) + { + using var document = JsonDocument.Parse(json); + return JsonSerializer.Serialize(document.RootElement, s_jsonSerializerOptions); + } + + private static string GetControllersDataPath(string fileName) + { + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Controllers", "Data", fileName)); + } + + private static bool ShouldUpdateSnapshots() + { + return String.Equals(Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS"), "true", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeLineEndings(string value) + { + return value.Replace("\r\n", "\n"); + } +} diff --git a/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs b/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs index 02017715de..437cc28f1a 100644 --- a/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Jobs; using Exceptionless.Core.Models; @@ -6,6 +7,7 @@ using Exceptionless.Core.Utility; using Exceptionless.Models.Data; using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; using Exceptionless.Web.Controllers; using Exceptionless.Web.Models; using Foundatio.Jobs; @@ -16,8 +18,10 @@ namespace Exceptionless.Tests.Controllers; +[Collection("EventQueue")] public class StackControllerTests : IntegrationTestsBase { + private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly IStackRepository _stackRepository; private readonly IEventRepository _eventRepository; private readonly IQueue _eventQueue; @@ -25,6 +29,7 @@ public class StackControllerTests : IntegrationTestsBase public StackControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { + _jsonSerializerOptions = GetService(); _stackRepository = GetService(); _eventRepository = GetService(); _eventQueue = GetService>(); @@ -300,6 +305,68 @@ public Task DeleteAsync_NonExistentStack_ReturnsNotFound() .StatusCodeShouldBeNotFound()); } + [Fact] + public async Task DeleteAsync_WithMixedAccess_ReturnsModelActionResults() + { + // Arrange + var (stacks, _) = await CreateDataAsync(d => + { + d.Event().TestProject(); + d.Event().FreeProject(); + }); + + var testStack = Assert.Single(stacks, s => String.Equals(s.OrganizationId, SampleDataService.TEST_ORG_ID, StringComparison.Ordinal)); + var freeStack = Assert.Single(stacks, s => String.Equals(s.OrganizationId, SampleDataService.FREE_ORG_ID, StringComparison.Ordinal)); + + // Act + var response = await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPath($"stacks/{testStack.Id},{freeStack.Id}") + .StatusCodeShouldBeBadRequest()); + + var result = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken), _jsonSerializerOptions); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Success); + Assert.Contains(testStack.Id, result.Success); + + var failure = Assert.Single(result.Failure); + Assert.False(failure.Allowed); + Assert.Equal(freeStack.Id, failure.Id); + Assert.Equal(StatusCodes.Status404NotFound, failure.StatusCode); + + await RefreshDataAsync(); + Assert.True((await _stackRepository.GetByIdAsync(testStack.Id, o => o.IncludeSoftDeletes()))!.IsDeleted); + Assert.False((await _stackRepository.GetByIdAsync(freeStack.Id))!.IsDeleted); + } + + [Fact] + public async Task GetByOrganizationAsync_SuspendedOrganization_ReturnsUpgradeRequired() + { + // Arrange + var organizationRepository = GetService(); + var userRepository = GetService(); + var user = await userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + var organization = await organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + + organization.IsSuspended = true; + organization.SuspensionCode = SuspensionCode.Billing; + organization.SuspensionDate = DateTime.UtcNow; + organization.SuspendedByUserId = user.Id; + await organizationRepository.SaveAsync(organization, o => o.Originals().ImmediateConsistency().Cache()); + + // Act & Assert + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", organization.Id, "stacks") + .StatusCodeShouldBeUpgradeRequired()); + } + [Fact] public async Task GetAll_WithDateRangeFilter_ReturnsOnlyMatchingStacks() { diff --git a/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs b/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs index 35e1e87793..6ef62a2c73 100644 --- a/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs @@ -1,8 +1,10 @@ +using System.Text.Json; using Exceptionless.Core.Authorization; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; +using RequestExtensions = Exceptionless.Tests.Extensions.RequestExtensions; using Exceptionless.Tests.Utility; using Exceptionless.Web.Models; using FluentRest; @@ -169,11 +171,7 @@ await SendRequestAsync(r => r .Patch() .BearerToken(token.Id) .AppendPath($"tokens/{token.Id}") - .Content(new UpdateToken - { - IsDisabled = true, - Notes = "Disabling until next release" - }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("is_disabled", true), ("notes", "Disabling until next release"))), "application/json-patch+json") .StatusCodeShouldBeForbidden() ); @@ -217,7 +215,7 @@ public async Task CanDisableApiKey() .Patch() .AsTestOrganizationUser() .AppendPath($"tokens/{token.Id}") - .Content(updateToken) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("is_disabled", updateToken.IsDisabled), ("notes", updateToken.Notes))), "application/json-patch+json") .StatusCodeShouldBeOk() ); @@ -747,7 +745,7 @@ public async Task PatchAsync_UpdateNotes_ChangesNotesOnly() .Patch() .AsGlobalAdminUser() .AppendPaths("tokens", createdToken.Id) - .Content(new UpdateToken { Notes = "Updated notes" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("notes", "Updated notes"))), "application/json-patch+json") .StatusCodeShouldBeOk() ); @@ -788,7 +786,7 @@ public async Task PatchAsync_DisableToken_ChangesIsDisabledOnly() .Patch() .AsGlobalAdminUser() .AppendPaths("tokens", createdToken.Id) - .Content(new UpdateToken { IsDisabled = true }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("is_disabled", true))), "application/json-patch+json") .StatusCodeShouldBeOk() ); @@ -811,7 +809,7 @@ public Task PatchAsync_NonExistentToken_ReturnsNotFound() .Patch() .AsGlobalAdminUser() .AppendPaths("tokens", "000000000000000000000000") - .Content(new UpdateToken { Notes = "Does not exist" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("notes", "Does not exist"))), "application/json-patch+json") .StatusCodeShouldBeNotFound() ); } @@ -841,7 +839,7 @@ await SendRequestAsync(r => r .Patch() .BearerToken(createdToken.Id) .AppendPaths("tokens", createdToken.Id) - .Content(new UpdateToken { Notes = "Hacked" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("notes", "Hacked"))), "application/json-patch+json") .StatusCodeShouldBeForbidden() ); diff --git a/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs b/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs index d5fe005bc7..ce78d07503 100644 --- a/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs @@ -1,8 +1,10 @@ +using System.Text.Json; using Exceptionless.Core.Authorization; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; +using RequestExtensions = Exceptionless.Tests.Extensions.RequestExtensions; using Exceptionless.Web.Controllers; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; @@ -351,7 +353,7 @@ public async Task PatchAsync_AnonymousUser_ReturnsUnauthorized() await SendRequestAsync(r => r .Patch() .AppendPaths("users", currentUser.Id) - .Content(new { FullName = "Hacker" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("full_name", "Hacker"))), "application/json-patch+json") .StatusCodeShouldBeUnauthorized() ); @@ -377,7 +379,7 @@ public async Task PatchAsync_UpdateFullName_ReturnsUpdatedUser() .Patch() .AsGlobalAdminUser() .AppendPaths("users", currentUser.Id) - .Content(new { FullName = "Updated Name" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("full_name", "Updated Name"))), "application/json-patch+json") .StatusCodeShouldBeOk() ); @@ -402,7 +404,7 @@ public async Task PatchAsync_UpdateNotifications_ReturnsUpdatedUser() .Patch() .AsGlobalAdminUser() .AppendPaths("users", currentUser.Id) - .Content(new { EmailNotificationsEnabled = false }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("email_notifications_enabled", false))), "application/json-patch+json") .StatusCodeShouldBeOk() ); @@ -418,7 +420,7 @@ public Task PatchAsync_WithNonExistentId_ReturnsNotFound() .Patch() .AsGlobalAdminUser() .AppendPaths("users", "000000000000000000000000") - .Content(new { FullName = "Nobody" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("full_name", "Nobody"))), "application/json-patch+json") .StatusCodeShouldBeNotFound() ); } @@ -439,7 +441,7 @@ public async Task PutAsync_UpdateFullName_ReturnsUpdatedUser() .Put() .AsGlobalAdminUser() .AppendPaths("users", currentUser.Id) - .Content(new { FullName = "Put Updated Name" }) + .Content(JsonSerializer.Serialize(RequestExtensions.JsonPatch(("full_name", "Put Updated Name"))), JsonPatchHelper.ContentType) .StatusCodeShouldBeOk() ); diff --git a/tests/Exceptionless.Tests/Controllers/ValidationSnapshotTests.cs b/tests/Exceptionless.Tests/Controllers/ValidationSnapshotTests.cs index ca2621fb7d..443d8d3a7f 100644 --- a/tests/Exceptionless.Tests/Controllers/ValidationSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ValidationSnapshotTests.cs @@ -39,11 +39,12 @@ public async Task PostAsync_EmptyProjectName_ReturnsProblemDetailsWithCamelCaseP [Fact] public async Task PatchAsync_EmptyBody_ReturnsProblemDetailsWithCamelCaseProperties() { - // Act + // Act — send empty body with correct content type to trigger binding failure var response = await SendRequestAsync(r => r .Patch() .AsTestOrganizationUser() .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) + .Content("", JsonPatchHelper.ContentType) .StatusCodeShouldBeBadRequest() ); diff --git a/tests/Exceptionless.Tests/EventQueueCollection.cs b/tests/Exceptionless.Tests/EventQueueCollection.cs new file mode 100644 index 0000000000..dde1de65ce --- /dev/null +++ b/tests/Exceptionless.Tests/EventQueueCollection.cs @@ -0,0 +1,10 @@ +using Xunit; + +namespace Exceptionless.Tests; + +/// +/// Collection definition for tests that assert on event queue state. +/// Tests in this collection run sequentially to prevent queue deletion races. +/// +[CollectionDefinition("EventQueue")] +public class EventQueueCollection : ICollectionFixture; diff --git a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj index 33a69625cb..f31d2d0050 100644 --- a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -4,6 +4,7 @@ False false true + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated @@ -13,6 +14,7 @@ + diff --git a/tests/Exceptionless.Tests/Extensions/RequestExtensions.cs b/tests/Exceptionless.Tests/Extensions/RequestExtensions.cs index b502952b03..f4ddecdafa 100644 --- a/tests/Exceptionless.Tests/Extensions/RequestExtensions.cs +++ b/tests/Exceptionless.Tests/Extensions/RequestExtensions.cs @@ -1,10 +1,79 @@ using System.Net; +using System.Text.Json; using Exceptionless.Tests.Utility; namespace Exceptionless.Tests.Extensions; +/// +/// Test helper for creating RFC 6902 JSON Patch operations arrays. +/// The correct content type for JSON Patch is "application/json-patch+json". +/// Use PatchContent() for FluentClient .Content(string, string) calls. +/// +public static class JsonPatchHelper +{ + public const string ContentType = "application/json-patch+json"; + + /// + /// Creates an RFC 6902 JSON Patch operations array with "replace" operations. + /// + public static object[] Patch(params (string path, object? value)[] replacements) + { + return replacements.Select(r => (object)new { op = "replace", path = $"/{r.path}", value = r.value }).ToArray(); + } + + /// + /// Creates an RFC 6902 JSON Patch operations array with a single "replace" operation. + /// + public static object[] Patch(string path, object? value) + { + return [new { op = "replace", path = $"/{path}", value }]; + } + + /// + /// Returns a serialized JSON Patch string for use with .Content(string, contentType). + /// + public static string PatchContent(params (string path, object? value)[] replacements) + { + return JsonSerializer.Serialize(Patch(replacements)); + } + + /// + /// Returns a serialized JSON Patch string with a single replace operation. + /// + public static string PatchContent(string path, object? value) + { + return JsonSerializer.Serialize(Patch(path, value)); + } +} + public static class RequestExtensions { + public static object[] JsonPatch(params (string path, object? value)[] replacements) + { + return JsonPatchHelper.Patch(replacements); + } + + public static object[] JsonPatch(string path, object? value) + { + return JsonPatchHelper.Patch(path, value); + } + + /// + /// Extension to set JSON Patch content with correct content type on AppSendBuilder. + /// + public static AppSendBuilder JsonPatchContent(this AppSendBuilder builder, params (string path, object? value)[] replacements) + { + return builder.Content(JsonPatchHelper.PatchContent(replacements), JsonPatchHelper.ContentType); + } + + /// + /// Extension to set JSON Patch content with correct content type on AppSendBuilder. + /// + public static AppSendBuilder JsonPatchContent(this AppSendBuilder builder, string path, object? value) + { + return builder.Content(JsonPatchHelper.PatchContent(path, value), JsonPatchHelper.ContentType); + } + public static AppSendBuilder StatusCodeShouldBeOk(this AppSendBuilder builder) { return builder.ExpectedStatus(HttpStatusCode.OK); diff --git a/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs b/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs index fb07d885e1..01a2b9c53f 100644 --- a/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs @@ -17,6 +17,7 @@ namespace Exceptionless.Tests.Jobs; +[Collection("EventQueue")] public class EventPostJobTests : IntegrationTestsBase { private readonly EventPostsJob _job; diff --git a/tests/Exceptionless.Tests/Miscellaneous/DeltaTests.cs b/tests/Exceptionless.Tests/Miscellaneous/DeltaTests.cs deleted file mode 100644 index aa9881847d..0000000000 --- a/tests/Exceptionless.Tests/Miscellaneous/DeltaTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Exceptionless.Web.Utility; -using Foundatio.Xunit; -using Xunit; - -namespace Exceptionless.Tests.Miscellaneous; - -public class DeltaTests : TestWithLoggingBase -{ - public DeltaTests(ITestOutputHelper output) : base(output) - { - } - - [Fact] - public void TrySetPropertyValue_UnknownProperty_AddsToUnknownProperties() - { - dynamic delta = new Delta(); - delta.Data = "Blah"; - delta.SomeUnknown = "yes"; - Assert.Equal(1, delta.UnknownProperties.Count); - } - - [Fact] - public void Patch_UnrelatedTypes_CopiesMatchingProperties() - { - dynamic delta = new Delta(); - delta.Data = "Blah"; - - var msg = new SimpleMessageB - { - Data = "Blah2" - }; - delta.Patch(msg); - - Assert.Equal(delta.Data, msg.Data); - } - - public record SimpleMessageA - { - public required string Data { get; set; } - } - - public record SimpleMessageB - { - public required string Data { get; set; } - } -} diff --git a/tests/Exceptionless.Tests/Serializer/SnakeCaseLowerNamingPolicyTests.cs b/tests/Exceptionless.Tests/Serializer/SnakeCaseLowerNamingPolicyTests.cs index 46b31d199f..b9e94b136d 100644 --- a/tests/Exceptionless.Tests/Serializer/SnakeCaseLowerNamingPolicyTests.cs +++ b/tests/Exceptionless.Tests/Serializer/SnakeCaseLowerNamingPolicyTests.cs @@ -1,8 +1,8 @@ using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.Web.Models; -using Exceptionless.Web.Utility; using Foundatio.Xunit; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Xunit; namespace Exceptionless.Tests.Serializer; @@ -22,8 +22,7 @@ public SnakeCaseLowerNamingPolicyTests(ITestOutputHelper output) : base(output) { _options = new() { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - Converters = { new DeltaJsonConverterFactory() } + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; } @@ -150,38 +149,37 @@ public void ExternalAuthInfo_Deserialize_ParsesCamelCaseJson() } [Fact] - public void Deserialize_DeltaFromSnakeCaseJson_SetsPropertyValues() + public void JsonPatch_Deserialize_SnakeCasePaths_ResolvesCorrectly() { // Arrange /* language=json */ - const string json = """{"data": "TestValue", "is_active": true}"""; + const string json = """[{"op": "replace", "path": "/data", "value": "TestValue"}, {"op": "replace", "path": "/is_active", "value": true}]"""; // Act - var delta = JsonSerializer.Deserialize>(json, _options); + var patch = JsonSerializer.Deserialize>(json, _options); // Assert - Assert.NotNull(delta); - Assert.True(delta.TryGetPropertyValue("Data", out object? dataValue)); - Assert.Equal("TestValue", dataValue); - Assert.True(delta.TryGetPropertyValue("IsActive", out object? isActiveValue)); - Assert.True(isActiveValue as bool?); + Assert.NotNull(patch); + Assert.Equal(2, patch.Operations.Count); + Assert.Equal("/data", patch.Operations[0].path); + Assert.Equal("/is_active", patch.Operations[1].path); } [Fact] - public void Deserialize_PartialDeltaUpdate_OnlyTracksProvidedProperties() + public void JsonPatch_ApplyTo_SnakeCasePaths_SetsPropertyValues() { // Arrange /* language=json */ - const string json = """{"is_active": false}"""; + const string json = """[{"op": "replace", "path": "/is_active", "value": false}]"""; + var patch = JsonSerializer.Deserialize>(json, _options)!; + var model = new SimpleModel { Data = "Original", IsActive = true }; // Act - var delta = JsonSerializer.Deserialize>(json, _options); + patch.ApplyTo(model, _ => { }); // Assert - Assert.NotNull(delta); - var changedProperties = delta.GetChangedPropertyNames(); - Assert.Single(changedProperties); - Assert.Contains("IsActive", changedProperties); + Assert.False(model.IsActive); + Assert.Equal("Original", model.Data); // Unchanged property preserved } [Fact] diff --git a/tests/Exceptionless.Tests/Validation/JsonPatchValidationTests.cs b/tests/Exceptionless.Tests/Validation/JsonPatchValidationTests.cs new file mode 100644 index 0000000000..13d9083dd3 --- /dev/null +++ b/tests/Exceptionless.Tests/Validation/JsonPatchValidationTests.cs @@ -0,0 +1,227 @@ +using System.Text.Json; +using Exceptionless.Web.Api.Infrastructure; +using Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; +using Xunit; + +namespace Exceptionless.Tests.Validation; + +public sealed class JsonPatchValidationTests +{ + private static readonly JsonSerializerOptions _options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver() + }; + + [Fact] + public void ValidateOperations_EmptyPatch_ReturnsSuccess() + { + var patch = new JsonPatchDocument([], _options); + var result = JsonPatchValidation.ValidateOperations(patch); + Assert.True(result.IsSuccess); + } + + [Fact] + public void ValidateOperations_ValidReplace_ReturnsSuccess() + { + var patch = new JsonPatchDocument( + [new Operation("replace", "/name", null, "new-name")], + _options); + + var result = JsonPatchValidation.ValidateOperations(patch); + Assert.True(result.IsSuccess); + } + + [Fact] + public void ValidateOperations_ValidTest_ReturnsSuccess() + { + var patch = new JsonPatchDocument( + [new Operation("test", "/name", null, "expected-value")], + _options); + + var result = JsonPatchValidation.ValidateOperations(patch); + Assert.True(result.IsSuccess); + } + + [Theory] + [InlineData("add")] + [InlineData("remove")] + [InlineData("move")] + [InlineData("copy")] + public void ValidateOperations_DisallowedOp_ReturnsInvalid(string op) + { + var patch = new JsonPatchDocument( + [new Operation(op, "/name", null, "val")], + _options); + + var result = JsonPatchValidation.ValidateOperations(patch); + Assert.Equal(ResultStatus.Invalid, result.Status); + Assert.Contains("not supported", GetErrorMessage(result)); + } + + [Theory] + [InlineData("")] + [InlineData("/")] + [InlineData(" ")] + public void ValidateOperations_RootOrEmptyPath_ReturnsInvalid(string path) + { + var patch = new JsonPatchDocument( + [new Operation("replace", path, null, "val")], + _options); + + var result = JsonPatchValidation.ValidateOperations(patch); + Assert.Equal(ResultStatus.Invalid, result.Status); + Assert.Contains("root path", GetErrorMessage(result)); + } + + [Theory] + [InlineData("name")] + [InlineData("//name")] + [InlineData("/a/b")] + [InlineData("/nested/deep/path")] + public void ValidateOperations_NestedOrMalformedPath_ReturnsInvalid(string path) + { + var patch = new JsonPatchDocument( + [new Operation("replace", path, null, "val")], + _options); + + var result = JsonPatchValidation.ValidateOperations(patch); + Assert.Equal(ResultStatus.Invalid, result.Status); + Assert.Contains("not valid", GetErrorMessage(result)); + } + + [Fact] + public void ValidateOperations_ImmutablePath_ReturnsInvalid() + { + var patch = new JsonPatchDocument( + [new Operation("replace", "/id", null, "new-id")], + _options); + + var result = JsonPatchValidation.ValidateOperations(patch, "/id"); + Assert.Equal(ResultStatus.Invalid, result.Status); + Assert.Contains("cannot be modified", GetErrorMessage(result)); + } + + [Fact] + public void ValidateOperations_ImmutablePathCaseInsensitive_ReturnsInvalid() + { + var patch = new JsonPatchDocument( + [new Operation("replace", "/Id", null, "new-id")], + _options); + + var result = JsonPatchValidation.ValidateOperations(patch, "/id"); + Assert.Equal(ResultStatus.Invalid, result.Status); + Assert.Contains("cannot be modified", GetErrorMessage(result)); + } + + [Fact] + public void ValidateOperations_ExceedsMaxOperations_ReturnsInvalid() + { + var ops = Enumerable.Range(0, 51) + .Select(i => new Operation("replace", "/name", null, $"val-{i}")) + .ToList(); + var patch = new JsonPatchDocument(ops, _options); + + var result = JsonPatchValidation.ValidateOperations(patch); + Assert.Equal(ResultStatus.Invalid, result.Status); + Assert.Contains("exceeds maximum", GetErrorMessage(result)); + } + + [Fact] + public void ApplyPatch_ValidReplace_MutatesTarget() + { + var target = new TestDto { Name = "old", Description = "desc" }; + var patch = new JsonPatchDocument( + [new Operation("replace", "/name", null, "new-name")], + _options); + + var result = JsonPatchValidation.ApplyPatch(patch, target); + Assert.True(result.IsSuccess); + Assert.Equal("new-name", target.Name); + Assert.Equal("desc", target.Description); + } + + [Fact] + public void AffectsPath_MatchingPath_ReturnsTrue() + { + var patch = new JsonPatchDocument( + [new Operation("replace", "/name", null, "val")], + _options); + + Assert.True(patch.AffectsPath("/name")); + } + + [Fact] + public void AffectsPath_NonMatchingPath_ReturnsFalse() + { + var patch = new JsonPatchDocument( + [new Operation("replace", "/name", null, "val")], + _options); + + Assert.False(patch.AffectsPath("/description")); + } + + [Fact] + public void IsEmpty_EmptyPatch_ReturnsTrue() + { + var patch = new JsonPatchDocument([], _options); + Assert.True(patch.IsEmpty()); + } + + [Fact] + public void IsEmpty_NonEmptyPatch_ReturnsFalse() + { + var patch = new JsonPatchDocument( + [new Operation("replace", "/name", null, "val")], + _options); + Assert.False(patch.IsEmpty()); + } + + [Fact] + public void FromPartialObject_ValidObject_CreatesReplaceOps() + { + var json = JsonSerializer.Deserialize("""{"name":"test","description":"hello"}"""); + var patch = JsonPatchValidation.FromPartialObject(json, _options); + + Assert.NotNull(patch); + Assert.Equal(2, patch!.Operations.Count); + Assert.All(patch.Operations, op => Assert.Equal(OperationType.Replace, op.OperationType)); + } + + [Fact] + public void FromPartialObject_NonObject_ReturnsNull() + { + var json = JsonSerializer.Deserialize("""[]"""); + var patch = JsonPatchValidation.FromPartialObject(json, _options); + Assert.Null(patch); + } + + [Fact] + public void FromPartialObject_EmptyObject_ReturnsEmptyPatch() + { + var json = JsonSerializer.Deserialize("""{}"""); + var patch = JsonPatchValidation.FromPartialObject(json, _options); + + Assert.NotNull(patch); + Assert.Empty(patch!.Operations); + } + + private static string GetErrorMessage(Result result) + { + // Result.Invalid populates ValidationErrors, not Message + var errors = result.ValidationErrors?.ToList(); + if (errors is { Count: > 0 }) + return string.Join("; ", errors.Select(e => e.ErrorMessage ?? "")); + + return result.Message ?? string.Empty; + } + + private sealed class TestDto + { + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + } +}