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..563dd8af9f --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs @@ -0,0 +1,887 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Repositories.Models; +using Exceptionless.Web.Api.Results; +using Foundatio.Mediator; +using Microsoft.AspNetCore.Mvc; +using Exceptionless.Web.Utility.OpenApi; +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) + endpoints.MapPatch("api/v1/error/{id:objectid}", async (string id, HttpContext httpContext, IMediator mediator, [FromBody] Delta changes) + => (await mediator.InvokeAsync(new LegacyPatchEvent(id, changes, 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() + .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() + .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() + .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() + .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() + .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) + { + var body = await ReadRequestBodyAsync(httpContext.Request); + return (await mediator.InvokeAsync(new SubmitEventByPost(projectId, apiVersion, httpContext.Request.GetClientUserAgent(), body, httpContext))).ToHttpResult(); + } + + private static async Task ReadRequestBodyAsync(HttpRequest request) + { + if (request.ContentLength is <= 0) + return []; + + using var body = new MemoryStream(); + await request.Body.CopyToAsync(body); + return body.ToArray(); + } +} + +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..07cb2a73f0 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs @@ -0,0 +1,337 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Billing; +using Exceptionless.Core.Repositories; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Mediator; +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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new OrganizationMessages.UpdateOrganizationMessage(id, changes, httpContext))).ToHttpResult()) + .Accepts>("application/json") + .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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new OrganizationMessages.UpdateOrganizationMessage(id, changes, httpContext))).ToHttpResult()) + .Accepts>("application/json") + .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..2221a7c42b --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs @@ -0,0 +1,560 @@ +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 Exceptionless.Web.Utility; +using Foundatio.Mediator; +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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectMessage(id, changes, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts>("application/json") + .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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectMessage(id, changes, httpContext))).ToHttpResult()) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts>("application/json") + .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..ab911a88a6 --- /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 Exceptionless.Web.Utility; +using Foundatio.Mediator; +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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new SavedViewMessages.UpdateSavedViewMessage(id, changes))).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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new SavedViewMessages.UpdateSavedViewMessage(id, changes))).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..246ee36633 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs @@ -0,0 +1,69 @@ +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.Caching; +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.HasValue ? HttpResults.Ok(result.Value) : HttpResults.Ok(); + }); + + group.MapPost("notifications/system", async (IMediator mediator, [FromBody] ValueFromBody message) => + { + if (String.IsNullOrWhiteSpace(message?.Value)) + return HttpResults.NotFound(); + + var result = await mediator.InvokeAsync(new PostSystemNotification(message.Value)); + return HttpResults.Ok(result); + }) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy); + + group.MapDelete("notifications/system", async (IMediator mediator) => + { + await mediator.InvokeAsync(new RemoveSystemNotification()); + 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..b7cc185482 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/TokenEndpoints.cs @@ -0,0 +1,229 @@ +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 Exceptionless.Web.Utility; +using Foundatio.Mediator; +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."] }); + + 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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new TokenMessages.UpdateTokenMessage(id, changes))).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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new TokenMessages.UpdateTokenMessage(id, changes))).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..d677042a4c --- /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 Exceptionless.Web.Utility; +using Foundatio.Mediator; +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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new UserMessages.UpdateUserMessage(id, changes))).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, [FromBody] Delta changes) + => (await mediator.InvokeAsync>(new UserMessages.UpdateUserMessage(id, changes))).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..b421c193ea --- /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 = new Dictionary(); + foreach (var error in errors) + normalizedErrors[error.Key.ToLowerUnderscoredWords()] = error.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..4530be231e --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -0,0 +1,759 @@ +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 bool _isFirstUserChecked; + 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) + { + if (_isFirstUserChecked) + return; + + bool isFirstUser = await userRepository.CountAsync() == 0; + if (isFirstUser) + user.Roles.Add(AuthorizationRoles.GlobalAdmin); + + _isFirstUserChecked = true; + } + + 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..83f5dfb4a6 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -0,0 +1,1012 @@ +using System.Text; +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Geo; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Plugins.Formatting; +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Base; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Repositories.Queries; +using Exceptionless.Core.Services; +using Exceptionless.Core.Validation; +using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Caching; +using Foundatio.Mediator; +using Foundatio.Queues; +using Foundatio.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, + 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; + + 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 Result.Forbidden("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 Result.Forbidden("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 Result.Forbidden("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 Result.Forbidden("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 Result.Forbidden("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 Result.Forbidden("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 Result.Forbidden("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 Result.Forbidden("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 Result.Forbidden("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 Result.Forbidden("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.Changes is null) + return Result.Success(); + + var changes = message.Changes; + 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 Handle(new SetEventUserDescription(message.Id, userDescription, null, httpContext)); + } + + 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 (message.Body.Length == 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 using var body = new MemoryStream(message.Body, writable: false); + 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, + }, 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()) : Result.BadRequest("One or more events could not be deleted."); + + var currentUser = httpContext.Request.GetUser(); + foreach (var projectEvents in list.GroupBy(ev => ev.ProjectId)) + { + var ev = projectEvents.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, projectEvents.Count(), ev.ProjectId); + } + + await eventRepository.RemoveAsync(list); + + if (results.Failure.Count == 0) + return new WorkInProgressResult(); + + results.Success.AddRange(list.Select(i => i.Id)); + return Result.BadRequest("Some events could not be deleted."); + } + + #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..7f40a32b7b --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -0,0 +1,897 @@ +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 Exceptionless.Web.Utility; +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.Changes.GetChangedPropertyNames().Any()) + return await MapToViewAsync(original); + + var error = await CanUpdateAsync(original, message.Changes, message.Context); + if (error is not null) + return error; + + message.Changes.Patch(original); + 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())) : Result.BadRequest("Unable to delete organizations."); + + 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, Delta changes, HttpContext httpContext) + { + var changed = changes.GetEntity(); + if (!await IsOrganizationNameAvailableInternalAsync(changed.Name, httpContext)) + return Result.BadRequest("A organization with this name already exists."); + + if (changes.GetChangedPropertyNames().Contains("OrganizationId")) + 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; + + 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; + + 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..d26b2f36c9 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -0,0 +1,725 @@ +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 Foundatio.Jobs; +using Foundatio.Queues; +using Foundatio.Mediator; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Exceptionless.Web.Utility; +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.Changes.GetChangedPropertyNames().Any()) + return await MapToViewAsync(original); + + var error = await CanUpdateAsync(original, message.Changes, message.Context); + if (error is not null) + return error; + + message.Changes.Patch(original); + 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())) : Result.BadRequest("Unable to delete projects."); + + 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; + + 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; + } + } + + 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, Delta changes, HttpContext httpContext) + { + var changed = changes.GetEntity(); + if (changes.ContainsChangedProperty(p => p.Name) && !await IsProjectNameAvailableInternalAsync(original.OrganizationId, changed.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 (changes.GetChangedPropertyNames().Contains(nameof(Project.OrganizationId))) + 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 57% rename from src/Exceptionless.Web/Controllers/SavedViewController.cs rename to src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index ecb0e2278a..d9978284e4 100644 --- a/src/Exceptionless.Web/Controllers/SavedViewController.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -4,325 +4,277 @@ 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.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 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 = 2; - 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, issues, 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.Changes.GetChangedPropertyNames().Any()) + return MapToViewModel(original); + + var error = await CanUpdateAsync(original, message.Changes); + if (error is not null) + return error; + + var changedNames = message.Changes.GetChangedPropertyNames(); + message.Changes.Patch(original); + + 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())) : Result.BadRequest("Unable to delete saved views."); + + 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, Delta changes) { - 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(); 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."); + return Result.Invalid(ValidationError.Create("name", "Name cannot be empty or whitespace.")); } if (changedNames.Contains(nameof(UpdateSavedView.Slug)) && changes.TryGetPropertyValue(nameof(UpdateSavedView.Slug), out object? slugValue) && (slugValue is not string slug || String.IsNullOrWhiteSpace(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.")); } - 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); + 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; + return Result.FromResult(lengthResult); if (changedNames.Contains(nameof(UpdateSavedView.Name)) && changes.TryGetPropertyValue(nameof(UpdateSavedView.Name), out nameValue) && nameValue 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)) @@ -331,13 +283,13 @@ protected override async Task CanUpdateAsync(SavedView origina { 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)) @@ -345,7 +297,7 @@ protected override async Task CanUpdateAsync(SavedView origina && filterDefsValue is string 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))) @@ -356,73 +308,121 @@ protected override async Task CanUpdateAsync(SavedView origina var validationError = ValidateColumns(original.ViewType, patchedChanges); 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.")); + + if (changedNames.Contains("OrganizationId")) + return Result.Invalid(ValidationError.Create("organization_id", "OrganizationId cannot be modified.")); + + 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 Result.Forbidden(permission.Message ?? "Access denied."); + } + + private static Result? ValidateStringLength(Delta changes, IEnumerable changedNames, string propertyName, int maxLength) + { + if (changedNames.Contains(propertyName) + && changes.TryGetPropertyValue(propertyName, out object? value) + && value is string s && s.Length > maxLength) + { + return Result.Invalid(ValidationError.Create(propertyName.ToLowerInvariant(), $"{propertyName} cannot exceed {maxLength} characters.")); + } + + return null; + } + + 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)]); - return await base.CanDeleteAsync(value); + 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 +433,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 +448,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 +467,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 +478,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 +486,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 +506,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 +538,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 +585,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 +600,7 @@ private SavedView CreateSystemPredefinedSavedView(SavedView source, string key, var savedView = new SavedView { OrganizationId = PredefinedSavedViewsDataSeed.SystemOrganizationId, - CreatedByUserId = CurrentUser.Id, + CreatedByUserId = GetCurrentUserId(), Version = 1 }; @@ -650,12 +650,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 +770,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 +806,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..f5cc486ece --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -0,0 +1,609 @@ +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; + + 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()) : Result.BadRequest("One or more stacks could not be deleted."); + + 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 Result.BadRequest("Some stacks could not be deleted."); + } + + 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 Result.Forbidden("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 Result.Forbidden("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..90465abfee --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs @@ -0,0 +1,112 @@ +using Exceptionless.Core; +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Web.Api.Messages; +using Foundatio.Caching; +using Foundatio.Messaging; +using Foundatio.Queues; + +namespace Exceptionless.Web.Api.Handlers; + +public class StatusHandler( + ICacheClient cacheClient, + IMessagePublisher messagePublisher, + IQueue eventQueue, + IQueue mailQueue, + IQueue notificationQueue, + IQueue webHooksQueue, + IQueue userDescriptionQueue, + AppOptions appOptions, + TimeProvider timeProvider) +{ + 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 async Task Handle(PostReleaseNotification message) + { + var notification = new ReleaseNotification + { + Critical = message.Critical, + Date = timeProvider.GetUtcNow().UtcDateTime, + Message = message.Message + }; + await messagePublisher.PublishAsync(notification); + return notification; + } + + public Task> Handle(GetSystemNotification message) + { + return cacheClient.GetAsync("system-notification"); + } + + public async Task Handle(PostSystemNotification message) + { + if (String.IsNullOrWhiteSpace(message.Message)) + return new SystemNotification { Date = DateTime.MinValue }; + + var notification = new SystemNotification + { + Date = timeProvider.GetUtcNow().UtcDateTime, + Message = message.Message + }; + await cacheClient.SetAsync("system-notification", notification); + await messagePublisher.PublishAsync(notification); + return notification; + } + + public async Task Handle(RemoveSystemNotification message) + { + await cacheClient.RemoveAsync("system-notification"); + await messagePublisher.PublishAsync(new SystemNotification { Date = timeProvider.GetUtcNow().UtcDateTime }); + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/StripeHandler.cs b/src/Exceptionless.Web/Api/Handlers/StripeHandler.cs new file mode 100644 index 0000000000..ee35d40575 --- /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) + { + _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..04eed5160e --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/TokenHandler.cs @@ -0,0 +1,382 @@ +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.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 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.Changes.GetChangedPropertyNames().Any()) + return MapToView(original); + + var error = CanUpdate(original, message.Changes); + if (error is not null) + return error; + + message.Changes.Patch(original); + 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 Result.BadRequest("Unable to delete tokens."); + } + + 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."); + + 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, Delta changes) + { + if (!HttpContext.Request.CanAccessOrganization(original.OrganizationId)) + return Result.Invalid(ValidationError.Create("organization_id", "Invalid organization id specified.")); + + if (changes.GetChangedPropertyNames().Contains(nameof(Token.OrganizationId))) + 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..76ba906c93 --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -0,0 +1,404 @@ +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.Api.Results; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Caching; +using Foundatio.Repositories; +using Foundatio.Mediator; +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.Changes.GetChangedPropertyNames().Any()) + return Result.Success(MapToView(original)); + + var permission = CanUpdate(original, message.Changes); + if (permission is not null) + return permission; + + message.Changes.Patch(original); + 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())) : Result.BadRequest("Unable to delete users."); + + 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, Delta changes) + { + // 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 (changes.GetChangedPropertyNames().Contains("OrganizationId")) + 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..2b270ad5df --- /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 Result.BadRequest("Unable to delete web hooks."); + } + + 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/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..bdc844d1ce --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/EventMessages.cs @@ -0,0 +1,42 @@ +using Exceptionless.Core.Models.Data; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; + +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, Delta Changes, 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, byte[] Body, 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..33bb07d5c5 --- /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 Exceptionless.Web.Utility; +using Microsoft.AspNetCore.Http; + +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, Delta Changes, 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..cb5939f450 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs @@ -0,0 +1,32 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Microsoft.AspNetCore.Http; + +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, Delta Changes, 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..4d59b4aeb1 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs @@ -0,0 +1,15 @@ +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; + +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, Delta Changes); +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..eef5f0dfae --- /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); +public record RemoveSystemNotification; +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..c594bdd667 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/TokenMessages.cs @@ -0,0 +1,14 @@ +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; + +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, Delta Changes); +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..2addb5d3e7 --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/UserMessages.cs @@ -0,0 +1,17 @@ +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; + +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, Delta Changes); +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..5166d91871 --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/ApiResultMapper.cs @@ -0,0 +1,107 @@ +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 +{ + public IResult MapResult(Foundatio.Mediator.IResult result) + { + return result.Status switch + { + ResultStatus.Success => MapSuccess(result), + ResultStatus.Created => MapCreated(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 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 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 ?? ""; + if (errorDict.TryGetValue(key, out var existing)) + errorDict[key] = [.. existing, error.ErrorMessage]; + else + errorDict[key] = [error.ErrorMessage]; + } + + return HttpResults.ValidationProblem(errorDict, title: result.Message ?? "Validation failed"); + } + + private static object? GetValue(Foundatio.Mediator.IResult result) + { + // Use reflection to get Value from Result + var type = result.GetType(); + var valueProp = type.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..25a630b640 --- /dev/null +++ b/src/Exceptionless.Web/Api/Results/ResultExtensions.cs @@ -0,0 +1,123 @@ +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); + + // WorkInProgressResult (and ModelActionResults) 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 ?? ""; + if (errorDict.TryGetValue(key, out var existing)) + errorDict[key] = [.. existing, error.ErrorMessage]; + else + errorDict[key] = [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/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 90100c092d..0000000000 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ /dev/null @@ -1,1471 +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; - - 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, - 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; - - 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 Task> DeleteModelsAsync(ICollection events) - { - var user = CurrentUser; - foreach (var projectEvents in events.GroupBy(ev => ev.ProjectId)) - { - var ev = projectEvents.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, projectEvents.Count(), ev.ProjectId); - } - - return base.DeleteModelsAsync(events); - } -} diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs deleted file mode 100644 index 29911323d4..0000000000 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ /dev/null @@ -1,1007 +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; - - 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; - - 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 90578a442d..0000000000 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ /dev/null @@ -1,837 +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; - - 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; - } - } - - 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 2c73d0f2fb..0000000000 --- a/src/Exceptionless.Web/Controllers/StatusController.cs +++ /dev/null @@ -1,155 +0,0 @@ -using Exceptionless.Core; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Messaging.Models; -using Exceptionless.Core.Queues.Models; -using Exceptionless.Web.Models; -using Foundatio.Caching; -using Foundatio.Messaging; -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 ICacheClient _cacheClient; - private readonly IMessagePublisher _messagePublisher; - 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( - ICacheClient cacheClient, - IMessagePublisher messagePublisher, - IQueue eventQueue, - IQueue mailQueue, - IQueue notificationQueue, - IQueue webHooksQueue, - IQueue userDescriptionQueue, - AppOptions appOptions, - TimeProvider timeProvider) : base(timeProvider) - { - _cacheClient = cacheClient; - _messagePublisher = messagePublisher; - _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 = new ReleaseNotification { Critical = critical, Date = _timeProvider.GetUtcNow().UtcDateTime, Message = message.Value }; - await _messagePublisher.PublishAsync(notification); - return Ok(notification); - } - - /// - /// Returns the current system notification messages. - /// - [HttpGet("notifications/system")] - public async Task> GetSystemNotificationAsync() - { - var notification = await _cacheClient.GetAsync("system-notification"); - if (!notification.HasValue) - return Ok(); - - return Ok(notification.Value); - } - - [HttpPost("notifications/system")] - [Consumes("application/json")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task> PostSystemNotificationAsync(ValueFromBody message) - { - if (String.IsNullOrWhiteSpace(message?.Value)) - return NotFound(); - - var notification = new SystemNotification { Date = _timeProvider.GetUtcNow().UtcDateTime, Message = message.Value }; - await _cacheClient.SetAsync("system-notification", notification); - await _messagePublisher.PublishAsync(notification); - - return Ok(notification); - } - - [HttpDelete("notifications/system")] - [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] - public async Task RemoveSystemNotificationAsync() - { - await _cacheClient.RemoveAsync("system-notification"); - await _messagePublisher.PublishAsync(new SystemNotification { Date = _timeProvider.GetUtcNow().UtcDateTime }); - 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..bbe1301a19 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 @@ -17,6 +18,8 @@ + + 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..d9465e14e5 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -1,25 +1,351 @@ 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(); + o.SerializerOptions.Converters.Add(new DeltaJsonConverterFactory()); + }); + + 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 +360,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 +433,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/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/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs index 34f000b90a..1f3f23b86c 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs @@ -30,8 +30,8 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext // Set the type to object schema.Type = JsonSchemaType.Object; - // Add properties from the inner type - schema.Properties ??= new Dictionary(); + // Clear any auto-generated properties from Delta itself (like UnknownProperties) + schema.Properties = new Dictionary(); foreach (var property in innerType.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.CanWrite)) 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/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs index 19aa9d17cf..fb13b0b3d7 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactory.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs @@ -2,15 +2,19 @@ using System.Net; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Exceptionless.Core; +using Exceptionless.Core.Extensions; using Exceptionless.Insulation.Configuration; -using Exceptionless.Web; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; 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 +90,49 @@ 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"); - } - - protected override IHostBuilder CreateHostBuilder() - { - var config = new ConfigurationBuilder() - .SetBasePath(AppContext.BaseDirectory) - .AddYamlFile("appsettings.yml", optional: false, reloadOnChange: false) - .AddInMemoryCollection(new Dictionary + 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 => { - ["AppScope"] = AppScope - }) - .Build(); - - return Web.Program.CreateHostBuilder(config, Environments.Development); + 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); + }); } public override ValueTask DisposeAsync() 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 e7ed4f142a..daeb498c31 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,223 +79,1183 @@ } ], "requestBody": { - "description": "The saved view.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NewSavedView" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewSavedView" + "$ref": "#/components/schemas/UpdateEvent" } } }, "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, issues, stream).", - "required": true, + "name": "message", + "in": "query", + "description": "The event message.", "schema": { "type": "string" } }, { - "name": "page", + "name": "reference", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { - "type": "integer", - "format": "int32", - "default": 1 + "type": "string" } }, { - "name": "limit", + "name": "date", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "description": "The number of duplicated events.", "schema": { "type": "integer", - "format": "int32", - "default": 25 + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewSavedView" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { - "description": "The organization could not be found." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/saved-views/{id}": { + "/api/v1/events/submit/{type}": { "get": { "tags": [ - "SavedView" + "Event" ], - "summary": "Get by id", - "operationId": "GetSavedViewById", "parameters": [ { - "name": "id", + "name": "type", "in": "path", - "description": "The identifier of the saved view.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "minLength": 1, "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewSavedView" - } - } - } }, - "404": { - "description": "The saved view could not be found." - } - } - }, - "patch": { - "tags": [ - "SavedView" - ], - "summary": "Update", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the saved view.", - "required": true, + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSavedView" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateSavedView" - } + }, + { + "name": "message", + "in": "query", + "description": "The event message.", + "schema": { + "type": "string" } }, - "required": true - }, + { + "name": "reference", + "in": "query", + "description": "An optional identifier to be used for referencing this event instance at a later time.", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "description": "The number of duplicated events.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/projects/{projectId}/events/submit": { + "get": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" + } + }, + { + "name": "message", + "in": "query", + "description": "The event message.", + "schema": { + "type": "string" + } + }, + { + "name": "reference", + "in": "query", + "description": "An optional identifier to be used for referencing this event instance at a later time.", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "description": "The number of duplicated events.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/projects/{projectId}/events/submit/{type}": { + "get": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" + } + }, + { + "name": "message", + "in": "query", + "description": "The event message.", + "schema": { + "type": "string" + } + }, + { + "name": "reference", + "in": "query", + "description": "An optional identifier to be used for referencing this event instance at a later time.", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "description": "The number of duplicated events.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/error": { + "post": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + } + ], + "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" + } + } + ], + "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" + } + } + ], + "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": "OK", + "description": "User Authentication Token", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$ref": "#/components/schemas/TokenResult" } } } }, - "400": { - "description": "An error occurred while updating the saved view." + "403": { + "description": "Account Creation is currently disabled", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "404": { - "description": "The saved view could not be found." + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "put": { + } + }, + "/api/v2/auth/live": { + "post": { "tags": [ - "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" - } - } + "Auth" ], + "summary": "Sign in with Microsoft", "requestBody": { - "description": "The changes", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSavedView" + "$ref": "#/components/schemas/ExternalAuthInfo" } }, - "application/*+json": { + "application/*\u002Bjson": { "schema": { - "$ref": "#/components/schemas/UpdateSavedView" + "$ref": "#/components/schemas/ExternalAuthInfo" } } }, @@ -319,78 +1263,134 @@ }, "responses": { "200": { - "description": "OK", + "description": "User Authentication Token", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$ref": "#/components/schemas/TokenResult" } } } }, - "400": { - "description": "An error occurred while updating the saved view." + "403": { + "description": "Account Creation is currently disabled", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "404": { - "description": "The saved view could not be found." + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{organizationId}/saved-views/predefined": { + "/api/v2/auth/unlink/{providerName}": { "post": { "tags": [ - "SavedView" + "Auth" ], - "summary": "Create or update predefined saved views", + "summary": "Removes an external login provider from the account", "parameters": [ { - "name": "organizationId", + "name": "providerName", "in": "path", - "description": "The identifier of the organization.", + "description": "The provider name.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "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": "The predefined saved views were created or updated.", + "description": "User Authentication Token", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewSavedView" - } + "$ref": "#/components/schemas/TokenResult" } } } }, - "404": { - "description": "The organization could not be found." + "400": { + "description": "Invalid provider name.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/saved-views/predefined": { - "get": { + "/api/v2/auth/change-password": { + "post": { "tags": [ - "SavedView" + "Auth" ], - "summary": "Get global predefined saved views as seed JSON", + "summary": "Change password", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordModel" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordModel" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "The current predefined saved views.", + "description": "User Authentication Token", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PredefinedSavedViewDefinition" - } + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -398,110 +1398,110 @@ } } }, - "/api/v2/saved-views/{id}/predefined": { - "post": { + "/api/v2/auth/forgot-password/{email}": { + "get": { "tags": [ - "SavedView" + "Auth" ], - "summary": "Save a saved view as a global predefined saved view", + "summary": "Forgot password", "parameters": [ { - "name": "id", + "name": "email", "in": "path", - "description": "The identifier of the saved view to promote.", + "description": "The email address.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "minLength": 1, "type": "string" } } ], "responses": { "200": { - "description": "The predefined saved view was created or updated.", + "description": "Forgot password email was sent." + }, + "400": { + "description": "Invalid email address.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewSavedView" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The saved view could not be found." } } - }, - "delete": { + } + }, + "/api/v2/auth/reset-password": { + "post": { "tags": [ - "SavedView" + "Auth" ], - "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": "Reset password", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordModel" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordModel" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "OK", + "description": "Password reset email was sent." + }, + "422": { + "description": "Invalid reset password model.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, - "204": { - "description": "The predefined saved view was deleted." - }, - "404": { - "description": "The saved view could not be found." } } } }, - "/api/v2/saved-views/{ids}": { - "delete": { + "/api/v2/auth/cancel-reset-password/{token}": { + "post": { "tags": [ - "SavedView" + "Auth" ], - "summary": "Remove", + "summary": "Cancel reset password", "parameters": [ { - "name": "ids", + "name": "token", "in": "path", - "description": "A comma-delimited list of saved view identifiers.", + "description": "The password reset token.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "minLength": 1, "type": "string" } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "Password reset email was cancelled." + }, + "400": { + "description": "Invalid password reset token.", "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 saved views were not found." - }, - "500": { - "description": "An error occurred while deleting one or more saved views." } } } @@ -546,20 +1546,17 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The organization could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewToken" - } + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The organization could not be found." } } }, @@ -568,7 +1565,7 @@ "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.", + "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", @@ -595,18 +1592,6 @@ } ] } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] - } } } }, @@ -622,10 +1607,34 @@ } }, "400": { - "description": "An error occurred while creating the token." + "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." + "description": "The token already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -670,20 +1679,17 @@ ], "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/ViewToken" - } + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The project could not be found." } } }, @@ -692,7 +1698,7 @@ "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.", + "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": "projectId", @@ -719,18 +1725,6 @@ } ] } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NewToken" - } - ] - } } } }, @@ -746,13 +1740,34 @@ } }, "400": { - "description": "An error occurred while creating the token." + "description": "An error occurred while creating the token.", + "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" + } + } + } }, "409": { - "description": "The token already exists." + "description": "The token already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -787,7 +1802,14 @@ } }, "404": { - "description": "The project could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -823,7 +1845,14 @@ } }, "404": { - "description": "The token could not be found." + "description": "The token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, @@ -851,11 +1880,6 @@ "schema": { "$ref": "#/components/schemas/UpdateToken" } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateToken" - } } }, "required": true @@ -872,10 +1896,24 @@ } }, "400": { - "description": "An error occurred while updating the token." + "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." + "description": "The token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, @@ -903,11 +1941,6 @@ "schema": { "$ref": "#/components/schemas/UpdateToken" } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateToken" - } } }, "required": true @@ -924,10 +1957,24 @@ } }, "400": { - "description": "An error occurred while updating the token." + "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." + "description": "The token could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -946,11 +1993,6 @@ "schema": { "$ref": "#/components/schemas/NewToken" } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewToken" - } } }, "required": true @@ -967,10 +2009,24 @@ } }, "400": { - "description": "An error occurred while creating the token." + "description": "An error occurred while creating the token.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "409": { - "description": "The token already exists." + "description": "The token already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -1005,13 +2061,34 @@ } }, "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 tokens 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 tokens." + "description": "An error occurred while deleting one or more tokens.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -1056,20 +2133,17 @@ ], "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/WebHook" - } + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The project could not be found." } } } @@ -1105,7 +2179,14 @@ } }, "404": { - "description": "The web hook could not be found." + "description": "The web hook could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -1123,11 +2204,6 @@ "schema": { "$ref": "#/components/schemas/NewWebHook" } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewWebHook" - } } }, "required": true @@ -1144,10 +2220,24 @@ } }, "400": { - "description": "An error occurred while creating the web hook." + "description": "An error occurred while creating the web hook.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "409": { - "description": "The web hook already exists." + "description": "The web hook already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -1182,208 +2272,313 @@ } }, "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 web hooks were not 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." + "description": "An error occurred while deleting one or more web hooks.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/{organizationId}/saved-views": { + "get": { + "tags": [ + "SavedView" + ], + "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": 25 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + } + }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "SavedView" + ], + "summary": "Create", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } } - } - } - }, - "/api/v2/auth/login": { - "post": { - "tags": [ - "Auth" ], - "summary": "Login", - "description": "Log in with your email address and password to generate a token scoped with your users roles.\r\n\r\n```{ \"email\": \"noreply@exceptionless.io\", \"password\": \"exceptionless\" }```\r\n\r\nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\r\nor append it onto the query string: ?access_token=MY_TOKEN\r\n\r\nPlease note that you can also use this token on the documentation site by placing it in the\r\nheaders api_key input box.", "requestBody": { + "description": "The saved view.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Login" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Login" + "$ref": "#/components/schemas/NewSavedView" } } }, "required": true }, "responses": { - "200": { - "description": "User Authentication Token", + "201": { + "description": "Created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ViewSavedView" } } } }, - "401": { - "description": "Login failed" - }, - "422": { - "description": "Validation error" - } - } - } - }, - "/api/v2/auth/intercom": { - "get": { - "tags": [ - "Auth" - ], - "summary": "Get the current user's Intercom messenger token.", - "responses": { - "200": { - "description": "Intercom messenger token", + "400": { + "description": "An error occurred while creating the saved view.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "401": { - "description": "User not logged in" + "409": { + "description": "The saved view already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "422": { - "description": "Intercom is not enabled." + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/logout": { + "/api/v2/organizations/{organizationId}/saved-views/{viewType}": { "get": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Logout the current user and remove the current access token", - "responses": { - "200": { - "description": "User successfully logged-out", - "content": { - "application/json": { } + "summary": "Get by organization and view", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } }, - "401": { - "description": "User not logged in" + { + "name": "viewType", + "in": "path", + "description": "The dashboard view type (events, issues, stream).", + "required": true, + "schema": { + "type": "string" + } }, - "403": { - "description": "Current action is not supported with user access token" - } - } - } - }, - "/api/v2/auth/signup": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Sign up", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Signup" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Signup" - } + { + "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 } }, - "required": true - }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], "responses": { "200": { - "description": "User Authentication Token", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } } } } }, - "401": { - "description": "Sign-up failed" - }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/github": { - "post": { + "/api/v2/saved-views/{id}": { + "get": { "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": "Get by id", + "operationId": "GetSavedViewById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { - "description": "User Authentication Token", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ViewSavedView" } } } }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" + "404": { + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/auth/google": { - "post": { + }, + "patch": { "tags": [ - "Auth" + "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": "Sign in with Google", "requestBody": { + "description": "The changes", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" + "$ref": "#/components/schemas/UpdateSavedView" } } }, @@ -1391,81 +2586,80 @@ }, "responses": { "200": { - "description": "User Authentication Token", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ViewSavedView" } } } }, - "403": { - "description": "Account Creation is currently disabled" + "400": { + "description": "An error occurred while updating the saved view.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "422": { - "description": "Validation error" - } - } - } - }, - "/api/v2/auth/facebook": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Sign in with Facebook", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" + "404": { + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "User Authentication Token", + "422": { + "description": "Unprocessable Entity", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "403": { - "description": "Account Creation is currently disabled" - }, - "422": { - "description": "Validation error" } } - } - }, - "/api/v2/auth/live": { - "post": { + }, + "put": { "tags": [ - "Auth" + "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": "Sign in with Microsoft", "requestBody": { + "description": "The changes", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalAuthInfo" + "$ref": "#/components/schemas/UpdateSavedView" } } }, @@ -1473,330 +2667,341 @@ }, "responses": { "200": { - "description": "User Authentication Token", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "$ref": "#/components/schemas/ViewSavedView" } } } }, - "403": { - "description": "Account Creation is currently disabled" + "400": { + "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": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "422": { - "description": "Validation error" + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/unlink/{providerName}": { + "/api/v2/organizations/{organizationId}/saved-views/predefined": { "post": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Removes an external login provider from the account", + "summary": "Create or update predefined saved views", "parameters": [ { - "name": "providerName", + "name": "organizationId", "in": "path", - "description": "The provider name.", + "description": "The identifier of the organization.", "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": "The predefined saved views were created or updated.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } } } } }, - "400": { - "description": "Invalid provider name." + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/auth/change-password": { - "post": { + "/api/v2/saved-views/predefined": { + "get": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Change password", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordModel" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordModel" - } - } - }, - "required": true - }, + "summary": "Get global predefined saved views as seed JSON", "responses": { "200": { - "description": "User Authentication Token", + "description": "The current predefined saved views.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResult" + "type": "array", + "items": { + "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + } } } } - }, - "422": { - "description": "Validation error" } } } }, - "/api/v2/auth/forgot-password/{email}": { - "get": { + "/api/v2/saved-views/{id}/predefined": { + "post": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Forgot password", + "summary": "Save a saved view as a global predefined saved view", "parameters": [ { - "name": "email", + "name": "id", "in": "path", - "description": "The email address.", + "description": "The identifier of the saved view to promote.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { "200": { - "description": "Forgot password email was sent.", + "description": "The predefined saved view was created or updated.", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } } }, - "400": { - "description": "Invalid email address." + "404": { + "description": "The saved view could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/auth/reset-password": { - "post": { + }, + "delete": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Reset password", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordModel" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordModel" - } + "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": "Password reset email was sent.", + "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/ProblemDetails" + } + } } - }, - "422": { - "description": "Invalid reset password model." } } } }, - "/api/v2/auth/cancel-reset-password/{token}": { - "post": { + "/api/v2/saved-views/{ids}": { + "delete": { "tags": [ - "Auth" + "SavedView" ], - "summary": "Cancel reset password", + "summary": "Remove", "parameters": [ { - "name": "token", + "name": "ids", "in": "path", - "description": "The password reset token.", + "description": "A comma-delimited list of saved view identifiers.", "required": true, "schema": { - "minLength": 1, + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } } ], "responses": { - "200": { - "description": "Password reset email was cancelled.", + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } + }, + "400": { + "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.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "An error occurred while deleting one or more saved views.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, - "400": { - "description": "Invalid password reset token." } } } }, - "/api/v2/events/count": { + "/api/v2/users/me": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Count", - "parameters": [ - { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "aggregations", - "in": "query", - "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", - "schema": { - "type": "string" + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewCurrentUser" + } + } } }, - { - "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 current user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } + } + }, + "delete": { + "tags": [ + "User" ], + "summary": "Delete current user", "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CountResult" + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, - "400": { - "description": "Invalid filter." + "404": { + "description": "The current user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{organizationId}/events/count": { + "/api/v2/users/{id}": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Count by organization", + "summary": "Get by id", + "operationId": "GetUserById", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the user.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "aggregations", - "in": "query", - "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", - "schema": { - "type": "string" - } - }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", - "schema": { - "type": "string" - } } ], "responses": { @@ -1805,191 +3010,160 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CountResult" + "$ref": "#/components/schemas/ViewUser" } } } }, - "400": { - "description": "Invalid filter." + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/projects/{projectId}/events/count": { - "get": { + }, + "patch": { "tags": [ - "Event" + "User" ], - "summary": "Count by project", + "summary": "Update", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the user.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "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": { - "type": "string" - } } ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUser" + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CountResult" + "$ref": "#/components/schemas/ViewUser" } } } }, "400": { - "description": "Invalid filter." + "description": "An error occurred while updating the user.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - } - }, - "/api/v2/events/{id}": { - "get": { + }, + "put": { "tags": [ - "Event" + "User" ], - "summary": "Get by id", - "operationId": "GetPersistentEventById", + "summary": "Update", "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" + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUser" + } } - } - ], + }, + "required": true + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ViewUser" } } } }, - "404": { - "description": "The event occurrence could not be found." + "400": { + "description": "An error occurred while updating the user.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "426": { - "description": "Unable to view event occurrence due to plan limits." + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events": { + "/api/v2/organizations/{organizationId}/users": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Get all", + "summary": "Get by organization", "parameters": [ { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", - "schema": { - "type": "string" - } - }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, @@ -1999,7 +3173,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 } }, { @@ -2011,22 +3186,6 @@ "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": { @@ -2037,80 +3196,98 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ViewUser" } } } } }, - "400": { - "description": "Invalid filter." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "post": { + } + }, + "/api/v2/users/{ids}": { + "delete": { "tags": [ - "Event" + "User" ], - "summary": "Submit event by POST", - "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\r\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\r\n object into the events data collection.\r\n\r\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\r\n\r\n Simple event:\r\n ```{ \"message\": \"Exceptionless is amazing!\" }```\r\n\r\n Simple log event with user identity:\r\n ```{\r\n \"type\": \"log\",\r\n \"message\": \"Exceptionless is amazing!\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\r\n}```\r\n\r\n Multiple events from string content:\r\n ```Exceptionless is amazing!\r\nExceptionless is really amazing!```\r\n\r\n Simple error:\r\n ```{\r\n \"type\": \"error\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@simple_error\": {\r\n \"message\": \"Simple Exception\",\r\n \"type\": \"System.Exception\",\r\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\r\n }\r\n}```", + "summary": "Remove", "parameters": [ { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "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" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" - } - } - }, - "required": true - }, "responses": { "202": { "description": "Accepted", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } } }, "400": { - "description": "No project id specified and no default project was found." + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "No project was found." + "description": "One or more 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": { - "get": { + "/api/v2/users/{id}/email-address/{email}": { + "post": { "tags": [ - "Event" + "User" ], - "summary": "Get 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}$", @@ -2118,124 +3295,157 @@ } }, { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "name": "email", + "in": "path", + "description": "The new email address.", + "required": true, "schema": { + "minLength": 1, "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", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEmailAddressResult" + } + } } }, - { - "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 updating the users email address.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "422": { + "description": "Validation error", + "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" + "429": { + "description": "Update email address rate limit reached.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/users/verify-email-address/{token}": { + "get": { + "tags": [ + "User" + ], + "summary": "Verify email address", + "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": "token", + "in": "path", + "description": "The token identifier.", + "required": true, "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": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." - }, - "404": { - "description": "The organization could not be found." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." + "422": { + "description": "Verify Email Address Token has expired.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events": { + "/api/v2/users/{id}/resend-verification-email": { "get": { "tags": [ - "Event" + "User" ], - "summary": "Get by project", + "summary": "Resend verification email", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the user.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + } + ], + "responses": { + "200": { + "description": "The user verification email has been sent." }, + "404": { + "description": "The user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects": { + "get": { + "tags": [ + "Project" + ], + "summary": "Get all", + "parameters": [ { "name": "filter", "in": "query", @@ -2247,31 +3457,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" } @@ -2282,7 +3468,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 } }, { @@ -2296,17 +3483,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" } @@ -2320,93 +3499,85 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PersistentEvent" + "$ref": "#/components/schemas/ViewProject" } } } } - }, - "400": { - "description": "Invalid filter." - }, - "404": { - "description": "The project could not be found." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." } } }, "post": { "tags": [ - "Event" - ], - "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}```", - "parameters": [ - { - "name": "projectId", - "in": "path", - "description": "The identifier of the project.", - "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" - } - } + "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/stacks/{stackId}/events": { + "/api/v2/organizations/{organizationId}/projects": { "get": { "tags": [ - "Event" + "Project" ], - "summary": "Get by stack", + "summary": "Get all", "parameters": [ { - "name": "stackId", + "name": "organizationId", "in": "path", - "description": "The identifier of the stack.", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -2424,31 +3595,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" } @@ -2459,7 +3606,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 } }, { @@ -2473,17 +3621,63 @@ } }, { - "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 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.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/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, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "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" } @@ -2495,194 +3689,296 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ViewProject" } } } }, - "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": { + }, + "patch": { "tags": [ - "Event" + "Project" ], - "summary": "Get by reference id", + "summary": "Update", "parameters": [ { - "name": "referenceId", + "name": "id", "in": "path", - "description": "An identifier used that references an event instance.", + "description": "The identifier of the project.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "offset", - "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": { + "schema": { + "$ref": "#/components/schemas/UpdateProject" + } } }, - { - "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/ViewProject" + } + } } }, - { - "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 project.", + "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" + } + } } }, - { - "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" + } + } } - }, + } + } + }, + "put": { + "tags": [ + "Project" + ], + "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 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" + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ViewProject" } } } }, "400": { - "description": "Invalid filter." + "description": "An error occurred while updating the project.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "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" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/by-ref/{referenceId}": { - "get": { + "/api/v2/projects/{ids}": { + "delete": { "tags": [ - "Event" + "Project" ], - "summary": "Get by reference id", + "summary": "Remove", "parameters": [ { - "name": "referenceId", + "name": "ids", "in": "path", - "description": "An identifier used that references an event instance.", + "description": "A comma-delimited list of project identifiers.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } - }, - { - "name": "projectId", - "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" + } + } } }, - { - "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": "One or more validation errors occurred.", + "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" + "404": { + "description": "One or more projects were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "500": { + "description": "An error occurred while deleting one or more projects.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects/config": { + "get": { + "tags": [ + "Project" + ], + "summary": "Get configuration settings", + "parameters": [ { - "name": "page", + "name": "v", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The client configuration version.", "schema": { "type": "integer", "format": "int32" } + } + ], + "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 + "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" } } ], @@ -2692,243 +3988,292 @@ "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" + } + } + } } } - } - }, - "/api/v2/events/sessions/{sessionId}": { - "get": { + }, + "post": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions or events by a session id", + "summary": "Add configuration value", "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": "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" } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" + } + ], + "requestBody": { + "description": "The configuration value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } } }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid configuration value.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Project" + ], + "summary": "Remove configuration value", + "parameters": [ { - "name": "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 configuration object.", + "required": true, "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + "400": { + "description": "Invalid key 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": "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 project.", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "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": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "Project" + ], + "summary": "Reset project data", + "parameters": [ { - "name": "after", - "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, - "400": { - "description": "Invalid filter." - }, - "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/users/{userId}/projects/{id}/notifications": { "get": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of by a session id", + "summary": "Get user notification settings", "parameters": [ { - "name": "sessionId", + "name": "id", "in": "path", - "description": "An identifier that represents a session of events.", + "description": "The identifier of the project.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "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" } - }, - { - "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" - } - }, - { - "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": { @@ -2937,140 +4282,198 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/NotificationSettings" } } } }, - "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": { - "get": { + }, + "put": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions", + "summary": "Set user notification settings", "parameters": [ { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "name": "userId", + "in": "path", + "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" + } + ], + "requestBody": { + "description": "The notification settings.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } } + } + }, + "responses": { + "200": { + "description": "OK" }, + "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": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "name": "userId", + "in": "path", + "description": "The identifier of the user.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + } + ], + "requestBody": { + "description": "The notification settings.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } } + } + }, + "responses": { + "200": { + "description": "OK" }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + }, + "delete": { + "tags": [ + "Project" + ], + "summary": "Remove 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" } } ], "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." } } } }, - "/api/v2/organizations/{organizationId}/events/sessions": { - "get": { + "/api/v2/projects/{id}/{integration}/notifications": { + "put": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions", + "summary": "Set an integrations notification settings", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -3078,116 +4481,139 @@ } }, { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", - "schema": { - "type": "string" - } - }, - { - "name": "sort", - "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", - "schema": { - "type": "string" - } - }, - { - "name": "time", - "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "name": "integration", + "in": "path", + "description": "The identifier of the integration.", + "required": true, "schema": { + "minLength": 1, "type": "string" } - }, - { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", - "schema": { - "type": "string" + } + ], + "requestBody": { + "description": "The notification settings.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } } + } + }, + "responses": { + "200": { + "description": "OK" }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32" + "404": { + "description": "The project or integration could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "426": { + "description": "Please upgrade your plan to enable integrations.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + }, + "post": { + "tags": [ + "Project" + ], + "summary": "Set an integrations notification settings", + "parameters": [ { - "name": "before", - "in": "query", - "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "after", - "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "name": "integration", + "in": "path", + "description": "The identifier of the integration.", + "required": true, "schema": { + "minLength": 1, "type": "string" } } ], + "requestBody": { + "description": "The notification settings.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } + } + } + }, "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "404": { + "description": "The project or integration could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." - }, - "404": { - "description": "The project could not be found." - }, "426": { - "description": "Unable to view event occurrences for the suspended organization." + "description": "Please upgrade your plan to enable integrations.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/sessions": { - "get": { + "/api/v2/projects/{id}/promotedtabs": { + "put": { "tags": [ - "Event" + "Project" ], - "summary": "Get a list of all sessions", + "summary": "Promote tab", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", "description": "The identifier of the project.", "required": true, @@ -3197,76 +4623,114 @@ } }, { - "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" } @@ -3274,168 +4738,135 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid tab name.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." - }, "404": { - "description": "The project could not be found." - }, - "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}/user-description": { - "post": { + "/api/v2/projects/check-name": { + "get": { "tags": [ - "Event" + "Project" ], - "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": "Check for unique name", "parameters": [ { - "name": "referenceId", - "in": "path", - "description": "An identifier used that references an event instance.", + "name": "name", + "in": "query", + "description": "The project name to check.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "type": "string" + } + }, + { + "name": "organizationId", + "in": "query", + "schema": { "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", - "content": { - "application/json": { } - } - }, - "400": { - "description": "Description must be specified." + "201": { + "description": "The project name is available." }, - "404": { - "description": "The event occurrence with the specified reference id could not be found." + "204": { + "description": "The project name is not available." } } } }, - "/api/v2/projects/{projectId}/events/by-ref/{referenceId}/user-description": { - "post": { + "/api/v2/organizations/{organizationId}/projects/check-name": { + "get": { "tags": [ - "Event" + "Project" ], - "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": "Check for unique name", "parameters": [ { - "name": "referenceId", + "name": "organizationId", "in": "path", - "description": "An identifier used that references an event instance.", + "description": "If set the check name will be scoped to a specific 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.", + "name": "name", + "in": "query", + "description": "The project name to check.", "required": true, "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": { } - } - }, - "400": { - "description": "Description must be specified." + "201": { + "description": "The project name is available." }, - "404": { - "description": "The event occurrence with the specified reference id could not be found." + "204": { + "description": "The project name is not available." } } } }, - "/api/v1/error/{id}": { - "patch": { + "/api/v2/projects/{id}/data": { + "post": { "tags": [ - "Event" + "Project" ], + "summary": "Add custom data", "parameters": [ { "name": "id", "in": "path", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + }, + { + "name": "key", + "in": "query", + "description": "The key name of the data object.", + "required": true, + "schema": { + "type": "string" + } } ], "requestBody": { + "description": "Any string value.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateEvent" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateEvent" + "$ref": "#/components/schemas/StringValueFromBody" } } }, @@ -3443,121 +4874,104 @@ }, "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid key or value.", "content": { - "application/json": { } + "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" + } + } } } - }, - "deprecated": true - } - }, - "/api/v2/events/session/heartbeat": { - "get": { + } + }, + "delete": { "tags": [ - "Event" + "Project" ], - "summary": "Submit heartbeat", + "summary": "Remove custom data", "parameters": [ { "name": "id", - "in": "query", - "description": "The session id or user id.", + "in": "path", + "description": "The identifier of the project.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "close", + "name": "key", "in": "query", - "description": "If true, the session will be closed.", + "description": "The key name of the data object.", + "required": true, "schema": { - "type": "boolean", - "default": false + "type": "string" } } ], "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/events/submit": { - "get": { - "tags": [ - "Event" - ], - "parameters": [ - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" - } - }, - { - "name": "parameters", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" + "description": "Invalid key or value.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } - } - ], - "responses": { - "200": { - "description": "OK", + }, + "404": { + "description": "The project could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/events/submit/{type}": { + "/api/v2/organizations": { "get": { "tags": [ - "Event" + "Organization" ], + "summary": "Get all", "parameters": [ { - "name": "type", - "in": "path", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - }, - { - "name": "userAgent", - "in": "header", + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "parameters", + "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.", "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "type": "string" } } ], @@ -3565,66 +4979,90 @@ "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewOrganization" + } + } + } } } - }, - "deprecated": true - } - }, - "/api/v1/projects/{projectId}/events/submit": { - "get": { + } + }, + "post": { "tags": [ - "Event" + "Organization" ], - "parameters": [ - { - "name": "projectId", - "in": "path", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" + "summary": "Create", + "requestBody": { + "description": "The organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewOrganization" + } } }, - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } } }, - { - "name": "parameters", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" + "400": { + "description": "An error occurred while creating the organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } - } - ], - "responses": { - "200": { - "description": "OK", + }, + "409": { + "description": "The organization already exists.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/projects/{projectId}/events/submit/{type}": { + "/api/v2/organizations/{id}": { "get": { "tags": [ - "Event" + "Organization" ], + "summary": "Get by id", + "operationId": "GetOrganizationById", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -3632,29 +5070,11 @@ } }, { - "name": "type", - "in": "path", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - }, - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" - } - }, - { - "name": "parameters", + "name": "mode", "in": "query", + "description": "If no mode is set then the a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned.", "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "type": "string" } } ], @@ -3662,295 +5082,502 @@ "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } + } + }, + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true - } - }, - "/api/v2/events/submit": { - "get": { + } + }, + "patch": { "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": "Update", "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).", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "message", - "in": "query", - "description": "The event message.", - "schema": { - "type": "string" + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewOrganization" + } } }, - { - "name": "reference", - "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", - "schema": { - "type": "string" + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } } }, - { - "name": "date", - "in": "query", - "description": "The date that the event occurred on.", - "schema": { - "type": "string" + "400": { + "description": "An error occurred while updating the organization.", + "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": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "value", - "in": "query", - "description": "The value of the event if any.", - "schema": { - "type": "number", - "format": "double" + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + }, + "put": { + "tags": [ + "Organization" + ], + "summary": "Update", + "parameters": [ { - "name": "geo", - "in": "query", - "description": "The geo coordinates where the event happened.", + "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": { + "schema": { + "$ref": "#/components/schemas/NewOrganization" + } + } }, - { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", - "schema": { - "type": "string" + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewOrganization" + } + } } }, - { - "name": "identity", - "in": "query", - "description": "The user's identity that the event happened to.", - "schema": { - "type": "string" + "400": { + "description": "An error occurred while updating the organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "identityname", - "in": "query", - "description": "The user's friendly name that the event happened to.", - "schema": { - "type": "string" + "404": { + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", - "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": "parameters", - "in": "query", - "description": "Query string parameters that control what properties are set on the event", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of organization identifiers.", + "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } } }, "400": { - "description": "No project id specified and no default project was found." + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "No project was found." + "description": "One or more organizations were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "An error occurred while deleting one or more organizations.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/events/submit/{type}": { + "/api/v2/organizations/invoice/{id}": { "get": { "tags": [ - "Event" + "Organization" ], - "summary": "Submit event type by GET", - "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage event named build with a value of 10:\r\n```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10```\r\n\r\nLog event with message, geo and extended data\r\n```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "summary": "Get invoice", "parameters": [ { - "name": "type", + "name": "id", "in": "path", - "description": "The event type (ie. error, log message, feature usage).", + "description": "The identifier of the invoice.", "required": true, "schema": { - "minLength": 1, + "minLength": 10, "type": "string" } - }, - { - "name": "source", - "in": "query", - "description": "The event source (ie. machine name, log name, feature name).", - "schema": { - "type": "string" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invoice" + } + } } }, + "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": "message", - "in": "query", - "description": "The event message.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "reference", + "name": "before", "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", + "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": "date", + "name": "after", "in": "query", - "description": "The date that the event occurred on.", + "description": "A cursor for use in pagination. after is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with obj_foo, your subsequent call can include after=obj_foo in order to fetch the next page of the list.", "schema": { "type": "string" } }, { - "name": "count", + "name": "limit", "in": "query", - "description": "The number of duplicated events.", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 12 } - }, - { - "name": "value", - "in": "query", - "description": "The value of the event if any.", - "schema": { - "type": "number", - "format": "double" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoiceGridModel" + } + } + } } }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/{id}/plans": { + "get": { + "tags": [ + "Organization" + ], + "summary": "Get plans", + "description": "Gets available plans for a specific organization.", + "parameters": [ { - "name": "geo", - "in": "query", - "description": "The geo coordinates where the event happened.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BillingPlan" + } + } + } + } }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/{id}/change-plan": { + "post": { + "tags": [ + "Organization" + ], + "summary": "Change plan", + "description": "Upgrades or downgrades the organization\u0027s plan. Accepts parameters via JSON body (preferred) or query string (legacy).", + "parameters": [ { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "identity", + "name": "planId", "in": "query", - "description": "The user's identity that the event happened to.", + "description": "Legacy query parameter: the plan identifier.", "schema": { "type": "string" } }, { - "name": "identityname", + "name": "stripeToken", "in": "query", - "description": "The user's friendly name that the event happened to.", + "description": "Legacy query parameter: the Stripe token.", "schema": { "type": "string" } }, { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "last4", + "in": "query", + "description": "Legacy query parameter: last four digits of the card.", "schema": { "type": "string" } }, { - "name": "parameters", + "name": "couponId", "in": "query", - "description": "Query string parameters that control what properties are set on the event", + "description": "Legacy query parameter: the coupon identifier.", "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "type": "string" } } ], + "requestBody": { + "description": "The plan change request (JSON body).", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + } + } + }, "responses": { "200": { "description": "OK", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePlanResult" + } + } } }, - "400": { - "description": "No project id specified and no default project was found." - }, "404": { - "description": "No project was found." + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/events/submit": { - "get": { + "/api/v2/organizations/{id}/users/{email}": { + "post": { "tags": [ - "Event" + "Organization" ], - "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "summary": "Add user", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", - "description": "The identifier of the project.", + "description": "The identifier of the organization.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -3958,104 +5585,266 @@ } }, { - "name": "source", - "in": "query", - "description": "The event source (ie. machine name, log name, feature name).", + "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" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } }, - { - "name": "message", - "in": "query", - "description": "The event message.", - "schema": { - "type": "string" + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Organization" + ], + "summary": "Remove user", + "parameters": [ { - "name": "reference", - "in": "query", - "description": "An optional identifier to be used for referencing this event instance at a later time.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "date", - "in": "query", - "description": "The date that the event occurred on.", + "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" } + } + ], + "responses": { + "200": { + "description": "OK" }, - { - "name": "count", - "in": "query", - "description": "The number of duplicated events.", - "schema": { - "type": "integer", - "format": "int32" + "400": { + "description": "The error occurred while removing the user from your organization", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/{id}/data/{key}": { + "post": { + "tags": [ + "Organization" + ], + "summary": "Add custom data", + "parameters": [ { - "name": "value", - "in": "query", - "description": "The value of the event if any.", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { - "type": "number", - "format": "double" + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" } }, { - "name": "geo", - "in": "query", - "description": "The geo coordinates where the event happened.", + "name": "key", + "in": "path", + "description": "The key name of the data object.", + "required": true, "schema": { + "minLength": 1, "type": "string" } + } + ], + "requestBody": { + "description": "Any string value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } + } }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Organization" + ], + "summary": "Remove custom data", + "parameters": [ { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", + "name": "id", + "in": "path", + "description": "The identifier of the organization.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "identity", - "in": "query", - "description": "The user's identity that the event happened to.", + "name": "key", + "in": "path", + "description": "The key name of the data object.", + "required": true, "schema": { + "minLength": 1, "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" }, + "404": { + "description": "The organization was not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/check-name": { + "get": { + "tags": [ + "Organization" + ], + "summary": "Check for unique name", + "parameters": [ { - "name": "identityname", + "name": "name", "in": "query", - "description": "The user's friendly name that the event happened to.", + "description": "The organization name to check.", + "required": true, "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "201": { + "description": "The organization name is available." }, + "204": { + "description": "The organization name is not available." + } + } + } + }, + "/api/v2/stacks/{id}": { + "get": { + "tags": [ + "Stack" + ], + "summary": "Get by id", + "operationId": "GetStackById", + "parameters": [ { - "name": "userAgent", - "in": "header", - "description": "The user agent that submitted the event.", + "name": "id", + "in": "path", + "description": "The identifier of the stack.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "parameters", + "name": "offset", "in": "query", - "description": "Query String parameters that control what properties are set on 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": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } + "type": "string" } } ], @@ -4063,310 +5852,397 @@ "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/projects/{projectId}/events/submit/{type}": { - "get": { + "/api/v2/stacks/{ids}/mark-fixed": { + "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": "Mark fixed", "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", + "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" }, - { - "name": "geo", - "in": "query", - "description": "The geo coordinates where the event happened.", - "schema": { - "type": "string" + "400": { + "description": "Invalid reference link.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", - "schema": { - "type": "string" + "404": { + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - }, + } + } + } + }, + "/api/v2/stacks/{id}/remove-link": { + "post": { + "tags": [ + "Stack" + ], + "summary": "Remove reference link", + "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" } + } + ], + "requestBody": { + "description": "The reference link.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StringValueFromBody" + } + } }, - { - "name": "identityname", - "in": "query", - "description": "The user's friendly name that the event happened to.", - "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": "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", - "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": { } - } - }, - "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/v1/error": { - "post": { + }, + "delete": { "tags": [ - "Event" + "Stack" ], + "summary": "Mark future occurrences as not critical", "parameters": [ { - "name": "userAgent", - "in": "header", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of stack identifiers.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" - } - } - }, - "required": true - }, "responses": { - "200": { - "description": "OK", + "204": { + "description": "The stacks were marked as not critical." + }, + "404": { + "description": "One or more stacks could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/events": { + "/api/v2/stacks/{ids}/change-status": { "post": { "tags": [ - "Event" + "Stack" ], + "summary": "Change stack status", "parameters": [ { - "name": "userAgent", - "in": "header", + "name": "ids", + "in": "path", + "description": "A comma-delimited list of stack identifiers.", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } + }, + { + "name": "status", + "in": "query", + "description": "The status that the stack should be changed to.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StackStatus" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" - } - } - }, - "required": true - }, "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK" + }, + "404": { + "description": "One or more stacks could not be found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "deprecated": true + } } }, - "/api/v1/projects/{projectId}/events": { + "/api/v2/stacks/{id}/promote": { "post": { "tags": [ - "Event" + "Stack" ], + "summary": "Promote to external service", "parameters": [ { - "name": "projectId", + "name": "id", "in": "path", + "description": "The identifier of the stack.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - }, - { - "name": "userAgent", - "in": "header", - "schema": { - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "string", - "example": "" - } - }, - "text/plain": { - "schema": { - "type": "string", - "example": "" + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "The stack could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } }, - "required": true - }, - "responses": { - "202": { - "description": "Accepted", + "426": { + "description": "Promote to External is a premium feature used to promote an error stack to an external system.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } + }, + "501": { + "description": "No promoted web hooks are configured for this project." } - }, - "deprecated": true + } } }, - "/api/v2/events/{ids}": { + "/api/v2/stacks/{ids}": { "delete": { "tags": [ - "Event" + "Stack" ], "summary": "Remove", "parameters": [ { "name": "ids", "in": "path", - "description": "A comma-delimited list of event identifiers.", + "description": "A comma-delimited list of stack identifiers.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", @@ -4386,24 +6262,140 @@ } }, "400": { - "description": "One or more validation errors occurred." + "description": "One or more validation errors occurred.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "One or more event occurrences were not found." + "description": "One or more stacks were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "500": { - "description": "An error occurred while deleting one or more event occurrences." + "description": "An error occurred while deleting one or more stacks.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations": { + "/api/v2/stacks": { + "get": { + "tags": [ + "Stack" + ], + "summary": "Get all", + "parameters": [ + { + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" + } + }, + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/organizations/{organizationId}/stacks": { "get": { "tags": [ - "Organization" + "Stack" ], - "summary": "Get all", + "summary": "Get by organization", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, { "name": "filter", "in": "query", @@ -4412,84 +6404,107 @@ "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" + } + }, { "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 stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { "type": "string" } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewOrganization" - } + "$ref": "#/components/schemas/ProblemDetails" } } } - } - } - }, - "post": { - "tags": [ - "Organization" - ], - "summary": "Create", - "requestBody": { - "description": "The organization.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewOrganization" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/NewOrganization" + }, + "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 stack occurrences for the suspended organization.", "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/projects/{projectId}/stacks": { "get": { "tags": [ - "Organization" + "Stack" ], - "summary": "Get by id", - "operationId": "GetOrganizationById", + "summary": "Get by project", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -4497,190 +6512,147 @@ } }, { - "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": "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" } - } - ], - "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": "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 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": "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 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": "mode", + "in": "query", + "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" } }, - "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": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", - "type": "string" + "type": "integer", + "format": "int32", + "default": 10 } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "One or more validation errors occurred." - }, "404": { - "description": "One or more organizations were not found." + "description": "The organization could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "500": { - "description": "An error occurred while deleting one or more organizations." + "426": { + "description": "Unable to view stack occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/invoice/{id}": { + "/api/v2/events/count": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Get invoice", + "summary": "Count", "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the invoice.", - "required": true, + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", + "schema": { + "type": "string" + } + }, + { + "name": "aggregations", + "in": "query", + "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", + "schema": { + "type": "string" + } + }, + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "minLength": 10, "type": "string" } } @@ -4691,26 +6663,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Invoice" + "$ref": "#/components/schemas/CountResult" } } } }, - "404": { - "description": "The invoice was not found." + "400": { + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{id}/invoices": { + "/api/v2/organizations/{organizationId}/events/count": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Get invoices", + "summary": "Count by organization", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", "description": "The identifier of the organization.", "required": true, @@ -4720,67 +6699,42 @@ } }, { - "name": "before", + "name": "filter", "in": "query", - "description": "A cursor for use in pagination. before is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with obj_bar, your subsequent call can include before=obj_bar in order to fetch the previous page of the list.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "after", + "name": "aggregations", "in": "query", - "description": "A cursor for use in pagination. after is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with obj_foo, your subsequent call can include after=obj_foo in order to fetch the next page of the list.", + "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", "schema": { "type": "string" } }, { - "name": "limit", + "name": "time", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { - "type": "integer", - "format": "int32", - "default": 12 + "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InvoiceGridModel" - } - } - } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" } }, - "404": { - "description": "The organization was not found." - } - } - } - }, - "/api/v2/organizations/{id}/plans": { - "get": { - "tags": [ - "Organization" - ], - "summary": "Get plans", - "description": "Gets available plans for a specific organization.", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } @@ -4791,32 +6745,35 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BillingPlan" - } + "$ref": "#/components/schemas/CountResult" } } } }, - "404": { - "description": "The organization was not found." + "400": { + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{id}/change-plan": { - "post": { + "/api/v2/projects/{projectId}/events/count": { + "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Change plan", - "description": "Upgrades or downgrades the organization's plan.\r\nAccepts parameters via JSON body (preferred) or query string (legacy).", + "summary": "Count by project", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -4824,131 +6781,82 @@ } }, { - "name": "planId", + "name": "filter", "in": "query", - "description": "Legacy query parameter: the plan identifier.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "stripeToken", + "name": "aggregations", "in": "query", - "description": "Legacy query parameter: the Stripe token.", + "description": "A list of values you want returned. Example: avg:value cardinality:value sum:users max:value min:value", "schema": { "type": "string" } }, { - "name": "last4", + "name": "time", "in": "query", - "description": "Legacy query parameter: last four digits of the card.", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { "type": "string" } }, { - "name": "couponId", + "name": "offset", "in": "query", - "description": "Legacy query parameter: the coupon identifier.", + "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 plan change request (JSON body).", - "content": { - "text/plain": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } - }, - "application/octet-stream": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } - }, - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChangePlanRequest" - } - ] - } + }, + { + "name": "mode", + "in": "query", + "description": "If mode is set to stack_new, then additional filters will be added.", + "schema": { + "type": "string" } } - }, + ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePlanResult" + "$ref": "#/components/schemas/CountResult" } } } }, - "404": { - "description": "The organization was not found." + "400": { + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/organizations/{id}/users/{email}": { - "post": { + "/api/v2/events/{id}": { + "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Add user", + "summary": "Get by id", + "operationId": "GetPersistentEventById", "parameters": [ { "name": "id", "in": "path", - "description": "The identifier of the organization.", + "description": "The identifier of the event.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -4956,12 +6864,18 @@ } }, { - "name": "email", - "in": "path", - "description": "The email address of the user you wish to add to your organization.", - "required": true, + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "minLength": 1, "type": "string" } } @@ -4972,209 +6886,239 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/PersistentEvent" } } } }, "404": { - "description": "The organization was not found." + "description": "The event occurrence could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "426": { - "description": "Please upgrade your plan to add an additional user." + "description": "Unable to view event occurrence due to plan limits.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "delete": { + } + }, + "/api/v2/events": { + "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Remove user", + "summary": "Get all", "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "email", - "in": "path", - "description": "The email address of the user you wish to remove from your organization.", - "required": true, + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", "schema": { - "minLength": 1, "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" } }, - "400": { - "description": "The error occurred while removing the user from your organization" + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } }, - "404": { - "description": "The organization was not found." - } - } - } - }, - "/api/v2/organizations/{id}/data/{key}": { - "post": { - "tags": [ - "Organization" - ], - "summary": "Add custom data", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "key", - "in": "path", - "description": "The key name of the data object.", - "required": true, + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + }, + { + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", + "schema": { + "type": "string" + } + }, + { + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "minLength": 1, "type": "string" } } ], - "requestBody": { - "description": "Any string value.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "404": { - "description": "The organization was not found." + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, - "delete": { + "post": { "tags": [ - "Organization" + "Event" ], - "summary": "Remove 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" } } ], "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" + } + } + } } } } }, - "/api/v2/organizations/check-name": { + "/api/v2/organizations/{organizationId}/events": { "get": { "tags": [ - "Organization" + "Event" ], - "summary": "Check for unique name", + "summary": "Get by organization", "parameters": [ { - "name": "name", + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "filter", "in": "query", - "description": "The organization name to check.", + "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" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } }, - "201": { - "description": "The organization name is available." - }, - "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 +7129,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 +7143,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 +7161,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}$", @@ -5293,370 +7224,405 @@ { "name": "sort", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -created returns the results descending by the created date.", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", "schema": { "type": "string" } }, { - "name": "page", + "name": "time", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { - "type": "integer", - "format": "int32", - "default": 1 + "type": "string" } }, { - "name": "limit", + "name": "offset", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "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", - "default": 10 + "type": "string" } }, { "name": "mode", "in": "query", - "description": "If no mode is set then the lightweight project object will be returned. If the mode is set to stats than the fully populated object will be returned.", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewProject" - } - } - } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" } }, - "404": { - "description": "The 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, + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" + "type": "integer", + "format": "int32", + "default": 10 } }, { - "name": "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" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewProject" - } - } - } }, - "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, + "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" } } ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProject" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateProject" - } - } - }, - "required": true - }, "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" } } } }, - "400": { - "description": "An error occurred while updating the project." - }, "404": { - "description": "The project could not be found." - } - } - }, - "put": { - "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." } } - } - }, - "/api/v2/projects/{ids}": { - "delete": { + }, + "post": { "tags": [ - "Project" + "Event" ], - "summary": "Remove", + "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": "ids", + "name": "projectId", "in": "path", - "description": "A comma-delimited list of project identifiers.", + "description": "The identifier of the project.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { "type": "string" } } ], "responses": { "202": { - "description": "Accepted", + "description": "Accepted" + }, + "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 projects were not found." - }, - "500": { - "description": "An error occurred while deleting one or more projects." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/project/config": { + "/api/v2/stacks/{stackId}/events": { "get": { "tags": [ - "Project" + "Event" ], + "summary": "Get by stack", "parameters": [ { - "name": "v", + "name": "stackId", + "in": "path", + "description": "The identifier of the stack.", + "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": "integer", - "format": "int32" + "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClientConfiguration" - } - } + }, + { + "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" } - } - }, - "deprecated": true - } - }, - "/api/v2/projects/config": { - "get": { - "tags": [ - "Project" - ], - "summary": "Get configuration settings", - "parameters": [ + }, { - "name": "v", + "name": "time", "in": "query", - "description": "The client configuration version.", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" + } + }, + { + "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/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": "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" } + }, + { + "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/ClientConfiguration" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "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.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "post": { + } + }, + "/api/v2/projects/{projectId}/events/by-ref/{referenceId}": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Add configuration value", + "summary": "Get by reference id", "parameters": [ { - "name": "id", + "name": "referenceId", + "in": "path", + "description": "An identifier used that references an event instance.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{8,100}$", + "type": "string" + } + }, + { + "name": "projectId", "in": "path", "description": "The identifier of the project.", "required": true, @@ -5666,65 +7632,52 @@ } }, { - "name": "key", + "name": "offset", "in": "query", - "description": "The key name of the configuration object.", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { "type": "string" } - } - ], - "requestBody": { - "description": "The configuration value.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } + }, + { + "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 configuration 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 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,588 +7685,379 @@ ], "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." - } - } - } - }, - "/api/v2/projects/{id}/sample-data": { - "post": { - "tags": [ - "Project" - ], - "summary": "Generate sample project data", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Accepted", + "description": "The project could not be found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "404": { - "description": "The project could not be found." + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{id}/reset-data": { + "/api/v2/events/sessions/{sessionId}": { "get": { "tags": [ - "Project" + "Event" ], - "summary": "Reset project data", + "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" } - } - ], - "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" } }, - "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": "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": { - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" - } - } + }, + { + "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." - } - } - } - }, - "/api/v2/users/{userId}/projects/{id}/notifications": { - "get": { - "tags": [ - "Project" - ], - "summary": "Get user notification settings", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "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": "userId", - "in": "path", - "description": "The identifier of the user.", - "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" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationSettings" - } - } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 } }, - "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": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "userId", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], - "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 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" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } }, - "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": "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": "userId", - "in": "path", - "description": "The identifier of the user.", - "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": { - "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": "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": "integration", - "in": "path", - "description": "The identifier of the integration.", - "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": { - "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "description": "The notification settings.", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/NotificationSettings" - } - ] - } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" } }, - "404": { - "description": "The project 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." + { + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", + "schema": { + "type": "string" + } }, - "404": { - "description": "The project could not be found." - } - } - }, - "post": { - "tags": [ - "Project" - ], - "summary": "Promote tab", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "name", + "name": "mode", "in": "query", - "description": "The tab name.", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32" } }, - "400": { - "description": "Invalid tab name." + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } }, - "404": { - "description": "The project could not be found." - } - } - }, - "delete": { - "tags": [ - "Project" - ], - "summary": "Demote tab", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "name", + "name": "after", "in": "query", - "description": "The tab name.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { "type": "string" } @@ -6321,153 +8065,160 @@ ], "responses": { "200": { - "description": "OK", - "content": { - "application/json": { } - } + "description": "OK" }, "400": { - "description": "Invalid tab name." - }, - "404": { - "description": "The project could not be found." + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/check-name": { + "/api/v2/organizations/{organizationId}/events/sessions": { "get": { "tags": [ - "Project" + "Event" ], - "summary": "Check for unique name", + "summary": "Get a list of all sessions", "parameters": [ { - "name": "name", + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "filter", "in": "query", - "description": "The project name to check.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } - } - ], - "responses": { - "201": { - "description": "The project name is available.", - "content": { - "application/json": { } + }, + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" } }, - "204": { - "description": "The project name is not available." - } - } - } - }, - "/api/v2/organizations/{organizationId}/projects/check-name": { - "get": { - "tags": [ - "Project" - ], - "summary": "Check for unique name", - "parameters": [ { - "name": "name", + "name": "time", "in": "query", - "description": "The project name to check.", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { "type": "string" } }, { - "name": "organizationId", - "in": "path", - "description": "If set the check name will be scoped to a specific organization.", - "required": true, + "name": "offset", + "in": "query", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "201": { - "description": "The project name is available.", - "content": { - "application/json": { } + }, + { + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "schema": { + "type": "string" + } + }, + { + "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 } }, - "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": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "key", + "name": "after", "in": "query", - "description": "The key name of the data object.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { "type": "string" } } ], - "requestBody": { - "description": "Any string value.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "OK", - "content": { - "application/json": { } - } + "description": "OK" }, "400": { - "description": "Invalid key or value." + "description": "Invalid filter.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "The project could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "delete": { + } + }, + "/api/v2/projects/{projectId}/events/sessions": { + "get": { "tags": [ - "Project" + "Event" ], - "summary": "Remove custom data", + "summary": "Get a list of all sessions", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", "description": "The identifier of the project.", "required": true, @@ -6477,214 +8228,204 @@ } }, { - "name": "key", + "name": "filter", "in": "query", - "description": "The key name of the data object.", + "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } }, - "400": { - "description": "Invalid key or value." + { + "name": "sort", + "in": "query", + "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "schema": { + "type": "string" + } }, - "404": { - "description": "The project could not be found." - } - } - } - }, - "/api/v2/stacks/{id}": { - "get": { - "tags": [ - "Stack" - ], - "summary": "Get by id", - "operationId": "GetStackById", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the stack.", - "required": true, + "name": "time", + "in": "query", + "description": "The time filter that limits the data being returned to a specific date range.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { "name": "offset", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the `time` filter. This is used for time zone support.", + "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Stack" - } - } - } }, - "404": { - "description": "The stack could not be found." - } - } - } - }, - "/api/v2/stacks/{ids}/mark-fixed": { - "post": { - "tags": [ - "Stack" - ], - "summary": "Mark fixed", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "mode", + "in": "query", + "description": "If no mode is set then the whole event object will be returned. If the mode is set to summary than a lightweight object will be returned.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "version", + "name": "page", "in": "query", - "description": "A version number that the stack was fixed in.", + "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "string" + "type": "integer", + "format": "int32" } - } - ], - "responses": { - "200": { - "description": "The stacks were marked as fixed.", - "content": { - "application/json": { } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 } }, - "404": { - "description": "One or more stacks could not be found." - } - } - } - }, - "/api/v2/stacks/{ids}/mark-snoozed": { - "post": { - "tags": [ - "Stack" - ], - "summary": "Mark the selected stacks as snoozed", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "before", + "in": "query", + "description": "The before parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "snoozeUntilUtc", + "name": "after", "in": "query", - "description": "A time that the stack should be snoozed until.", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "type": "string", - "format": "date-time" + "type": "string" } } ], "responses": { "200": { - "description": "The stacks were snoozed.", + "description": "OK" + }, + "400": { + "description": "Invalid filter.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "One or more stacks could not be found." + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "426": { + "description": "Unable to view event occurrences for the suspended organization.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/stacks/{id}/add-link": { + "/api/v2/events/by-ref/{referenceId}/user-description": { "post": { "tags": [ - "Stack" + "Event" ], - "summary": "Add reference link", + "summary": "Set user description", + "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", "parameters": [ { - "name": "id", + "name": "referenceId", "in": "path", - "description": "The identifier of the stack.", + "description": "An identifier used that references an event instance.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "pattern": "^[a-zA-Z\\d-]{8,100}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "query", + "schema": { "type": "string" } } ], "requestBody": { - "description": "The reference link.", + "description": "The user description.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/UserDescription" } } }, "required": true }, "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } - } + "202": { + "description": "Accepted" }, "400": { - "description": "Invalid reference link." + "description": "Description must be specified.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "The stack could not be found." + "description": "The event occurrence with the specified reference id could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/stacks/{id}/remove-link": { + "/api/v2/projects/{projectId}/events/by-ref/{referenceId}/user-description": { "post": { "tags": [ - "Stack" + "Event" ], - "summary": "Remove reference link", + "summary": "Set user description", + "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", "parameters": [ { - "name": "id", + "name": "referenceId", + "in": "path", + "description": "An identifier used that references an event instance.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{8,100}$", + "type": "string" + } + }, + { + "name": "projectId", "in": "path", - "description": "The identifier of the stack.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", @@ -6693,412 +8434,430 @@ } ], "requestBody": { - "description": "The reference link.", + "description": "The user description.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/UserDescription" } } }, "required": true }, "responses": { - "204": { - "description": "The reference link was removed.", - "content": { - "application/json": { } - } + "202": { + "description": "Accepted" }, "400": { - "description": "Invalid reference link." + "description": "Description must be specified.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { - "description": "The stack could not be found." + "description": "The event occurrence with the specified reference id could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/stacks/{ids}/mark-critical": { - "post": { + "/api/v2/events/session/heartbeat": { + "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Mark future occurrences as critical", + "summary": "Submit heartbeat", "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "id", + "in": "query", + "description": "The session id or user id.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } + }, + { + "name": "close", + "in": "query", + "description": "If true, the session will be closed.", + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "One or more stacks could not be found." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } - }, - "delete": { + } + }, + "/api/v2/events/submit": { + "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Mark future occurrences as not critical", + "summary": "Submit event by GET", + "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\n\nFeature usage named build with a duration of 10:\n\u0060\u0060\u0060/events/submit?access_token=YOUR_API_KEY\u0026type=usage\u0026source=build\u0026value=10\u0060\u0060\u0060\n\nLog with message, geo and extended data\n\u0060\u0060\u0060/events/submit?access_token=YOUR_API_KEY\u0026type=log\u0026message=Hello World\u0026source=server01\u0026geo=32.85,-96.9613\u0026randomproperty=true\u0060\u0060\u0060", "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "type", + "in": "query", + "description": "The event type (ie. error, log message, feature usage).", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } - } - ], - "responses": { - "204": { - "description": "The stacks were marked as not critical.", - "content": { - "application/json": { } + }, + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" } }, - "404": { - "description": "One or more stacks could not be found." - } - } - } - }, - "/api/v2/stacks/{ids}/change-status": { - "post": { - "tags": [ - "Stack" - ], - "summary": "Change stack status", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "message", + "in": "query", + "description": "The event message.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } }, { - "name": "status", + "name": "reference", "in": "query", - "description": "The status that the stack should be changed to.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { - "$ref": "#/components/schemas/StackStatus" + "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", + "schema": { + "type": "string" } }, - "404": { - "description": "One or more stacks could not be found." - } - } - } - }, - "/api/v2/stacks/{id}/promote": { - "post": { - "tags": [ - "Stack" - ], - "summary": "Promote to external service", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the stack.", - "required": true, + "name": "count", + "in": "query", + "description": "The number of duplicated events.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "value", + "in": "query", + "description": "The value of the event if any.", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { } + }, + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" } }, - "404": { - "description": "The stack could not be found." + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } }, - "426": { - "description": "Promote to External is a premium feature used to promote an error stack to an external system." + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } }, - "501": { - "description": "No promoted web hooks are configured for this project." - } - } - } - }, - "/api/v2/stacks/{ids}": { - "delete": { - "tags": [ - "Stack" - ], - "summary": "Remove", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of stack identifiers.", - "required": true, + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } + }, + { + "name": "parameters", + "in": "query", + "description": "Query string parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "One or more validation errors occurred." - }, "404": { - "description": "One or more stacks were not found." - }, - "500": { - "description": "An error occurred while deleting one or more stacks." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/stacks": { + "/api/v2/events/submit/{type}": { "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Get all", + "summary": "Submit event type by GET", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage event named build with a value of 10:\n\u0060\u0060\u0060/events/submit/usage?access_token=YOUR_API_KEY\u0026source=build\u0026value=10\u0060\u0060\u0060\n\nLog event with message, geo and extended data\n\u0060\u0060\u0060/events/submit/log?access_token=YOUR_API_KEY\u0026message=Hello World\u0026source=server01\u0026geo=32.85,-96.9613\u0026randomproperty=true\u0060\u0060\u0060", "parameters": [ { - "name": "filter", - "in": "query", - "description": "A filter that controls what data is returned from the server.", + "name": "type", + "in": "path", + "description": "The event type (ie. error, log message, feature usage).", + "required": true, "schema": { + "minLength": 1, "type": "string" } }, { - "name": "sort", + "name": "source", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "description": "The event source (ie. machine name, log name, feature name).", "schema": { "type": "string" } }, { - "name": "time", + "name": "message", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "The event message.", "schema": { "type": "string" } }, { - "name": "offset", + "name": "reference", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { "type": "string" } }, { - "name": "mode", + "name": "date", "in": "query", - "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "The date that the event occurred on.", "schema": { "type": "string" } }, { - "name": "page", + "name": "count", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The number of duplicated events.", "schema": { "type": "integer", - "format": "int32", - "default": 1 + "format": "int32" } }, { - "name": "limit", + "name": "value", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Stack" - } - } - } - } - }, - "400": { - "description": "Invalid filter." - } - } - } - }, - "/api/v2/organizations/{organizationId}/stacks": { - "get": { - "tags": [ - "Stack" - ], - "summary": "Get by organization", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "description": "The value of the event if any.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" + "type": "number", + "format": "double" } }, { - "name": "filter", + "name": "geo", "in": "query", - "description": "A filter that controls what data is returned from the server.", + "description": "The geo coordinates where the event happened.", "schema": { "type": "string" } }, { - "name": "sort", + "name": "tags", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "description": "A list of tags used to categorize this event (comma separated).", "schema": { "type": "string" } }, { - "name": "time", + "name": "identity", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "The user\u0027s identity that the event happened to.", "schema": { "type": "string" } }, { - "name": "offset", + "name": "identityname", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "The user\u0027s friendly name that the event happened to.", "schema": { "type": "string" } }, { - "name": "mode", - "in": "query", - "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", "schema": { "type": "string" } }, { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "limit", + "name": "parameters", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "Query string parameters that control what properties are set on the event", "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Stack" - } + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Invalid filter." - }, "404": { - "description": "The organization could not be found." - }, - "426": { - "description": "Unable to view stack occurrences for the suspended organization." + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/projects/{projectId}/stacks": { + "/api/v2/projects/{projectId}/events/submit": { "get": { "tags": [ - "Stack" + "Event" ], - "summary": "Get by project", + "summary": "Submit event type by GET for a specific project", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n\u0060\u0060\u0060/projects/{projectId}/events/submit?access_token=YOUR_API_KEY\u0026type=usage\u0026source=build\u0026value=10\u0060\u0060\u0060\n\nLog with message, geo and extended data\n\u0060\u0060\u0060/projects/{projectId}/events/submit?access_token=YOUR_API_KEY\u0026type=log\u0026message=Hello World\u0026source=server01\u0026geo=32.85,-96.9613\u0026randomproperty=true\u0060\u0060\u0060", "parameters": [ { "name": "projectId", @@ -7111,487 +8870,392 @@ } }, { - "name": "filter", + "name": "type", "in": "query", - "description": "A filter that controls what data is returned from the server.", "schema": { "type": "string" } }, { - "name": "sort", + "name": "source", "in": "query", - "description": "Controls the sort order that the data is returned in. In this example -date returns the results descending by date.", + "description": "The event source (ie. machine name, log name, feature name).", "schema": { "type": "string" } }, { - "name": "time", + "name": "message", "in": "query", - "description": "The time filter that limits the data being returned to a specific date range.", + "description": "The event message.", "schema": { "type": "string" } }, { - "name": "offset", + "name": "reference", "in": "query", - "description": "The time offset in minutes that controls what data is returned based on the time filter. This is used for time zone support.", + "description": "An optional identifier to be used for referencing this event instance at a later time.", "schema": { "type": "string" } }, { - "name": "mode", + "name": "date", "in": "query", - "description": "If no mode is set then the whole stack object will be returned. If the mode is set to summary than a lightweight object will be returned.", + "description": "The date that the event occurred on.", "schema": { "type": "string" } }, { - "name": "page", + "name": "count", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The number of duplicated events.", "schema": { "type": "integer", - "format": "int32", - "default": 1 + "format": "int32" } }, { - "name": "limit", + "name": "value", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The value of the event if any.", "schema": { - "type": "integer", - "format": "int32", - "default": 10 + "type": "number", + "format": "double" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Stack" - } - } - } + }, + { + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", + "schema": { + "type": "string" } }, - "400": { - "description": "Invalid filter." + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } }, - "404": { - "description": "The organization could not be found." + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } }, - "426": { - "description": "Unable to view stack occurrences for the suspended organization." + { + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query String parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } } - } - } - }, - "/api/v2/users/me": { - "get": { - "tags": [ - "User" ], - "summary": "Get current user", "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/ViewCurrentUser" + "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { - "description": "The current user could not be found." - } - } - }, - "delete": { - "tags": [ - "User" - ], - "summary": "Delete current user", - "responses": { - "202": { - "description": "Accepted", + "description": "No project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" + "$ref": "#/components/schemas/ProblemDetails" } } } - }, - "404": { - "description": "The current user could not be found." } } } }, - "/api/v2/users/{id}": { + "/api/v2/projects/{projectId}/events/submit/{type}": { "get": { "tags": [ - "User" + "Event" ], - "summary": "Get by id", - "operationId": "GetUserById", + "summary": "Submit event type by GET for a specific project", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n\u0060\u0060\u0060/projects/{projectId}/events/submit?access_token=YOUR_API_KEY\u0026type=usage\u0026source=build\u0026value=10\u0060\u0060\u0060\n\nLog with message, geo and extended data\n\u0060\u0060\u0060/projects/{projectId}/events/submit?access_token=YOUR_API_KEY\u0026type=log\u0026message=Hello World\u0026source=server01\u0026geo=32.85,-96.9613\u0026randomproperty=true\u0060\u0060\u0060", "parameters": [ { - "name": "id", + "name": "projectId", "in": "path", - "description": "The identifier of the user.", + "description": "The identifier of the project.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } - } }, - "404": { - "description": "The user could not be found." - } - } - }, - "patch": { - "tags": [ - "User" - ], - "summary": "Update", - "parameters": [ { - "name": "id", + "name": "type", "in": "path", - "description": "The identifier of the user.", + "description": "The event type (ie. error, log message, feature usage).", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", + "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateUser" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateUser" - } - } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } + { + "name": "source", + "in": "query", + "description": "The event source (ie. machine name, log name, feature name).", + "schema": { + "type": "string" } }, - "400": { - "description": "An error occurred while updating the user." - }, - "404": { - "description": "The user could not be found." - } - } - }, - "put": { - "tags": [ - "User" - ], - "summary": "Update", - "parameters": [ { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, + "name": "message", + "in": "query", + "description": "The event message.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The changes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateUser" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateUser" - } - } }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewUser" - } - } + { + "name": "reference", + "in": "query", + "description": "An optional identifier to be used for referencing this event instance at a later time.", + "schema": { + "type": "string" } }, - "400": { - "description": "An error occurred while updating the user." - }, - "404": { - "description": "The user could not be found." - } - } - } - }, - "/api/v2/organizations/{organizationId}/users": { - "get": { - "tags": [ - "User" - ], - "summary": "Get by organization", - "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "The identifier of the organization.", - "required": true, + "name": "date", + "in": "query", + "description": "The date that the event occurred on.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "page", + "name": "count", "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", + "description": "The number of duplicated events.", "schema": { "type": "integer", - "format": "int32", - "default": 1 + "format": "int32" } }, { - "name": "limit", + "name": "value", "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "description": "The value of the event if any.", "schema": { - "type": "integer", - "format": "int32", - "default": 10 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewUser" - } - } - } + "type": "number", + "format": "double" } }, - "404": { - "description": "The organization could not be found." - } - } - } - }, - "/api/v2/users/{ids}": { - "delete": { - "tags": [ - "User" - ], - "summary": "Remove", - "parameters": [ { - "name": "ids", - "in": "path", - "description": "A comma-delimited list of user identifiers.", - "required": true, + "name": "geo", + "in": "query", + "description": "The geo coordinates where the event happened.", "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" - } - } - } }, - "400": { - "description": "One or more validation errors occurred." + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } }, - "404": { - "description": "One or more users were not found." + { + "name": "identity", + "in": "query", + "description": "The user\u0027s identity that the event happened to.", + "schema": { + "type": "string" + } }, - "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, + "name": "identityname", + "in": "query", + "description": "The user\u0027s friendly name that the event happened to.", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "email", - "in": "path", - "description": "The new email address.", - "required": true, + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", "schema": { - "minLength": 1, "type": "string" } + }, + { + "name": "parameters", + "in": "query", + "description": "Query String parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } } ], "responses": { "200": { - "description": "OK", + "description": "OK" + }, + "400": { + "description": "No project id specified and no default project was found.", "content": { - "application/json": { + "application/problem\u002Bjson": { "schema": { - "$ref": "#/components/schemas/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." + "404": { + "description": "No project was found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v2/users/verify-email-address/{token}": { - "get": { + "/api/v2/events/{ids}": { + "delete": { "tags": [ - "User" + "Event" ], - "summary": "Verify email address", + "summary": "Remove", "parameters": [ { - "name": "token", + "name": "ids", "in": "path", - "description": "The token identifier.", + "description": "A comma-delimited list of event identifiers.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{24,40}$", + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "202": { + "description": "Accepted", "content": { - "application/json": { } + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } } }, - "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.", + "400": { + "description": "One or more validation errors occurred.", "content": { - "application/json": { } + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, "404": { - "description": "The user could not be found." + "description": "One or more event occurrences were not found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "An error occurred while deleting one or more event occurrences.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -7817,7 +9481,7 @@ "properties": { "data": { "type": "object", - "additionalProperties": { }, + "additionalProperties": {}, "description": "Additional data associated with the aggregate." } }, @@ -7929,7 +9593,7 @@ } } }, - "JsonElement": { }, + "JsonElement": {}, "Login": { "required": [ "email", @@ -7938,8 +9602,7 @@ "type": "object", "properties": { "email": { - "type": "string", - "description": "The email address or domain username" + "type": "string" }, "password": { "maxLength": 100, @@ -8030,7 +9693,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 +9745,7 @@ "type": [ "null", "boolean" - ], - "description": "If true, the view will only be visible to the current user. Defaults to false." + ] } } }, @@ -8170,12 +9832,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 +9914,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 +9946,7 @@ "null", "object" ], - "additionalProperties": { }, - "description": "Used to store primitive data type custom data values for searching the event." + "additionalProperties": {} }, "type": { "maxLength": 100, @@ -8300,8 +9954,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 +9962,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 +9976,7 @@ ], "items": { "type": "string" - }, - "description": "A list of tags used to categorize this event." + } }, "message": { "maxLength": 2000, @@ -8334,22 +9984,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 +10004,6 @@ "null", "integer" ], - "description": "The number of duplicated events.", "format": "int32" }, "data": { @@ -8365,15 +10011,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 +10102,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 +10169,7 @@ "type": "string" }, "email": { - "type": "string", - "description": "The email address or domain username" + "type": "string" }, "password": { "maxLength": 100, @@ -8535,31 +10214,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 +10241,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 +10342,6 @@ "Discarded" ] }, - "StringStringValuesKeyValuePair": { - "required": [ - "key", - "value" - ], - "type": "object", - "properties": { - "key": { - "type": [ - "null", - "string" - ] - }, - "value": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "StringValueFromBody": { "required": [ "value" @@ -8755,8 +10394,7 @@ "string" ] } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, "UpdateProject": { "type": "object", @@ -8767,8 +10405,7 @@ "delete_bot_data_enabled": { "type": "boolean" } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, "UpdateSavedView": { "type": "object", @@ -8840,8 +10477,7 @@ "boolean" ] } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, "UpdateToken": { "type": "object", @@ -8855,8 +10491,7 @@ "string" ] } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, "UpdateUser": { "type": "object", @@ -8867,8 +10502,7 @@ "email_notifications_enabled": { "type": "boolean" } - }, - "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + } }, "UsageHourInfo": { "required": [ @@ -8961,16 +10595,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": [ @@ -9001,8 +10633,7 @@ } }, "full_name": { - "type": "string", - "description": "Gets or sets the users Full Name." + "type": "string" }, "email_address": { "type": "string", @@ -9025,8 +10656,7 @@ "format": "date-time" }, "is_active": { - "type": "boolean", - "description": "Gets or sets the users active state." + "type": "boolean" }, "roles": { "uniqueItems": true, @@ -9066,8 +10696,7 @@ "null", "object" ], - "additionalProperties": { }, - "description": "Extended data entries for this user description." + "additionalProperties": {} } } }, @@ -9330,7 +10959,7 @@ "null", "object" ], - "additionalProperties": { } + "additionalProperties": {} }, "is_throttled": { "type": "boolean" @@ -9391,7 +11020,7 @@ "null", "object" ], - "additionalProperties": { } + "additionalProperties": {} }, "promoted_tabs": { "uniqueItems": true, @@ -9748,8 +11377,7 @@ "type": "boolean" }, "version": { - "type": "string", - "description": "The schema version that should be used." + "type": "string" }, "created_utc": { "type": "string", @@ -9780,12 +11408,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" } @@ -9793,31 +11421,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 913036dc60..4d8e8c59fc 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -32,6 +32,7 @@ namespace Exceptionless.Tests.Controllers; +[Collection("EventQueue")] public class EventControllerTests : IntegrationTestsBase { private readonly JsonSerializerOptions _jsonSerializerOptions; diff --git a/tests/Exceptionless.Tests/Controllers/MinimalApiTestApp.cs b/tests/Exceptionless.Tests/Controllers/MinimalApiTestApp.cs new file mode 100644 index 0000000000..b72e398b11 --- /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 73% rename from tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs rename to tests/Exceptionless.Tests/Controllers/OpenApiSnapshotTests.cs index dd6e08830b..d325fdbf2f 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] @@ -85,18 +70,27 @@ 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"); diff --git a/tests/Exceptionless.Tests/Controllers/SerializationAuditTests.cs b/tests/Exceptionless.Tests/Controllers/SerializationAuditTests.cs new file mode 100644 index 0000000000..83b305079f --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/SerializationAuditTests.cs @@ -0,0 +1,1091 @@ +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": ["