diff --git a/.agents/skills/backend-architecture/SKILL.md b/.agents/skills/backend-architecture/SKILL.md index 0ac880fffc..3fa4408d4f 100644 --- a/.agents/skills/backend-architecture/SKILL.md +++ b/.agents/skills/backend-architecture/SKILL.md @@ -22,7 +22,7 @@ aspire run --project src/Exceptionless.AppHost ```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 ``` @@ -89,37 +89,121 @@ 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 -[Route(API_PREFIX + "/organizations")] -[Authorize(Policy = AuthorizationRoles.UserPolicy)] -public class OrganizationController : RepositoryApiController +public static class TokenEndpoints { - [HttpGet] - public async Task>> GetAllAsync(string? mode = null) + public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder endpoints) { - var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray()); - return Ok(await MapCollectionAsync(organizations, true)); + 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 +public class TokenHandler(ITokenRepository repository, ...) : HandlerBase +{ + public async Task> Handle(GetTokenById message) + { + 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) + +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 -Exceptions auto-convert via `ExceptionToProblemDetailsHandler`: `MiniValidatorException`/`ValidationException` → 422, others → 500. +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 @@ -127,11 +211,12 @@ After any API change (new endpoint, changed status codes, modified request/respo ```bash # Requires the API to be running (aspire run --project src/Exceptionless.AppHost) -Invoke-WebRequest -Uri "http://localhost:7110/docs/v2/openapi.json" \ - -OutFile "tests/Exceptionless.Tests/Controllers/Data/openapi.json" +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. +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. + +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 5cd0861747..6051305c54 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ debug-storybook.log .devcontainer/devcontainer-lock.json *.lscache +audit-output/ 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..0b0b675e79 --- /dev/null +++ b/src/Exceptionless.Web/Api/ApiEndpoints.cs @@ -0,0 +1,25 @@ +using Exceptionless.Web.Api.Endpoints; + +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(); + + 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..5da160d322 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -0,0 +1,56 @@ +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("settings", async (IMediator mediator) + => (await mediator.InvokeAsync>(new GetAdminSettings())).ToHttpResult()); + + group.MapGet("stats", async (IMediator mediator) + => (await mediator.InvokeAsync>(new GetAdminStats())).ToHttpResult()); + + group.MapGet("migrations", async (IMediator mediator) + => (await mediator.InvokeAsync>(new GetAdminMigrations())).ToHttpResult()); + + group.MapGet("echo", async (HttpContext httpContext, IMediator mediator) + => (await mediator.InvokeAsync>(new GetAdminEcho(httpContext))).ToHttpResult()); + + group.MapGet("assemblies", async (IMediator mediator) + => (await mediator.InvokeAsync>(new GetAdminAssemblies())).ToHttpResult()); + + group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, string organizationId, string planId) + => (await mediator.InvokeAsync>(new AdminChangePlan(organizationId, planId, httpContext))).ToHttpResult()); + + group.MapPost("set-bonus", async (HttpContext httpContext, IMediator mediator, string organizationId, int bonusEvents, DateTime? expires = null) + => (await mediator.InvokeAsync(new AdminSetBonus(organizationId, bonusEvents, expires, httpContext))).ToHttpResult()); + + group.MapGet("requeue", async (IMediator mediator, string? path = null, bool archive = false) + => (await mediator.InvokeAsync>(new AdminRequeue(path, archive))).ToHttpResult()); + + group.MapGet("maintenance/{name:minlength(1)}", async (string name, IMediator mediator, DateTime? utcStart = null, DateTime? utcEnd = null, string? organizationId = null) + => (await mediator.InvokeAsync(new AdminRunMaintenance(name, utcStart, utcEnd, organizationId))).ToHttpResult()); + + group.MapGet("elasticsearch", async (IMediator mediator) + => (await mediator.InvokeAsync>(new GetAdminElasticsearch())).ToHttpResult()); + + group.MapGet("elasticsearch/snapshots", async (IMediator mediator) + => (await mediator.InvokeAsync>(new GetAdminElasticsearchSnapshots())).ToHttpResult()); + + group.MapPost("generate-sample-events", async (IMediator mediator, int eventCount = 250, int daysBack = 7) + => (await mediator.InvokeAsync>(new AdminGenerateSampleEvents(eventCount, daysBack))).ToHttpResult()); + + 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..c53d7bb150 --- /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, 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(); + }) + .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, HttpContext httpContext) + => (await mediator.InvokeAsync>(new AuthMessages.GetIntercomToken(httpContext))).ToHttpResult()) + .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, HttpContext httpContext) + => (await mediator.InvokeAsync(new AuthMessages.LogoutMessage(httpContext))).ToHttpResult()) + .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, 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(); + }) + .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, 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(); + }) + .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, 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(); + }) + .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, 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(); + }) + .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, 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(); + }) + .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, HttpContext httpContext, [FromBody] ValueFromBody providerUserId) + => (await mediator.InvokeAsync>(new AuthMessages.RemoveExternalLogin(providerName, providerUserId, httpContext))).ToHttpResult()) + .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, 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(); + }) + .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, HttpContext httpContext) + => (await mediator.InvokeAsync(new AuthMessages.CheckEmailAddress(email, httpContext))).ToHttpResult()) + .AllowAnonymous() + .ExcludeFromDescription(); + + group.MapGet("forgot-password/{email:minlength(1)}", async (string email, IMediator mediator, HttpContext httpContext) + => (await mediator.InvokeAsync(new AuthMessages.ForgotPassword(email, httpContext))).ToHttpResult()) + .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, 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(); + }) + .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, HttpContext httpContext) + => (await mediator.InvokeAsync(new AuthMessages.CancelResetPassword(token, httpContext))).ToHttpResult()) + .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..e8ab3ee233 --- /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, 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()) + .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, 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()) + .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, 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()) + .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, string? time = null, string? offset = null) + => (await mediator.InvokeAsync>(new GetEventById(id, time, offset, httpContext))).ToHttpResult()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, 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()) + .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, [FromBody] UserDescription description, string? projectId = null) + => (await mediator.InvokeAsync(new SetEventUserDescription(referenceId, description, projectId, httpContext))).ToHttpResult()) + .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, [FromBody] UserDescription description) + => (await mediator.InvokeAsync(new SetEventUserDescription(referenceId, description, projectId, httpContext))).ToHttpResult()) + .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, [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(); + }) + .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, string? id = null, bool close = false) + => (await mediator.InvokeAsync(new RecordEventHeartbeat(id, close, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new SubmitEventByGet(null, 1, null, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new SubmitEventByGet(null, 1, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new SubmitEventByGet(projectId, 1, null, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new SubmitEventByGet(projectId, 1, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult()) + .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, string? type = null) + => (await mediator.InvokeAsync(new SubmitEventByGet(null, 2, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new SubmitEventByGet(null, 2, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult()) + .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, string? type = null) + => (await mediator.InvokeAsync(new SubmitEventByGet(projectId, 2, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new SubmitEventByGet(projectId, 2, type, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult()) + .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) + => await SubmitEventByPostAsync(null, 1, httpContext, mediator)) + .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) + => await SubmitEventByPostAsync(null, 1, httpContext, mediator)) + .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) + => await SubmitEventByPostAsync(projectId, 1, httpContext, mediator)) + .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) + => await SubmitEventByPostAsync(null, 2, httpContext, mediator)) + .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) + => await SubmitEventByPostAsync(projectId, 2, httpContext, mediator)) + .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) + => (await mediator.InvokeAsync>(new DeleteEvents(ids, httpContext))).ToHttpResult()) + .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) + { + 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(); + } +} + +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..491b8c9835 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs @@ -0,0 +1,335 @@ +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 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; + +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, string? filter = null, string? mode = null) + => (await mediator.InvokeAsync>>(new OrganizationMessages.GetOrganizations(filter, mode, httpContext))).ToHttpResult()) + .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, 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()) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces>() + .ExcludeFromDescription(); + + group.MapGet("admin/organizations/stats", async (HttpContext httpContext, IMediator mediator) + => (await mediator.InvokeAsync>(new OrganizationMessages.GetOrganizationPlanStats(httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces() + .ExcludeFromDescription(); + + group.MapGet("organizations/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, string? mode = null) + => (await mediator.InvokeAsync>(new OrganizationMessages.GetOrganizationById(id, mode, httpContext))).ToHttpResult()) + .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, 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(); + }) + .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, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new OrganizationMessages.UpdateOrganizationMessage(id, patchDocument, httpContext))).ToHttpResult()) + .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, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new OrganizationMessages.UpdateOrganizationMessage(id, patchDocument, httpContext))).ToHttpResult()) + .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.MapDelete("organizations/{ids:objectids}", async (string ids, HttpContext httpContext, IMediator mediator) + => (await mediator.InvokeAsync>(new OrganizationMessages.DeleteOrganizations(ids.FromDelimitedString(), httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new OrganizationMessages.GetInvoice(id, httpContext))).ToHttpResult()) + .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, string? before = null, string? after = null, int limit = 12) + => (await mediator.InvokeAsync>>(new OrganizationMessages.GetInvoices(id, before, after, limit, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>>(new OrganizationMessages.GetPlans(id, httpContext))).ToHttpResult()) + .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, + [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()) + .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) + => (await mediator.InvokeAsync>(new OrganizationMessages.AddOrganizationUser(id, email, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new OrganizationMessages.RemoveOrganizationUser(id, email, httpContext))).ToHttpResult()) + .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, SuspensionCode? code = null, string? notes = null) + => (await mediator.InvokeAsync(new OrganizationMessages.SuspendOrganization(id, code ?? SuspensionCode.Billing, notes, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapDelete("organizations/{id:objectid}/suspend", async (string id, HttpContext httpContext, IMediator mediator) + => (await mediator.InvokeAsync(new OrganizationMessages.UnsuspendOrganization(id, httpContext))).ToHttpResult()) + .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, [FromBody] ValueFromBody value) + => (await mediator.InvokeAsync(new OrganizationMessages.SetOrganizationData(id, key, value, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new OrganizationMessages.DeleteOrganizationData(id, key, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new OrganizationMessages.SetOrganizationFeature(id, feature, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new OrganizationMessages.RemoveOrganizationFeature(id, feature, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new OrganizationMessages.CheckOrganizationName(name, httpContext))).ToHttpResult()) + .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; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs new file mode 100644 index 0000000000..852e3b72df --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs @@ -0,0 +1,558 @@ +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 ProjectMessages = Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Utility.OpenApi; + +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, 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()) + .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, 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()) + .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, string? mode = null) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectById(id, mode, httpContext))).ToHttpResult()) + .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, 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(); + }) + .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}", async (string id, HttpContext httpContext, IMediator mediator, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectMessage(id, patchDocument, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .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 project.", + }, + ResponseDescriptions = new() { + ["400"] = "An error occurred while updating the project.", + ["404"] = "The project could not be found.", + } + }); + + group.MapPut("projects/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectMessage(id, patchDocument, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .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 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) + => (await mediator.InvokeAsync>(new ProjectMessages.DeleteProjects(ids.FromDelimitedString(), httpContext))).ToHttpResult()) + .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, int? v = null) + => (await mediator.InvokeAsync>(new ProjectMessages.GetLegacyProjectConfig(v, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .WithTags("Project") + .Produces() + .Produces(StatusCodes.Status304NotModified) + .ProducesProblem(StatusCodes.Status404NotFound); + + group.MapGet("projects/config", async (HttpContext httpContext, IMediator mediator, int? v = null) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectConfig(null, v, httpContext))).ToHttpResult()) + .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, int? v = null) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectConfig(id, v, httpContext))).ToHttpResult()) + .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, [FromBody] ValueFromBody value) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectConfig(id, key, value, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new ProjectMessages.DeleteProjectConfig(id, key, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new ProjectMessages.GenerateProjectSampleData(id, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new ProjectMessages.ResetProjectData(id, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new ProjectMessages.ResetProjectData(id, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>>(new ProjectMessages.GetProjectNotificationSettings(id, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectUserNotificationSettings(id, userId, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new ProjectMessages.GetProjectIntegrationNotificationSettings(id, integration, httpContext))).ToHttpResult()) + .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, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NotificationSettings? settings = null) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectUserNotificationSettings(id, userId, settings, httpContext))).ToHttpResult()) + .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, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NotificationSettings? settings = null) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectUserNotificationSettings(id, userId, settings, httpContext))).ToHttpResult()) + .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, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NotificationSettings? settings = null) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectIntegrationNotificationSettings(id, integration, settings, httpContext))).ToHttpResult()) + .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, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] NotificationSettings? settings = null) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectIntegrationNotificationSettings(id, integration, settings, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new ProjectMessages.DeleteProjectNotificationSettings(id, userId, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new ProjectMessages.PromoteProjectTab(id, name, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new ProjectMessages.PromoteProjectTab(id, name, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new ProjectMessages.DemoteProjectTab(id, name, httpContext))).ToHttpResult()) + .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, string? organizationId = null) + => (await mediator.InvokeAsync(new ProjectMessages.CheckProjectName(name, organizationId, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new ProjectMessages.CheckProjectName(name, organizationId, httpContext))).ToHttpResult()) + .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, [FromBody] ValueFromBody value) + => (await mediator.InvokeAsync(new ProjectMessages.SetProjectData(id, key, value, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new ProjectMessages.DeleteProjectData(id, key, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new ProjectMessages.AddProjectSlack(id, code, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new ProjectMessages.RemoveProjectSlack(id, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs new file mode 100644 index 0000000000..5eb7b30592 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs @@ -0,0 +1,218 @@ +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, int page = 1, int limit = 25) + => (await mediator.InvokeAsync>>(new SavedViewMessages.GetSavedViewsByOrganization(organizationId, page, limit))).ToHttpResult()) + .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, int page = 1, int limit = 25) + => (await mediator.InvokeAsync>>(new SavedViewMessages.GetSavedViewsByView(organizationId, viewType, page, limit))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new SavedViewMessages.GetSavedViewById(id))).ToHttpResult()) + .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, 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(); + }) + .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) + => (await mediator.InvokeAsync>>(new SavedViewMessages.CreatePredefinedSavedViews(organizationId))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>>(new SavedViewMessages.GetPredefinedSavedViews())).ToHttpResult()) + .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.MapPost("saved-views/{id:objectid}/predefined", async (string id, IMediator mediator) + => (await mediator.InvokeAsync>(new SavedViewMessages.PromoteToPredefinedSavedView(id))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new SavedViewMessages.DeletePredefinedSavedView(id))).ToHttpResult()) + .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, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new SavedViewMessages.UpdateSavedViewMessage(id, patchDocument))).ToHttpResult()) + .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, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new SavedViewMessages.UpdateSavedViewMessage(id, patchDocument))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new SavedViewMessages.DeleteSavedViews(ids.FromDelimitedString()))).ToHttpResult()) + .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..57196190fd --- /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, string? offset = null) + => (await mediator.InvokeAsync>(new GetStackById(id, offset, httpContext))).ToHttpResult()) + .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, string? version = null) + => (await mediator.InvokeAsync(new MarkStacksFixed(ids, version, httpContext))).ToHttpResult()) + .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, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new MarkStacksFixedByZapier(data, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + // Mark fixed - Zapier v2 (no id in route) + group.MapPost("stacks/mark-fixed", async (HttpContext httpContext, IMediator mediator, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new MarkStacksFixedByZapier(data, httpContext))).ToHttpResult()) + .ExcludeFromDescription(); + + // Snooze + group.MapPost("stacks/{ids:objectids}/mark-snoozed", async (string ids, HttpContext httpContext, IMediator mediator, DateTime snoozeUntilUtc) + => (await mediator.InvokeAsync(new SnoozeStacks(ids, snoozeUntilUtc, httpContext))).ToHttpResult()) + .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, [FromBody] ValueFromBody url) + => (await mediator.InvokeAsync(new AddStackLink(id, url, httpContext))).ToHttpResult()) + .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, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new AddStackLinkByZapier(data, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + // Add link - Zapier v2 (no id in route) + group.MapPost("stacks/add-link", async (HttpContext httpContext, IMediator mediator, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new AddStackLinkByZapier(data, httpContext))).ToHttpResult()) + .ExcludeFromDescription(); + + // Remove link + group.MapPost("stacks/{id:objectid}/remove-link", async (string id, HttpContext httpContext, IMediator mediator, [FromBody] ValueFromBody url) + => (await mediator.InvokeAsync(new RemoveStackLink(id, url, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new MarkStacksCritical(ids, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new MarkStacksNotCritical(ids, httpContext))).ToHttpResult()) + .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, StackStatus status) + => (await mediator.InvokeAsync(new ChangeStacksStatus(ids, status, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new PromoteStack(id, httpContext))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new DeleteStacks(ids, httpContext))).ToHttpResult()) + .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, 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()) + .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, 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()) + .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, 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()) + .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..0db408b9d5 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs @@ -0,0 +1,68 @@ +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.MapGet("about", async (IMediator mediator) => + { + var result = await mediator.InvokeAsync(new GetAboutInfo()); + return HttpResults.Ok(result); + }) + .AllowAnonymous() + .WithName("GetAboutInfo"); + + group.MapGet("queue-stats", async (IMediator mediator) => + { + var result = await mediator.InvokeAsync(new GetQueueStats()); + return HttpResults.Ok(result); + }) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy); + + 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] ValueFromBody message, bool publish = true) => + { + if (String.IsNullOrWhiteSpace(message?.Value)) + return HttpResults.NotFound(); + + var result = await mediator.InvokeAsync(new PostSystemNotification(message.Value, 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..99ddd2baa9 --- /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) => + { + 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(); + }) + .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..7bf8a972a4 --- /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, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new TokenMessages.GetTokensByOrganization(organizationId, page, limit))).ToHttpResult()) + .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, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new TokenMessages.GetTokensByProject(projectId, page, limit))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new TokenMessages.GetDefaultToken(projectId))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new TokenMessages.GetTokenById(id))).ToHttpResult()) + .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, 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(); + }) + .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, 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(); + }) + .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, 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(); + }) + .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, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new TokenMessages.UpdateTokenMessage(id, patchDocument))).ToHttpResult()) + .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, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new TokenMessages.UpdateTokenMessage(id, patchDocument))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new TokenMessages.DeleteTokens(ids.FromDelimitedString()))).ToHttpResult()) + .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..4efdf4ffae --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -0,0 +1,206 @@ +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 Foundatio.Mediator; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Mvc; +using UserMessages = Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Utility.OpenApi; + +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) + => (await mediator.InvokeAsync>(new UserMessages.GetCurrentUser())).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new UserMessages.GetUserById(id))).ToHttpResult()) + .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, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new UserMessages.GetUsersByOrganization(organizationId, page, limit))).ToHttpResult()) + .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, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new UserMessages.UpdateUserMessage(id, patchDocument))).ToHttpResult()) + .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, JsonPatchDocument patchDocument) + => (await mediator.InvokeAsync>(new UserMessages.UpdateUserMessage(id, patchDocument))).ToHttpResult()) + .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.MapDelete("users/me", async (IMediator mediator) + => (await mediator.InvokeAsync>(new UserMessages.DeleteCurrentUser())).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new UserMessages.DeleteUsers(ids.FromDelimitedString()))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new UserMessages.UpdateEmailAddress(id, email))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new UserMessages.VerifyEmailAddress(token))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new UserMessages.ResendVerificationEmail(id))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync(new UserMessages.UnverifyEmailAddresses())).ToHttpResult()) + .Accepts("text/plain") + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ExcludeFromDescription(); + + group.MapPost("users/{id:objectid}/admin-role", async (string id, IMediator mediator) + => (await mediator.InvokeAsync(new UserMessages.AddAdminRole(id))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + group.MapDelete("users/{id:objectid}/admin-role", async (string id, IMediator mediator) + => (await mediator.InvokeAsync(new UserMessages.RemoveAdminRole(id))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ExcludeFromDescription(); + + return endpoints; + } +} 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..27ddc758e9 --- /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, int page = 1, int limit = 10) + => (await mediator.InvokeAsync>>(new WebHookMessages.GetWebHooksByProject(projectId, page, limit))).ToHttpResult()) + .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) + => (await mediator.InvokeAsync>(new WebHookMessages.GetWebHookById(id))).ToHttpResult()) + .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, 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(); + }) + .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) + => (await mediator.InvokeAsync>(new WebHookMessages.DeleteWebHooks(ids.FromDelimitedString()))).ToHttpResult()) + .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, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync>(new WebHookMessages.SubscribeWebHook(data, 1))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + endpoints.MapPost("api/v{apiVersion:int}/webhooks/subscribe", async (int apiVersion, IMediator mediator, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync>(new WebHookMessages.SubscribeWebHook(data, apiVersion))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + group.MapPost("webhooks/unsubscribe", async (IMediator mediator, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new WebHookMessages.UnsubscribeWebHook(data))).ToHttpResult()) + .AllowAnonymous() + .ExcludeFromDescription(); + + group.MapGet("webhooks/test", async (IMediator mediator) + => (await mediator.InvokeAsync>(new WebHookMessages.TestWebHook())).ToHttpResult()) + .ExcludeFromDescription(); + + group.MapPost("webhooks/test", async (IMediator mediator) + => (await mediator.InvokeAsync>(new WebHookMessages.TestWebHook())).ToHttpResult()) + .ExcludeFromDescription(); + + endpoints.MapPost("api/v1/projecthook/subscribe", async (IMediator mediator, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync>(new WebHookMessages.SubscribeWebHook(data, 1))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + endpoints.MapPost("api/v1/projecthook/unsubscribe", async (IMediator mediator, [FromBody] JsonDocument data) + => (await mediator.InvokeAsync(new WebHookMessages.UnsubscribeWebHook(data))).ToHttpResult()) + .AllowAnonymous() + .ExcludeFromDescription(); + + endpoints.MapGet("api/v1/projecthook/test", async (IMediator mediator) + => (await mediator.InvokeAsync>(new WebHookMessages.TestWebHook())).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .ExcludeFromDescription(); + + endpoints.MapPost("api/v1/projecthook/test", async (IMediator mediator) + => (await mediator.InvokeAsync>(new WebHookMessages.TestWebHook())).ToHttpResult()) + .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..1b01c6433d --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -0,0 +1,384 @@ +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(); + + public Task> Handle(GetAdminSettings message) + { + return Task.FromResult>(appOptions); + } + + 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 + ); + } + + 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() + }); + } + + 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(); + } + + 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 }; + } + + 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(); + } + + public async Task> Handle(GetAdminElasticsearch message) + { + var client = configuration.Client; + var healthTask = client.Cluster.HealthAsync(); + var statsTask = client.Cluster.StatsAsync(); + var catIndicesTask = client.Cat.IndicesAsync(r => r.Bytes(Elasticsearch.Net.Bytes.B)); + var catShardsTask = client.Cat.ShardsAsync(); + await Task.WhenAll(healthTask, statsTask, catIndicesTask, catShardsTask); + + var healthResponse = await healthTask; + var statsResponse = await statsTask; + var catIndicesResponse = await catIndicesTask; + var catShardsResponse = await catShardsTask; + + if (!healthResponse.IsValid || !statsResponse.IsValid || !catIndicesResponse.IsValid || !catShardsResponse.IsValid) + return Result.Error("Elasticsearch cluster information is unavailable."); + + var unassignedByIndex = (catShardsResponse.Records ?? []) + .Where(s => string.Equals(s.State, "UNASSIGNED", StringComparison.OrdinalIgnoreCase)) + .GroupBy(s => s.Index ?? String.Empty, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); + + var indexDetails = (catIndicesResponse.Records ?? []) + .OrderByDescending(i => long.TryParse(i.StoreSize, out var s) ? s : 0) + .Select(i => new ElasticsearchIndexDetailResponse( + Index: i.Index, + Health: i.Health, + Status: i.Status, + Primary: int.TryParse(i.Primary, out var p) ? p : 0, + Replica: int.TryParse(i.Replica, out var r) ? r : 0, + DocsCount: long.TryParse(i.DocsCount, out var dc) ? dc : 0, + StoreSizeInBytes: long.TryParse(i.StoreSize, out var ss) ? ss : 0, + UnassignedShards: unassignedByIndex.GetValueOrDefault(i.Index ?? String.Empty, 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.Documents.Count, + StoreSizeInBytes: statsResponse.Indices.Store.SizeInBytes + ), + IndexDetails: indexDetails + ); + } + + public async Task> Handle(GetAdminElasticsearchSnapshots message) + { + var client = configuration.Client; + try + { + var repositoryResponse = await client.Cat.RepositoriesAsync(); + if (!repositoryResponse.IsValid) + return Result.Error("Snapshot repository information is unavailable."); + + if (!(repositoryResponse.Records?.Any() ?? false)) + return new ElasticsearchSnapshotsResponse([], []); + + var repositoryNames = repositoryResponse.Records + .Where(r => !String.IsNullOrEmpty(r.Id)) + .Select(r => r.Id!) + .ToArray(); + + var snapshotTasks = repositoryNames + .Select(async repositoryName => + { + var snapshotResponse = await client.Cat.SnapshotsAsync(r => r.RepositoryName(repositoryName)); + if (!snapshotResponse.IsValid) + return ( + RepositoryName: repositoryName, + Snapshots: Array.Empty(), + Error: $"Unable to retrieve snapshots for repository: {repositoryName}." + ); + + var snapshotRecords = snapshotResponse.Records?.ToArray() ?? []; + return ( + RepositoryName: repositoryName, + Snapshots: snapshotRecords.Select(s => new ElasticsearchSnapshotResponse( + Repository: repositoryName, + Name: s.Id ?? String.Empty, + Status: s.Status ?? String.Empty, + StartTime: s.StartEpoch > 0 ? DateTimeOffset.FromUnixTimeSeconds(s.StartEpoch).UtcDateTime : null, + EndTime: s.EndEpoch > 0 ? DateTimeOffset.FromUnixTimeSeconds(s.EndEpoch).UtcDateTime : null, + Duration: s.Duration?.ToString() ?? String.Empty, + IndicesCount: s.Indices, + SuccessfulShards: s.SuccessfulShards, + FailedShards: s.FailedShards, + TotalShards: s.TotalShards + )).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 (Exception ex) + { + _logger.LogError(ex, "Unable to retrieve snapshot information"); + return Result.Error("Unable to retrieve snapshot information."); + } + } + + 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..75f670ab8a --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -0,0 +1,1059 @@ +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 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 Newtonsoft.Json; +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, + JsonSerializerSettings jsonSerializerSettings, + 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."); + + 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(jsonSerializerSettings)); + 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, + Data = summaryData.Data + }; + }).ToList(); + return new PagedResult(summaries.Cast().ToList(), events.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, events.Total, events.Hits.FirstOrDefault()?.GetSortToken(), events.Hits.LastOrDefault()?.GetSortToken()); + 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(), events.Hits.LastOrDefault()?.GetSortToken()); + } + } + 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).SearchAfterToken(after).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..d2dad9f7a7 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -0,0 +1,911 @@ +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(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); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Unexpected 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 (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."); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "An unexpected error occurred while trying to update your billing plan: {Message}", ex.Message); + return ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again."); + } + + 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) + { + 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 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..b01b4cc4b8 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -0,0 +1,742 @@ +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.Queues; +using Foundatio.Mediator; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +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, + 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 + }; + + 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; + + 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."); + + project.PromotedTabs ??= []; + if (project.PromotedTabs.Add(message.Name.Trim())) + 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(); + 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); +} diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs similarity index 53% rename from src/Exceptionless.Web/Controllers/SavedViewController.cs rename to src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index 4b266f15e7..c46bc3f146 100644 --- a/src/Exceptionless.Web/Controllers/SavedViewController.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -4,425 +4,435 @@ 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 class SavedViewController : RepositoryApiController +public 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 = 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) - { - var viewModel = _mapper.MapToViewSavedView(model); - if (String.IsNullOrWhiteSpace(viewModel.Slug)) - viewModel.Slug = ToFallbackSlug(viewModel.Name, viewModel.Id); - - return viewModel; - } - - 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); } - /// - /// 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."); + + if (!HttpContext.Request.CanAccessOrganization(value.OrganizationId)) + return Result.Invalid(ValidationError.Create("organization_id", "Invalid organization id specified.")); - return await base.CanAddAsync(value); + 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 = ToFallbackSlug(original.Name, original.Id); + 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."); + + if (permission.StatusCode is StatusCodes.Status422UnprocessableEntity) + return Result.Invalid(ValidationError.Create("general", permission.Message ?? "Validation failed.")); - return await base.CanDeleteAsync(value); + 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; @@ -433,9 +443,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; @@ -448,7 +458,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) @@ -467,7 +477,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); } @@ -478,7 +488,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; @@ -486,8 +496,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); @@ -506,7 +516,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); } @@ -538,7 +548,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, @@ -585,13 +595,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; } @@ -600,7 +610,7 @@ private SavedView CreateSystemPredefinedSavedView(SavedView source, string key, var savedView = new SavedView { OrganizationId = PredefinedSavedViewsDataSeed.SystemOrganizationId, - CreatedByUserId = CurrentUser.Id, + CreatedByUserId = GetCurrentUserId(), Version = 1 }; @@ -650,12 +660,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(); } @@ -770,13 +780,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)); + 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)); } @@ -806,4 +816,7 @@ 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; } diff --git a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs new file mode 100644 index 0000000000..038696bf12 --- /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).FilterExpression(String.Join(" OR ", stacks.Select(r => $"stack:{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..7ac87a6b2e --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs @@ -0,0 +1,94 @@ +using Exceptionless.Core; +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Services; +using Exceptionless.Web.Api.Messages; +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) +{ + public object Handle(GetAboutInfo message) + { + return new + { + appOptions.InformationalVersion, + AppMode = appOptions.AppMode.ToString(), + Environment.MachineName + }; + } + + 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.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..b847223cf8 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -0,0 +1,420 @@ +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 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 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 ?? "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/JsonPatchValidation.cs b/src/Exceptionless.Web/Api/Infrastructure/JsonPatchValidation.cs new file mode 100644 index 0000000000..2a0c844444 --- /dev/null +++ b/src/Exceptionless.Web/Api/Infrastructure/JsonPatchValidation.cs @@ -0,0 +1,182 @@ +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 op in patch.Operations) + { + var pathSegment = 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(); + foreach (var prop in body.EnumerateObject()) + { + var op = new JsonObject + { + ["op"] = "replace", + ["path"] = $"/{prop.Name}", + ["value"] = JsonNode.Parse(prop.Value.GetRawText()) + }; + ops.Add(op); + } + + 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/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..c12f4c06dc --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/OrganizationMessages.cs @@ -0,0 +1,28 @@ +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 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..00a70a544e --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs @@ -0,0 +1,15 @@ +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 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..d8af39e22c --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/StatusMessages.cs @@ -0,0 +1,9 @@ +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, bool Publish = true); +public record RemoveSystemNotification(bool Publish = true); +public record ValidateSearchQuery(string Query); 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..7db05eca1e --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/UserMessages.cs @@ -0,0 +1,17 @@ +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 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..dde03f1848 --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/ApiResultMapper.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Reflection; +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(); + + public IResult MapResult(Foundatio.Mediator.IResult result) + { + return result.Status switch + { + ResultStatus.Success => MapSuccess(result), + ResultStatus.Created => MapCreated(result), + ResultStatus.Accepted => MapAccepted(result), + ResultStatus.NoContent => HttpResults.NoContent(), + ResultStatus.BadRequest => HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status400BadRequest, title: "Bad Request"), + ResultStatus.Invalid => MapValidation(result), + ResultStatus.NotFound => HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status404NotFound, title: "Not Found"), + ResultStatus.Unauthorized => HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status401Unauthorized, title: "Unauthorized"), + ResultStatus.Forbidden => HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status403Forbidden, title: "Forbidden"), + ResultStatus.Conflict => HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status409Conflict, title: "Conflict"), + ResultStatus.Error => HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Internal Server Error"), + ResultStatus.CriticalError => HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Critical Error"), + ResultStatus.Unavailable => HttpResults.Problem( + detail: result.Message, statusCode: StatusCodes.Status503ServiceUnavailable, title: "Service Unavailable"), + _ => HttpResults.Problem( + detail: result.Message ?? "An unexpected error occurred", statusCode: StatusCodes.Status500InternalServerError) + }; + } + + 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); + + // Handle WorkInProgressResponse + 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 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.Status400BadRequest, 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); + + // Convert to dictionary format matching existing ProblemDetails shape + 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"); + } + + 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/ResultExtensions.cs b/src/Exceptionless.Web/Api/Results/ResultExtensions.cs new file mode 100644 index 0000000000..b50c49a65d --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/ResultExtensions.cs @@ -0,0 +1,126 @@ +using Exceptionless.Web.Controllers; +using Foundatio.Mediator; +using HttpResults = Microsoft.AspNetCore.Http.Results; +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. + /// + public static IHttpResult ToHttpResult(this Result result) + { + return result.Status switch + { + ResultStatus.Success => HttpResults.Ok(), + ResultStatus.Created => HttpResults.Created(result.Location, null), + ResultStatus.Accepted => HttpResults.StatusCode(StatusCodes.Status202Accepted), + ResultStatus.NoContent => HttpResults.NoContent(), + ResultStatus.NotFound => HttpResults.Problem(statusCode: StatusCodes.Status404NotFound, title: result.Message ?? "Not Found"), + ResultStatus.Forbidden => HttpResults.Problem(statusCode: StatusCodes.Status403Forbidden, title: result.Message ?? "Forbidden"), + ResultStatus.Unauthorized => HttpResults.Problem(statusCode: StatusCodes.Status401Unauthorized, title: result.Message ?? "Unauthorized"), + ResultStatus.BadRequest => HttpResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: result.Message ?? "Bad Request"), + ResultStatus.Conflict => HttpResults.Problem(statusCode: StatusCodes.Status409Conflict, title: result.Message ?? "Conflict"), + ResultStatus.Invalid => MapValidation(result), + ResultStatus.Error => HttpResults.Problem(detail: result.Message, statusCode: StatusCodes.Status500InternalServerError), + ResultStatus.CriticalError => HttpResults.Problem(detail: result.Message, statusCode: StatusCodes.Status500InternalServerError), + ResultStatus.Unavailable => HttpResults.Problem(detail: result.Message, statusCode: StatusCodes.Status503ServiceUnavailable), + _ => HttpResults.Problem(detail: result.Message ?? "An unexpected error occurred", statusCode: StatusCodes.Status500InternalServerError) + }; + } + + /// + /// Converts a Result<T> to an HTTP IResult with the value as the body. + /// + public static IHttpResult ToHttpResult(this Result result) + { + if (!result.IsSuccess) + return ((Foundatio.Mediator.IResult)result).ToHttpResultError(); + + var value = result.ValueOrDefault; + if (value is null) + { + return result.Status switch + { + ResultStatus.Accepted => HttpResults.StatusCode(StatusCodes.Status202Accepted), + ResultStatus.Created => HttpResults.Created(result.Location, null), + ResultStatus.NoContent => HttpResults.NoContent(), + _ => HttpResults.Ok() + }; + } + + if (value is IPagedResult paged) + return new PagedHttpResult(paged); + + if (value is NotModifiedResponse) + return HttpResults.StatusCode(StatusCodes.Status304NotModified); + + // ModelActionResults with failures returns 400 BadRequest (preserving legacy behavior) + if (value is Controllers.ModelActionResults { Failure.Count: > 0 } modelAction) + return HttpResults.Json(modelAction, statusCode: StatusCodes.Status400BadRequest); + + // WorkInProgressResult (and ModelActionResults with no failures) returns 202 Accepted + if (value is Controllers.WorkInProgressResult) + return HttpResults.Json(value, statusCode: StatusCodes.Status202Accepted); + + return result.Status switch + { + ResultStatus.Accepted => HttpResults.Json(value, statusCode: StatusCodes.Status202Accepted), + ResultStatus.Created => HttpResults.Created(result.Location, value), + _ => HttpResults.Ok(value) + }; + } + + private static IHttpResult ToHttpResultError(this Foundatio.Mediator.IResult result) + { + return result.Status switch + { + ResultStatus.NotFound => HttpResults.Problem(statusCode: StatusCodes.Status404NotFound, title: result.Message ?? "Not Found"), + ResultStatus.Forbidden => HttpResults.Problem(statusCode: StatusCodes.Status403Forbidden, title: result.Message ?? "Forbidden"), + ResultStatus.Unauthorized => HttpResults.Problem(statusCode: StatusCodes.Status401Unauthorized, title: result.Message ?? "Unauthorized"), + ResultStatus.BadRequest => HttpResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: result.Message ?? "Bad Request"), + ResultStatus.Conflict => HttpResults.Problem(statusCode: StatusCodes.Status409Conflict, title: result.Message ?? "Conflict"), + ResultStatus.Invalid => MapValidation(result), + ResultStatus.Error => HttpResults.Problem(detail: result.Message, statusCode: StatusCodes.Status500InternalServerError), + ResultStatus.CriticalError => HttpResults.Problem(detail: result.Message, statusCode: StatusCodes.Status500InternalServerError), + ResultStatus.Unavailable => HttpResults.Problem(detail: result.Message, statusCode: StatusCodes.Status503ServiceUnavailable), + _ => HttpResults.Problem(detail: result.Message ?? "An unexpected error occurred", statusCode: StatusCodes.Status500InternalServerError) + }; + } + + private static IHttpResult 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); + } +} diff --git a/src/Exceptionless.Web/Bootstrapper.cs b/src/Exceptionless.Web/Bootstrapper.cs index f83edd3615..2fbce851ee 100644 --- a/src/Exceptionless.Web/Bootstrapper.cs +++ b/src/Exceptionless.Web/Bootstrapper.cs @@ -25,7 +25,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 96d51972c1..e0d279853e 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 @@ -3,6 +3,7 @@ import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/gene import type { QueryClient } from '@tanstack/svelte-query'; 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, useQueryClient } from '@tanstack/svelte-query'; @@ -393,7 +394,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 0311f42257..62b45a1505 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'; @@ -555,7 +556,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 f6e3af95ed..a807fc9782 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 @@ -2,6 +2,7 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; import { setUserIdentity } from '$features/auth/exceptionless-session'; import { accessToken } from '$features/auth/index.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'; @@ -117,7 +118,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 b8f2d6b009..0000000000 --- a/src/Exceptionless.Web/Controllers/AdminController.cs +++ /dev/null @@ -1,459 +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(); - var statsTask = client.Cluster.StatsAsync(); - var catIndicesTask = client.Cat.IndicesAsync(r => r.Bytes(Elasticsearch.Net.Bytes.B)); - var catShardsTask = client.Cat.ShardsAsync(); - await Task.WhenAll(healthTask, statsTask, catIndicesTask, catShardsTask); - - var healthResponse = await healthTask; - var statsResponse = await statsTask; - var catIndicesResponse = await catIndicesTask; - var catShardsResponse = await catShardsTask; - - if (!healthResponse.IsValid || !statsResponse.IsValid || !catIndicesResponse.IsValid || !catShardsResponse.IsValid) - return Problem(title: "Elasticsearch cluster information is unavailable."); - - // Count unassigned shards per index - var unassignedByIndex = (catShardsResponse.Records ?? []) - .Where(s => string.Equals(s.State, "UNASSIGNED", StringComparison.OrdinalIgnoreCase)) - .GroupBy(s => s.Index ?? String.Empty, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); - - var indexDetails = (catIndicesResponse.Records ?? []) - .OrderByDescending(i => long.TryParse(i.StoreSize, out var s) ? s : 0) - .Select(i => new ElasticsearchIndexDetailResponse( - Index: i.Index, - Health: i.Health, - Status: i.Status, - Primary: int.TryParse(i.Primary, out var p) ? p : 0, - Replica: int.TryParse(i.Replica, out var r) ? r : 0, - DocsCount: long.TryParse(i.DocsCount, out var dc) ? dc : 0, - StoreSizeInBytes: long.TryParse(i.StoreSize, out var ss) ? ss : 0, - UnassignedShards: unassignedByIndex.GetValueOrDefault(i.Index ?? String.Empty, 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.Documents.Count, - StoreSizeInBytes: statsResponse.Indices.Store.SizeInBytes - ), - IndexDetails: indexDetails - )); - } - - [HttpGet("elasticsearch/snapshots")] - public async Task> GetElasticsearchSnapshotsAsync() - { - var client = _configuration.Client; - try - { - var repositoryResponse = await client.Cat.RepositoriesAsync(); - if (!repositoryResponse.IsValid) - return Problem(title: "Snapshot repository information is unavailable."); - - if (!(repositoryResponse.Records?.Any() ?? false)) - return Ok(new ElasticsearchSnapshotsResponse([], [])); - - var repositoryNames = repositoryResponse.Records - .Where(r => !String.IsNullOrEmpty(r.Id)) - .Select(r => r.Id!) - .ToArray(); - - var snapshotTasks = repositoryNames - .Select(async repositoryName => - { - var snapshotResponse = await client.Cat.SnapshotsAsync(r => r.RepositoryName(repositoryName)); - if (!snapshotResponse.IsValid) - return ( - RepositoryName: repositoryName, - Snapshots: Array.Empty(), - Error: $"Unable to retrieve snapshots for repository: {repositoryName}." - ); - - var snapshotRecords = snapshotResponse.Records?.ToArray() ?? []; - return ( - RepositoryName: repositoryName, - Snapshots: snapshotRecords.Select(s => new ElasticsearchSnapshotResponse( - Repository: repositoryName, - Name: s.Id ?? String.Empty, - Status: s.Status ?? String.Empty, - StartTime: s.StartEpoch > 0 ? DateTimeOffset.FromUnixTimeSeconds(s.StartEpoch).UtcDateTime : null, - EndTime: s.EndEpoch > 0 ? DateTimeOffset.FromUnixTimeSeconds(s.EndEpoch).UtcDateTime : null, - Duration: s.Duration?.ToString() ?? String.Empty, - IndicesCount: s.Indices, - SuccessfulShards: s.SuccessfulShards, - FailedShards: s.FailedShards, - TotalShards: s.TotalShards - )).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 7470abb854..0000000000 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ /dev/null @@ -1,1493 +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 Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; -using Newtonsoft.Json; - -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 JsonSerializerSettings _jsonSerializerSettings; - 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, - JsonSerializerSettings jsonSerializerSettings, - 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; - _jsonSerializerSettings = jsonSerializerSettings; - _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. - /// 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 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, string? time = null, string? offset = null) - { - var model = await GetModelAsync(id, false); - if (model is null) - return NotFound(); - - 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, - Data = summaryData.Data - }; - }).ToList(), events.HasMore && !NextPageExceedsSkipLimit(page, limit), page, events.Total, events.Hits.FirstOrDefault()?.GetSortToken(), events.Hits.LastOrDefault()?.GetSortToken()); - 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(), events.Hits.LastOrDefault()?.GetSortToken()); - } - } - 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).SearchAfterToken(after).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(); - } - - var stream = new MemoryStream(ev.GetBytes(_jsonSerializerSettings)); - 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 7d11666508..0000000000 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ /dev/null @@ -1,1009 +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 Foundatio.Caching; -using Foundatio.Messaging; -using Foundatio.Repositories; -using Foundatio.Repositories.Models; -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 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, - 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; - _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); - } - - /// - /// 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 (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) - { - 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; - } -} diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs deleted file mode 100644 index 23d4690910..0000000000 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ /dev/null @@ -1,839 +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 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 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, - 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; - _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(); - - project.PromotedTabs ??= []; - if (project.PromotedTabs.Add(name.Trim())) - { - 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(); - 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.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); - } - - 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 9b9b5630c4..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).FilterExpression(String.Join(" OR ", stacks.Select(r => $"stack:{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 d685cc5bf9..0000000000 --- a/src/Exceptionless.Web/Controllers/StatusController.cs +++ /dev/null @@ -1,149 +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(), - 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(ValueFromBody message, bool publish = true) - { - if (String.IsNullOrWhiteSpace(message?.Value)) - return NotFound(); - - var notification = await _notificationService.SetSystemNotificationAsync(message.Value, publish); - - return Ok(notification); - } - - [HttpDelete("notifications/system")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task RemoveSystemNotificationAsync(bool publish = true) - { - await _notificationService.ClearSystemNotificationAsync(publish); - - return Ok(); - } -} 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 a06bcdc17d..0000000000 --- a/src/Exceptionless.Web/Controllers/UserController.cs +++ /dev/null @@ -1,390 +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 Foundatio.Caching; -using Foundatio.Repositories; -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 IMailer _mailer; - private readonly IntercomOptions _intercomOptions; - - public UserController( - IUserRepository userRepository, - 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"); - _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(new ViewCurrentUser(currentUser, _intercomOptions)); - } - - /// - /// 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); - } - - /// - /// 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(new ViewCurrentUser(model, _intercomOptions)); - - return await base.OkModelAsync(model); - } - - 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); - } -} 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 7f1fb15deb..528eae12af 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,7 +17,10 @@ + + + @@ -42,8 +46,7 @@ - + @@ -56,8 +59,7 @@ - + @@ -66,8 +68,7 @@ - + wwwroot\next\%(RecursiveDir)%(FileName)%(Extension) Always true @@ -75,8 +76,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..a2e8c00066 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -1,25 +1,350 @@ 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.ConfigureExceptionlessDefaults(); + }); + + 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(); + 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 +359,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.Extensions.Add("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 +432,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 4a37df1963..0000000000 --- a/src/Exceptionless.Web/Startup.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System.Diagnostics; -using System.Security.Claims; -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(LowerCaseUnderscoreNamingPolicy.Instance)); - o.InputFormatters.Insert(0, new RawRequestBodyFormatter()); - }) - .AddJsonOptions(o => - { - o.JsonSerializerOptions.ConfigureExceptionlessDefaults(); - 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.ConfigureExceptionlessDefaults(); - 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.Extensions.Add("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) - { - // This might be possible to accomplish via serializer. - // 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 - ); - } - - // errors - // TODO: Check casing of property names of model state validation errors. - } - - 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 38c402227e..0000000000 --- a/src/Exceptionless.Web/Utility/Delta/Delta.cs +++ /dev/null @@ -1,371 +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!; - - /// - /// Initializes a new instance of . - /// - public Delta() : this(typeof(TEntityType)) - { - } - - /// - /// Initializes a new instance of . - /// - /// - /// The derived entity type for which the changes would be tracked. - /// should be assignable to instances of . - /// - public Delta(Type entityType) - { - 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.ContainsKey(name)) - return false; - - var cacheHit = _propertiesThatExist[name]; - - if (value is null && !IsNullable(cacheHit.MemberType)) - return false; - - if (value is not null) - { - if (value is JsonElement jsonElement) - { - try - { - value = 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 68e5f729a0..0000000000 --- a/src/Exceptionless.Web/Utility/Delta/DeltaJsonConverter.cs +++ /dev/null @@ -1,133 +0,0 @@ -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 _jsonNameToPropertyName; - - public DeltaJsonConverter(JsonSerializerOptions options) - { - // Create a copy without the converter to avoid infinite recursion - _options = new JsonSerializerOptions(options); - - // Build a mapping from JSON property names (snake_case) to C# property names (PascalCase) - _jsonNameToPropertyName = new Dictionary(StringComparer.OrdinalIgnoreCase); - var entityType = typeof(TEntityType); - foreach (var prop in entityType.GetProperties()) - { - var jsonName = options.PropertyNamingPolicy?.ConvertName(prop.Name) ?? prop.Name; - _jsonNameToPropertyName[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(); - - 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 = _jsonNameToPropertyName.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/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 19aa9d17cf..6565d76663 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.ApplicationModel; +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 static int s_counter = -1; private static readonly ConcurrentQueue s_pool = new(); @@ -86,21 +95,87 @@ 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 + }); + }); + + // 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 - }) - .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 ff8ab13fee..39915c5042 100644 --- a/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs @@ -16,6 +16,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 94e88d48b6..0000000000 --- a/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json +++ /dev/null @@ -1,2999 +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": "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": "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": "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": "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..7aa7e35a1c --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/Data/endpoint-manifest.json @@ -0,0 +1,2599 @@ +[ + { + "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": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/assemblies", + "displayName": "HTTP: GET api/v2/admin/assemblies", + "tags": [], + "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": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/elasticsearch/snapshots", + "displayName": "HTTP: GET api/v2/admin/elasticsearch/snapshots", + "tags": [], + "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": [], + "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": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/migrations", + "displayName": "HTTP: GET api/v2/admin/migrations", + "tags": [], + "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": [], + "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": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/admin/stats", + "displayName": "HTTP: GET api/v2/admin/stats", + "tags": [], + "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": "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": "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": "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": "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 74b0d01dcc..072735af09 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,205 +79,1676 @@ } ], "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 - }, - "responses": { - "200": { - "description": "OK", + { + "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": "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/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": "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" + } + } + }, + "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" + } + } + } + } + } + } + }, + "/api/v2/auth/forgot-password/{email}": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Forgot password", + "parameters": [ + { + "name": "email", + "in": "path", + "description": "The email address.", + "required": true, + "schema": { + "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": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordModel" + } + }, + "application/*\u002Bjson": { + "schema": { + "$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" + } + } + } + } + } + } + }, + "/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" + }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "Token" + ], + "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", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The token.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NewToken" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$ref": "#/components/schemas/ViewToken" } } } }, "400": { - "description": "An error occurred while updating the saved view." + "description": "An error occurred while creating the token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not 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/projects/{projectId}/tokens": { + "get": { + "tags": [ + "Token" + ], + "summary": "Get by project", + "parameters": [ + { + "name": "projectId", + "in": "path", + "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": "OK" }, "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" + } + } + } } } }, - "put": { + "post": { "tags": [ - "SavedView" + "Token" ], - "summary": "Update", + "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.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -302,52 +1757,77 @@ } ], "requestBody": { - "description": "The changes", + "description": "The token.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSavedView" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateSavedView" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NewToken" + } + ] } } - }, - "required": true + } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$ref": "#/components/schemas/ViewToken" } } } }, "400": { - "description": "An error occurred while updating the saved view." + "description": "An error occurred while creating the token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "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/organizations/{organizationId}/saved-views/predefined": { - "post": { + "/api/v2/projects/{projectId}/tokens/default": { + "get": { "tags": [ - "SavedView" + "Token" ], - "summary": "Create or update predefined saved views", + "summary": "Get a projects default token", "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}$", @@ -357,128 +1837,259 @@ ], "responses": { "200": { - "description": "The predefined saved views were created or updated.", + "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewSavedView" - } + "$ref": "#/components/schemas/ViewToken" } } } }, "404": { - "description": "The organization could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/saved-views/predefined": { + "/api/v2/tokens/{id}": { "get": { "tags": [ - "SavedView" + "Token" + ], + "summary": "Get by id", + "operationId": "GetTokenById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the token.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}$", + "type": "string" + } + } ], - "summary": "Get global predefined saved views as seed JSON", "responses": { "200": { - "description": "The current predefined saved views.", + "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" - } + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "404": { + "description": "The token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } } } - } - }, - "/api/v2/saved-views/{id}/predefined": { - "post": { + }, + "patch": { "tags": [ - "SavedView" + "Token" ], - "summary": "Save a saved view as a global predefined saved view", + "summary": "Update", "parameters": [ { "name": "id", "in": "path", - "description": "The identifier of the saved view to promote.", + "description": "The identifier of the token.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateTokenJsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$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 token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "tags": [ + "Token" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "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 changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateTokenJsonPatchDocument" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "The predefined saved view was created or updated.", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$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 saved view could not be found." + "description": "The token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "delete": { + } + }, + "/api/v2/tokens": { + "post": { "tags": [ - "SavedView" + "Token" ], - "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" + "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" + } } - } - ], + }, + "required": true + }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } } }, - "204": { - "description": "The predefined saved view was deleted." + "400": { + "description": "An error occurred while creating the token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "404": { - "description": "The saved view could not be found." + "409": { + "description": "The token already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/saved-views/{ids}": { + "/api/v2/tokens/{ids}": { "delete": { "tags": [ - "SavedView" + "Token" ], "summary": "Remove", "parameters": [ { "name": "ids", "in": "path", - "description": "A comma-delimited list of saved view identifiers.", + "description": "A comma-delimited list of token identifiers.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", "type": "string" } } @@ -495,28 +2106,49 @@ } }, "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 saved views were not found." + "description": "One or more tokens were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "500": { - "description": "An error occurred while deleting one or more saved views." + "description": "An error occurred while deleting one or more tokens.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{organizationId}/tokens": { + "/api/v2/projects/{projectId}/webhooks": { "get": { "tags": [ - "Token" + "WebHook" ], - "summary": "Get by organization", + "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}$", @@ -546,126 +2178,38 @@ ], "responses": { "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewToken" - } - } - } - } + "description": "OK" }, "404": { - "description": "The organization could not be found." - } - } - }, - "post": { - "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.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "The identifier of the organization.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - } - ], - "requestBody": { - "description": "The token.", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] - } - } - } - }, - "responses": { - "201": { - "description": "Created", + "description": "The project could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "400": { - "description": "An error occurred while creating the token." - }, - "409": { - "description": "The token already exists." } } } }, - "/api/v2/projects/{projectId}/tokens": { + "/api/v2/webhooks/{id}": { "get": { "tags": [ - "Token" + "WebHook" ], - "summary": "Get by project", + "summary": "Get by id", + "operationId": "GetWebHookById", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the web hook.", "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": { @@ -674,65 +2218,40 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewToken" - } + "$ref": "#/components/schemas/WebHook" } } } }, "404": { - "description": "The project could not be found." + "description": "The web hook could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, + } + }, + "/api/v2/webhooks": { "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" - } - } + "WebHook" ], + "summary": "Create", "requestBody": { - "description": "The token.", + "description": "The web hook.", "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] + "$ref": "#/components/schemas/NewWebHook" } } - } + }, + "required": true }, "responses": { "201": { @@ -740,296 +2259,255 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/WebHook" } } } }, "400": { - "description": "An error occurred while creating the token." - }, - "404": { - "description": "The project could not be found." + "description": "An error occurred while creating the web hook.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "409": { - "description": "The token already exists." + "description": "The web hook already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/tokens/default": { - "get": { + "/api/v2/webhooks/{ids}": { + "delete": { "tags": [ - "Token" + "WebHook" ], - "summary": "Get a projects default token", + "summary": "Remove", "parameters": [ { - "name": "projectId", + "name": "ids", "in": "path", - "description": "The identifier of the project.", + "description": "A comma-delimited list of web hook identifiers.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "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": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } + }, + "400": { + "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 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/{id}": { + "/api/v2/organizations/{organizationId}/saved-views": { "get": { "tags": [ - "Token" + "SavedView" ], - "summary": "Get by id", - "operationId": "GetTokenById", + "summary": "Get by organization", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "The identifier of the token.", + "description": "The identifier of the organization.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{24,40}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewToken" - } - } + }, + { + "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 } }, - "404": { - "description": "The token could not be found." - } - } - }, - "patch": { - "tags": [ - "Token" - ], - "summary": "Update", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the token.", - "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,40}(,[a-zA-Z\\d-]{24,40})*$", - "type": "string" + "type": "integer", + "format": "int32", + "default": 25 } } ], - "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", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } } } } }, - "400": { - "description": "An error occurred while updating the token." - }, "404": { - "description": "The token could not be found." + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, - "put": { + "post": { "tags": [ - "Token" + "SavedView" ], - "summary": "Update", + "summary": "Create", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "The identifier of the token.", + "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 changes", + "description": "The saved view.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateToken" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateToken" + "$ref": "#/components/schemas/NewSavedView" } } }, "required": true }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/ViewSavedView" } } } }, "400": { - "description": "An error occurred while updating the token." - }, - "404": { - "description": "The token could not be found." - } - } - } - }, - "/api/v2/tokens": { - "post": { - "tags": [ - "Token" - ], - "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" + "description": "An error occurred while creating the saved view.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "201": { - "description": "Created", + "409": { + "description": "The saved view already exists.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewToken" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "An error occurred while creating the token." - }, - "409": { - "description": "The token already exists." + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/tokens/{ids}": { - "delete": { + "/api/v2/organizations/{organizationId}/saved-views/{viewType}": { + "get": { "tags": [ - "Token" + "SavedView" ], - "summary": "Remove", + "summary": "Get by organization and view", "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" } - } - ], - "responses": { - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" - } - } - } - }, - "400": { - "description": "One or more validation errors occurred." }, - "404": { - "description": "One or more tokens were not found." - }, - "500": { - "description": "An error occurred while deleting one or more tokens." - } - } - } - }, - "/api/v2/projects/{projectId}/webhooks": { - "get": { - "tags": [ - "WebHook" - ], - "summary": "Get by project", - "parameters": [ { - "name": "projectId", + "name": "viewType", "in": "path", - "description": "The identifier of the project.", + "description": "The dashboard view type (events, issues, stream).", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, @@ -1050,7 +2528,7 @@ "schema": { "type": "integer", "format": "int32", - "default": 10 + "default": 25 } } ], @@ -1062,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}$", @@ -1099,534 +2584,517 @@ "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." + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "500": { - "description": "An error occurred while deleting one or more web hooks." + "409": { + "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/auth/login": { + "/api/v2/organizations/{organizationId}/saved-views/predefined": { "post": { "tags": [ - "Auth" + "SavedView" ], - "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" - } + "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" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "User Authentication 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": "Login failed" - }, - "422": { - "description": "Validation error" + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/intercom": { + "/api/v2/saved-views/predefined": { "get": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Get the current user's Intercom messenger token.", + "summary": "Get global predefined saved views as seed JSON", "responses": { "200": { - "description": "Intercom messenger token", + "description": "The current predefined saved views.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + } } } } - }, - "401": { - "description": "User not logged in" - }, - "422": { - "description": "Intercom is not enabled." } } } }, - "/api/v2/auth/logout": { - "get": { + "/api/v2/saved-views/{id}/predefined": { + "post": { "tags": [ - "Auth" + "SavedView" + ], + "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" + } + } ], - "summary": "Logout the current user and remove the current access token", "responses": { "200": { - "description": "User successfully logged-out", + "description": "The predefined saved view was created or updated.", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } } }, - "401": { - "description": "User not logged in" - }, - "403": { - "description": "Current action is not supported with user access token" + "404": { + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/auth/signup": { - "post": { + }, + "delete": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Sign up", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Signup" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Signup" - } + "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" } } } - }, - "401": { - "description": "Sign-up failed" - }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" } } } }, - "/api/v2/auth/github": { - "post": { + "/api/v2/saved-views/{ids}": { + "delete": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Sign in with GitHub", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } + "summary": "Remove", + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "A comma-delimited list of saved view identifiers.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "type": "string" } - }, - "required": true - }, + } + ], "responses": { - "200": { - "description": "User Authentication Token", + "202": { + "description": "Accepted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" - } - } - } - }, - "/api/v2/auth/google": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Sign in with Google", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" + "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/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" + "500": { + "description": "An error occurred while deleting one or more saved views.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/facebook": { - "post": { + "/api/v2/users/me": { + "get": { "tags": [ - "Auth" + "User" ], - "summary": "Sign in with Facebook", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - } - }, - "required": true - }, + "summary": "Get current user", "responses": { "200": { - "description": "User Authentication Token", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ViewCurrentUser" } } } }, - "403": { - "description": "Account Creation is currently disabled" - }, - "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/live": { - "post": { + }, + "delete": { "tags": [ - "Auth" + "User" ], - "summary": "Sign in with Microsoft", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - } - }, - "required": true - }, + "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" } } } }, - "403": { - "description": "Account Creation is currently disabled" - }, - "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/unlink/{providerName}": { - "post": { + "/api/v2/users/{id}": { + "get": { "tags": [ - "Auth" + "User" ], - "summary": "Removes an external login provider from the account", + "summary": "Get by id", + "operationId": "GetUserById", "parameters": [ { - "name": "providerName", + "name": "id", "in": "path", - "description": "The provider name.", + "description": "The identifier of the user.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], - "requestBody": { - "description": "The provider user id.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "User Authentication Token", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ViewUser" } } } }, - "400": { - "description": "Invalid provider name." - } - } - } - }, - "/api/v2/auth/change-password": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Change password", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordModel" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordModel" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "User Authentication Token", + "404": { + "description": "The user could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "422": { - "description": "Validation error" } - } - } - }, - "/api/v2/auth/forgot-password/{email}": { - "get": { + } + }, + "patch": { "tags": [ - "Auth" + "User" ], - "summary": "Forgot password", + "summary": "Update", "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.", - "content": { - "application/json": { } - } - }, - "400": { - "description": "Invalid email address." - } - } - } - }, - "/api/v2/auth/reset-password": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Reset password", "requestBody": { + "description": "The changes", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordModel" - } - }, - "application/*+json": { + "application/json-patch\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ResetPasswordModel" + "$ref": "#/components/schemas/UpdateUserJsonPatchDocument" } } }, @@ -1634,124 +3102,237 @@ }, "responses": { "200": { - "description": "Password reset email was sent.", + "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewUser" + } + } } }, - "422": { - "description": "Invalid reset password model." + "400": { + "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/auth/cancel-reset-password/{token}": { - "post": { + }, + "put": { "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": { + "/api/v2/organizations/{organizationId}/users": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Count", + "summary": "Get by organization", "parameters": [ { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "aggregations", + "name": "page", "in": "query", - "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", + "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": "time", + "name": "limit", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "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 } - }, - { - "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": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewUser" + } + } + } } }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/users/{ids}": { + "delete": { + "tags": [ + "User" + ], + "summary": "Remove", + "parameters": [ { - "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": "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" } } ], "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CountResult" + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, "400": { - "description": "Invalid filter." + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "One or more users were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "An error occurred while deleting one or more users.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{organizationId}/events/count": { - "get": { + "/api/v2/users/{id}/email-address/{email}": { + "post": { "tags": [ - "Event" + "User" ], - "summary": "Count by organization", + "summary": "Update email address", "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}$", @@ -1759,197 +3340,154 @@ } }, { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", + "name": "email", + "in": "path", + "description": "The new email address.", + "required": true, "schema": { + "minLength": 1, "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" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEmailAddressResult" + } + } } }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" + "400": { + "description": "An error occurred while updating the users email address.", + "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" + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "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", + "422": { + "description": "Validation error", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/CountResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." + "429": { + "description": "Update email address rate limit reached.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/count": { + "/api/v2/users/verify-email-address/{token}": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Count by project", + "summary": "Verify email address", "parameters": [ { - "name": "projectId", + "name": "token", "in": "path", - "description": "The identifier of the project.", + "description": "The token identifier.", "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": "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 mode is set to stack_new, then additional filters will be added.", - "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}$", "type": "string" } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The user could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/CountResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." + "422": { + "description": "Verify Email Address Token has expired.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/{id}": { + "/api/v2/users/{id}/resend-verification-email": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Get by id", - "operationId": "GetPersistentEventById", + "summary": "Resend verification email", "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}$", "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" - } } ], "responses": { "200": { - "description": "OK", + "description": "The user verification email has been sent." + }, + "404": { + "description": "The user could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The event occurrence could not be found." - }, - "426": { - "description": "Unable to view event occurrence due to plan limits." } } } }, - "/api/v2/events": { + "/api/v2/projects": { "get": { "tags": [ - "Event" + "Project" ], "summary": "Get all", "parameters": [ @@ -1964,31 +3502,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" } @@ -1999,7 +3513,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 } }, { @@ -2013,17 +3528,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" } @@ -2037,75 +3544,80 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ViewProject" } } } } - }, - "400": { - "description": "Invalid filter." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." } } }, "post": { "tags": [ - "Event" - ], - "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}```", - "parameters": [ - { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", - "schema": { - "type": "string" - } - } + "Project" ], + "summary": "Create", "requestBody": { + "description": "The project.", "content": { "application/json": { "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" + "$ref": "#/components/schemas/NewProject" } } }, "required": true }, "responses": { - "202": { - "description": "Accepted", + "201": { + "description": "Created", "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 creating the project.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "404": { - "description": "No project was found." + "409": { + "description": "The project 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/organizations/{organizationId}/events": { + "/api/v2/organizations/{organizationId}/projects": { "get": { "tags": [ - "Event" + "Project" ], - "summary": "Get by organization", + "summary": "Get all", "parameters": [ { "name": "organizationId", @@ -2128,31 +3640,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" } @@ -2163,7 +3651,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 } }, { @@ -2177,17 +3666,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" } @@ -2201,33 +3682,35 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ViewProject" } } } } }, - "400": { - "description": "Invalid filter." - }, "404": { - "description": "The organization 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" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events": { + "/api/v2/projects/{id}": { "get": { "tags": [ - "Event" + "Project" ], - "summary": "Get by project", + "summary": "Get by id", + "operationId": "GetProjectById", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", "description": "The identifier of the project.", "required": true, @@ -2237,78 +3720,310 @@ } }, { - "name": "filter", + "name": "mode", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "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": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "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-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectJsonPatchDocument" + } + } }, - { - "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", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewProject" + } + } } }, + "400": { + "description": "An error occurred while updating the project.", + "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" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "tags": [ + "Project" + ], + "summary": "Update", + "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" } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectJsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewProject" + } + } + } + }, + "400": { + "description": "An error occurred while updating the project.", + "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" + } + } + } }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects/{ids}": { + "delete": { + "tags": [ + "Project" + ], + "summary": "Remove", + "parameters": [ { - "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": "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": "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": "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": "limit", + "name": "v", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The client configuration version.", "schema": { "type": "integer", - "format": "int32", - "default": 10 + "format": "int32" + } + } + ], + "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" } } ], @@ -2318,34 +4033,34 @@ "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 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" + } + } + } } } }, "post": { "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": "Add configuration value", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", "description": "The identifier of the project.", "required": true, @@ -2355,58 +4070,62 @@ } }, { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "key", + "in": "query", + "description": "The key name of the configuration object.", + "required": true, "schema": { "type": "string" } } ], "requestBody": { + "description": "The configuration value.", "content": { "application/json": { "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" + "$ref": "#/components/schemas/StringValueFromBody" } } }, "required": true }, "responses": { - "202": { - "description": "Accepted", - "content": { - "application/json": { } - } + "200": { + "description": "OK" }, "400": { - "description": "No project id specified and no default project was found." + "description": "Invalid configuration value.", + "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" + } + } + } } } - } - }, - "/api/v2/stacks/{stackId}/events": { - "get": { + }, + "delete": { "tags": [ - "Event" + "Project" ], - "summary": "Get by stack", + "summary": "Remove configuration value", "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}$", @@ -2414,77 +4133,190 @@ } }, { - "name": "filter", + "name": "key", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "description": "The key name of the configuration object.", + "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 key 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" + } + } + } + } + } + } + }, + "/api/v2/projects/{id}/sample-data": { + "post": { + "tags": [ + "Project" + ], + "summary": "Generate sample project data", + "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" } + } + ], + "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" + } + } + } + } + } + } + }, + "/api/v2/projects/{id}/reset-data": { + "get": { + "tags": [ + "Project" + ], + "summary": "Reset project 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" } + } + ], + "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": "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 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" + } + ], + "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/users/{userId}/projects/{id}/notifications": { + "get": { + "tags": [ + "Project" + ], + "summary": "Get 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" } } @@ -2495,311 +4327,455 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/NotificationSettings" } } } }, - "400": { - "description": "Invalid filter." - }, "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": { + }, + "put": { "tags": [ - "Event" + "Project" ], - "summary": "Get by reference id", + "summary": "Set user notification settings", "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}$", - "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": { + "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/by-ref/{referenceId}": { - "get": { + }, + "delete": { "tags": [ - "Event" + "Project" ], - "summary": "Get by reference id", + "summary": "Remove user notification settings", "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": "projectId", + "name": "userId", "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" } + } + ], + "responses": { + "200": { + "description": "OK" }, + "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": "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": "integration", + "in": "path", + "description": "The identifier of the integration.", + "required": true, "schema": { + "minLength": 1, "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" + } + ] + } } - }, - { - "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" + }, + "404": { + "description": "The project or integration could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "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/{sessionId}": { - "get": { + "/api/v2/projects/{id}/promotedtabs": { + "put": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions or events by a session id", + "summary": "Promote tab", "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", + "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" + } + } } - }, + } + } + }, + "post": { + "tags": [ + "Project" + ], + "summary": "Promote tab", + "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": "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" + } + } } - }, + } + } + }, + "delete": { + "tags": [ + "Project" + ], + "summary": "Demote 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" } @@ -2807,125 +4783,187 @@ ], "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." - }, - "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/sessions/{sessionId}": { + "/api/v2/projects/check-name": { "get": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of by a session id", + "summary": "Check for unique name", "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", - "in": "path", - "description": "The identifier of the project.", + "name": "name", + "in": "query", + "description": "The project name to check.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "filter", + "name": "organizationId", "in": "query", - "description": "A filter that controls what data is returned from the server.", "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": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "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": "time", + "name": "name", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "The project name to check.", + "required": true, "schema": { "type": "string" } + } + ], + "responses": { + "201": { + "description": "The project name is available." }, + "204": { + "description": "The project name is not available." + } + } + } + }, + "/api/v2/projects/{id}/data": { + "post": { + "tags": [ + "Project" + ], + "summary": "Add 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" } - }, - { - "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": "Any string value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } } }, - { - "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 + "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": "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 data object.", + "required": true, "schema": { "type": "string" } @@ -2933,36 +4971,37 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid key or value.", "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": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/sessions": { + "/api/v2/organizations": { "get": { "tags": [ - "Event" + "Organization" ], - "summary": "Get a list of all sessions", + "summary": "Get all", "parameters": [ { "name": "filter", @@ -2973,102 +5012,219 @@ } }, { - "name": "sort", + "name": "mode", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "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" } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "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" + } } }, - { - "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" + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } } }, - { - "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": "An error occurred while creating the organization.", + "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" + "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/organizations/{id}": { + "get": { + "tags": [ + "Organization" + ], + "summary": "Get by id", + "operationId": "GetOrganizationById", + "parameters": [ { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } }, { - "name": "before", + "name": "mode", "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "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": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } + } }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Organization" + ], + "summary": "Update", + "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 organization.", + "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/NewOrganizationJsonPatchDocument" + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ViewOrganization" } } } }, "400": { - "description": "Invalid filter." + "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" + } + } + } } } - } - }, - "/api/v2/organizations/{organizationId}/events/sessions": { - "get": { + }, + "put": { "tags": [ - "Event" + "Organization" ], - "summary": "Get a list of all sessions", + "summary": "Update", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", "description": "The identifier of the organization.", "required": true, @@ -3076,173 +5232,198 @@ "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.", - "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" + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json-patch\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/NewOrganizationJsonPatchDocument" + } } }, - { - "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" + "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" + "400": { + "description": "An error occurred while updating the organization.", + "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 organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "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" + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/organizations/{ids}": { + "delete": { + "tags": [ + "Organization" + ], + "summary": "Remove", + "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": "ids", + "in": "path", + "description": "A comma-delimited list of organization identifiers.", + "required": true, "schema": { + "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": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, "400": { - "description": "Invalid filter." + "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 organizations were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "500": { + "description": "An error occurred while deleting one or more organizations.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/sessions": { + "/api/v2/organizations/invoice/{id}": { "get": { "tags": [ - "Event" + "Organization" ], - "summary": "Get a list of all sessions", + "summary": "Get invoice", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the invoice.", "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": { + "minLength": 10, "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/Invoice" + } + } } }, - { - "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 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": "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 organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "mode", + "name": "before", "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 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": "page", + "name": "after", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "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": "integer", - "format": "int32" + "type": "string" } }, { @@ -3252,23 +5433,7 @@ "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" + "default": 12 } } ], @@ -3280,240 +5445,198 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/InvoiceGridModel" } } } } }, - "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 was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/by-ref/{referenceId}/user-description": { - "post": { + "/api/v2/organizations/{id}/plans": { + "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 plans", + "description": "Gets available plans for a specific organization.", "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" } } ], - "requestBody": { - "description": "The identifier of the project.", - "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": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BillingPlan" + } + } + } } }, - "400": { - "description": "Description must be specified." - }, "404": { - "description": "The event occurrence with the specified reference id could not be found." + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/by-ref/{referenceId}/user-description": { + "/api/v2/organizations/{id}/change-plan": { "post": { "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": "Change plan", + "description": "Upgrades or downgrades the organization\u0027s plan. Accepts parameters via JSON body (preferred) or query string (legacy).", "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": "planId", + "in": "query", + "description": "Legacy query parameter: the plan identifier.", "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", - "content": { - "application/json": { } + { + "name": "stripeToken", + "in": "query", + "description": "Legacy query parameter: the Stripe token.", + "schema": { + "type": "string" } }, - "400": { - "description": "Description must be specified." + { + "name": "last4", + "in": "query", + "description": "Legacy query parameter: last four digits of the card.", + "schema": { + "type": "string" + } }, - "404": { - "description": "The event occurrence with the specified reference id could not be found." - } - } - } - }, - "/api/v1/error/{id}": { - "patch": { - "tags": [ - "Event" - ], - "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "couponId", + "in": "query", + "description": "Legacy query parameter: the coupon identifier.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "requestBody": { + "description": "The plan change request (JSON body).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateEvent" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateEvent" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] } } - }, - "required": true + } }, "responses": { "200": { "description": "OK", "content": { - "application/json": { } - } - } - }, - "deprecated": true - } - }, - "/api/v2/events/session/heartbeat": { - "get": { - "tags": [ - "Event" - ], - "summary": "Submit heartbeat", - "parameters": [ - { - "name": "id", - "in": "query", - "description": "The session id or user id.", - "schema": { - "type": "string" + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePlanResult" + } + } } }, - { - "name": "close", - "in": "query", - "description": "If true, the session will be closed.", - "schema": { - "type": "boolean", - "default": false - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "The organization was not found.", "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." + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/events/submit": { - "get": { + "/api/v2/organizations/{id}/users/{email}": { + "post": { "tags": [ - "Event" + "Organization" ], + "summary": "Add user", "parameters": [ { - "name": "userAgent", - "in": "header", + "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", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "name": "email", + "in": "path", + "description": "The email address of the user you wish to add to your organization.", + "required": true, + "schema": { + "minLength": 1, + "type": "string" } } ], @@ -3521,66 +5644,100 @@ "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "404": { + "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" + } + } } } - }, - "deprecated": true - } - }, - "/api/v1/events/submit/{type}": { - "get": { + } + }, + "delete": { "tags": [ - "Event" + "Organization" ], + "summary": "Remove user", "parameters": [ { - "name": "type", + "name": "id", "in": "path", + "description": "The identifier of the organization.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "userAgent", - "in": "header", + "name": "email", + "in": "path", + "description": "The email address of the user you wish to remove from your organization.", + "required": true, "schema": { + "minLength": 1, "type": "string" } - }, - { - "name": "parameters", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } - } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "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/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/projects/{projectId}/events/submit": { - "get": { + "/api/v2/organizations/{id}/data/{key}": { + "post": { "tags": [ - "Event" + "Organization" ], + "summary": "Add custom data", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -3588,43 +5745,63 @@ } }, { - "name": "userAgent", - "in": "header", + "name": "key", + "in": "path", + "description": "The key name of the data object.", + "required": true, "schema": { + "minLength": 1, "type": "string" } - }, - { - "name": "parameters", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } - } } ], + "requestBody": { + "description": "Any string value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The organization was not found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true - } - }, - "/api/v1/projects/{projectId}/events/submit/{type}": { - "get": { + } + }, + "delete": { "tags": [ - "Event" + "Organization" ], + "summary": "Remove custom data", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -3632,688 +5809,747 @@ } }, { - "name": "type", + "name": "key", "in": "path", + "description": "The key name of the data object.", "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 organization was not found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v2/events/submit": { + "/api/v2/organizations/check-name": { "get": { "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": "Check for unique name", "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", + "name": "name", "in": "query", - "description": "The geo coordinates where the event happened.", + "description": "The organization name to check.", + "required": true, "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", - "schema": { - "type": "string" - } + "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": "identity", - "in": "query", - "description": "The user's identity that the event happened to.", + "name": "id", + "in": "path", + "description": "The identifier of the stack.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "identityname", + "name": "offset", "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": "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" } - }, - { - "name": "parameters", - "in": "query", - "description": "Query string parameters that control what properties are set on the event", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } - } } ], "responses": { "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stack" + } + } } }, - "400": { - "description": "No project id specified and no default project was found." - }, "404": { - "description": "No project was found." + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/submit/{type}": { - "get": { + "/api/v2/stacks/{ids}/mark-fixed": { + "post": { "tags": [ - "Event" + "Stack" ], - "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": "Mark fixed", "parameters": [ { - "name": "type", + "name": "ids", "in": "path", - "description": "The event type (ie. error, log message, feature usage).", + "description": "A comma-delimited list of stack identifiers.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "source", + "name": "version", "in": "query", - "description": "The event source (ie. machine name, log name, feature name).", + "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": "message", - "in": "query", - "description": "The event message.", + "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": "reference", + "name": "snoozeUntilUtc", "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", + "description": "A time that the stack should be snoozed until.", + "required": true, "schema": { - "type": "string" + "type": "string", + "format": "date-time" } + } + ], + "responses": { + "200": { + "description": "The stacks were snoozed." }, + "404": { + "description": "One or more stacks could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/stacks/{id}/add-link": { + "post": { + "tags": [ + "Stack" + ], + "summary": "Add reference link", + "parameters": [ { - "name": "date", - "in": "query", - "description": "The date that the event occurred on.", + "name": "id", + "in": "path", + "description": "The identifier of the stack.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "count", - "in": "query", - "description": "The number of duplicated events.", - "schema": { - "type": "integer", - "format": "int32" + } + ], + "requestBody": { + "description": "The reference link.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } } }, - { - "name": "value", - "in": "query", - "description": "The value of the event if any.", - "schema": { - "type": "number", - "format": "double" + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "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/{id}/remove-link": { + "post": { + "tags": [ + "Stack" + ], + "summary": "Remove reference link", + "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" } + } + ], + "requestBody": { + "description": "The reference link.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } + } }, - { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", - "schema": { - "type": "string" + "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": "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" } + } + ], + "responses": { + "200": { + "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": "identityname", - "in": "query", - "description": "The user's friendly name 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" } + } + ], + "responses": { + "204": { + "description": "The stacks were marked as not critical." }, + "404": { + "description": "One or more stacks could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/stacks/{ids}/change-status": { + "post": { + "tags": [ + "Stack" + ], + "summary": "Change stack status", + "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": "status", "in": "query", - "description": "Query string parameters that control what properties are set on the event", + "description": "The status that the stack should be changed to.", + "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "$ref": "#/components/schemas/StackStatus" } } ], "responses": { "200": { - "description": "OK", - "content": { - "application/json": { } - } - }, - "400": { - "description": "No project id specified and no default project was found." + "description": "OK" }, "404": { - "description": "No project was found." + "description": "One or more stacks could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/submit": { - "get": { + "/api/v2/stacks/{id}/promote": { + "post": { "tags": [ - "Event" + "Stack" ], - "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": "Promote to external service", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the stack.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "source", - "in": "query", - "description": "The event source (ie. machine name, log name, feature name).", - "schema": { - "type": "string" + "404": { + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "message", - "in": "query", - "description": "The event message.", - "schema": { - "type": "string" + "426": { + "description": "Promote to External is a premium feature used to promote an error stack to an external system.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "501": { + "description": "No promoted web hooks are configured for this project." + } + } + } + }, + "/api/v2/stacks/{ids}": { + "delete": { + "tags": [ + "Stack" + ], + "summary": "Remove", + "parameters": [ { - "name": "reference", - "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", + "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": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } }, - { - "name": "date", - "in": "query", - "description": "The date that the event occurred on.", - "schema": { - "type": "string" + "400": { + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "count", - "in": "query", - "description": "The number of duplicated events.", - "schema": { - "type": "integer", - "format": "int32" + "404": { + "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 stacks.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/stacks": { + "get": { + "tags": [ + "Stack" + ], + "summary": "Get all", + "parameters": [ { - "name": "value", + "name": "filter", "in": "query", - "description": "The value of the event if any.", + "description": "A filter that controls what data is returned from the server.", "schema": { - "type": "number", - "format": "double" + "type": "string" } }, { - "name": "geo", + "name": "sort", "in": "query", - "description": "The geo coordinates where the event happened.", + "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": "tags", + "name": "time", "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { "type": "string" } }, { - "name": "identity", + "name": "offset", "in": "query", - "description": "The user's identity that the event happened to.", + "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": "identityname", + "name": "mode", "in": "query", - "description": "The user's friendly name that the event happened to.", + "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": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "page", + "in": "query", + "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": "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": 10 } } ], "responses": { "200": { - "description": "OK", - "content": { - "application/json": { } - } + "description": "OK" }, "400": { - "description": "No project id specified and no default project was found." - }, - "404": { - "description": "No project was found." + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/submit/{type}": { + "/api/v2/organizations/{organizationId}/stacks": { "get": { "tags": [ - "Event" - ], - "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```", - "parameters": [ - { - "name": "projectId", - "in": "path", - "description": "The identifier of the project.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - }, - { - "name": "type", - "in": "path", - "description": "The event type (ie. error, log message, feature usage).", - "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" - } - }, + "Stack" + ], + "summary": "Get by organization", + "parameters": [ { - "name": "reference", - "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "date", + "name": "filter", "in": "query", - "description": "The date that the event occurred on.", + "description": "A filter that controls what data is returned from the server.", "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", + "name": "sort", "in": "query", - "description": "The geo coordinates where the event happened.", + "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": "tags", + "name": "time", "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { "type": "string" } }, { - "name": "identity", + "name": "offset", "in": "query", - "description": "The user's identity that the event happened to.", + "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": "identityname", + "name": "mode", "in": "query", - "description": "The user's friendly name that the event happened to.", + "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": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "page", + "in": "query", + "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": "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": 10 } } ], "responses": { "200": { - "description": "OK", - "content": { - "application/json": { } - } + "description": "OK" }, "400": { - "description": "No project id specified and no default project was found." - }, - "404": { - "description": "No project was found." - } - } - } - }, - "/api/v1/error": { - "post": { - "tags": [ - "Event" - ], - "parameters": [ - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", + "404": { + "description": "The organization could not be found.", "content": { - "application/json": { } - } - } - }, - "deprecated": true - } - }, - "/api/v1/events": { - "post": { - "tags": [ - "Event" - ], - "parameters": [ - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "202": { - "description": "Accepted", + "426": { + "description": "Unable to view stack occurrences for the suspended organization.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/projects/{projectId}/events": { - "post": { + "/api/v2/projects/{projectId}/stacks": { + "get": { "tags": [ - "Event" + "Stack" ], + "summary": "Get by project", "parameters": [ { "name": "projectId", "in": "path", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -4321,88 +6557,109 @@ } }, { - "name": "userAgent", - "in": "header", + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" - } + }, + { + "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" } }, - "required": true - }, - "responses": { - "202": { - "description": "Accepted", - "content": { - "application/json": { } + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" } - } - }, - "deprecated": true - } - }, - "/api/v2/events/{ids}": { - "delete": { - "tags": [ - "Event" - ], - "summary": "Remove", - "parameters": [ + }, { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of event identifiers.", - "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": { + "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": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[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": { - "202": { - "description": "Accepted", + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid filter.", + "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" + } + } + } + }, + "426": { + "description": "Unable to view stack occurrences for the suspended organization.", "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 event occurrences were not found." - }, - "500": { - "description": "An error occurred while deleting one or more event occurrences." } } } }, - "/api/v2/organizations": { + "/api/v2/events/count": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Get all", + "summary": "Count", "parameters": [ { "name": "filter", @@ -4412,10 +6669,34 @@ "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 a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned.", + "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" } @@ -4427,67 +6708,33 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewOrganization" - } + "$ref": "#/components/schemas/CountResult" } } } - } - } - }, - "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", + "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 creating the organization." - }, - "409": { - "description": "The organization already exists." } } } }, - "/api/v2/organizations/{id}": { + "/api/v2/organizations/{organizationId}/events/count": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Get by id", - "operationId": "GetOrganizationById", + "summary": "Count by organization", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", "description": "The identifier of the organization.", "required": true, @@ -4497,222 +6744,81 @@ } }, { - "name": "mode", + "name": "filter", "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": "A filter that controls what data is returned from the server.", "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": "aggregations", + "in": "query", + "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", "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" - } - } - } }, - "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": "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" } - } - ], - "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": "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" } }, - "400": { - "description": "An error occurred while updating the organization." - }, - "404": { - "description": "The organization 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": "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" } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/CountResult" } } } }, "400": { - "description": "One or more validation errors occurred." - }, - "404": { - "description": "One or more organizations were not found." - }, - "500": { - "description": "An error occurred while deleting one or more organizations." - } - } - } - }, - "/api/v2/organizations/invoice/{id}": { - "get": { - "tags": [ - "Organization" - ], - "summary": "Get invoice", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the invoice.", - "required": true, - "schema": { - "minLength": 10, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/Invoice" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The invoice was not found." } } } }, - "/api/v2/organizations/{id}/invoices": { + "/api/v2/projects/{projectId}/events/count": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Get invoices", + "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}$", @@ -4720,29 +6826,43 @@ } }, { - "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" + } + }, + { + "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" } } ], @@ -4752,37 +6872,57 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InvoiceGridModel" - } + "$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}/plans": { + "/api/v2/events/{id}": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Get plans", - "description": "Gets available plans for a specific organization.", + "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}$", "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" + } } ], "responses": { @@ -4791,307 +6931,210 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BillingPlan" - } + "$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": "Unable to view event occurrence due to plan limits.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{id}/change-plan": { - "post": { + "/api/v2/events": { + "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": "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": "planId", + "name": "sort", "in": "query", - "description": "Legacy query parameter: the plan identifier.", + "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": "stripeToken", + "name": "time", "in": "query", - "description": "Legacy query parameter: the Stripe token.", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { "type": "string" } }, { - "name": "last4", + "name": "offset", "in": "query", - "description": "Legacy query parameter: last four digits of the card.", + "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": "couponId", + "name": "mode", "in": "query", - "description": "Legacy query parameter: the coupon identifier.", + "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 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" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePlanResult" - } - } - } }, - "404": { - "description": "The organization was not found." - } - } - } - }, - "/api/v2/organizations/{id}/users/{email}": { - "post": { - "tags": [ - "Organization" - ], - "summary": "Add user", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" + "type": "integer", + "format": "int32" } }, { - "name": "email", - "in": "path", - "description": "The email address of the user you wish to add to your 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": { - "minLength": 1, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } + "type": "integer", + "format": "int32", + "default": 10 } }, - "404": { - "description": "The organization was not found." - }, - "426": { - "description": "Please upgrade your plan to add an additional user." - } - } - }, - "delete": { - "tags": [ - "Organization" - ], - "summary": "Remove user", - "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": "email", - "in": "path", - "description": "The email address of the user you wish to remove from your organization.", - "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", - "content": { - "application/json": { } - } + "description": "OK" }, "400": { - "description": "The error occurred while removing the user from your organization" + "description": "Invalid filter.", + "content": { + "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/{id}/data/{key}": { + }, "post": { "tags": [ - "Organization" + "Event" ], - "summary": "Add custom data", + "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": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, - "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": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", "schema": { - "minLength": 1, "type": "string" } } ], "requestBody": { - "description": "Any string value.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "type": "string" } }, - "application/*+json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "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/ProblemDetails" + } + } } }, "404": { - "description": "The organization was not found." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "delete": { + } + }, + "/api/v2/organizations/{organizationId}/events": { + "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Remove custom data", + "summary": "Get by organization", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", "description": "The identifier of the organization.", "required": true, @@ -5101,80 +7144,41 @@ } }, { - "name": "key", - "in": "path", - "description": "The key name of the data object.", - "required": true, + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", "schema": { - "minLength": 1, "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } }, - "404": { - "description": "The organization was not found." - } - } - } - }, - "/api/v2/organizations/check-name": { - "get": { - "tags": [ - "Organization" - ], - "summary": "Check for unique name", - "parameters": [ { - "name": "name", + "name": "sort", "in": "query", - "description": "The organization name to check.", + "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": { } - } }, - "201": { - "description": "The organization name is available." + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } }, - "204": { - "description": "The organization name is not available." - } - } - } - }, - "/api/v2/projects": { - "get": { - "tags": [ - "Project" - ], - "summary": "Get all", - "parameters": [ { - "name": "filter", + "name": "offset", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "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": "sort", + "name": "mode", "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": "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" } @@ -5185,8 +7189,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" } }, { @@ -5200,9 +7203,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" } @@ -5210,72 +7221,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}$", @@ -5283,17 +7274,41 @@ } }, { - "name": "filter", + "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": "A filter that controls what data is returned from the server.", + "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": "sort", + "name": "mode", "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": "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" } @@ -5304,8 +7319,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" } }, { @@ -5319,56 +7333,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" } @@ -5376,80 +7351,49 @@ ], "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, @@ -5457,274 +7401,252 @@ "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": "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": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClientConfiguration" - } - } + }, + { + "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 } - } - }, - "deprecated": true - } - }, - "/api/v2/projects/config": { - "get": { - "tags": [ - "Project" - ], - "summary": "Get configuration settings", - "parameters": [ + }, { - "name": "v", + "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 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" + } + } + } } } } }, - "/api/v2/projects/{id}/config": { + "/api/v2/events/by-ref/{referenceId}": { "get": { "tags": [ - "Project" + "Event" ], - "summary": "Get configuration settings", + "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": "v", + "name": "offset", "in": "query", - "description": "The client configuration version.", + "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" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClientConfiguration" - } - } + "type": "string" } }, - "304": { - "description": "The client configuration version is the current version." - }, - "404": { - "description": "The project could not be found." - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Add configuration value", - "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": "key", + "name": "page", "in": "query", - "description": "The key name of the configuration object.", + "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "The configuration value.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } + "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 } }, - "400": { - "description": "Invalid configuration value." - }, - "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": "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 configuration object.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { "type": "string" } @@ -5732,64 +7654,50 @@ ], "responses": { "200": { - "description": "OK", - "content": { - "application/json": { } - } + "description": "OK" }, "400": { - "description": "Invalid key value." + "description": "Invalid filter.", + "content": { + "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}/sample-data": { - "post": { + "/api/v2/projects/{projectId}/events/by-ref/{referenceId}": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Generate sample 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." - } - } - } - }, - "/api/v2/projects/{id}/reset-data": { - "get": { - "tags": [ - "Project" - ], - "summary": "Reset project data", - "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", "description": "The identifier of the project.", "required": true, @@ -5797,523 +7705,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." - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Reset project data", - "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": { + "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" + } + }, + { + "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": { - "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." + "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/users/{userId}/projects/{id}/notifications": { + "/api/v2/events/sessions/{sessionId}": { "get": { "tags": [ - "Project" + "Event" ], - "summary": "Get 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" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationSettings" - } - } + }, + { + "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." - } - } - }, - "put": { - "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" + } + }, + { + "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 could not be found." + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "post": { + } + }, + "/api/v2/projects/{projectId}/events/sessions/{sessionId}": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Set user 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": "userId", + "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" } - } - ], - "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 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": "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" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } }, - "404": { - "description": "The project could not be found." - } - } - } - }, - "/api/v2/projects/{id}/{integration}/notifications": { - "put": { - "tags": [ - "Project" - ], - "summary": "Set an integrations 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": "integration", - "in": "path", - "description": "The identifier of the integration.", - "required": true, + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", "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" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + "type": "integer", + "format": "int32" } }, - "404": { - "description": "The project or integration could not be found." + { + "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." - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Set an integrations notification 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": "integration", - "in": "path", - "description": "The identifier of the integration.", - "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" } } ], - "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", + "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." }, - "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": "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": "name", + "name": "offset", "in": "query", - "description": "The tab name.", + "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": { } + }, + { + "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 tab name." + { + "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 + } }, - "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" } @@ -6321,165 +8140,109 @@ ], "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", - "in": "query", - "description": "The project name to check.", + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "201": { - "description": "The project name is available.", - "content": { - "application/json": { } - } }, - "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": "filter", "in": "query", - "description": "The project name to check.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "organizationId", - "in": "path", - "description": "If set the check name will be scoped to a specific 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": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "201": { - "description": "The project name is available.", - "content": { - "application/json": { } - } }, - "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": "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": "key", + "name": "offset", "in": "query", - "description": "The key name of the data 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": "Any string value.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } + }, + { + "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" } }, - "required": true - }, - "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 key or value." + { + "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": "Remove custom data", - "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": "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" } @@ -6487,353 +8250,257 @@ ], "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" + } + } + } } } } }, - "/api/v2/stacks/{id}": { + "/api/v2/projects/{projectId}/events/sessions": { "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Get by id", - "operationId": "GetStackById", + "summary": "Get a list of all sessions", "parameters": [ { - "name": "id", + "name": "projectId", "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" } }, + { + "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.", + "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" - } - } + }, + { + "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" } }, - "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": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", - "type": "string" + "type": "integer", + "format": "int32" } }, { - "name": "version", + "name": "limit", "in": "query", - "description": "A version number that the stack was fixed in.", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "The stacks were marked as fixed.", - "content": { - "application/json": { } + "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." - } - } - } - }, - "/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" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "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/ProblemDetails" + } + } } - }, - "400": { - "description": "Invalid reference link." - }, - "404": { - "description": "The stack could not be found." } } } }, - "/api/v2/stacks/{id}/remove-link": { + "/api/v2/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]{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": { - "204": { - "description": "The reference link was removed.", - "content": { - "application/json": { } - } + "202": { + "description": "Accepted" }, "400": { - "description": "Invalid reference link." - }, - "404": { - "description": "The stack could not be found." - } - } - } - }, - "/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": "Description must be specified.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "One or more stacks could not be found." - } - } - }, - "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.", + "description": "The event occurrence with the specified reference id could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, - "404": { - "description": "One or more stacks could not be found." } } } }, - "/api/v2/stacks/{ids}/change-status": { + "/api/v2/projects/{projectId}/events/by-ref/{referenceId}/user-description": { "post": { "tags": [ - "Stack" + "Event" ], - "summary": "Change stack status", + "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": "ids", + "name": "referenceId", "in": "path", - "description": "A comma-delimited list of stack identifiers.", + "description": "An identifier used that references an event instance.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "pattern": "^[a-zA-Z\\d-]{8,100}$", "type": "string" } }, { - "name": "status", - "in": "query", - "description": "The status that the stack should be changed to.", - "schema": { - "$ref": "#/components/schemas/StackStatus" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } - }, - "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", + "name": "projectId", "in": "path", - "description": "The identifier of the stack.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -6841,264 +8508,431 @@ } } ], + "requestBody": { + "description": "The user description.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDescription" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted" + }, + "400": { + "description": "Description must be specified.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "The stack could not be found." - }, - "426": { - "description": "Promote to External is a premium feature used to promote an error stack to an external system." - }, - "501": { - "description": "No promoted web hooks are configured for this project." + "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}": { - "delete": { + "/api/v2/events/session/heartbeat": { + "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Remove", + "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": { - "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": { "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Get all", + "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": "filter", + "name": "type", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "description": "The event type (ie. error, log message, feature usage).", "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" + } + }, + { + "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/Stack" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{organizationId}/stacks": { + "/api/v2/events/submit/{type}": { "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Get by organization", + "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": "organizationId", + "name": "type", "in": "path", - "description": "The identifier of the organization.", + "description": "The event type (ie. error, log message, feature usage).", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "minLength": 1, "type": "string" } }, { - "name": "filter", + "name": "source", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "description": "The event source (ie. machine name, log name, feature name).", "schema": { "type": "string" } }, { - "name": "sort", + "name": "message", "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 message.", "schema": { "type": "string" } }, { - "name": "time", + "name": "reference", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { "type": "string" } }, { - "name": "offset", + "name": "date", "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 date that the event occurred on.", "schema": { "type": "string" } }, { - "name": "mode", + "name": "count", "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 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": "page", + "name": "tags", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "A list of tags used to categorize this event (comma separated).", "schema": { - "type": "integer", - "format": "int32", - "default": 1 + "type": "string" } }, { - "name": "limit", + "name": "identity", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The user\u0027s identity that the event happened to.", "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "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": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Stack" - } + "application/problem\u002Bjson": { + "schema": { + "$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", @@ -7111,345 +8945,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" - } + }, + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } + { + "name": "message", + "in": "query", + "description": "The event message.", + "schema": { + "type": "string" } }, - "400": { - "description": "An error occurred while updating the user." + { + "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" + } }, - "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": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", "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": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "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": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "page", + "name": "identityname", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The user\u0027s friendly name that the event happened to.", "schema": { - "type": "integer", - "format": "int32", - "default": 1 + "type": "string" } }, { - "name": "limit", + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "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/ViewUser" - } + "$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/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})*$", @@ -7469,129 +9303,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." } } } @@ -7817,7 +9556,7 @@ "properties": { "data": { "type": "object", - "additionalProperties": { }, + "additionalProperties": {}, "description": "Additional data associated with the aggregate." } }, @@ -7929,7 +9668,7 @@ } } }, - "JsonElement": { }, + "JsonElement": {}, "Login": { "required": [ "email", @@ -7938,8 +9677,7 @@ "type": "object", "properties": { "email": { - "type": "string", - "description": "The email address or domain username" + "type": "string" }, "password": { "maxLength": 100, @@ -7967,6 +9705,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", @@ -8030,7 +9799,7 @@ }, "slug": { "maxLength": 100, - "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "pattern": "^[a-z0-9]\u002B(?:-[a-z0-9]\u002B)*$", "type": [ "null", "string" @@ -8082,8 +9851,7 @@ "type": [ "null", "boolean" - ], - "description": "If true, the view will only be visible to the current user. Defaults to false." + ] } } }, @@ -8170,12 +9938,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." + ] } } }, @@ -8253,37 +10020,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": { @@ -8291,8 +10052,7 @@ "null", "object" ], - "additionalProperties": { }, - "description": "Used to store primitive data type custom data values for searching the event." + "additionalProperties": {} }, "type": { "maxLength": 100, @@ -8300,8 +10060,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, @@ -8309,12 +10068,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": { @@ -8325,8 +10082,7 @@ ], "items": { "type": "string" - }, - "description": "A list of tags used to categorize this event." + } }, "message": { "maxLength": 2000, @@ -8334,22 +10090,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": { @@ -8357,7 +10110,6 @@ "null", "integer" ], - "description": "The number of duplicated events.", "format": "int32" }, "data": { @@ -8365,15 +10117,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." + ] } } }, @@ -8458,6 +10208,42 @@ } } }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "status": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "instance": { + "type": [ + "null", + "string" + ] + } + } + }, "ResetPasswordModel": { "required": [ "password_reset_token", @@ -8489,8 +10275,7 @@ "type": "string" }, "email": { - "type": "string", - "description": "The email address or domain username" + "type": "string" }, "password": { "maxLength": 100, @@ -8535,31 +10320,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": { @@ -8567,85 +10347,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", @@ -8682,27 +10448,6 @@ "Discarded" ] }, - "StringStringValuesKeyValuePair": { - "required": [ - "key", - "value" - ], - "type": "object", - "properties": { - "key": { - "type": [ - "null", - "string" - ] - }, - "value": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "StringValueFromBody": { "required": [ "value" @@ -8739,136 +10484,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" + "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": [ @@ -8971,16 +10709,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": [ @@ -9011,8 +10747,7 @@ } }, "full_name": { - "type": "string", - "description": "Gets or sets the users Full Name." + "type": "string" }, "email_address": { "type": "string", @@ -9035,8 +10770,7 @@ "format": "date-time" }, "is_active": { - "type": "boolean", - "description": "Gets or sets the users active state." + "type": "boolean" }, "roles": { "uniqueItems": true, @@ -9076,8 +10810,7 @@ "null", "object" ], - "additionalProperties": { }, - "description": "Extended data entries for this user description." + "additionalProperties": {} } } }, @@ -9340,7 +11073,7 @@ "null", "object" ], - "additionalProperties": { } + "additionalProperties": {} }, "is_throttled": { "type": "boolean" @@ -9401,7 +11134,7 @@ "null", "object" ], - "additionalProperties": { } + "additionalProperties": {} }, "promoted_tabs": { "uniqueItems": true, @@ -9758,8 +11491,7 @@ "type": "boolean" }, "version": { - "type": "string", - "description": "The schema version that should be used." + "type": "string" }, "created_utc": { "type": "string", @@ -9790,12 +11522,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" } @@ -9803,31 +11535,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 066c3d7d6c..dc8f28fd39 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; @@ -32,6 +33,7 @@ namespace Exceptionless.Tests.Controllers; +[Collection("EventQueue")] public 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..60e0ca7828 --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/MinimalApiTestApp.cs @@ -0,0 +1,118 @@ +using System.Reflection; +using Exceptionless.Core.Serialization; +using Exceptionless.Web.Api; +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.ConfigureExceptionlessDefaults(); + }); + 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(_ => 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 62% rename from tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs rename to tests/Exceptionless.Tests/Controllers/OpenApiSnapshotTests.cs index dd6e08830b..42f7e17661 100644 --- a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OpenApiSnapshotTests.cs @@ -1,35 +1,20 @@ +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(); // Assert - string expectedJson = (await File.ReadAllTextAsync(baselinePath, TestCancellationToken)).Replace("\\r\\n", "\\n"); - actualJson = actualJson.Replace("\\r\\n", "\\n"); - - Assert.Equal(expectedJson, actualJson); + await SnapshotTestHelper.AssertMatchesJsonSnapshotAsync("openapi.json", actualJson, TestContext.Current.CancellationToken); } [Fact] @@ -56,6 +41,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] @@ -85,22 +78,39 @@ public async Task GetOpenApiJson_ContainsExpectedSchemasAndSecuritySchemes() Assert.Equal("access_token", token.GetProperty("name").GetString()); } - private async Task GetOpenApiDocumentAsync() + private static async Task GetOpenApiDocumentAsync() { - var response = await SendRequestAsync(r => r - .BaseUri(_server.BaseAddress) - .AppendPaths("docs", "v2", "openapi.json") - .StatusCodeShouldBeOk() - ); - - string json = await response.Content.ReadAsStringAsync(TestCancellationToken); + string json = await GetOpenApiJsonAsync(); return JsonDocument.Parse(json); } + private static async Task GetOpenApiJsonAsync() + { + await using var app = MinimalApiTestApp.Create(useTestServer: true, includeOpenApi: true); + await app.StartAsync(TestContext.Current.CancellationToken); + + 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) { var responses = operation.GetProperty("responses"); 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 93c4faf491..b564e435bb 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; @@ -1477,7 +1478,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() ); } @@ -1495,7 +1496,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() ); @@ -1513,7 +1514,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() ); } @@ -1530,7 +1531,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() ); @@ -1557,7 +1558,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 3859a0f3ac..140a2f845c 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); @@ -740,11 +745,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() ); @@ -755,7 +756,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 @@ -775,26 +776,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); @@ -824,6 +848,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 32b096f555..4825e98ce7 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -732,17 +732,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() ); @@ -863,7 +859,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() ); @@ -886,7 +882,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() ); @@ -907,7 +903,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() ); @@ -928,7 +924,7 @@ public async Task PatchAsync_UpdateSort_UpdatesSortString() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Sort = "-date" }) + .JsonPatchContent("sort", "-date") .StatusCodeShouldBeOk() ); @@ -944,7 +940,7 @@ public Task PatchAsync_NonExistentFilter_ReturnsNotFound() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", "000000000000000000000000") - .Content(new UpdateSavedView { Name = "Nope" }) + .JsonPatchContent("name", "Nope") .StatusCodeShouldBeNotFound() ); } @@ -1057,7 +1053,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() ); @@ -1305,7 +1301,7 @@ await SendRequestAsync(r => r .Patch() .AsTestOrganizationUser() .AppendPaths("saved-views", privateView.Id) - .Content(new UpdateSavedView { Name = "Hacked" }) + .JsonPatchContent("name", "Hacked") .StatusCodeShouldBeNotFound() ); @@ -1328,7 +1324,7 @@ public async Task PatchAsync_UpdateFilterDefinitions_PersistsJsonBlob() .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { FilterDefinitions = filterDefs }) + .JsonPatchContent("filter_definitions", filterDefs) .StatusCodeShouldBeOk() ); @@ -1351,7 +1347,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) ); } @@ -1370,7 +1366,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) ); } @@ -1387,7 +1383,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView { Slug = "507f1f77bcf86cd799439011" }) + .JsonPatchContent("slug", "507f1f77bcf86cd799439011") .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1404,7 +1400,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() ); } @@ -1577,7 +1573,7 @@ public Task PatchAsync_AnonymousUser_ReturnsUnauthorized() .Patch() .AsAnonymousUser() .AppendPaths("saved-views", "000000000000000000000000") - .Content(new UpdateSavedView { Name = "Hacked" }) + .JsonPatchContent("name", "Hacked") .StatusCodeShouldBeUnauthorized() ); } @@ -1739,10 +1735,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() ); } @@ -1797,10 +1790,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() ); } @@ -1817,7 +1807,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() ); } @@ -1834,7 +1824,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() ); } @@ -1851,7 +1841,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() ); } @@ -1889,7 +1879,7 @@ await SendRequestAsync(r => r .Patch() .AsTestOrganizationUser() .AppendPaths("saved-views", privateView.Id) - .Content(new UpdateSavedView { Name = "Hijacked" }) + .JsonPatchContent("name", "Hijacked") .StatusCodeShouldBeNotFound() ); @@ -1937,7 +1927,7 @@ await SendRequestAsync(r => r .Patch() .AsGlobalAdminUser() .AppendPaths("saved-views", created.Id) - .Content(new { columns }) + .JsonPatchContent("columns", columns) .StatusCodeShouldBeUnprocessableEntity() ); } @@ -1954,7 +1944,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/SerializationAuditTests.cs b/tests/Exceptionless.Tests/Controllers/SerializationAuditTests.cs new file mode 100644 index 0000000000..3ea9bc7f7a --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/SerializationAuditTests.cs @@ -0,0 +1,1107 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Elasticsearch.Net; +using Exceptionless.Core.Jobs; +using Exceptionless.Core.Models; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Foundatio.Jobs; +using Foundatio.Queues; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +/// +/// Serialization audit tests that submit payloads with different JSON casing conventions +/// to critical API endpoints and capture request/elasticsearch/response JSON files. +/// These files are saved to branch-specific folders for diffing between branches. +/// +public sealed class SerializationAuditTests : IntegrationTestsBase +{ + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly IEventRepository _eventRepository; + private readonly IStackRepository _stackRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; + private readonly IQueue _eventQueue; + private readonly ExceptionlessElasticConfiguration _esConfiguration; + private Elasticsearch.Net.IElasticLowLevelClient? _lowLevelClient; + + /// + /// Base output directory. Set AUDIT_RUN_ID env var to use a dated sub-folder, e.g.: + /// AUDIT_RUN_ID=post-fixes → audit-output/post-fixes/{branch}/ + /// If not set, falls back to audit-output/{branch}/ (original behavior). + /// + private static readonly string s_outputDir = GetOutputDir(); + + private static string GetOutputDir() + { + string root = Path.GetFullPath(Path.Combine("..", "..", "..", "..", "..", "audit-output")); + string? runId = Environment.GetEnvironmentVariable("AUDIT_RUN_ID"); + return string.IsNullOrWhiteSpace(runId) ? root : Path.Combine(root, runId); + } + + public SerializationAuditTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _jsonSerializerOptions = GetService(); + _eventRepository = GetService(); + _stackRepository = GetService(); + _organizationRepository = GetService(); + _projectRepository = GetService(); + _eventQueue = GetService>(); + _esConfiguration = GetService(); + _lowLevelClient = _esConfiguration.Client.LowLevel; + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await _eventQueue.DeleteQueueAsync(); + + var service = GetService(); + await service.CreateDataAsync(); + } + + private string GetBranchOutputDir() + { + // Detect current git branch + string branch = "feature-system-text-json-v2"; + try + { + var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "git", + Arguments = "rev-parse --abbrev-ref HEAD", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }); + if (proc is not null) + { + branch = proc.StandardOutput.ReadToEnd().Trim(); + proc.WaitForExit(); + } + } + catch { /* fallback to hardcoded */ } + + // Sanitize branch name for filesystem + branch = branch.Replace("/", "-").Replace("\\", "-"); + return Path.Combine(s_outputDir, branch); + } + + private Task SaveAuditFileAsync(string testName, string suffix, string json) + { + string dir = Path.Combine(GetBranchOutputDir(), testName); + Directory.CreateDirectory(dir); + string filePath = Path.Combine(dir, suffix); + return File.WriteAllTextAsync(filePath, PrettyPrint(json)); + } + + private static string PrettyPrint(string json) + { + try + { + var doc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return json; + } + } + + /// + /// Get the raw JSON document from Elasticsearch by ID using Nest's low-level API. + /// Works with main branch (Nest v7 / IElasticClient). + /// + private async Task GetElasticsearchDocumentAsync(string indexPattern, string id) + { + var llc = _lowLevelClient!; + + // Step 1: resolve wildcard pattern to concrete indices via _cat/indices + // (GET /{wildcard}/{id} does not work — GET requires a concrete index) + string concreteIndex = await ResolveConcreteIndexAsync(llc, indexPattern, id); + + // Step 2: direct GET on the concrete index + var getResponse = await llc.GetAsync(concreteIndex, id); + if (getResponse.Success && getResponse.Body is not null) + { + try + { + var doc = JsonDocument.Parse(getResponse.Body); + if (doc.RootElement.TryGetProperty("_source", out var source)) + return PrettyPrint(JsonSerializer.Serialize(source)); + return PrettyPrint(getResponse.Body); + } + catch { return PrettyPrint(getResponse.Body); } + } + + // Step 3: fallback search + string searchJson = "{\"query\":{\"ids\":{\"values\":[\"" + id + "\"]}},\"size\":1}"; + var searchResponse = await llc.SearchAsync( + concreteIndex, + Elasticsearch.Net.PostData.String(searchJson)); + if (searchResponse.Success && searchResponse.Body is not null) + { + try + { + var doc = JsonDocument.Parse(searchResponse.Body); + var hits = doc.RootElement.GetProperty("hits").GetProperty("hits"); + if (hits.GetArrayLength() > 0 && hits[0].TryGetProperty("_source", out var source)) + return PrettyPrint(JsonSerializer.Serialize(source)); + } + catch { /* fall through */ } + } + + return $"{{\"error\": \"Document {id} not found in {indexPattern}\"}}"; + } + + private async Task ResolveConcreteIndexAsync(Elasticsearch.Net.IElasticLowLevelClient llc, string indexPattern, string id) + { + // If already concrete (no wildcards), return as-is + if (!indexPattern.Contains('*') && !indexPattern.Contains('?')) + return indexPattern; + + // Use search across the pattern to find which index holds the document + string searchJson = "{\"query\":{\"ids\":{\"values\":[\"" + id + "\"]}},\"size\":1,\"_source\":false}"; + var resp = await llc.SearchAsync( + indexPattern, + Elasticsearch.Net.PostData.String(searchJson), + new Elasticsearch.Net.SearchRequestParameters + { + IgnoreUnavailable = true, + AllowNoIndices = true + }); + + if (resp.Success && resp.Body is not null) + { + try + { + var doc = JsonDocument.Parse(resp.Body); + var hits = doc.RootElement.GetProperty("hits").GetProperty("hits"); + if (hits.GetArrayLength() > 0) + return hits[0].GetProperty("_index").GetString() ?? indexPattern; + } + catch { /* fall through */ } + } + + return indexPattern; + } + + private string GetEventsIndexPattern() + { + // Events use daily indices like: {scope}-events-v1-{date} + return $"*-events-*"; + } + + private string GetStacksIndexPattern() + { + return $"*-stacks-*"; + } + + private string GetOrganizationsIndexPattern() + { + return $"*-organizations-*"; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // EVENT ENDPOINT TESTS - Different casing variants + // ═══════════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Events_SnakeCase_FullPayload() + { + const string testName = "events-post-snake-case"; + /* language=json */ + const string requestJson = """ + { + "type": "error", + "message": "Test error with snake_case payload", + "date": "2026-05-20T12:00:00+00:00", + "tags": ["audit", "snake_case"], + "reference_id": "audit-snake-001", + "count": 1, + "value": 42.5, + "geo": "40.7128,-74.0060", + "data": { + "custom_field": "custom_value", + "nested_object": { + "inner_key": "inner_value", + "inner_number": 123 + } + }, + "@user": { + "identity": "user@example.com", + "name": "Test User", + "data": { + "plan_name": "premium" + } + }, + "@environment": { + "o_s_name": "Windows 11", + "o_s_version": "10.0.22621", + "ip_address": "192.168.1.100", + "machine_name": "AUDIT-MACHINE", + "runtime_version": ".NET 8.0.1", + "processor_count": 8, + "total_physical_memory": 17179869184, + "available_physical_memory": 8589934592, + "process_name": "AuditApp", + "process_id": "12345", + "process_memory_size": 104857600, + "thread_id": "1", + "command_line": "AuditApp.exe --test" + }, + "@request": { + "client_ip_address": "10.0.0.100", + "http_method": "POST", + "user_agent": "AuditAgent/1.0", + "is_secure": true, + "host": "audit.localhost", + "path": "/api/audit?key=value&other=123", + "port": 443, + "query_string": { + "key": "value", + "special_chars": "" + }, + "cookies": { + "session_id": "abc123" + } + }, + "@simple_error": { + "message": "Null reference exception occurred", + "type": "System.NullReferenceException", + "stack_trace": " at Audit.Tests.Run() in AuditTests.cs:line 42\n at Audit.Main() in Program.cs:line 10" + } + } + """; + + await SaveAuditFileAsync(testName, "request.json", requestJson); + await SubmitAndCaptureEventAsync(testName, requestJson); + } + + [Fact] + public async Task Events_CamelCase_FullPayload() + { + const string testName = "events-post-camel-case"; + /* language=json */ + const string requestJson = """ + { + "type": "error", + "message": "Test error with camelCase payload", + "date": "2026-05-20T12:00:00+00:00", + "tags": ["audit", "camelCase"], + "referenceId": "audit-camel-001", + "count": 1, + "value": 42.5, + "geo": "40.7128,-74.0060", + "data": { + "customField": "custom_value", + "nestedObject": { + "innerKey": "inner_value", + "innerNumber": 123 + } + }, + "@user": { + "identity": "user@example.com", + "name": "Test User", + "data": { + "planName": "premium" + } + }, + "@environment": { + "osName": "Windows 11", + "osVersion": "10.0.22621", + "ipAddress": "192.168.1.100", + "machineName": "AUDIT-MACHINE", + "runtimeVersion": ".NET 8.0.1", + "processorCount": 8, + "totalPhysicalMemory": 17179869184, + "availablePhysicalMemory": 8589934592, + "processName": "AuditApp", + "processId": "12345", + "processMemorySize": 104857600, + "threadId": "1", + "commandLine": "AuditApp.exe --test" + }, + "@request": { + "clientIpAddress": "10.0.0.100", + "httpMethod": "POST", + "userAgent": "AuditAgent/1.0", + "isSecure": true, + "host": "audit.localhost", + "path": "/api/audit?key=value&other=123", + "port": 443, + "queryString": { + "key": "value", + "specialChars": "" + }, + "cookies": { + "sessionId": "abc123" + } + }, + "@simpleError": { + "message": "Null reference exception occurred", + "type": "System.NullReferenceException", + "stackTrace": " at Audit.Tests.Run() in AuditTests.cs:line 42\n at Audit.Main() in Program.cs:line 10" + } + } + """; + + await SaveAuditFileAsync(testName, "request.json", requestJson); + await SubmitAndCaptureEventAsync(testName, requestJson); + } + + [Fact] + public async Task Events_PascalCase_FullPayload() + { + const string testName = "events-post-pascal-case"; + /* language=json */ + const string requestJson = """ + { + "Type": "error", + "Message": "Test error with PascalCase payload", + "Date": "2026-05-20T12:00:00+00:00", + "Tags": ["audit", "PascalCase"], + "ReferenceId": "audit-pascal-001", + "Count": 1, + "Value": 42.5, + "Geo": "40.7128,-74.0060", + "Data": { + "CustomField": "custom_value", + "NestedObject": { + "InnerKey": "inner_value", + "InnerNumber": 123 + } + }, + "@user": { + "Identity": "user@example.com", + "Name": "Test User", + "Data": { + "PlanName": "premium" + } + }, + "@environment": { + "OSName": "Windows 11", + "OSVersion": "10.0.22621", + "IpAddress": "192.168.1.100", + "MachineName": "AUDIT-MACHINE", + "RuntimeVersion": ".NET 8.0.1", + "ProcessorCount": 8, + "TotalPhysicalMemory": 17179869184, + "AvailablePhysicalMemory": 8589934592, + "ProcessName": "AuditApp", + "ProcessId": "12345", + "ProcessMemorySize": 104857600, + "ThreadId": "1", + "CommandLine": "AuditApp.exe --test" + }, + "@request": { + "ClientIpAddress": "10.0.0.100", + "HttpMethod": "POST", + "UserAgent": "AuditAgent/1.0", + "IsSecure": true, + "Host": "audit.localhost", + "Path": "/api/audit?key=value&other=123", + "Port": 443, + "QueryString": { + "key": "value", + "SpecialChars": "" + }, + "Cookies": { + "SessionId": "abc123" + } + }, + "@simple_error": { + "Message": "Null reference exception occurred", + "Type": "System.NullReferenceException", + "StackTrace": " at Audit.Tests.Run() in AuditTests.cs:line 42\n at Audit.Main() in Program.cs:line 10" + } + } + """; + + await SaveAuditFileAsync(testName, "request.json", requestJson); + await SubmitAndCaptureEventAsync(testName, requestJson); + } + + [Fact] + public async Task Events_MixedCase_FullPayload() + { + const string testName = "events-post-mixed-case"; + /* language=json */ + const string requestJson = """ + { + "TYPE": "error", + "message": "Test error with MIXED casing", + "Date": "2026-05-20T12:00:00+00:00", + "TAGS": ["audit", "MIXED"], + "reference_id": "audit-mixed-001", + "COUNT": 1, + "value": 42.5, + "GEO": "40.7128,-74.0060", + "data": { + "CUSTOM_FIELD": "custom_value", + "nestedObject": { + "INNER_KEY": "inner_value", + "innerNumber": 123 + } + }, + "@user": { + "IDENTITY": "user@example.com", + "name": "Test User" + }, + "@environment": { + "O_S_NAME": "Windows 11", + "osVersion": "10.0.22621", + "IP_ADDRESS": "192.168.1.100", + "machineName": "AUDIT-MACHINE" + }, + "@request": { + "CLIENT_IP_ADDRESS": "10.0.0.100", + "httpMethod": "POST", + "USER_AGENT": "AuditAgent/1.0", + "isSecure": true, + "HOST": "audit.localhost", + "path": "/api/audit", + "PORT": 443 + }, + "@simple_error": { + "MESSAGE": "Null reference exception", + "type": "System.NullReferenceException", + "STACK_TRACE": " at Audit.Tests.Run() in AuditTests.cs:line 42" + } + } + """; + + await SaveAuditFileAsync(testName, "request.json", requestJson); + await SubmitAndCaptureEventAsync(testName, requestJson); + } + + [Fact] + public async Task Events_SpecialCharacters_Payload() + { + const string testName = "events-post-special-chars"; + /* language=json */ + const string requestJson = """ + { + "type": "error", + "message": "A potentially dangerous Request.Path value was detected from the client (&).", + "date": "2026-05-20T12:00:00+00:00", + "tags": ["