diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..51ed46c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,147 @@ +name: "?? Bug Report" +description: "Report a bug or issue with SwaggerUI.Themes or SwaggerUI.Extensions" +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thank you for reporting an issue! Please fill out the information below to help us investigate. + + - type: dropdown + id: package_type + attributes: + label: "Package Type" + description: "Which type of package are you reporting an issue with?" + options: + - "Theme (AspNetCore.SwaggerUI.Themes)" + - "Theme (NSwag.AspNetCore.Themes)" + - "Extension (AspNetCore.SwaggerUI.Extensions)" + - "Extension (NSwag.AspNetCore.Extensions)" + - "Common (AspNetCore.Swagger.Themes.Common)" + - "Unsure" + validations: + required: true + + - type: dropdown + id: integration + attributes: + label: "Integration Framework" + description: "Which Swagger integration are you using?" + options: + - "Swashbuckle.AspNetCore" + - "NSwag.AspNetCore" + - "Both" + - "Not applicable" + validations: + required: true + + - type: input + id: dotnet_version + attributes: + label: ".NET Version" + description: "Which .NET version are you using? (e.g., 8.0, 9.0, 10.0)" + placeholder: "e.g., 8.0" + validations: + required: true + + - type: input + id: package_version + attributes: + label: "Package Version" + description: "Which version of the package are you using?" + placeholder: "e.g., 3.0.0" + validations: + required: true + + - type: textarea + id: description + attributes: + label: "Description" + description: "Clear and concise description of the bug" + placeholder: "What happened?" + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: "Steps to Reproduce" + description: "Step-by-step instructions to reproduce the issue" + placeholder: | + 1. Configure the package with... + 2. Navigate to... + 3. See error... + validations: + required: true + + - type: textarea + id: expected_behavior + attributes: + label: "Expected Behavior" + description: "What should happen?" + placeholder: "Describe the expected behavior" + validations: + required: true + + - type: textarea + id: actual_behavior + attributes: + label: "Actual Behavior" + description: "What actually happens?" + placeholder: "Describe what actually happens" + validations: + required: true + + - type: dropdown + id: browser + attributes: + label: "Browser (if UI-related)" + description: "Which browser are you using? (Only relevant for theme/UI issues)" + options: + - "Chrome" + - "Firefox" + - "Safari" + - "Edge" + - "Other" + - "Not applicable" + + - type: textarea + id: code_snippet + attributes: + label: "Code Snippet" + description: "Relevant code that demonstrates the issue (optional)" + render: csharp + placeholder: | + // Your code here + app.UseSwaggerUI(Theme.Dark); + + - type: textarea + id: error_logs + attributes: + label: "Error Logs or Stack Trace" + description: "Any relevant error messages or stack traces (optional)" + render: plain_text + + - type: textarea + id: screenshots + attributes: + label: "Screenshots" + description: "Screenshots showing the issue (optional, for UI-related issues)" + + - type: textarea + id: additional_context + attributes: + label: "Additional Context" + description: "Any other information that might be helpful" + placeholder: "Add any other context about the problem here" + + - type: checkboxes + id: checklist + attributes: + label: "Verification" + options: + - label: "I have searched existing issues and found no duplicates" + required: true + - label: "I have provided clear reproduction steps" + required: true + - label: "I have included relevant error messages or logs" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a49eab2..7557f1f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,8 @@ -blank_issues_enabled: true \ No newline at end of file +ο»Ώblank_issues_enabled: false +contact_links: + - name: "❓ Ask a Question" + url: "https://github.com/teociaps/SwaggerUI.Themes/discussions" + about: "Have a question or want to discuss? Use GitHub Discussions!" + - name: "πŸ“š View Documentation" + url: "https://github.com/teociaps/SwaggerUI.Themes/wiki" + about: "Check the wiki for comprehensive documentation and guides" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..2cea18d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,105 @@ +name: "? Feature Request" +description: "Suggest a new feature or enhancement for SwaggerUI.Themes or SwaggerUI.Extensions" +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thank you for suggesting a feature! Please provide details to help us understand your idea. + + - type: dropdown + id: feature_type + attributes: + label: "Feature Type" + description: "What category is your feature request for?" + options: + - "Theme (visual/styling enhancement)" + - "Extension (functional capability)" + - "Documentation" + - "Developer Experience" + - "Other" + validations: + required: true + + - type: dropdown + id: package_scope + attributes: + label: "Package Scope" + description: "Which packages should this feature apply to?" + options: + - "Swashbuckle only (AspNetCore.SwaggerUI.*)" + - "NSwag only (NSwag.AspNetCore.*)" + - "Both Swashbuckle and NSwag" + - "Common/Shared" + - "Not sure" + validations: + required: true + + - type: textarea + id: problem + attributes: + label: "Problem Statement" + description: "What problem does this feature solve? What is the use case?" + placeholder: | + Current situation: ... + Problem: ... + Why this matters: ... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: "Proposed Solution" + description: "Describe how you envision this feature working" + placeholder: | + Describe the ideal behavior/UI/API + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: "Alternative Solutions" + description: "Are there any alternative approaches? (optional)" + placeholder: "Have you considered other ways to solve this?" + + - type: textarea + id: example_usage + attributes: + label: "Example Usage" + description: "How would users interact with this feature? (optional)" + render: csharp + placeholder: | + // Example code showing how the feature would be used + app.UseSwaggerUI(Theme.Dark, c => + { + c.MyNewFeature(); + }); + + - type: textarea + id: design_considerations + attributes: + label: "Design Considerations" + description: "Any thoughts on API design, backward compatibility, or performance? (optional)" + placeholder: "Backward compatibility impact, performance considerations, naming suggestions, etc." + + - type: checkboxes + id: checklist + attributes: + label: "Verification" + options: + - label: "I have searched existing feature requests and found no duplicates" + required: true + - label: "This feature aligns with the project scope" + required: true + + - type: textarea + id: additional_context + attributes: + label: "Additional Context" + description: "Any other information or references? (optional)" + placeholder: | + - Related issues: #123 + - Similar features in other projects: ... + - References: ... diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..ab86ca8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,67 @@ +name: "? Question/Discussion" +description: "Ask a question or start a discussion about using the packages" +labels: ["question"] +body: + - type: markdown + attributes: + value: | + Have a question about how to use SwaggerUI.Themes or SwaggerUI.Extensions? Please ask away! + + - type: dropdown + id: package_type + attributes: + label: "Package" + description: "Which package is your question about?" + options: + - "AspNetCore.SwaggerUI.Themes" + - "NSwag.AspNetCore.Themes" + - "AspNetCore.SwaggerUI.Extensions" + - "NSwag.AspNetCore.Extensions" + - "General/Not sure" + validations: + required: true + + - type: input + id: dotnet_version + attributes: + label: ".NET Version" + description: "Which .NET version are you using?" + placeholder: "e.g., 8.0, 9.0, 10.0" + + - type: input + id: package_version + attributes: + label: "Package Version" + description: "Which version of the package are you using?" + placeholder: "e.g., 3.0.0" + + - type: textarea + id: question + attributes: + label: "Your Question" + description: "What would you like to know?" + placeholder: "Ask your question here..." + validations: + required: true + + - type: textarea + id: context + attributes: + label: "Context" + description: "What are you trying to achieve? Include any relevant code." + render: csharp + placeholder: | + // Your code/setup here + + - type: textarea + id: what_you_tried + attributes: + label: "What Have You Tried?" + description: "What solutions have you already attempted?" + placeholder: "Describe any troubleshooting steps you've already taken" + + - type: textarea + id: additional_info + attributes: + label: "Additional Information" + description: "Any other details that might be helpful? (optional)" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..70f60d7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,65 @@ +## Description + + + +## Type of Change + +- [ ] ?? **Theme** (visual/styling updates) +- [ ] ?? **Extension** (functional capability) +- [ ] ?? **Documentation** (docs/comments only) +- [ ] ?? **Bug Fix** (fixes an issue) +- [ ] ?? **Refactoring** (internal improvements) +- [ ] ?? **Tests** (test updates/additions) + +## Related Issue + +Fixes # or Relates to # + +## Package(s) Affected + +- [ ] AspNetCore.SwaggerUI.Themes +- [ ] NSwag.AspNetCore.Themes +- [ ] AspNetCore.SwaggerUI.Extensions +- [ ] NSwag.AspNetCore.Extensions +- [ ] AspNetCore.Swagger.Themes.Common + +## Testing + +Please describe the testing done to verify this change: + + + +- [ ] I have built the solution (`dotnet build -c Release`) +- [ ] I have run the test suite (`dotnet test`) +- [ ] I have tested with relevant sample projects +- [ ] All changes work across .NET 8, 9, and 10 (if applicable) + +## Checklist + +- [ ] My code follows the style guidelines of this project +- [ ] I have added/updated tests for my changes +- [ ] I have updated relevant documentation (README, CONTRIBUTING, wiki, etc.) +- [ ] My changes do not break any existing functionality +- [ ] I have verified no new compiler warnings are introduced + +## Theme-Specific (if applicable) + +- [ ] Minified CSS/JS files have been updated +- [ ] Minified file headers are correctly formatted (`/*Name*/...`) +- [ ] All placeholders (e.g., `{$THEME_SWITCHER}`) are preserved in minified files +- [ ] Theme is discoverable and testable + +## Extension-Specific (if applicable) + +- [ ] Implementation exists for both Swashbuckle and NSwag (if cross-platform) +- [ ] Extension works independently and with other extensions +- [ ] Tests cover the new functionality +- [ ] Sample applications demonstrate the feature + +## Screenshots/Demo + + + +## Additional Context + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ca5a09..208032b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,11 +3,11 @@ name: Build on: push: branches: [main] - tags: ['v*.*.*'] + tags: ['themes-v*.*.*', 'extensions-v*.*.*'] paths-ignore: ['**/*.md', '**/*.png', '**/*.txt'] pull_request: branches: [main] - types: [opened, reopened, edited] + types: [opened, reopened, edited, synchronize] paths-ignore: ['**/*.md', '**/*.png', '**/*.txt'] env: diff --git a/.github/workflows/pack_and_deploy.yml b/.github/workflows/pack_and_deploy.yml index 497f5ef..38d555f 100644 --- a/.github/workflows/pack_and_deploy.yml +++ b/.github/workflows/pack_and_deploy.yml @@ -13,6 +13,9 @@ env: jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required so GitHub can mint the OIDC token NuGet Trusted Publishing exchanges for a short-lived API key steps: - name: Checkout repository @@ -31,6 +34,27 @@ jobs: 9.0.x 10.0.x + - name: Determine package family from release tag + id: family + shell: bash + env: + TAG: ${{ github.event.release.tag_name }} + run: | + if [[ "$TAG" == themes-v* ]]; then + echo "filter=Themes" >> "$GITHUB_OUTPUT" + elif [[ "$TAG" == extensions-v* ]]; then + echo "filter=Extensions" >> "$GITHUB_OUTPUT" + else + echo "::error::Release tag '$TAG' does not match themes-v* or extensions-v* β€” cannot determine package family to deploy." >&2 + exit 1 + fi + + - name: NuGet login (OIDC β†’ temp API key) + uses: NuGet/login@v1 + id: nuget_login + with: + user: ${{ secrets.NUGET_USER }} # nuget.org username (profile name), NOT the email address + - name: Pack & Push to NuGet shell: pwsh - run: ./deploy.ps1 -Configuration ${{ env.CONFIGURATION }} -PackageOutput ${{ env.PACKAGE_OUTPUT }} -NuGetSourceUrl ${{ env.NUGET_SOURCE_URL }} -NuGetApiKey ${{ secrets.NUGET_API_KEY }} \ No newline at end of file + run: ./deploy.ps1 -Configuration ${{ env.CONFIGURATION }} -PackageOutput ${{ env.PACKAGE_OUTPUT }} -NuGetSourceUrl ${{ env.NUGET_SOURCE_URL }} -NuGetApiKey ${{ steps.nuget_login.outputs.NUGET_API_KEY }} -Filter ${{ steps.family.outputs.filter }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb9c05f..e23841d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: - completed pull_request: branches: [main] - types: [opened, reopened, edited] + types: [opened, reopened, edited, synchronize] paths-ignore: ['**/*.md', '**/*.png', '**/*.txt'] env: @@ -31,4 +31,8 @@ jobs: 10.0.x - name: Test - run: dotnet test ${{ env.TEST_PROJECTS_PATH }} --configuration ${{ env.CONFIGURATION }} \ No newline at end of file + shell: bash + run: | + for project in ${{ env.TEST_PROJECTS_PATH }}; do + dotnet test "$project" --configuration ${{ env.CONFIGURATION }} + done \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0380fa7..0f78073 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,12 +35,18 @@ This project and everyone participating in it are governed by our [Code of Condu Understanding the project layout helps you navigate the codebase: - **`src/AspNetCore.Swagger.Themes.Common/`** - Core theme system shared between packages -- **`src/AspNetCore.SwaggerUI.Themes/`** - Swashbuckle.AspNetCore integration -- **`src/NSwag.AspNetCore.Themes/`** - NSwag.AspNetCore integration +- **`src/AspNetCore.SwaggerUI.Themes/`** - Swashbuckle.AspNetCore theme integration +- **`src/NSwag.AspNetCore.Themes/`** - NSwag.AspNetCore theme integration +- **`src/AspNetCore.SwaggerUI.Extensions/`** - Swashbuckle.AspNetCore functional extensions +- **`src/NSwag.AspNetCore.Extensions/`** - NSwag.AspNetCore functional extensions - **`samples/`** - Example applications demonstrating usage - **`tests/`** - Unit and integration tests -## Working with Themes +--- + +## Contributing Themes + +Themes customize the visual appearance of Swagger UI. When contributing a new theme: ### Adding or Modifying Themes @@ -107,6 +113,82 @@ git add src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/* git commit -m "Add new ocean theme" ``` +### Theme Organization + +When adding example custom themes, use subfolders for clarity: + +``` +samples/YourSample/SwaggerThemes/ +β”œβ”€β”€ CompanyThemes/ +β”‚ └── corporate-blue.css +└── SeasonalThemes/ + └── holiday-red.css +``` + +This demonstrates best practices for users organizing their own themes. + +### Standalone vs. Regular Themes + +- **Regular themes** - Depend on `common.css` and may use JavaScript features +- **Standalone themes** - Prefix with `standalone.` (e.g., `standalone.custom.css`) for zero dependencies + +Choose based on whether you need shared styles or prefer total independence. + +--- + +## Contributing Extensions + +Extensions add functional capabilities to Swagger UI beyond theming. When contributing a new extension: + +### Planning an Extension + +Before implementing, consider: + +1. **Scope** - Is it a focused feature or part of a larger system? +2. **Compatibility** - Does it work with both Swashbuckle and NSwag? +3. **Integration points** - Where should it hook into the pipeline? +4. **User value** - What problem does it solve for users? + +### Implementation Guidelines + +Extensions should be implemented for both integrations: + +- **Swashbuckle implementation** - `src/AspNetCore.SwaggerUI.Extensions/` + - Add service extension methods in `Microsoft.Extensions.DependencyInjection/` + - Add filters in `AspNetCore/Swagger/Extensions/Filters/` + +- **NSwag implementation** - `src/NSwag.AspNetCore.Extensions/` + - Add service extension methods in `Microsoft.Extensions.DependencyInjection/` + - Add processors in `AspNetCore/Swagger/Extensions/Processors/` + +### Extension Structure Example + +``` +src/AspNetCore.SwaggerUI.Extensions/ +β”œβ”€β”€ Microsoft/Extensions/DependencyInjection/ +β”‚ └── SwaggerGenOptionsExtensions.cs // Service configuration +β”œβ”€β”€ AspNetCore/Swagger/Extensions/ +β”‚ └── Filters/ +β”‚ └── MyFeatureFilter.cs // Swagger/OpenAPI filter +└── package-readme.md // NuGet package documentation + +src/NSwag.AspNetCore.Extensions/ +β”œβ”€β”€ Microsoft/Extensions/DependencyInjection/ +β”‚ └── AspNetCoreOpenApiDocumentGeneratorSettingsExtensions.cs +β”œβ”€β”€ AspNetCore/Swagger/Extensions/ +β”‚ └── Processors/ +β”‚ └── MyFeatureProcessor.cs // NSwag processor +└── package-readme.md +``` + +### Testing Extensions + +1. Add tests in the appropriate test project +2. Test with both sample applications +3. Verify the extension works independently and alongside other extensions + +--- + ## Testing Your Changes Before submitting a pull request: @@ -126,8 +208,9 @@ dotnet test ``` All tests must pass, including: -- Minified header format tests -- Theme discovery tests +- Minified header format tests (for themes) +- Theme discovery tests (for themes) +- Extension functionality tests (for extensions) - Integration tests ### 3. Test with Sample Applications @@ -140,15 +223,22 @@ dotnet run --project samples/Sample.AspNetCore.SwaggerUI.Swashbuckle # NSwag sample dotnet run --project samples/Sample.AspNetCore.SwaggerUI.NSwag + +# Swashbuckle Extensions sample +dotnet run --project samples/Sample.AspNetCore.SwaggerUI.Extensions + +# NSwag Extensions sample +dotnet run --project samples/Sample.NSwag.AspNetCore.Extensions ``` ### 4. Manual Verification Open the sample app in your browser and verify: -- βœ… Themes render correctly -- βœ… Theme switcher dropdown appears and works (if enabled) -- βœ… Custom themes are discovered automatically +- βœ… Themes render correctly (if theme changes) +- βœ… Theme switcher dropdown appears and works (if theme changes) +- βœ… Custom themes are discovered automatically (if theme changes) +- βœ… Extensions work as expected (if extension changes) - βœ… Advanced features work (pinnable topbar, back-to-top, etc.) - βœ… No console errors in browser developer tools - βœ… Standalone themes load without extra dependencies @@ -164,9 +254,10 @@ Open the sample app in your browser and verify: ### Good PR Practices -- Use descriptive titles: `feat: add sunset theme` not `update css` +- Use descriptive titles: `feat: add sunset theme` or `feat: add api metadata extension` - Reference issues: `Fixes #123` or `Closes #456` -- Include screenshots for visual changes +- Include screenshots for visual changes (themes) +- Include before/after examples for functional changes (extensions) - Test across .NET 8, 9, and 10 if possible ## Reporting Bugs @@ -177,20 +268,26 @@ Found a bug? Please open an issue with: - **Steps to reproduce** the issue - **Expected vs. actual behavior** - **Environment details:** - - Which package (Swashbuckle or NSwag) + - Which package (Themes or Extensions) + - Which integration (Swashbuckle or NSwag) - .NET version - Browser (if UI-related) - **Screenshots or code snippets** if applicable +See the bug report template for a structured approach. + ## Suggesting Features We welcome feature suggestions! When proposing a feature: - **Describe the use case** - What problem does it solve? +- **Specify the scope** - Is it for Themes, Extensions, or both? - **Explain the benefit** - How does it improve the project? - **Consider backward compatibility** - Will it break existing code? - **Offer to help** - Can you contribute the implementation? +See the feature request template for a structured approach. + ## Development Tips ### Quick Testing Loop @@ -216,13 +313,6 @@ samples/YourSample/SwaggerThemes/ This demonstrates best practices for users organizing their own themes. -### Standalone vs. Regular Themes - -- **Regular themes** - Depend on `common.css` and may use JavaScript features -- **Standalone themes** - Prefix with `standalone.` (e.g., `standalone.custom.css`) for zero dependencies - -Choose based on whether you need shared styles or prefer total independence. - ## Questions? Not sure about something? Feel free to: diff --git a/README.md b/README.md index aad1d79..cee55a3 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@
_**...and more!**_ -**[Get Started](https://github.com/teociaps/SwaggerUI.Themes/wiki/Getting-Started)** β€’ **[View Built-in Themes](https://github.com/teociaps/SwaggerUI.Themes/wiki/Predefined-Themes)** β€’ **[Full Documentation](https://github.com/teociaps/SwaggerUI.Themes/wiki)** +**[Get Started](https://github.com/teociaps/SwaggerUI.Themes/wiki/Getting-Started)** β€’ **[View Built-in Themes](https://github.com/teociaps/SwaggerUI.Themes/wiki/Predefined-Themes)** β€’ **[Troubleshooting](https://github.com/teociaps/SwaggerUI.Themes/wiki/Troubleshooting)** β€’ **[Full Documentation](https://github.com/teociaps/SwaggerUI.Themes/wiki)** SwaggerUI.Themes Demo @@ -29,17 +29,30 @@ _Note: GIF framerate is limited. Actual transitions are buttery smooth!_ --- +## πŸ“¦ Available Packages + +### Theme Packages +Customize the look and feel of your Swagger UI with runtime theme switching and custom theme creation. + | Package | Purpose | NuGet | |---------|---------|-------| | **AspNetCore.SwaggerUI.Themes** | For [Swashbuckle.AspNetCore][swashbuckle-link] | [![swashbuckle-nuget]][swashbuckle-nuget-link] | | **NSwag.AspNetCore.Themes** | For [NSwag.AspNetCore][nswag-link] | [![nswag-nuget]][nswag-nuget-link] | +### Extension Packages +Enhance your Swagger UI with additional functional capabilities beyond theming. + +| Package | Purpose | NuGet | +|---------|---------|-------| +| **AspNetCore.SwaggerUI.Extensions** | Functional enhancements for [Swashbuckle.AspNetCore][swashbuckle-link] | [![swashbuckle-ext-nuget]][swashbuckle-ext-nuget-link] | +| **NSwag.AspNetCore.Extensions** | Functional enhancements for [NSwag.AspNetCore][nswag-link] | [![nswag-ext-nuget]][nswag-ext-nuget-link] | + --- > [!WARNING] -> **Version 3.x Breaking Changes** +> **Version 3.x Breaking Changes** (Theme packages only) > > Upgrading from v2.x? Please review the **[Migration Guide](https://github.com/teociaps/SwaggerUI.Themes/wiki/Migration-v3)** for important API changes. @@ -47,12 +60,19 @@ _Note: GIF framerate is limited. Actual transitions are buttery smooth!_ ## πŸš€ Quick Start ```bash -# Install package +# Install theme package dotnet add package AspNetCore.SwaggerUI.Themes # or dotnet add package NSwag.AspNetCore.Themes + +# Or install extensions package +dotnet add package AspNetCore.SwaggerUI.Extensions +# or +dotnet add package NSwag.AspNetCore.Extensions ``` +### Themes Quick Start + ```csharp // Apply a theme - that's it! app.UseSwaggerUI(Theme.Dark); // Swashbuckle @@ -61,10 +81,31 @@ app.UseSwaggerUi(Theme.Dark); // NSwag // Enable runtime theme switcher! app.UseSwaggerUI(Theme.Dark, c => c.EnableThemeSwitcher()); + +// Enable a pinnable filter box +app.UseSwaggerUI(Theme.Dark, c => c.EnableFilter(pinnable: true)); +``` + +### Extensions Quick Start + +```csharp +// Add extensions to your configuration +builder.Services.AddSwaggerGen(c => +{ + c.AppendOperationCountToTags(); // Swashbuckle +}); + +// or for NSwag +builder.Services.AddOpenApiDocument(c => +{ + c.AppendOperationCountToTags(); // NSwag +}); ``` + ## ✨ Features +### Themes - πŸ”₯ **[Theme Switcher](https://github.com/teociaps/SwaggerUI.Themes/wiki/Feature-Dynamic-Theme-Switcher)** - Switch built-in and custom themes dynamically without page reload - **[Built-in Themes](https://github.com/teociaps/SwaggerUI.Themes/wiki/Predefined-Themes)** - Choose from predefined themes ready to use @@ -73,11 +114,14 @@ app.UseSwaggerUI(Theme.Dark, c => c.EnableThemeSwitcher()); - **[Advanced Features](https://github.com/teociaps/SwaggerUI.Themes/wiki/Advanced-Options)** - Enhance your documentation with powerful UI capabilities -- _...discover more in the [Wiki](https://github.com/teociaps/SwaggerUI.Themes/wiki/Features)!_ +### Extensions +- **API Counter** - Automatically display operation counts in tag descriptions for better API surface area visibility + +_Discover more features in the [Wiki](https://github.com/teociaps/SwaggerUI.Themes/wiki/Features)!_ ## πŸ“š Basic Usage Examples -### Swashbuckle +### Swashbuckle Themes ```csharp using AspNetCore.Swagger.Themes; @@ -102,7 +146,7 @@ app.UseSwaggerUI(Theme.Dark, c => ... ``` -### NSwag +### NSwag Themes ```csharp using AspNetCore.Swagger.Themes; @@ -127,6 +171,44 @@ app.UseSwaggerUi(Theme.Dark, c => ... ``` +### Swashbuckle Extensions + +```csharp +using AspNetCore.Swagger.Extensions; + +... + +builder.Services.AddSwaggerGen(c => +{ + // Add operation count to tag descriptions + c.AppendOperationCountToTags(); + + // Or with custom template + c.AppendOperationCountToTags(" [{0} endpoints]"); +}); + +... +``` + +### NSwag Extensions + +```csharp +using AspNetCore.Swagger.Extensions; + +... + +builder.Services.AddOpenApiDocument(c => +{ + // Add operation count to tag descriptions + c.AppendOperationCountToTags(); + + // Or with custom template + c.AppendOperationCountToTags(" [{0} endpoints]"); +}); + +... +``` + ### πŸ’‘ Build your own Custom Theme ```csharp @@ -151,7 +233,7 @@ _Learn advanced usages in the [Wiki](https://github.com/teociaps/SwaggerUI.Theme ## 🀝 Contributing -Contributions are welcome! See the [Contributing Guide](CONTRIBUTING.md) for details. +Contributions are welcome! See the [Contributing Guide](CONTRIBUTING.md) for details on contributing themes and extensions. ## πŸ“œ License @@ -168,7 +250,11 @@ MIT Licensed - see [LICENSE](LICENSE) for details. [swashbuckle-nuget]: https://img.shields.io/nuget/v/AspNetCore.SwaggerUI.Themes?logo=nuget&label=Version&color=blue [swashbuckle-nuget-link]: https://www.nuget.org/packages/AspNetCore.SwaggerUI.Themes/ +[swashbuckle-ext-nuget]: https://img.shields.io/nuget/v/AspNetCore.SwaggerUI.Extensions?logo=nuget&label=Version&color=blue +[swashbuckle-ext-nuget-link]: https://www.nuget.org/packages/AspNetCore.SwaggerUI.Extensions/ [nswag-nuget]: https://img.shields.io/nuget/v/NSwag.AspNetCore.Themes?logo=nuget&label=Version&color=blue [nswag-nuget-link]: https://www.nuget.org/packages/NSwag.AspNetCore.Themes/ +[nswag-ext-nuget]: https://img.shields.io/nuget/v/NSwag.AspNetCore.Extensions?logo=nuget&label=Version&color=blue +[nswag-ext-nuget-link]: https://www.nuget.org/packages/NSwag.AspNetCore.Extensions/ [swashbuckle-link]: https://github.com/domaindrivendev/Swashbuckle.AspNetCore [nswag-link]: https://github.com/RicoSuter/NSwag \ No newline at end of file diff --git a/SwaggerUI.Themes.sln b/SwaggerUI.Themes.sln index fc8d08a..c94ef4c 100644 --- a/SwaggerUI.Themes.sln +++ b/SwaggerUI.Themes.sln @@ -1,7 +1,7 @@ ο»Ώ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11205.157 d18.0 +VisualStudioVersion = 18.0.11205.157 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCore.SwaggerUI.Themes", "src\AspNetCore.SwaggerUI.Themes\AspNetCore.SwaggerUI.Themes.csproj", "{5FC2CEDA-5475-4096-BDEC-1C9BFC933FB8}" EndProject @@ -29,6 +29,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NSwag.AspNetCore.Themes", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCore.Swagger.Themes.Common", "src\AspNetCore.Swagger.Themes.Common\AspNetCore.Swagger.Themes.Common.csproj", "{DCD47D0E-ADD5-4B6A-BDAA-DD6559EB384D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.SwaggerUI.Extensions", "src\AspNetCore.SwaggerUI.Extensions\AspNetCore.SwaggerUI.Extensions.csproj", "{8E498417-7FF6-E0E3-9BC1-AB5197733AFB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NSwag.AspNetCore.Extensions", "src\NSwag.AspNetCore.Extensions\NSwag.AspNetCore.Extensions.csproj", "{605058F0-1D5E-C122-1ABD-B73D053665B7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.Swagger.Extensions.Tests", "tests\AspNetCore.Swagger.Extensions.Tests\AspNetCore.Swagger.Extensions.Tests.csproj", "{ECD67BCE-D96A-B1D4-48F1-95AF9F6A49E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.AspNetCore.SwaggerUI.Extensions", "samples\Sample.AspNetCore.SwaggerUI.Extensions\Sample.AspNetCore.SwaggerUI.Extensions.csproj", "{B90ED39A-C413-8F86-152F-7D2EB7AAB349}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.NSwag.AspNetCore.Extensions", "samples\Sample.NSwag.AspNetCore.Extensions\Sample.NSwag.AspNetCore.Extensions.csproj", "{9050F5CA-0F00-25EA-236F-8659C3AD917F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,6 +69,26 @@ Global {DCD47D0E-ADD5-4B6A-BDAA-DD6559EB384D}.Debug|Any CPU.Build.0 = Debug|Any CPU {DCD47D0E-ADD5-4B6A-BDAA-DD6559EB384D}.Release|Any CPU.ActiveCfg = Release|Any CPU {DCD47D0E-ADD5-4B6A-BDAA-DD6559EB384D}.Release|Any CPU.Build.0 = Release|Any CPU + {8E498417-7FF6-E0E3-9BC1-AB5197733AFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E498417-7FF6-E0E3-9BC1-AB5197733AFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E498417-7FF6-E0E3-9BC1-AB5197733AFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E498417-7FF6-E0E3-9BC1-AB5197733AFB}.Release|Any CPU.Build.0 = Release|Any CPU + {605058F0-1D5E-C122-1ABD-B73D053665B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {605058F0-1D5E-C122-1ABD-B73D053665B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {605058F0-1D5E-C122-1ABD-B73D053665B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {605058F0-1D5E-C122-1ABD-B73D053665B7}.Release|Any CPU.Build.0 = Release|Any CPU + {ECD67BCE-D96A-B1D4-48F1-95AF9F6A49E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECD67BCE-D96A-B1D4-48F1-95AF9F6A49E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECD67BCE-D96A-B1D4-48F1-95AF9F6A49E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECD67BCE-D96A-B1D4-48F1-95AF9F6A49E1}.Release|Any CPU.Build.0 = Release|Any CPU + {B90ED39A-C413-8F86-152F-7D2EB7AAB349}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B90ED39A-C413-8F86-152F-7D2EB7AAB349}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B90ED39A-C413-8F86-152F-7D2EB7AAB349}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B90ED39A-C413-8F86-152F-7D2EB7AAB349}.Release|Any CPU.Build.0 = Release|Any CPU + {9050F5CA-0F00-25EA-236F-8659C3AD917F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9050F5CA-0F00-25EA-236F-8659C3AD917F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9050F5CA-0F00-25EA-236F-8659C3AD917F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9050F5CA-0F00-25EA-236F-8659C3AD917F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -70,6 +100,11 @@ Global {E8C376AC-9933-4ECF-92D8-66BC706FD6D0} = {FF08B180-6923-46E0-8426-99391CFB47CD} {38614F76-C962-4CDC-9C9D-25469B92CDE2} = {19896985-FCB0-4EA0-8E5F-FF3902AC13FC} {DCD47D0E-ADD5-4B6A-BDAA-DD6559EB384D} = {19896985-FCB0-4EA0-8E5F-FF3902AC13FC} + {8E498417-7FF6-E0E3-9BC1-AB5197733AFB} = {19896985-FCB0-4EA0-8E5F-FF3902AC13FC} + {605058F0-1D5E-C122-1ABD-B73D053665B7} = {19896985-FCB0-4EA0-8E5F-FF3902AC13FC} + {ECD67BCE-D96A-B1D4-48F1-95AF9F6A49E1} = {B89F6FD2-68B6-4A70-A070-FF22FE42DDE4} + {B90ED39A-C413-8F86-152F-7D2EB7AAB349} = {FF08B180-6923-46E0-8426-99391CFB47CD} + {9050F5CA-0F00-25EA-236F-8659C3AD917F} = {FF08B180-6923-46E0-8426-99391CFB47CD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {660A434B-79DE-4C3F-892C-333338978C59} diff --git a/assets/pinnable-filter-bar.gif b/assets/pinnable-filter-bar.gif new file mode 100644 index 0000000..2c1dadc Binary files /dev/null and b/assets/pinnable-filter-bar.gif differ diff --git a/build/Versioning.Extensions.props b/build/Versioning.Extensions.props new file mode 100644 index 0000000..6c7c96e --- /dev/null +++ b/build/Versioning.Extensions.props @@ -0,0 +1,12 @@ + + + + + 1 + 0 + 0 + + + diff --git a/build/Versioning.Themes.props b/build/Versioning.Themes.props new file mode 100644 index 0000000..cdb5aed --- /dev/null +++ b/build/Versioning.Themes.props @@ -0,0 +1,12 @@ + + + + + 3 + 1 + 0 + + + diff --git a/build/Versioning.props b/build/Versioning.props index d078017..2c30a01 100644 --- a/build/Versioning.props +++ b/build/Versioning.props @@ -1,6 +1,15 @@ - + + + true $([System.DateTime]::Now.Ticks) @@ -10,10 +19,15 @@ $([System.Math]::Floor($(MinutesSinceMidnight))) $([System.DateTime]::Today.ToString("yyMMdd")) $(Today)-$(Floored) - 3 - 0 - 2 -$(Revision) + + + + + + + + $(MajorVersion).$(MinorVersion).$(PatchVersion)$(VersionSuffix) @@ -24,4 +38,4 @@ - \ No newline at end of file + diff --git a/deploy.ps1 b/deploy.ps1 index 001711a..43d19a2 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -3,16 +3,23 @@ param ( [string]$Configuration = "Release", [string]$PackageOutput = "$(pwd)/artifacts", [string]$NuGetSourceUrl = "https://api.nuget.org/v3/index.json", - [string]$NuGetApiKey + [string]$NuGetApiKey, + [string]$Filter ) +# Validate the package family filter (case-sensitive: "themes" must not silently +# slip through and produce a glob that matches nothing on case-sensitive filesystems) +if ($Filter -cne "Themes" -and $Filter -cne "Extensions") { + throw "Invalid -Filter value '${Filter}'. Must be 'Themes' or 'Extensions'." +} + # Ensure the output directory exists if (-not (Test-Path -Path $PackageOutput)) { New-Item -ItemType Directory -Path $PackageOutput | Out-Null } # Iterate over each project to pack -Get-ChildItem -Path src -Filter *.Themes -Directory | ForEach-Object { +Get-ChildItem -Path src -Filter "*.$Filter" -Directory | ForEach-Object { $projectPath = $_.FullName $projectName = $_.Name $csprojPath = Join-Path -Path $projectPath -ChildPath "${projectName}.csproj" diff --git a/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/OrdersController.cs b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/OrdersController.cs new file mode 100644 index 0000000..8db5b48 --- /dev/null +++ b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/OrdersController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Controllers; + +[ApiController] +[Route("api/[controller]")] +[Tags("Orders")] +public class OrdersController : ControllerBase +{ + /// + /// Gets all orders + /// + [HttpGet] + public IActionResult GetAll() + { + return Ok(new[] { new { Id = 1, Total = 150.00 }, new { Id = 2, Total = 200.00 } }); + } + + /// + /// Gets an order by ID + /// + [HttpGet("{id}")] + public IActionResult GetById(int id) + { + return Ok(new { Id = id, Total = 150.00, Status = "Pending" }); + } +} \ No newline at end of file diff --git a/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/ProductsController.cs b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/ProductsController.cs new file mode 100644 index 0000000..ef7b567 --- /dev/null +++ b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/ProductsController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Controllers; + +[ApiController] +[Route("api/[controller]")] +[Tags("Products")] +public class ProductsController : ControllerBase +{ + /// + /// Gets all products + /// + [HttpGet] + public IActionResult GetAll() + { + return Ok(new[] { new { Id = 1, Name = "Product A" }, new { Id = 2, Name = "Product B" } }); + } + + /// + /// Gets a product by ID + /// + [HttpGet("{id}")] + public IActionResult GetById(int id) + { + return Ok(new { Id = id, Name = "Product", Price = 99.99 }); + } + + /// + /// Creates a new product + /// + [HttpPost] + public IActionResult Create([FromBody] object product) + { + return CreatedAtAction(nameof(GetById), new { id = 1 }, product); + } +} \ No newline at end of file diff --git a/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/UsersController.cs b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/UsersController.cs new file mode 100644 index 0000000..68160dd --- /dev/null +++ b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Controllers/UsersController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Controllers; + +[ApiController] +[Route("api/[controller]")] +[Tags("Users")] +public class UsersController : ControllerBase +{ + /// + /// Gets all users + /// + [HttpGet] + public IActionResult GetAll() + { + return Ok(new[] { new { Id = 1, Name = "John" }, new { Id = 2, Name = "Jane" } }); + } + + /// + /// Gets a user by ID + /// + [HttpGet("{id}")] + public IActionResult GetById(int id) + { + return Ok(new { Id = id, Name = "John Doe" }); + } + + /// + /// Creates a new user + /// + [HttpPost] + public IActionResult Create([FromBody] object user) + { + return CreatedAtAction(nameof(GetById), new { id = 1 }, user); + } + + /// + /// Updates an existing user + /// + [HttpPut("{id}")] + public IActionResult Update(int id, [FromBody] object user) + { + return NoContent(); + } + + /// + /// Deletes a user + /// + [HttpDelete("{id}")] + public IActionResult Delete(int id) + { + return NoContent(); + } +} \ No newline at end of file diff --git a/samples/Sample.AspNetCore.SwaggerUI.Extensions/Program.cs b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Program.cs new file mode 100644 index 0000000..dcaf26b --- /dev/null +++ b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Program.cs @@ -0,0 +1,29 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new() { Title = "Sample API - Extensions Demo", Version = "v1" }); + + // API Counter Extension + // Automatically appends operation count to tag descriptions + c.AppendOperationCountToTags(); + + // Or with custom template: + // c.AppendOperationCountToTags(" [{0} endpoints]"); + // c.AppendOperationCountToTags(" (APIs: {0})"); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.MapControllers(); + +await app.RunAsync(); \ No newline at end of file diff --git a/samples/Sample.AspNetCore.SwaggerUI.Extensions/Properties/launchSettings.json b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Properties/launchSettings.json new file mode 100644 index 0000000..d0318af --- /dev/null +++ b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7100;http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Sample.AspNetCore.SwaggerUI.Extensions/Sample.AspNetCore.SwaggerUI.Extensions.csproj b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Sample.AspNetCore.SwaggerUI.Extensions.csproj new file mode 100644 index 0000000..aec7bfb --- /dev/null +++ b/samples/Sample.AspNetCore.SwaggerUI.Extensions/Sample.AspNetCore.SwaggerUI.Extensions.csproj @@ -0,0 +1,28 @@ + + + + + + true + false + false + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Sample.AspNetCore.SwaggerUI.Extensions/appsettings.Development.json b/samples/Sample.AspNetCore.SwaggerUI.Extensions/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/Sample.AspNetCore.SwaggerUI.Extensions/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/Sample.AspNetCore.SwaggerUI.Extensions/appsettings.json b/samples/Sample.AspNetCore.SwaggerUI.Extensions/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/Sample.AspNetCore.SwaggerUI.Extensions/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/Sample.AspNetCore.SwaggerUI.NSwag/Program.cs b/samples/Sample.AspNetCore.SwaggerUI.NSwag/Program.cs index 503ef0b..5560742 100644 --- a/samples/Sample.AspNetCore.SwaggerUI.NSwag/Program.cs +++ b/samples/Sample.AspNetCore.SwaggerUI.NSwag/Program.cs @@ -5,7 +5,10 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddOpenApiDocument(OpenApiDocGenConfigurer.Configure); +builder.Services.AddOpenApiDocument(c => +{ + OpenApiDocGenConfigurer.Configure(c); +}); var app = builder.Build(); @@ -126,11 +129,11 @@ // ======================================== // 13. Enable all advanced UI features - app.UseSwaggerUi(Theme.Dark, c => - { - c.DocumentTitle = "Sample API - All Features"; - c.EnableAllAdvancedOptions(); - }); + //app.UseSwaggerUi(Theme.Dark, c => + //{ + // c.DocumentTitle = "Sample API - All Features"; + // c.EnableAllAdvancedOptions(); + //}); // 14. Individual advanced features //app.UseSwaggerUi(Theme.Light, c => @@ -187,6 +190,14 @@ // .WithThemes(Theme.Dark, Theme.Light) // .WithCustomThemes(CustomThemeMode.None)); //}); + + // 19. Pinnable filter bar + app.UseSwaggerUi(Theme.Dark, c => + { + c.DocumentTitle = "Sample API - Pinnable Filter Bar"; + c.EnableAllAdvancedOptions(); + c.EnableFilter(pinnable: true); + }); } app.UseHttpsRedirection(); diff --git a/samples/Sample.AspNetCore.SwaggerUI.NSwag/Sample.AspNetCore.SwaggerUI.NSwag.csproj b/samples/Sample.AspNetCore.SwaggerUI.NSwag/Sample.AspNetCore.SwaggerUI.NSwag.csproj index ef4ed4a..3e3a122 100644 --- a/samples/Sample.AspNetCore.SwaggerUI.NSwag/Sample.AspNetCore.SwaggerUI.NSwag.csproj +++ b/samples/Sample.AspNetCore.SwaggerUI.NSwag/Sample.AspNetCore.SwaggerUI.NSwag.csproj @@ -9,7 +9,7 @@ - + diff --git a/samples/Sample.AspNetCore.SwaggerUI.Swashbuckle/Program.cs b/samples/Sample.AspNetCore.SwaggerUI.Swashbuckle/Program.cs index e9974d9..25489c7 100644 --- a/samples/Sample.AspNetCore.SwaggerUI.Swashbuckle/Program.cs +++ b/samples/Sample.AspNetCore.SwaggerUI.Swashbuckle/Program.cs @@ -5,7 +5,10 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(SwaggerGenConfigurer.Configure); +builder.Services.AddSwaggerGen(c => +{ + SwaggerGenConfigurer.Configure(c); +}); var app = builder.Build(); @@ -126,11 +129,11 @@ // ======================================== // 13. Enable all advanced UI features - app.UseSwaggerUI(Theme.Dark, c => - { - c.DocumentTitle = "Sample API - All Features"; - c.EnableAllAdvancedOptions(); - }); + //app.UseSwaggerUI(Theme.Dark, c => + //{ + // c.DocumentTitle = "Sample API - All Features"; + // c.EnableAllAdvancedOptions(); + //}); // 14. Individual advanced features //app.UseSwaggerUI(Theme.Light, c => @@ -188,6 +191,14 @@ // .WithThemes(Theme.Dark, Theme.Light) // .WithCustomThemes(CustomThemeMode.None)); //}); + + // 19. Pinnable filter bar + app.UseSwaggerUI(Theme.Dark, c => + { + c.DocumentTitle = "Sample API - Pinnable Filter Bar"; + c.EnableAllAdvancedOptions(); + c.EnableFilter(pinnable: true); + }); } app.UseHttpsRedirection(); diff --git a/samples/Sample.AspNetCore.SwaggerUI.Swashbuckle/Sample.AspNetCore.SwaggerUI.Swashbuckle.csproj b/samples/Sample.AspNetCore.SwaggerUI.Swashbuckle/Sample.AspNetCore.SwaggerUI.Swashbuckle.csproj index 3b05d88..c825bf9 100644 --- a/samples/Sample.AspNetCore.SwaggerUI.Swashbuckle/Sample.AspNetCore.SwaggerUI.Swashbuckle.csproj +++ b/samples/Sample.AspNetCore.SwaggerUI.Swashbuckle/Sample.AspNetCore.SwaggerUI.Swashbuckle.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/OrdersController.cs b/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/OrdersController.cs new file mode 100644 index 0000000..8db5b48 --- /dev/null +++ b/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/OrdersController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Controllers; + +[ApiController] +[Route("api/[controller]")] +[Tags("Orders")] +public class OrdersController : ControllerBase +{ + /// + /// Gets all orders + /// + [HttpGet] + public IActionResult GetAll() + { + return Ok(new[] { new { Id = 1, Total = 150.00 }, new { Id = 2, Total = 200.00 } }); + } + + /// + /// Gets an order by ID + /// + [HttpGet("{id}")] + public IActionResult GetById(int id) + { + return Ok(new { Id = id, Total = 150.00, Status = "Pending" }); + } +} \ No newline at end of file diff --git a/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/ProductsController.cs b/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/ProductsController.cs new file mode 100644 index 0000000..ef7b567 --- /dev/null +++ b/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/ProductsController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Controllers; + +[ApiController] +[Route("api/[controller]")] +[Tags("Products")] +public class ProductsController : ControllerBase +{ + /// + /// Gets all products + /// + [HttpGet] + public IActionResult GetAll() + { + return Ok(new[] { new { Id = 1, Name = "Product A" }, new { Id = 2, Name = "Product B" } }); + } + + /// + /// Gets a product by ID + /// + [HttpGet("{id}")] + public IActionResult GetById(int id) + { + return Ok(new { Id = id, Name = "Product", Price = 99.99 }); + } + + /// + /// Creates a new product + /// + [HttpPost] + public IActionResult Create([FromBody] object product) + { + return CreatedAtAction(nameof(GetById), new { id = 1 }, product); + } +} \ No newline at end of file diff --git a/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/UsersController.cs b/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/UsersController.cs new file mode 100644 index 0000000..68160dd --- /dev/null +++ b/samples/Sample.NSwag.AspNetCore.Extensions/Controllers/UsersController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Controllers; + +[ApiController] +[Route("api/[controller]")] +[Tags("Users")] +public class UsersController : ControllerBase +{ + /// + /// Gets all users + /// + [HttpGet] + public IActionResult GetAll() + { + return Ok(new[] { new { Id = 1, Name = "John" }, new { Id = 2, Name = "Jane" } }); + } + + /// + /// Gets a user by ID + /// + [HttpGet("{id}")] + public IActionResult GetById(int id) + { + return Ok(new { Id = id, Name = "John Doe" }); + } + + /// + /// Creates a new user + /// + [HttpPost] + public IActionResult Create([FromBody] object user) + { + return CreatedAtAction(nameof(GetById), new { id = 1 }, user); + } + + /// + /// Updates an existing user + /// + [HttpPut("{id}")] + public IActionResult Update(int id, [FromBody] object user) + { + return NoContent(); + } + + /// + /// Deletes a user + /// + [HttpDelete("{id}")] + public IActionResult Delete(int id) + { + return NoContent(); + } +} \ No newline at end of file diff --git a/samples/Sample.NSwag.AspNetCore.Extensions/Program.cs b/samples/Sample.NSwag.AspNetCore.Extensions/Program.cs new file mode 100644 index 0000000..508b5f8 --- /dev/null +++ b/samples/Sample.NSwag.AspNetCore.Extensions/Program.cs @@ -0,0 +1,30 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApiDocument(c => +{ + c.Title = "Sample API - Extensions Demo"; + c.Version = "v1"; + + // API Counter Extension + // Automatically appends operation count to tag descriptions + c.AppendOperationCountToTags(); + + // Or with custom template: + // c.AppendOperationCountToTags(" [{0} endpoints]"); + // c.AppendOperationCountToTags(" (APIs: {0})"); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseOpenApi(); + app.UseSwaggerUi(); +} + +app.UseHttpsRedirection(); +app.MapControllers(); + +await app.RunAsync(); \ No newline at end of file diff --git a/samples/Sample.NSwag.AspNetCore.Extensions/Properties/launchSettings.json b/samples/Sample.NSwag.AspNetCore.Extensions/Properties/launchSettings.json new file mode 100644 index 0000000..bc6351d --- /dev/null +++ b/samples/Sample.NSwag.AspNetCore.Extensions/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5101", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7101;http://localhost:5101", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Sample.NSwag.AspNetCore.Extensions/Sample.NSwag.AspNetCore.Extensions.csproj b/samples/Sample.NSwag.AspNetCore.Extensions/Sample.NSwag.AspNetCore.Extensions.csproj new file mode 100644 index 0000000..4980a36 --- /dev/null +++ b/samples/Sample.NSwag.AspNetCore.Extensions/Sample.NSwag.AspNetCore.Extensions.csproj @@ -0,0 +1,27 @@ +ο»Ώ + + + + + true + false + false + + + + + + + + + + + + + + + + + + + diff --git a/samples/Sample.NSwag.AspNetCore.Extensions/appsettings.Development.json b/samples/Sample.NSwag.AspNetCore.Extensions/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/Sample.NSwag.AspNetCore.Extensions/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/Sample.NSwag.AspNetCore.Extensions/appsettings.json b/samples/Sample.NSwag.AspNetCore.Extensions/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/Sample.NSwag.AspNetCore.Extensions/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/AdvancedOptions.cs b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/AdvancedOptions.cs index e7bf728..dbd37ed 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/AdvancedOptions.cs +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/AdvancedOptions.cs @@ -6,6 +6,7 @@ internal static class AdvancedOptions { internal const string PinnableTopbar = nameof(PinnableTopbar); + internal const string PinnableFilterBar = nameof(PinnableFilterBar); internal const string BackToTop = nameof(BackToTop); internal const string StickyOperations = nameof(StickyOperations); internal const string ExpandOrCollapseAllOperations = nameof(ExpandOrCollapseAllOperations); @@ -18,12 +19,14 @@ internal static class AdvancedOptions // JS placeholders internal const string PinnableTopbarJsPH = "{$PINNABLE_TOPBAR}"; + internal const string PinnableFilterBarJsPH = "{$PINNABLE_FILTER_BAR}"; internal const string BackToTopJsPH = "{$BACK_TO_TOP}"; internal const string ExpandOrCollapseAllOperationsJsPH = "{$EXPAND_COLLAPSE_ALL_OPERATIONS}"; internal const string ThemeSwitcherJsPH = "{$THEME_SWITCHER}"; private static readonly string[][] s_jsDependentFeatures = [ [PinnableTopbar, PinnableTopbarJsPH], + [PinnableFilterBar, PinnableFilterBarJsPH], [BackToTop, BackToTopJsPH], [ExpandOrCollapseAllOperations, ExpandOrCollapseAllOperationsJsPH], [ThemeSwitcher, ThemeSwitcherJsPH] @@ -34,6 +37,11 @@ internal static void EnablePinnableTopbar(this IDictionary optio options.TryAdd(PinnableTopbar, true); } + internal static void EnablePinnableFilterBar(this IDictionary options) + { + options.TryAdd(PinnableFilterBar, true); + } + internal static void EnableBackToTop(this IDictionary options) { options.TryAdd(BackToTop, true); diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/FileProvider.cs b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/FileProvider.cs index 9f5915c..db6d12b 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/FileProvider.cs +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/FileProvider.cs @@ -1,7 +1,9 @@ ο»Ώusing Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; using System.Collections.Frozen; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.Json; namespace AspNetCore.Swagger.Themes; @@ -21,8 +23,12 @@ internal static class FileProvider internal const string ThemeMetadataPath = "/themes/metadata.json"; internal const string JsFilename = "ui.min.js"; - // Track registered endpoints to prevent duplicates - private static readonly HashSet s_registeredEndpoints = new(StringComparer.OrdinalIgnoreCase); + // Track registered endpoint paths to prevent duplicate registration across the process + private static readonly ConcurrentDictionary s_registeredEndpoints = new(StringComparer.OrdinalIgnoreCase); + + // Per-app dispatch table backing the single middleware registered for classic IApplicationBuilder hosts. + // WebApplication hosts are served through routing (MapGet) instead, so they never need an entry here. + private static readonly ConditionalWeakTable> s_appDispatchTables = new(); private static FrozenSet s_frozenEndpoints; @@ -115,11 +121,14 @@ internal static string GetResourceText(string fileName, Assembly assembly, out s /// internal static void AddGetEndpoint(IApplicationBuilder app, string path, string content, string contentType = MimeTypes.Text.Css) { - if (!s_registeredEndpoints.Add(path)) + if (!s_registeredEndpoints.TryAdd(path, 0)) return; if (app is WebApplication webApp) { + // WebApplication hosts are served through ASP.NET Core's routing tree, which already + // gives O(1)-ish lookup regardless of how many themes are registered, and MapGet lets + // us bypass any global fallback authorization policy via AllowAnonymous. webApp.MapGet(path, (HttpContext context) => { SetCacheHeaders(context); @@ -127,23 +136,35 @@ internal static void AddGetEndpoint(IApplicationBuilder app, string path, string }) .ExcludeFromDescription() .AllowAnonymous(); + + return; } - else + + // Register the single dispatch middleware only the first time this app instance is seen. + // Every subsequent call just adds an entry to this app's own dispatch table, so requests + // are only ever served for paths that this specific app instance registered. + var dispatchTable = s_appDispatchTables.GetValue(app, static application => { - app.Use(async (context, next) => + var table = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + application.Use(async (context, next) => { - if (context.Request.Path.Equals(path, StringComparison.OrdinalIgnoreCase)) + if (table.TryGetValue(context.Request.Path.Value ?? string.Empty, out var endpoint)) { SetCacheHeaders(context); - context.Response.ContentType = contentType; - await context.Response.WriteAsync(content); + context.Response.ContentType = endpoint.ContentType; + await context.Response.WriteAsync(endpoint.Content); } else { await next(); } }); - } + + return table; + }); + + dispatchTable[path] = (content, contentType); static void SetCacheHeaders(HttpContext context) { @@ -161,7 +182,7 @@ internal static void ExposeThemeMetadata( string currentThemeName, string displayFormat) { - if (s_registeredEndpoints.Contains(ThemeMetadataPath)) + if (s_registeredEndpoints.ContainsKey(ThemeMetadataPath)) return; var themeList = themes.Select(rt => new @@ -199,7 +220,7 @@ internal static void ExposeThemeMetadata( /// Freezes collections after startup for better read performance. /// internal static void FreezeCollections() => - s_frozenEndpoints ??= s_registeredEndpoints.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + s_frozenEndpoints ??= s_registeredEndpoints.Keys.ToFrozenSet(StringComparer.OrdinalIgnoreCase); /// /// Checks if a theme name is predefined. diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/ui.js b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/ui.js index 1055e75..fbfbde1 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/ui.js +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/ui.js @@ -13,8 +13,12 @@ window.onpageshow = function () { console.log('Hello Swagger UI!'); + suppressNativeDarkModeClass(); + setUpPinnableTopbar({$PINNABLE_TOPBAR}); + setUpPinnableFilterBar({$PINNABLE_FILTER_BAR}); + setUpScrollToTopButton({$BACK_TO_TOP}); setUpExpandAndCollapseOperationsButtons({$EXPAND_COLLAPSE_ALL_OPERATIONS}); @@ -28,30 +32,149 @@ function setUpPinnableTopbar(enabled) { if (enabled === false) return; - const topbarWrapper = document.querySelector('.topbar-wrapper'); + setUpPinnableToggle({ + buttonId: 'pin-topbar-btn', + container: document.querySelector('.topbar-wrapper'), + // 3 parentNode hops up from the button (appended to .topbar-wrapper) reaches .topbar. + pinnedAncestorDepth: 3, + storageKey: 'swaggerui-pinnable-topbar-preference', + pinTitle: 'Pin topbar', + unpinTitle: 'Unpin topbar', + // No saved preference defaults to pinned, matching the topbar's original + // always-pinned-on-load behavior. + defaultPinned: true + }); +} + +// Swashbuckle.AspNetCore 10.x's swagger-ui-dist ships its own dark-mode toggle, hidden via CSS +// (see common.css) because it layers swagger-ui's own dark styling on top of this library's +// theme. Hiding the button doesn't stop the toggle's React component from mounting though: it +// still auto-applies a 'dark-mode' class to based on prefers-color-scheme regardless of +// the button's visibility. A MutationObserver strips it back off whenever it appears, since +// there's no reliable synchronous point to catch it once and be done - the toggle component can +// mount before or after this script runs. +function suppressNativeDarkModeClass() { + // Only remove (and thus only mutate the attribute) when the class is actually present, so + // this can never trigger the observer with a mutation of its own. + const strip = () => { + if (rootElement.classList.contains('dark-mode')) + rootElement.classList.remove('dark-mode'); + }; + strip(); + new MutationObserver(strip).observe(rootElement, { attributes: true, attributeFilter: ['class'] }); +} + +function setUpPinnableFilterBar(enabled) { + if (enabled === false) + return; + + // Unlike #swagger-ui itself, .filter only renders once the OpenAPI document has been + // fetched and parsed - it commonly doesn't exist yet at the moment #swagger-ui does, so a + // single lookup here would silently and permanently no-op. Poll for it with the same + // bounded-retry pattern used by setUpExpandAndCollapseOperationsButtons below. + const MAX_ATTEMPTS = 60; + const RETRY_DELAY_MS = 200; + let attempts = 0; - const pinTopbarBtn = document.createElement('button'); - pinTopbarBtn.setAttribute('id', 'pin-topbar-btn'); - pinTopbarBtn.addEventListener('click', () => pinOrUnpinTopbar(pinTopbarBtn)) + (function waitForFilterBar() { + attempts++; + const filterBar = document.querySelector('.filter'); - topbarWrapper.appendChild(pinTopbarBtn); + if (filterBar) { + initPinnableFilterBar(filterBar); + return; + } - pinOrUnpinTopbar(pinTopbarBtn); + if (attempts < MAX_ATTEMPTS) + setTimeout(waitForFilterBar, RETRY_DELAY_MS); + })(); } -function pinOrUnpinTopbar(pinTopbarBtn) { - if (pinTopbarBtn.parentNode.parentNode.parentNode.classList.contains('pinned')) { - pinTopbarBtn.parentNode.parentNode.parentNode.classList.remove('pinned'); - setUnpinnedIconTo(pinTopbarBtn); - pinTopbarBtn.setAttribute('title', 'Pin topbar'); +function initPinnableFilterBar(filterBar) { + // CSS alone can't tell whether a sticky element has actually reached its sticky top, so + // toggle a 'stuck' class here (styled in common.css) by comparing the sticky wrapper's + // viewport position against its computed sticky offset. The wrapper is 2 parentElement + // hops up from .filter (.filter -> .filter-container -> sticky div, see common.css). + const stickyWrapper = filterBar.parentElement?.parentElement; + + function updateStuckState() { + if (!stickyWrapper) + return; + + // getComputedStyle().top is 'auto' (NaN) while unpinned, so the comparison alone + // could never be true then; the explicit pinned check just makes that intent clear. + const stuck = filterBar.classList.contains('pinned') + && stickyWrapper.getBoundingClientRect().top <= parseFloat(getComputedStyle(stickyWrapper).top) + 1; + filterBar.classList.toggle('stuck', stuck); } - else { - pinTopbarBtn.parentNode.parentNode.parentNode.classList.add('pinned'); - setPinnedIconTo(pinTopbarBtn); - pinTopbarBtn.setAttribute('title', 'Unpin topbar'); + + window.addEventListener('scroll', updateStuckState, { passive: true }); + window.addEventListener('resize', updateStuckState); + + setUpPinnableToggle({ + buttonId: 'pin-filterbar-btn', + container: filterBar, + // 1 parentNode hop up from the button (appended directly to .filter) reaches .filter itself. + pinnedAncestorDepth: 1, + storageKey: 'swaggerui-pinnable-filterbar-preference', + pinTitle: 'Pin filter bar', + unpinTitle: 'Unpin filter bar', + defaultPinned: false, + // Re-evaluate on every pin/unpin so unpinning mid-scroll clears the 'stuck' class + // immediately instead of waiting for the next scroll event. + onPinnedStateApplied: updateStuckState + }); +} + +// Shared behavior behind every pinnable element (topbar, filter bar, ...): injects a pin +// button into `container`, toggles a 'pinned' class on the ancestor `pinnedAncestorDepth` +// parentNodes up from that button, persists the choice to localStorage, and notifies the +// optional `onPinnedStateApplied` callback after every applied state (initial load included). +function setUpPinnableToggle({ buttonId, container, pinnedAncestorDepth, storageKey, pinTitle, unpinTitle, defaultPinned, onPinnedStateApplied }) { + if (!container) + return; + + const pinBtn = document.createElement('button'); + pinBtn.setAttribute('id', buttonId); + pinBtn.addEventListener('click', () => { + const currentlyPinned = pinnedAncestorOf(pinBtn).classList.contains('pinned'); + applyPinnedState(!currentlyPinned, true); + pinBtn?.blur(); + }) + + container.appendChild(pinBtn); + + // Apply the saved (or default) pinned state directly, so there's no flash of the wrong + // state on page load. + const savedState = localStorage.getItem(storageKey); + const isPinned = savedState !== null ? savedState === 'pinned' : defaultPinned; + applyPinnedState(isPinned, false); + + function pinnedAncestorOf(button) { + let ancestor = button; + for (let i = 0; i < pinnedAncestorDepth; i++) + ancestor = ancestor.parentNode; + return ancestor; } - pinTopbarBtn?.blur(); + function applyPinnedState(pinned, saveToStorage) { + if (pinned) { + pinnedAncestorOf(pinBtn).classList.add('pinned'); + setPinnedIconTo(pinBtn); + pinBtn.setAttribute('title', unpinTitle); + } + else { + pinnedAncestorOf(pinBtn).classList.remove('pinned'); + setUnpinnedIconTo(pinBtn); + pinBtn.setAttribute('title', pinTitle); + } + + if (saveToStorage) { + localStorage.setItem(storageKey, pinned ? 'pinned' : 'unpinned'); + } + + onPinnedStateApplied?.(); + } } function setPinnedIconTo(element) { diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/ui.min.js b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/ui.min.js index fd477c8..aa56ee4 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/ui.min.js +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Scripts/ui.min.js @@ -1,5 +1 @@ -ο»Ώ/*Swagger UI https://github.com/teociaps/SwaggerUI.Themes */const rootElement=document.documentElement;function setUpPinnableTopbar(e){if(!1===e)return;let t=document.querySelector(".topbar-wrapper"),n=document.createElement("button");n.setAttribute("id","pin-topbar-btn"),n.addEventListener("click",()=>pinOrUnpinTopbar(n)),t.appendChild(n),pinOrUnpinTopbar(n)}function pinOrUnpinTopbar(e){e.parentNode.parentNode.parentNode.classList.contains("pinned")?(e.parentNode.parentNode.parentNode.classList.remove("pinned"),setUnpinnedIconTo(e),e.setAttribute("title","Pin topbar")):(e.parentNode.parentNode.parentNode.classList.add("pinned"),setPinnedIconTo(e),e.setAttribute("title","Unpin topbar")),e?.blur()}function setPinnedIconTo(e){e.innerHTML=` - - `}function setUnpinnedIconTo(e){e.innerHTML=` - - `}function setUpScrollToTopButton(e){if(!1===e)return;let t=document.createElement("div");t.classList.add("scroll-to-top-wrapper");let n=document.createElement("button");n.setAttribute("id","scroll-to-top-btn"),n.setAttribute("title","Back to top"),n.addEventListener("click",()=>{scrollToTop(),n?.blur()}),t.appendChild(n);let l=document.getElementById("swagger-ui");l.appendChild(t);let r=()=>{window.scrollY>=200?n.classList.add("showBtn"):n.classList.remove("showBtn")};window.addEventListener("scroll",r),window.addEventListener("resize",r)}function scrollToTop(){rootElement.scrollTo({top:0,behavior:"smooth"})}function setUpExpandAndCollapseOperationsButtons(e){if(!1===e)return;let t=0,n=document.getElementById("swagger-ui")||document.querySelector(".swagger-ui");function l(){let e=document.querySelectorAll(".opblock-tag-section");return!!e&&0!==e.length&&(e.forEach(e=>(function e(t){if(!t||"true"===t.dataset.expandCollapseAttached)return;let n=t.querySelector("h3"),l=n?.querySelector("button.expand-operation");if(n&&!n.querySelector(".expand-collapse-all-btn")){let r=document.createElement("button");r.setAttribute("title","Expand/Collapse all the operations"),r.classList.add("expand-collapse-all-btn"),r.innerHTML="Expand/Collapse All",l?l.before(r):n.appendChild(r),r.addEventListener("click",e=>{e.preventDefault(),e.stopPropagation();let n=t.querySelectorAll(".opblock .opblock-control-arrow");if(!n||0===n.length)return;let l=Array.from(n).every(e=>"true"===e.getAttribute("aria-expanded"));l?n.forEach(e=>{"true"===e.getAttribute("aria-expanded")&&e.click()}):n.forEach(e=>{"false"===e.getAttribute("aria-expanded")&&e.click()})})}t.dataset.expandCollapseAttached="true"})(e)),!0)}!function e(){t++;let r=l();if(r){try{let a=n||document.body,o=new MutationObserver(e=>{for(let t of e)t.addedNodes&&t.addedNodes.length>0&&l()});o.observe(a,{childList:!0,subtree:!0})}catch(i){}return}t<60&&setTimeout(e,200)}()}function setUpThemeSwitcher(e){if(!1===e)return;let t="swaggerui-theme-preference",n=null,l=null;function r(e,r){let a=n?.themes.find(t=>t.name===e);if(!a){console.warn(`[ThemeSwitcher] Theme not found: ${e}`);return}let o=document.querySelectorAll('link[rel="stylesheet"]'),i=n.themes.map(e=>e.cssPath),s=!1;o.forEach(t=>{let n=t.dataset.theme||i.some(e=>t.href?.endsWith(e));if(n){let l=t.dataset.theme===e||t.href?.endsWith(a.cssPath);l?(t.disabled=!1,t.dataset.theme=e,s=!0):t.disabled=!0}}),s||console.warn(`[ThemeSwitcher] Could not activate theme: ${e}`),l=e,r&&localStorage.setItem(t,e);let $=document.getElementById("theme-switcher-select");$&&$.value!==e&&($.value=e)}fetch("/themes/metadata.json").then(e=>e.ok?e.json():(console.warn("[ThemeSwitcher] Failed to load theme metadata"),null)).then(e=>{if(!e?.themes||e.themes.length<2){console.warn("[ThemeSwitcher] Not enough themes available for switcher");return}n=e,l=detectCurrentTheme(e.themes),function e(n){let a=localStorage.getItem(t);a&&n.some(e=>e.name===a)&&a!==l&&r(a,!1)}(e.themes),function e(t){let n=document.querySelector(".topbar-wrapper");if(!n){console.warn("[ThemeSwitcher] Topbar not found");return}let a=document.createElement("select");a.id="theme-switcher-select",a.setAttribute("aria-label","Select theme"),a.title="Switch theme",t.themes.forEach(e=>{let n=document.createElement("option");n.value=e.name,n.textContent=formatThemeName(e.name,t.config?.displayFormat||"{name}"),e.name===l&&(n.selected=!0),a.appendChild(n)}),a.addEventListener("change",e=>{r(e.target.value,!0)});let o=document.getElementById("pin-topbar-btn");o?o.before(a):n.appendChild(a)}(e)}).catch(e=>{console.error("[ThemeSwitcher] Error loading themes:",e)})}function detectCurrentTheme(e){let t=document.querySelector('link[rel="stylesheet"]:not([disabled])[data-theme]');if(t?.dataset.theme)return t.dataset.theme;let n=document.querySelectorAll('link[rel="stylesheet"]:not([disabled])');for(let l of n){let r=e.find(e=>!!l.href&&(l.href.includes(e.cssPath)||l.href.endsWith(e.cssPath)||l.href.includes(e.name.toLowerCase())));if(r)return r.name}return e.length>0?e[0].name:null}function formatThemeName(e,t){let n=e.replaceAll(/([A-Z])/g," $1").trim().split(/[\s_-]+/).map(e=>e.charAt(0).toUpperCase()+e.slice(1).toLowerCase()).join(" ");return t.replace("{name}",n)}window.onpageshow=function(){let e=setInterval(function(){null!=document.getElementById("swagger-ui")&&(clearInterval(e),console.log("Hello Swagger UI!"),setUpPinnableTopbar({$PINNABLE_TOPBAR}),setUpScrollToTopButton({$BACK_TO_TOP}),setUpExpandAndCollapseOperationsButtons({$EXPAND_COLLAPSE_ALL_OPERATIONS}),setUpThemeSwitcher({$THEME_SWITCHER}))},100)}; \ No newline at end of file +ο»Ώ/*Swagger UI https://github.com/teociaps/SwaggerUI.Themes */const rootElement=document.documentElement;function setUpPinnableTopbar(e){!1!==e&&setUpPinnableToggle({buttonId:"pin-topbar-btn",container:document.querySelector(".topbar-wrapper"),pinnedAncestorDepth:3,storageKey:"swaggerui-pinnable-topbar-preference",pinTitle:"Pin topbar",unpinTitle:"Unpin topbar",defaultPinned:!0})}function suppressNativeDarkModeClass(){const e=()=>{rootElement.classList.contains("dark-mode")&&rootElement.classList.remove("dark-mode")};e(),new MutationObserver(e).observe(rootElement,{attributes:!0,attributeFilter:["class"]})}function setUpPinnableFilterBar(e){if(!1===e)return;let n=0;!function r(){n++;const t=document.querySelector(".filter");if(t)return void initPinnableFilterBar(t);n<60&&setTimeout(r,200)}()}function initPinnableFilterBar(t){const n=t.parentElement?.parentElement;function o(){if(!n)return;const e=t.classList.contains("pinned")&&n.getBoundingClientRect().top<=parseFloat(getComputedStyle(n).top)+1;t.classList.toggle("stuck",e)}window.addEventListener("scroll",o,{passive:!0}),window.addEventListener("resize",o),setUpPinnableToggle({buttonId:"pin-filterbar-btn",container:t,pinnedAncestorDepth:1,storageKey:"swaggerui-pinnable-filterbar-preference",pinTitle:"Pin filter bar",unpinTitle:"Unpin filter bar",defaultPinned:!1,onPinnedStateApplied:o})}function setUpPinnableToggle({buttonId:e,container:t,pinnedAncestorDepth:n,storageKey:o,pinTitle:l,unpinTitle:a,defaultPinned:r,onPinnedStateApplied:p}){if(!t)return;const i=document.createElement("button");i.setAttribute("id",e),i.addEventListener("click",()=>{d(!c(i).classList.contains("pinned"),!0),i?.blur()}),t.appendChild(i);const s=localStorage.getItem(o);function c(e){let t=e;for(let e=0;e{scrollToTop(),n?.blur()}),t.appendChild(n);document.getElementById("swagger-ui").appendChild(t);const o=()=>{window.scrollY>=200?n.classList.add("showBtn"):n.classList.remove("showBtn")};window.addEventListener("scroll",o),window.addEventListener("resize",o)}function scrollToTop(){rootElement.scrollTo({top:0,behavior:"smooth"})}function setUpExpandAndCollapseOperationsButtons(e){if(!1===e)return;let t=0;const n=document.getElementById("swagger-ui")||document.querySelector(".swagger-ui");function o(){const e=document.querySelectorAll(".opblock-tag-section");return!(!e||0===e.length)&&(e.forEach(e=>function(e){if(!e||"true"===e.dataset.expandCollapseAttached)return;const t=e.querySelector("h3"),n=t?.querySelector("button.expand-operation");if(t&&!t.querySelector(".expand-collapse-all-btn")){const o=document.createElement("button");o.setAttribute("title","Expand/Collapse all the operations"),o.classList.add("expand-collapse-all-btn"),o.innerHTML="Expand/Collapse All",n?n.before(o):t.appendChild(o),o.addEventListener("click",t=>{t.preventDefault(),t.stopPropagation();const n=e.querySelectorAll(".opblock .opblock-control-arrow");n&&0!==n.length&&(Array.from(n).every(e=>"true"===e.getAttribute("aria-expanded"))?n.forEach(e=>{"true"===e.getAttribute("aria-expanded")&&e.click()}):n.forEach(e=>{"false"===e.getAttribute("aria-expanded")&&e.click()}))})}e.dataset.expandCollapseAttached="true"}(e)),!0)}!function e(){if(t++,o())try{const e=n||document.body;new MutationObserver(e=>{for(const t of e)t.addedNodes&&t.addedNodes.length>0&&o()}).observe(e,{childList:!0,subtree:!0})}catch(e){}else t<60&&setTimeout(e,200)}()}function setUpThemeSwitcher(e){if(!1===e)return;const t="swaggerui-theme-preference";let n=null,o=null;function l(e,l){const a=n?.themes.find(t=>t.name===e);if(!a)return void console.warn(`[ThemeSwitcher] Theme not found: ${e}`);const r=document.querySelectorAll('link[rel="stylesheet"]'),i=n.themes.map(e=>e.cssPath);let s=!1;r.forEach(t=>{if(t.dataset.theme||i.some(e=>t.href?.endsWith(e))){t.dataset.theme===e||t.href?.endsWith(a.cssPath)?(t.disabled=!1,t.dataset.theme=e,s=!0):t.disabled=!0}}),s||console.warn(`[ThemeSwitcher] Could not activate theme: ${e}`),o=e,l&&localStorage.setItem(t,e);const c=document.getElementById("theme-switcher-select");c&&c.value!==e&&(c.value=e)}fetch("/themes/metadata.json").then(e=>e.ok?e.json():(console.warn("[ThemeSwitcher] Failed to load theme metadata"),null)).then(e=>{!e?.themes||e.themes.length<2?console.warn("[ThemeSwitcher] Not enough themes available for switcher"):(n=e,o=detectCurrentTheme(e.themes),function(e){const n=localStorage.getItem(t);n&&e.some(e=>e.name===n)&&n!==o&&l(n,!1)}(e.themes),function(e){const t=document.querySelector(".topbar-wrapper");if(!t)return void console.warn("[ThemeSwitcher] Topbar not found");const n=document.createElement("select");n.id="theme-switcher-select",n.setAttribute("aria-label","Select theme"),n.title="Switch theme",e.themes.forEach(t=>{const l=document.createElement("option");l.value=t.name,l.textContent=formatThemeName(t.name,e.config?.displayFormat||"{name}"),t.name===o&&(l.selected=!0),n.appendChild(l)}),n.addEventListener("change",e=>{l(e.target.value,!0)});const a=document.getElementById("pin-topbar-btn");a?a.before(n):t.appendChild(n)}(e))}).catch(e=>{console.error("[ThemeSwitcher] Error loading themes:",e)})}function detectCurrentTheme(e){const t=document.querySelector('link[rel="stylesheet"]:not([disabled])[data-theme]');if(t?.dataset.theme)return t.dataset.theme;const n=document.querySelectorAll('link[rel="stylesheet"]:not([disabled])');for(const t of n){const n=e.find(e=>!!t.href&&(t.href.includes(e.cssPath)||t.href.endsWith(e.cssPath)||t.href.includes(e.name.toLowerCase())));if(n)return n.name}return e.length>0?e[0].name:null}function formatThemeName(e,t){const n=e.replaceAll(/([A-Z])/g," $1").trim().split(/[\s_-]+/).map(e=>e.charAt(0).toUpperCase()+e.slice(1).toLowerCase()).join(" ");return t.replace("{name}",n)}window.onpageshow=function(){let e=setInterval(function(){null!=document.getElementById("swagger-ui")&&(clearInterval(e),console.log("Hello Swagger UI!"),suppressNativeDarkModeClass(),setUpPinnableTopbar({$PINNABLE_TOPBAR}),setUpPinnableFilterBar({$PINNABLE_FILTER_BAR}),setUpScrollToTopButton({$BACK_TO_TOP}),setUpExpandAndCollapseOperationsButtons({$EXPAND_COLLAPSE_ALL_OPERATIONS}),setUpThemeSwitcher({$THEME_SWITCHER}))},100)}; \ No newline at end of file diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/common.css b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/common.css index 7a42fe6..30e94c0 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/common.css +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/common.css @@ -6,6 +6,15 @@ @charset "UTF-8"; +:root { + /* Rendered heights of the pinned topbar/filter bar, used to stack sticky elements below + them without overlap. Declared once here so the two DOM-branch-specific copies below + (needed because of how .topbar.pinned/.filter.pinned relate to the elements that read + them) can't drift out of sync. */ + --topbar-pinned-height: 4rem; + --filterbar-pinned-height: 5rem; +} + *::-webkit-scrollbar { width: 16px; } @@ -95,6 +104,15 @@ body { .swagger-ui .topbar.pinned { position: sticky; top: 10px; + --pinned-topbar-offset: var(--topbar-pinned-height); + } + + /* The filter bar and the sticky operation tag headers live in a different branch of the + DOM than the topbar, so they can't inherit --pinned-topbar-offset directly from + .topbar.pinned. Re-declare it here, on their closest common ancestor, so it only + carries a nonzero value while the topbar is actually pinned. */ + .swagger-ui:has(> .topbar.pinned) { + --pinned-topbar-offset: var(--topbar-pinned-height); } .swagger-ui .topbar.pinned::before { @@ -122,7 +140,10 @@ body { fill: var(--topbar-pin-icon-color, white); } - .swagger-ui .topbar .topbar-wrapper #pin-topbar-btn { + /* Base/hover/active button styling is shared with the filter bar's pin button + (#pin-filterbar-btn below); only the icon fill color differs between the two. */ + .swagger-ui .topbar .topbar-wrapper #pin-topbar-btn, + .swagger-ui .filter #pin-filterbar-btn { cursor: pointer; background: transparent; border: none; @@ -133,12 +154,15 @@ body { } .swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:hover, - .swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:focus-within { + .swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:focus-within, + .swagger-ui .filter #pin-filterbar-btn:hover, + .swagger-ui .filter #pin-filterbar-btn:focus-within { scale: 1.05; animation: pintobbar 1.2s infinite; } - .swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:active { + .swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:active, + .swagger-ui .filter #pin-filterbar-btn:active { scale: 0.95; } @@ -149,13 +173,21 @@ body { backdrop-filter: blur(20px); } - .swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn { - translate: 0 5px; + /* rotate is shared with the filter bar's pinned pin button (#pin-filterbar-btn + below); translate is topbar-specific, nudging the button into the pinned layout. */ + .swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn, + .swagger-ui .filter.pinned #pin-filterbar-btn { rotate: z 0deg; } + .swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn { + translate: 0 5px; + } + .swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn:hover, - .swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn:focus-within { + .swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn:focus-within, + .swagger-ui .filter.pinned #pin-filterbar-btn:hover, + .swagger-ui .filter.pinned #pin-filterbar-btn:focus-within { animation: unpintobbar 1.2s infinite; } @@ -251,6 +283,15 @@ body { color: var(--topbar-download-url-button-color, #fff); } +/* Swashbuckle.AspNetCore 10.x's swagger-ui-dist ships its own native dark-mode toggle in the + topbar. It layers swagger-ui's own bundled dark CSS on top of whatever theme is active here, + visibly clashing with it - hide it since this library's own theme (and, when enabled, its + EnableThemeSwitcher) already cover light/dark. Harmless no-op on older Swashbuckle versions + that don't render this element at all. */ +.swagger-ui .topbar .dark-mode-toggle { + display: none; +} + #theme-switcher-select { background: var(--select-background-icon, #f7f7f7 url('data:image/svg+xml;charset=utf-8,') right 10px center no-repeat); border: 2px solid var(--topbar-select-border-color, #62a03f); @@ -402,8 +443,8 @@ body { background: var(--opblock-tag-pinned-background-color-hover, #f1f1f1); } -.swagger-ui:has(> .topbar.pinned) .opblock-tag-section.is-open .opblock-tag { - top: 4.2rem; +.swagger-ui .opblock-tag-section.is-open .opblock-tag { + top: calc(var(--pinned-topbar-offset, 0px) + var(--pinned-filter-offset, 0px)); } .swagger-ui .opblock { @@ -646,10 +687,72 @@ body { } /* Other of operations */ +/* The pin button is appended as a sibling of .filter-container (swagger-ui-dist's own, + unstyled, full-width wrapper around the filter input) directly inside .filter. It's + absolutely positioned over the input's right end (like an input-embedded icon), with + position: relative here making .filter its containing block; align-items keeps its + static position vertically centered. */ +.swagger-ui .filter { + position: relative; + display: flex; + align-items: center; +} + + .swagger-ui .filter .filter-container { + flex: 1; + min-width: 0; + } + + .swagger-ui .filter #pin-filterbar-btn { + position: absolute; + right: 1.5rem; + } + .swagger-ui .filter .operation-filter-input { border: 2px solid var(--filter-operation-filter-input-border-color, #d8dde7); } + /* Only while the pinned filter bar has actually reached its sticky top (the 'stuck' + class is toggled by ui.js β€” CSS can't detect that state): fake a bottom edge for the + sticky bar by stacking two hard shadows below the input β€” a background-colored fill, + then a 1px line in the operations' border color. */ + .swagger-ui .filter.pinned.stuck .operation-filter-input { + box-shadow: 0 19px 0 0 var(--body-background-color, #fafafa), 0 20px 0 0 var(--opblock-tag-section-border-color, #dddddd); + } + + /* .filter's own wrapper (.filter-container) is exactly as tall as the filter bar itself + (its only child), so making .filter, or .filter-container, position:sticky would have no + visible effect: a sticky box is bounded by its own containing block, and that block would + offer no extra room for it to remain "stuck" while the page scrolls. What actually needs + position: sticky is .filter-container's own parent, matched here by this selector (the + ">" combinators pin it down to exactly that element): unlike .filter-container, that div's + *own* parent, one level further up, spans the full page content (info box, filter, + operations list, models, ...) as siblings, which gives this element room to stick. It has + no stable class of its own in Swagger UI's markup, so it's targeted structurally via its + known descendants instead. */ + .swagger-ui div:has(> .filter-container > .filter.pinned) { + position: sticky; + top: var(--pinned-topbar-offset, 0px); + z-index: 5; + background: var(--body-background-color, #fafafa); + --pinned-filter-offset: var(--filterbar-pinned-height); + } + +/* The sticky operation tag headers live in a different branch of the DOM than the filter bar, + so they can't inherit --pinned-filter-offset directly from the rule above. Re-declare it here, + on their closest common ancestor, so it only carries a nonzero value while the filter bar is + actually pinned. */ +.swagger-ui:has(.filter.pinned) { + --pinned-filter-offset: var(--filterbar-pinned-height); +} + + .swagger-ui .filter #pin-filterbar-btn svg { + fill: var(--filterbar-pin-icon-color, #707070); + } + + /* Base, hover/focus, active, and pinned button styling is shared with the topbar's pin + button (#pin-topbar-btn) β€” see the .topbar .topbar-wrapper #pin-topbar-btn rules above. */ + .swagger-ui .download-url-wrapper .failed, .swagger-ui .filter .failed { color: var(--filter-download-failed-color, red) diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/common.min.css b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/common.min.css index bd03f59..0e51dc6 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/common.min.css +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/common.min.css @@ -1 +1 @@ -ο»Ώ/*Common Theme https://github.com/teociaps/SwaggerUI.Themes */@charset "UTF-8";*,:focus{font-family:system-ui,sans-serif}.swagger-ui,.swagger-ui .checkbox p,.swagger-ui .dialog-ux .modal-ux-content h4,.swagger-ui .dialog-ux .modal-ux-content p,.swagger-ui .dialog-ux .modal-ux-header h3,.swagger-ui .errors-wrapper .errors h4,.swagger-ui .errors-wrapper hgroup h4,.swagger-ui .info .base-url,.swagger-ui .info .title,.swagger-ui .info h1,.swagger-ui .info h2,.swagger-ui .info h3,.swagger-ui .info h4,.swagger-ui .info h5,.swagger-ui .info li,.swagger-ui .info p,.swagger-ui .info table,.swagger-ui .loading-container .loading:after,.swagger-ui .model,.swagger-ui .opblock .opblock-section-header h4,.swagger-ui .opblock .opblock-section-header>label,.swagger-ui .opblock-description-wrapper,.swagger-ui .opblock-description-wrapper h4,.swagger-ui .opblock-description-wrapper p,.swagger-ui .opblock-external-docs-wrapper,.swagger-ui .opblock-external-docs-wrapper h4,.swagger-ui .opblock-external-docs-wrapper p,.swagger-ui .opblock-tag,.swagger-ui .opblock-tag small,.swagger-ui .opblock-title_normal,.swagger-ui .opblock-title_normal h4,.swagger-ui .opblock-title_normal p,.swagger-ui .parameter__name,.swagger-ui .parameter__type,.swagger-ui .response-col_links,.swagger-ui .response-col_status,.swagger-ui .responses-inner h4,.swagger-ui .responses-inner h5,.swagger-ui .scheme-container .schemes>label,.swagger-ui .scopes h2,.swagger-ui .servers>label,.swagger-ui .tab li,.swagger-ui label,.swagger-ui section h3,.swagger-ui table.headers td{color:var(--swagger-main-color,#3b4151)}::-webkit-scrollbar{width:16px}::-webkit-scrollbar-thumb{height:56px;border-radius:8px;border:4px solid transparent;background-clip:content-box;background-color:var(--scrollbar-thumb-color,#a5a5a5);transition:background-color .3s}::-webkit-scrollbar-thumb:hover{background-color:var(--scrollbar-thumb-hover-color,#727272)}body::-webkit-scrollbar-thumb{background-color:var(--scrollbar-thumb-color,#a5a5a5)}#theme-switcher-select,.swagger-ui select{background:var(--select-background-icon, #f7f7f7 url('data:image/svg+xml;charset=utf-8,') right 10px center no-repeat)}:focus{outline:0!important}:focus-visible{outline:auto!important;outline-offset:1px}body{background-color:var(--body-background-color,#fafafa);padding-bottom:1rem}*,::after,::before{box-sizing:border-box}.swagger-ui{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;line-height:1.15;margin:0}.swagger-ui .loading-container .loading:before{border:2px solid var(--loading-container-border-color,rgba(85,85,85,.1));border-top-color:var(--loading-container-border-top-color,rgba(0,0,0,.6))}.swagger-ui .errors-wrapper{background:var(--errors-wrapper-background-color,rgba(249,62,62,.1));border:2px solid var(--errors-wrapper-border-color,#f93e3e)}.swagger-ui .errors-wrapper .errors small{color:var(--errors-wrapper-errors-color,#606060)}.swagger-ui .topbar{background-color:transparent;z-index:1000;padding:0}.swagger-ui .topbar.pinned{position:sticky;top:10px}.swagger-ui .topbar.pinned::before{content:'';position:absolute;inset:-10px 0 0;pointer-events:none;user-select:none;backdrop-filter:blur(20px);-webkit-mask-image:linear-gradient(to bottom,black 40%,transparent);mask-image:linear-gradient(to bottom,black 40%,transparent);background:linear-gradient(90deg,rgba(0,0,0,0) 5%,rgba(8,8,8,.4) 50%,rgba(0,0,0,0) 95%)}.swagger-ui .topbar .topbar-wrapper{background-color:var(--topbar-background-color,#000);border:1px solid var(--topbar-border-color,gray);border-top:0;border-radius:0 0 .5rem .5rem;padding:.5rem;box-shadow:0 5px 15px 0 var(--topbar-pinned-shadow-color,rgba(0,0,0,.5))}.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn svg{fill:var(--topbar-pin-icon-color,white)}.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn{cursor:pointer;background:0 0;border:none;margin:.2rem;padding:.2rem;transition:.2s;rotate:z 359deg}.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:focus-within,.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:hover{scale:1.05;animation:1.2s infinite pintobbar}#scroll-to-top-btn:active,.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:active{scale:0.95}.swagger-ui .topbar.pinned .topbar-wrapper{background-color:var(--topbar-pinned-background-color,rgba(0,0,0,.7));border:1px solid var(--topbar-border-color,gray);border-radius:.5rem;backdrop-filter:blur(20px)}#theme-switcher-select,.swagger-ui .topbar .download-url-wrapper .select-label select,.swagger-ui .topbar .download-url-wrapper input[type=text]{border:2px solid var(--topbar-select-border-color,#62a03f)}.swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn{translate:0 5px;rotate:z 0deg}.swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn:focus-within,.swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn:hover{animation:1.2s infinite unpintobbar}@keyframes pintobbar{30%{rotate:z 313deg;translate:0px 1px}40%{translate:0 5px}50%{rotate:z 313deg}}@-moz-keyframes pintobbar{30%{rotate:z 313deg;translate:0px 1px}40%{translate:0 5px}50%{rotate:z 313deg}}@-webkit-keyframes pintobbar{30%{rotate:z 313deg;translate:0px 1px}40%{translate:0 5px}50%{rotate:z 313deg}}@keyframes unpintobbar{10%{translate:0 1px}70%{rotate:z 50deg;translate:-3px 1px}}@-moz-keyframes unpintobbar{10%{translate:0 -4px}70%{rotate:z 50deg;translate:-3px 1px}}@-webkit-keyframes unpintobbar{10%{translate:0 -4px}70%{rotate:z 50deg;translate:-3px 1px}}.swagger-ui .topbar .download-url-wrapper .select-label{color:var(--topbar-select-label-color,#f0f0f0)}.swagger-ui .topbar .download-url-wrapper .download-url-button{background:var(--topbar-download-url-button-background-color,#62a03f);color:var(--topbar-download-url-button-color,#fff)}#theme-switcher-select{min-width:120px;box-shadow:none}.swagger-ui .info a,.swagger-ui .scopes h2 a{color:var(--swagger-info-link,#4990e2)}.swagger-ui .info a:hover{color:var(--swagger-info-link-hover,#1f69c0)}.swagger-ui .info .title small{background:var(--api-version-background-color,#7d8492)}.swagger-ui .info .title small.version-stamp{background-color:var(--api-version-stamp-background-color,#89bf04)}.swagger-ui .info .title small pre{color:var(--api-version-color,#fff)}.swagger-ui .scheme-container{background:0 0;box-shadow:none;margin:0;padding:0}.swagger-ui .auth-wrapper{display:flex;flex:1;justify-content:flex-end;border:1px solid var(--auth-wrapper-border-color,#d5d5d5);padding:0;border-radius:5px;box-shadow:0 3px 5px -5px var(--auth-wrapper-box-shadow-color,#000);background:var(--auth-wrapper-background-color,#fff)}.swagger-ui .auth-container{border-bottom:1px solid var(--auth-container-border-bottom-color,#ebebeb);color:var(--swagger-main-color,#3b4151)}.swagger-ui .auth-container .errors{background-color:var(--auth-container-background-color,#fee);color:var(--auth-container-errors-color,red)}.swagger-ui .btn.authorize{background-color:var(--btn-authorize-background-color,transparent);border-color:var(--btn-authorize-border-color,#49cc90);color:var(--btn-authorize-font-color,#49cc90)}.swagger-ui .btn.authorize svg{fill:var(--btn-authorize-svg-fill-color,#49cc90)}.swagger-ui .opblock-tag-section{display:flex;flex-direction:column;border:1px solid var(--opblock-tag-section-border-color,#ddd);border-radius:.5rem;margin-block:1rem;overflow:clip}.swagger-ui .opblock-tag-section .expand-collapse-all-btn{display:none;user-select:none}.swagger-ui .opblock-tag-section.is-open .expand-collapse-all-btn{background:var(--opblock-tag-section-expandcollapse-background-color,rgb(220 220 220 / 45%));color:var(--swagger-main-color,#3b4151);border:1px solid var(--opblock-tag-section-border-color,#ddd);border-radius:.5rem;padding:5px 10px;font-size:medium;margin-inline:10px;display:block}.swagger-ui .opblock-tag{align-items:center;cursor:pointer;display:flex;padding:10px 20px 10px 10px;color:var(--swagger-main-color,#3b4151);font-size:24px;border-bottom:0 dashed var(--opblock-tag-border-bottom-color,#e1e1e1);margin:0;transition:.2s}.swagger-ui .opblock-tag:focus-within,.swagger-ui .opblock-tag:hover{background:var(--opblock-tag-background-color-hover,rgba(0,0,0,.02))}.swagger-ui .opblock-tag+div .operation-tag-content{margin-inline:5px}#STICKY_OPERATIONS.swagger-ui .opblock-tag-section.is-open .opblock-tag{position:sticky;top:0;z-index:4;margin-bottom:1rem;border-bottom:1px dashed var(--opblock-tag-border-bottom-color,#e1e1e1);background:var(--body-background-color,#fafafa)}#STICKY_OPERATIONS.swagger-ui .opblock-tag-section.is-open .opblock-tag:focus-within,#STICKY_OPERATIONS.swagger-ui .opblock-tag-section.is-open .opblock-tag:hover{background:var(--opblock-tag-pinned-background-color-hover,#f1f1f1)}.swagger-ui:has(> .topbar.pinned) .opblock-tag-section.is-open .opblock-tag{top:4.2rem}.swagger-ui .opblock{border:1px solid var(--opblock-border-color,#000);box-shadow:0 0 3px var(--opblock-shadow-color,rgba(0,0,0,.19))}.swagger-ui .opblock .tab-header .tab-item.active h4 span:after{background:var(--opblock-tabheader-underline-color,gray)}.swagger-ui .opblock .opblock-summary button svg{fill:var(--opblock-summary-svg-icons-color,#000)}.swagger-ui .opblock.is-open .opblock-summary{border-bottom:1px solid var(--opblock-summary-border-bottom-color,#000)}.swagger-ui .opblock.opblock-post,.swagger-ui .opblock.opblock-post .opblock-summary{border-color:var(--opblock-post-border-color,#49cc90)}.swagger-ui .opblock .opblock-section-header{background:var(--opblock-section-header-background-color,hsla(0,0%,100%,.8));box-shadow:0 1px 2px var(--opblock-section-header-shadow-color,rgba(0,0,0,.1))}.swagger-ui .opblock .opblock-summary-method{background:var(--opblock-summary-method-background-color,#000);color:var(--opblock-summary-method-color,#fff);text-shadow:0 1px 0 var(--opblock-summary-method-shadow-color,rgba(0,0,0,.1))}.swagger-ui .opblock .opblock-summary-description,.swagger-ui .opblock .opblock-summary-operation-id,.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{color:var(--swagger-main-color,#3b4151);word-break:normal}.swagger-ui .opblock.opblock-post{background:var(--opblock-post-background-color,rgba(73,204,144,.1))}.swagger-ui .opblock.opblock-post .opblock-summary-method{background:var(--opblock-post-method-color,#49cc90)}.swagger-ui .opblock.opblock-put,.swagger-ui .opblock.opblock-put .opblock-summary{border-color:var(--opblock-put-border-color,#fca130)}.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span:after{background:var(--opblock-post-method-color,#49cc90);border-radius:1rem}.swagger-ui .opblock.opblock-put{background:var(--opblock-put-background-color,rgba(252,161,48,.1))}.swagger-ui .opblock.opblock-put .opblock-summary-method,.swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span:after{background:var(--opblock-put-method-color,#fca130)}.swagger-ui .opblock.opblock-delete,.swagger-ui .opblock.opblock-delete .opblock-summary{border-color:var(--opblock-delete-border-color,#f93e3e)}.swagger-ui .opblock.opblock-delete{background:var(--opblock-delete-background-color,rgba(249,62,62,.1))}.swagger-ui .opblock.opblock-delete .opblock-summary-method,.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span:after{background:var(--opblock-delete-method-color,#f93e3e)}.swagger-ui .opblock.opblock-get,.swagger-ui .opblock.opblock-get .opblock-summary{border-color:var(--opblock-get-border-color,#61affe)}.swagger-ui .opblock.opblock-get{background:var(--opblock-get-background-color,rgba(97,175,254,.1))}.swagger-ui .opblock.opblock-get .opblock-summary-method,.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span:after{background:var(--opblock-get-method-color,#61affe)}.swagger-ui .opblock.opblock-patch,.swagger-ui .opblock.opblock-patch .opblock-summary{border-color:var(--opblock-patch-border-color,#50e3c2)}.swagger-ui .opblock.opblock-patch{background:var(--opblock-patch-background-color,rgba(80,227,194,.1))}.swagger-ui .opblock.opblock-patch .opblock-summary-method,.swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span:after{background:var(--opblock-patch-method-color,#50e3c2)}.swagger-ui .opblock.opblock-head,.swagger-ui .opblock.opblock-head .opblock-summary{border-color:var(--opblock-head-border-color,#9012fe)}.swagger-ui .opblock.opblock-head{background:var(--opblock-head-background-color,rgba(144,18,254,.1))}.swagger-ui .opblock.opblock-head .opblock-summary-method,.swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span:after{background:var(--opblock-head-method-color,#9012fe)}.swagger-ui .opblock.opblock-options,.swagger-ui .opblock.opblock-options .opblock-summary{border-color:var(--opblock-options-border-color,#0d5aa7)}.swagger-ui .opblock.opblock-options{background:var(--opblock-options-background-color,rgba(13,90,167,.1))}.swagger-ui .opblock.opblock-options .opblock-summary-method,.swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span:after{background:var(--opblock-options-method-color,#0d5aa7)}.swagger-ui .opblock.opblock-deprecated,.swagger-ui .opblock.opblock-deprecated .opblock-summary{border-color:var(--opblock-deprecated-border-color,#ebebeb)}.swagger-ui .opblock.opblock-deprecated{background:var(--opblock-deprecated-background-color,hsla(0,0%,92%,.1))}.swagger-ui .opblock.opblock-deprecated .opblock-summary-method,.swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span:after{background:var(--opblock-deprecated-method-color,#ebebeb)}.swagger-ui .tab li:first-of-type:after{background:var(--swagger-tabs-divider-color,rgba(0,0,0,.2))}.swagger-ui .response-col_links .response-undocumented,.swagger-ui .response-col_status .response-undocumented{color:var(--response-undocumented-color,#909090)}.swagger-ui .response-control-media-type--accept-controller select{border-color:var(--response-control-media-type-color,green)}.swagger-ui .response-control-media-type__accept-message{color:var(--response-control-media-type-color,green)}.swagger-ui .opblock-body pre.microlight{background:var(--opblock-pre-microlight-background-color,#333)!important;color:var(--opblock-pre-microlight-color,#fff)!important}.swagger-ui .download-contents{background:var(--download-contents-background-color,#7d8293);color:var(--download-contents-color,#fff)}.swagger-ui .copy-to-clipboard{background:var(--copy-to-clipboard-background-color,#7d8293)}.swagger-ui .copy-to-clipboard button{background:var(--copy-to-clipboard-icon, url('data:image/svg+xml;charset=utf-8,') 50% no-repeat)}.swagger-ui .filter .operation-filter-input{border:2px solid var(--filter-operation-filter-input-border-color,#d8dde7)}.swagger-ui .download-url-wrapper .failed,.swagger-ui .filter .failed{color:var(--filter-download-failed-color,red)}.swagger-ui .download-url-wrapper .loading,.swagger-ui .filter .loading{color:var(--filter-download-loading-color,#aaa)}.swagger-ui table.headers .header-example{color:var(--table-headers-example-color,#999)}.swagger-ui table thead tr td,.swagger-ui table thead tr th{border-bottom:1px solid var(--table-thead-border-bottom-color,rgba(59,65,81,.2));color:var(--swagger-main-color,#3b4151)}.swagger-ui .parameter__name.required span{color:var(--parameter-name-required-symbol-color,red)}.swagger-ui .parameter__name.required::after{color:var(--parameter-name-required-color,rgba(255,0,0,.6))}.swagger-ui .parameter__extension,.swagger-ui .parameter__in,.swagger-ui .response__extension{color:var(--parameter-in-extension-color,gray)}.swagger-ui .parameter__deprecated{color:var(--parameter-deprecated-color,red)}.swagger-ui .btn,.swagger-ui select,.swagger-ui textarea{color:var(--swagger-main-color,#3b4151)}.swagger-ui .btn{background:var(--button-background-color,transparent);border:2px solid var(--button-border-color,gray);box-shadow:0 1px 2px var(--button-shadow-color,rgba(0,0,0,.1))}.swagger-ui .btn:hover{box-shadow:0 0 5px var(--button-shadow-hover-color,rgba(0,0,0,.3))}.swagger-ui .btn.cancel{background-color:var(--button-cancel-background-color,transparent);border-color:var(--button-cancel-border-color,#ff6060);color:var(--button-cancel-color,#ff6060)}.swagger-ui .btn.execute{background-color:var(--button-execute-background-color,#4990e2);border-color:var(--button-execute-border-color,#4990e2);color:var(--button-execute-color,#fff)}.swagger-ui button.invalid{background:var(--button-invalid-background-color,#feebeb);border-color:var(--button-invalid-border-color,#f93e3e)}.swagger-ui .expand-methods svg,.swagger-ui .expand-operation svg{fill:var(--expand-operation-svg-arrow-color,#707070)}.swagger-ui .expand-methods:hover svg,.swagger-ui .expand-operation:hover svg{fill:var(--expand-operation-svg-arrow-hover-color,#404040)}.swagger-ui select{border:2px solid var(--select-border-color,#41444e);box-shadow:0 1px 2px 0 var(--select-shadow-color,rgba(0,0,0,.25));cursor:pointer}.swagger-ui select[multiple]{background:var(--select-background-color,#f7f7f7)}.swagger-ui select:disabled{opacity:.5;cursor:not-allowed}.swagger-ui input[type=email],.swagger-ui input[type=file],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text],.swagger-ui textarea{background:var(--input-background-color,#fff);border:1px solid var(--input-border-color,#d9d9d9);color:var(--swagger-main-color,#3b4151)}.swagger-ui input[type=email].invalid,.swagger-ui input[type=file].invalid,.swagger-ui input[type=password].invalid,.swagger-ui input[type=search].invalid,.swagger-ui input[type=text].invalid,.swagger-ui select.invalid,.swagger-ui textarea.invalid{background:var(--input-invalid-background-color,#feebeb);border-color:var(--input-invalid-border-color,#f93e3e);color:var(--input-invalid-color,#3b4151)}.swagger-ui input[disabled],.swagger-ui select[disabled]{background-color:var(--input-disabled-background-color,#fafafa);color:var(--input-disabled-color,#888)}.swagger-ui select[disabled]{border-color:var(--select-disabled-border-color,#888)}.swagger-ui textarea[disabled]{background-color:var(--textarea-disabled-background-color,#41444e);color:var(--textarea-disabled-color,#fff)}.swagger-ui textarea{background:var(--textarea-background-color,hsla(0,0%,100%,.8))}.swagger-ui textarea:focus{border:2px solid var(--textarea-border-focus-color,#61affe)}.swagger-ui textarea.curl{background:var(--textarea-curl-background-color,#41444e);color:var(--textarea-curl-color,#fff)}.swagger-ui .checkbox{color:var(--checkbox-color,#303030)}.swagger-ui .checkbox input[type=checkbox]+label>.item{background:var(--checkbox-label-item-background-color,#e8e8e8);box-shadow:0 0 0 2px var(--checkbox-label-item-shadow-color,#e8e8e8)}.swagger-ui .checkbox input[type=checkbox]:checked+label>.item{background:var(--checkbox-label-item-icon, #e8e8e8 url('data:image/svg+xml;charset=utf-8,') 50% no-repeat)}.swagger-ui .dialog-ux .backdrop-ux{background:var(--dialog-backdrop-color,rgba(0,0,0,.8))}.swagger-ui .dialog-ux .modal-ux{background:var(--dialog-background-color,#fff);border:1px solid var(--dialog-border-color,#ebebeb);box-shadow:0 10px 30px 0 var(--dialog-shadow-color,rgba(0,0,0,.2))}.swagger-ui .dialog-ux .modal-ux-header{border-bottom:1px solid var(--dialog-border-color,#ebebeb)}.swagger-ui .dialog-ux .modal-ux-header button svg{fill:var(--dialog-close-button-icon-color,black)}.swagger-ui .model .deprecated span,.swagger-ui .model .deprecated td{color:var(--model-deprecated-color,#a0a0a0)!important}.swagger-ui .model-toggle{cursor:pointer;-webkit-transition:-webkit-transform .15s ease-in;transition:transform .15s ease-in;transition:transform .15s ease-in,-webkit-transform .15s ease-in;-webkit-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:50% 50%;transform-origin:50% 50%}.swagger-ui .model-toggle.collapsed{-webkit-transform:rotate(0);transform:rotate(0)}.swagger-ui .model-toggle::after{background:var(--model-arrow-icon, url('data:image/svg+xml;charset=utf-8,') 50% no-repeat)}.swagger-ui .model-hint{background:var(--model-hint-background-color,rgba(0,0,0,.7));color:var(--model-hint-color,#ebebeb)}.swagger-ui .model .property{color:var(--model-property-color,#999)}.swagger-ui .model .property.primitive{color:var(--model-property-primitive-color,#6b6b6b)}.swagger-ui table.model tr.property-row .star{color:var(--model-property-required-symbol-color,red)}.swagger-ui .model .external-docs,.swagger-ui table.model tr.description{color:var(--model-property-description,#666)}.swagger-ui table.model tr.extension{color:var(--model-property-extension,#777)}.swagger-ui section.models{border:1px solid var(--model-section-border-color,rgba(59,65,81,.3));border-radius:.5rem;padding:0;margin:0}.swagger-ui section.models.is-open h4{border-bottom:1px solid var(--model-section-border-color,rgba(59,65,81,.3))}.swagger-ui section.models h4{color:var(--model-section-header-color,#606060);padding:0}.swagger-ui section.models h4:hover{background:var(--model-section-header-hover-background-color,rgba(0,0,0,.02))}.swagger-ui section.models h4 svg{fill:var(--expand-model-svg-arrow-color,#707070)}.swagger-ui section.models h4 button.models-control{color:var(--model-section-header-color,#606060);padding:20px}.swagger-ui section.models h5{color:var(--model-section-little-header-color,#707070)}.swagger-ui section.models .model-container{background:var(--model-container-background-color,rgba(0,0,0,.05))}.swagger-ui section.models .model-container:hover{background:var(--model-container-hover-background-color,rgba(0,0,0,.07))}.swagger-ui .model-box{background:var(--model-box-background-color,rgba(0,0,0,.1))}.swagger-ui .model-title{color:var(--model-title-color,#505050)}.swagger-ui .model-deprecated-warning{color:var(--model-deprecated-warning-color,#f93e3e)}.swagger-ui .prop-type{color:var(--model-prop-type-color,#55a)}.swagger-ui .prop-format{color:var(--model-prop-format-color,#606060)}.swagger-ui .markdown pre,.swagger-ui .renderedMarkdown pre{color:var(--rendered-markdown-pre-color,#000)}.swagger-ui .markdown code,.swagger-ui .renderedMarkdown code{background:var(--rendered-markdown-code-background-color,rgba(0,0,0,.05));color:var(--rendered-markdown-code-color,#9012fe)}.scroll-to-top-wrapper{position:fixed;bottom:0;left:0;right:0;text-align:end;box-sizing:border-box;margin:0 auto;max-width:1460px;padding:0 20px;width:100%;pointer-events:none;z-index:1000}#scroll-to-top-btn{background-color:var(--scroll-to-top-button-background-color,#000000d1);border:1px solid var(--scroll-to-top-button-border-color,#d1c9c9);border-radius:50%;color:#fff;cursor:pointer;width:2.5rem;aspect-ratio:1;backdrop-filter:blur(20px);margin:10px;position:relative;transition:background-color .5s,opacity .6s .2s,translate .8s,scale .2s;opacity:0;translate:0 100px;box-shadow:0 0 10px 0 var(--scroll-to-top-button-shadow-color,rgba(0,0,0,.4))}#scroll-to-top-btn.showBtn{opacity:1;translate:0 0;pointer-events:auto}#scroll-to-top-btn::before{position:absolute;content:'';top:10px;left:12px;right:12px;background-color:var(--scroll-to-top-button-icon-color,#fff);border-radius:1rem;height:2px}#scroll-to-top-btn::after{position:absolute;content:'';top:15px;left:50%;border:solid var(--scroll-to-top-button-icon-color,#fff);border-width:0 2px 2px 0;padding:3px;transform:rotate(-135deg);translate:-50% 4px}#scroll-to-top-btn:focus-within,#scroll-to-top-btn:hover{background-color:var(--scroll-to-top-button-hover-background-color,#0d0d0dba)}#scroll-to-top-btn:focus-within::after,#scroll-to-top-btn:hover::after{animation:1.2s ease-in infinite bounceUp}@keyframes bounceUp{100%{translate:-50% 4px}25%{translate:-50% 6px}60%{translate:-50% -2px}}@-moz-keyframes bounceUp{100%{translate:-50% 4px}25%{translate:-50% 6px}60%{translate:-50% -2px}}@-webkit-keyframes bounceUp{100%{translate:-50% 4px}25%{translate:-50% 6px}60%{translate:-50% -2px}}@media only screen and (max-width:680px){.swagger-ui .opblock .opblock-summary-description,.swagger-ui .opblock .opblock-summary-operation-id,.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{word-break:break-all}} \ No newline at end of file +/*Common Theme https://github.com/teociaps/SwaggerUI.Themes */@charset "UTF-8";:root{--topbar-pinned-height:4rem;--filterbar-pinned-height:5rem}::-webkit-scrollbar{width:16px}::-webkit-scrollbar-thumb{height:56px;border-radius:8px;border:4px solid transparent;background-clip:content-box;background-color:var(--scrollbar-thumb-color,#a5a5a5);transition:background-color .3s}::-webkit-scrollbar-thumb:hover{background-color:var(--scrollbar-thumb-hover-color,#727272)}body::-webkit-scrollbar-thumb{background-color:var(--scrollbar-thumb-color,#a5a5a5)}*{font-family:system-ui,sans-serif}:focus{font-family:system-ui,sans-serif;outline:0!important}:focus-visible{outline:auto!important;outline-offset:1px}body{background-color:var(--body-background-color,#fafafa);padding-bottom:1rem}*,::after,::before{box-sizing:border-box}.swagger-ui{color:var(--swagger-main-color,#3b4151);-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;line-height:1.15;margin:0}.swagger-ui .loading-container .loading:after{color:var(--swagger-main-color,#3b4151)}.swagger-ui .loading-container .loading:before{border:2px solid var(--loading-container-border-color,rgba(85,85,85,.1));border-top-color:var(--loading-container-border-top-color,rgba(0,0,0,.6))}.swagger-ui .errors-wrapper{background:var(--errors-wrapper-background-color,rgba(249,62,62,.1));border:2px solid var(--errors-wrapper-border-color,#f93e3e)}.swagger-ui .errors-wrapper .errors h4,.swagger-ui .errors-wrapper hgroup h4{color:var(--swagger-main-color,#3b4151)}.swagger-ui .errors-wrapper .errors small{color:var(--errors-wrapper-errors-color,#606060)}.swagger-ui .topbar{background-color:transparent;z-index:1000;padding:0}.swagger-ui .topbar.pinned{position:sticky;top:10px;--pinned-topbar-offset:var(--topbar-pinned-height)}.swagger-ui:has(> .topbar.pinned){--pinned-topbar-offset:var(--topbar-pinned-height)}.swagger-ui .topbar.pinned::before{content:'';position:absolute;inset:-10px 0 0;pointer-events:none;user-select:none;backdrop-filter:blur(20px);-webkit-mask-image:linear-gradient(to bottom,black 40%,transparent);mask-image:linear-gradient(to bottom,black 40%,transparent);background:linear-gradient(90deg,rgba(0,0,0,0) 5%,rgba(8,8,8,.4) 50%,rgba(0,0,0,0) 95%)}.swagger-ui .topbar .topbar-wrapper{background-color:var(--topbar-background-color,#000);border:1px solid var(--topbar-border-color,gray);border-top:0;border-radius:0 0 .5rem .5rem;padding:.5rem;box-shadow:0 5px 15px 0 var(--topbar-pinned-shadow-color,rgba(0,0,0,.5))}.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn svg{fill:var(--topbar-pin-icon-color,white)}.swagger-ui .filter #pin-filterbar-btn,.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn{cursor:pointer;background:0 0;border:none;margin:.2rem;padding:.2rem;transition:all .2s;rotate:z 359deg}.swagger-ui .filter #pin-filterbar-btn:focus-within,.swagger-ui .filter #pin-filterbar-btn:hover,.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:focus-within,.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:hover{scale:1.05;animation:pintobbar 1.2s infinite}.swagger-ui .filter #pin-filterbar-btn:active,.swagger-ui .topbar .topbar-wrapper #pin-topbar-btn:active{scale:0.95}.swagger-ui .topbar.pinned .topbar-wrapper{background-color:var(--topbar-pinned-background-color,rgba(0,0,0,.7));border:1px solid var(--topbar-border-color,gray);border-radius:.5rem;backdrop-filter:blur(20px)}.swagger-ui .filter.pinned #pin-filterbar-btn,.swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn{rotate:z 0deg}.swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn{translate:0 5px}.swagger-ui .filter.pinned #pin-filterbar-btn:focus-within,.swagger-ui .filter.pinned #pin-filterbar-btn:hover,.swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn:focus-within,.swagger-ui .topbar.pinned .topbar-wrapper #pin-topbar-btn:hover{animation:unpintobbar 1.2s infinite}@keyframes pintobbar{30%{rotate:z 313deg;translate:0px 1px}40%{translate:0 5px}50%{rotate:z 313deg}}@-moz-keyframes pintobbar{30%{rotate:z 313deg;translate:0px 1px}40%{translate:0 5px}50%{rotate:z 313deg}}@-webkit-keyframes pintobbar{30%{rotate:z 313deg;translate:0px 1px}40%{translate:0 5px}50%{rotate:z 313deg}}@keyframes unpintobbar{10%{translate:0 1px}70%{rotate:z 50deg;translate:-3px 1px}}@-moz-keyframes unpintobbar{10%{translate:0 -4px}70%{rotate:z 50deg;translate:-3px 1px}}@-webkit-keyframes unpintobbar{10%{translate:0 -4px}70%{rotate:z 50deg;translate:-3px 1px}}.swagger-ui .topbar .download-url-wrapper .select-label select,.swagger-ui .topbar .download-url-wrapper input[type=text]{border:2px solid var(--topbar-select-border-color,#62a03f)}.swagger-ui .topbar .download-url-wrapper .select-label{color:var(--topbar-select-label-color,#f0f0f0)}.swagger-ui .topbar .download-url-wrapper .download-url-button{background:var(--topbar-download-url-button-background-color,#62a03f);color:var(--topbar-download-url-button-color,#fff)}.swagger-ui .topbar .dark-mode-toggle{display:none}#theme-switcher-select{background:var(--select-background-icon, #f7f7f7 url('data:image/svg+xml;charset=utf-8,') right 10px center no-repeat);border:2px solid var(--topbar-select-border-color,#62a03f);min-width:120px;box-shadow:none}.swagger-ui .info .base-url,.swagger-ui .info .title,.swagger-ui .info h1,.swagger-ui .info h2,.swagger-ui .info h3,.swagger-ui .info h4,.swagger-ui .info h5,.swagger-ui .info li,.swagger-ui .info p,.swagger-ui .info table{color:var(--swagger-main-color,#3b4151)}.swagger-ui .info a{color:var(--swagger-info-link,#4990e2)}.swagger-ui .info a:hover{color:var(--swagger-info-link-hover,#1f69c0)}.swagger-ui .info .title small{background:var(--api-version-background-color,#7d8492)}.swagger-ui .info .title small.version-stamp{background-color:var(--api-version-stamp-background-color,#89bf04)}.swagger-ui .info .title small pre{color:var(--api-version-color,#fff)}.swagger-ui .scheme-container{background:0 0;box-shadow:none;margin:0;padding:0}.swagger-ui .auth-wrapper{display:flex;flex:1;justify-content:flex-end;border:1px solid var(--auth-wrapper-border-color,#d5d5d5);padding:0;border-radius:5px;box-shadow:0 3px 5px -5px var(--auth-wrapper-box-shadow-color,#000);background:var(--auth-wrapper-background-color,#fff)}.swagger-ui .scheme-container .schemes>label{color:var(--swagger-main-color,#3b4151)}.swagger-ui .auth-container{border-bottom:1px solid var(--auth-container-border-bottom-color,#ebebeb);color:var(--swagger-main-color,#3b4151)}.swagger-ui .auth-container .errors{background-color:var(--auth-container-background-color,#fee);color:var(--auth-container-errors-color,red)}.swagger-ui .btn.authorize{background-color:var(--btn-authorize-background-color,transparent);border-color:var(--btn-authorize-border-color,#49cc90);color:var(--btn-authorize-font-color,#49cc90)}.swagger-ui .btn.authorize svg{fill:var(--btn-authorize-svg-fill-color,#49cc90)}.swagger-ui .opblock-tag-section{display:flex;flex-direction:column;border:1px solid var(--opblock-tag-section-border-color,#ddd);border-radius:.5rem;margin-block:1rem;overflow:clip}.swagger-ui .opblock-tag-section .expand-collapse-all-btn{display:none;user-select:none}.swagger-ui .opblock-tag-section.is-open .expand-collapse-all-btn{background:var(--opblock-tag-section-expandcollapse-background-color,rgb(220 220 220 / 45%));color:var(--swagger-main-color,#3b4151);border:1px solid var(--opblock-tag-section-border-color,#ddd);border-radius:.5rem;padding:5px 10px;font-size:medium;margin-inline:10px;display:block}.swagger-ui .opblock-tag,.swagger-ui .opblock-tag small,.swagger-ui .parameter__type{color:var(--swagger-main-color,#3b4151)}.swagger-ui .opblock-tag{align-items:center;cursor:pointer;display:flex;padding:10px 20px 10px 10px;color:var(--swagger-main-color,#3b4151);font-size:24px;border-bottom:0 dashed var(--opblock-tag-border-bottom-color,#e1e1e1);margin:0;transition:all .2s}.swagger-ui .opblock-tag:focus-within,.swagger-ui .opblock-tag:hover{background:var(--opblock-tag-background-color-hover,rgba(0,0,0,.02))}.swagger-ui .opblock-tag+div .operation-tag-content{margin-inline:5px}#STICKY_OPERATIONS.swagger-ui .opblock-tag-section.is-open .opblock-tag{position:sticky;top:0;z-index:4;margin-bottom:1rem;border-bottom:1px dashed var(--opblock-tag-border-bottom-color,#e1e1e1);background:var(--body-background-color,#fafafa)}#STICKY_OPERATIONS.swagger-ui .opblock-tag-section.is-open .opblock-tag:focus-within,#STICKY_OPERATIONS.swagger-ui .opblock-tag-section.is-open .opblock-tag:hover{background:var(--opblock-tag-pinned-background-color-hover,#f1f1f1)}.swagger-ui .opblock-tag-section.is-open .opblock-tag{top:calc(var(--pinned-topbar-offset,0px) + var(--pinned-filter-offset,0px))}.swagger-ui .opblock{border:1px solid var(--opblock-border-color,#000);box-shadow:0 0 3px var(--opblock-shadow-color,rgba(0,0,0,.19))}.swagger-ui .opblock .tab-header .tab-item.active h4 span:after{background:var(--opblock-tabheader-underline-color,gray)}.swagger-ui .opblock .opblock-summary button svg{fill:var(--opblock-summary-svg-icons-color,#000)}.swagger-ui .opblock.is-open .opblock-summary{border-bottom:1px solid var(--opblock-summary-border-bottom-color,#000)}.swagger-ui .opblock .opblock-section-header{background:var(--opblock-section-header-background-color,hsla(0,0%,100%,.8));box-shadow:0 1px 2px var(--opblock-section-header-shadow-color,rgba(0,0,0,.1))}.swagger-ui .opblock .opblock-section-header h4,.swagger-ui .opblock .opblock-section-header>label{color:var(--swagger-main-color,#3b4151)}.swagger-ui .opblock .opblock-summary-method{background:var(--opblock-summary-method-background-color,#000);color:var(--opblock-summary-method-color,#fff);text-shadow:0 1px 0 var(--opblock-summary-method-shadow-color,rgba(0,0,0,.1))}.swagger-ui .opblock .opblock-summary-description,.swagger-ui .opblock .opblock-summary-operation-id,.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{color:var(--swagger-main-color,#3b4151);word-break:normal}.swagger-ui .opblock.opblock-post{background:var(--opblock-post-background-color,rgba(73,204,144,.1));border-color:var(--opblock-post-border-color,#49cc90)}.swagger-ui .opblock.opblock-post .opblock-summary-method{background:var(--opblock-post-method-color,#49cc90)}.swagger-ui .opblock.opblock-post .opblock-summary{border-color:var(--opblock-post-border-color,#49cc90)}.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span:after{background:var(--opblock-post-method-color,#49cc90);border-radius:1rem}.swagger-ui .opblock.opblock-put{background:var(--opblock-put-background-color,rgba(252,161,48,.1));border-color:var(--opblock-put-border-color,#fca130)}.swagger-ui .opblock.opblock-put .opblock-summary-method{background:var(--opblock-put-method-color,#fca130)}.swagger-ui .opblock.opblock-put .opblock-summary{border-color:var(--opblock-put-border-color,#fca130)}.swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span:after{background:var(--opblock-put-method-color,#fca130)}.swagger-ui .opblock.opblock-delete{background:var(--opblock-delete-background-color,rgba(249,62,62,.1));border-color:var(--opblock-delete-border-color,#f93e3e)}.swagger-ui .opblock.opblock-delete .opblock-summary-method{background:var(--opblock-delete-method-color,#f93e3e)}.swagger-ui .opblock.opblock-delete .opblock-summary{border-color:var(--opblock-delete-border-color,#f93e3e)}.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span:after{background:var(--opblock-delete-method-color,#f93e3e)}.swagger-ui .opblock.opblock-get{background:var(--opblock-get-background-color,rgba(97,175,254,.1));border-color:var(--opblock-get-border-color,#61affe)}.swagger-ui .opblock.opblock-get .opblock-summary-method{background:var(--opblock-get-method-color,#61affe)}.swagger-ui .opblock.opblock-get .opblock-summary{border-color:var(--opblock-get-border-color,#61affe)}.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span:after{background:var(--opblock-get-method-color,#61affe)}.swagger-ui .opblock.opblock-patch{background:var(--opblock-patch-background-color,rgba(80,227,194,.1));border-color:var(--opblock-patch-border-color,#50e3c2)}.swagger-ui .opblock.opblock-patch .opblock-summary-method{background:var(--opblock-patch-method-color,#50e3c2)}.swagger-ui .opblock.opblock-patch .opblock-summary{border-color:var(--opblock-patch-border-color,#50e3c2)}.swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span:after{background:var(--opblock-patch-method-color,#50e3c2)}.swagger-ui .opblock.opblock-head{background:var(--opblock-head-background-color,rgba(144,18,254,.1));border-color:var(--opblock-head-border-color,#9012fe)}.swagger-ui .opblock.opblock-head .opblock-summary-method{background:var(--opblock-head-method-color,#9012fe)}.swagger-ui .opblock.opblock-head .opblock-summary{border-color:var(--opblock-head-border-color,#9012fe)}.swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span:after{background:var(--opblock-head-method-color,#9012fe)}.swagger-ui .opblock.opblock-options{background:var(--opblock-options-background-color,rgba(13,90,167,.1));border-color:var(--opblock-options-border-color,#0d5aa7)}.swagger-ui .opblock.opblock-options .opblock-summary-method{background:var(--opblock-options-method-color,#0d5aa7)}.swagger-ui .opblock.opblock-options .opblock-summary{border-color:var(--opblock-options-border-color,#0d5aa7)}.swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span:after{background:var(--opblock-options-method-color,#0d5aa7)}.swagger-ui .opblock.opblock-deprecated{background:var(--opblock-deprecated-background-color,hsla(0,0%,92%,.1));border-color:var(--opblock-deprecated-border-color,#ebebeb)}.swagger-ui .opblock.opblock-deprecated .opblock-summary-method{background:var(--opblock-deprecated-method-color,#ebebeb)}.swagger-ui .opblock.opblock-deprecated .opblock-summary{border-color:var(--opblock-deprecated-border-color,#ebebeb)}.swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span:after{background:var(--opblock-deprecated-method-color,#ebebeb)}.swagger-ui .tab li{color:var(--swagger-main-color,#3b4151)}.swagger-ui .tab li:first-of-type:after{background:var(--swagger-tabs-divider-color,rgba(0,0,0,.2))}.swagger-ui .opblock-description-wrapper,.swagger-ui .opblock-description-wrapper h4,.swagger-ui .opblock-description-wrapper p,.swagger-ui .opblock-external-docs-wrapper,.swagger-ui .opblock-external-docs-wrapper h4,.swagger-ui .opblock-external-docs-wrapper p,.swagger-ui .opblock-title_normal,.swagger-ui .opblock-title_normal h4,.swagger-ui .opblock-title_normal p,.swagger-ui .response-col_links,.swagger-ui .response-col_status,.swagger-ui .responses-inner h4,.swagger-ui .responses-inner h5{color:var(--swagger-main-color,#3b4151)}.swagger-ui .response-col_links .response-undocumented,.swagger-ui .response-col_status .response-undocumented{color:var(--response-undocumented-color,#909090)}.swagger-ui .response-control-media-type--accept-controller select{border-color:var(--response-control-media-type-color,green)}.swagger-ui .response-control-media-type__accept-message{color:var(--response-control-media-type-color,green)}.swagger-ui .opblock-body pre.microlight{background:var(--opblock-pre-microlight-background-color,#333)!important;color:var(--opblock-pre-microlight-color,#fff)!important}.swagger-ui .download-contents{background:var(--download-contents-background-color,#7d8293);color:var(--download-contents-color,#fff)}.swagger-ui .copy-to-clipboard{background:var(--copy-to-clipboard-background-color,#7d8293)}.swagger-ui .copy-to-clipboard button{background:var(--copy-to-clipboard-icon, url('data:image/svg+xml;charset=utf-8,') 50% no-repeat)}.swagger-ui .filter{position:relative;display:flex;align-items:center}.swagger-ui .filter .filter-container{flex:1;min-width:0}.swagger-ui .filter #pin-filterbar-btn{position:absolute;right:1.5rem}.swagger-ui .filter .operation-filter-input{border:2px solid var(--filter-operation-filter-input-border-color,#d8dde7)}.swagger-ui .filter.pinned.stuck .operation-filter-input{box-shadow:0 19px 0 0 var(--body-background-color,#fafafa),0 20px 0 0 var(--opblock-tag-section-border-color,#ddd)}.swagger-ui div:has(> .filter-container > .filter.pinned){position:sticky;top:var(--pinned-topbar-offset,0);z-index:5;background:var(--body-background-color,#fafafa);--pinned-filter-offset:var(--filterbar-pinned-height)}.swagger-ui:has(.filter.pinned){--pinned-filter-offset:var(--filterbar-pinned-height)}.swagger-ui .filter #pin-filterbar-btn svg{fill:var(--filterbar-pin-icon-color,#707070)}.swagger-ui .download-url-wrapper .failed,.swagger-ui .filter .failed{color:var(--filter-download-failed-color,red)}.swagger-ui .download-url-wrapper .loading,.swagger-ui .filter .loading{color:var(--filter-download-loading-color,#aaa)}.swagger-ui section h3{color:var(--swagger-main-color,#3b4151)}.swagger-ui .servers>label,.swagger-ui table.headers td{color:var(--swagger-main-color,#3b4151)}.swagger-ui table.headers .header-example{color:var(--table-headers-example-color,#999)}.swagger-ui table thead tr td,.swagger-ui table thead tr th{border-bottom:1px solid var(--table-thead-border-bottom-color,rgba(59,65,81,.2));color:var(--swagger-main-color,#3b4151)}.swagger-ui .parameter__name{color:var(--swagger-main-color,#3b4151)}.swagger-ui .parameter__name.required span{color:var(--parameter-name-required-symbol-color,red)}.swagger-ui .parameter__name.required::after{color:var(--parameter-name-required-color,rgba(255,0,0,.6))}.swagger-ui .parameter__extension,.swagger-ui .parameter__in,.swagger-ui .response__extension{color:var(--parameter-in-extension-color,gray)}.swagger-ui .parameter__deprecated{color:var(--parameter-deprecated-color,red)}.swagger-ui .btn{background:var(--button-background-color,transparent);border:2px solid var(--button-border-color,gray);box-shadow:0 1px 2px var(--button-shadow-color,rgba(0,0,0,.1));color:var(--swagger-main-color,#3b4151)}.swagger-ui .btn:hover{box-shadow:0 0 5px var(--button-shadow-hover-color,rgba(0,0,0,.3))}.swagger-ui .btn.cancel{background-color:var(--button-cancel-background-color,transparent);border-color:var(--button-cancel-border-color,#ff6060);color:var(--button-cancel-color,#ff6060)}.swagger-ui .btn.execute{background-color:var(--button-execute-background-color,#4990e2);border-color:var(--button-execute-border-color,#4990e2);color:var(--button-execute-color,#fff)}.swagger-ui button.invalid{background:var(--button-invalid-background-color,#feebeb);border-color:var(--button-invalid-border-color,#f93e3e)}.swagger-ui .expand-methods svg,.swagger-ui .expand-operation svg{fill:var(--expand-operation-svg-arrow-color,#707070)}.swagger-ui .expand-methods:hover svg,.swagger-ui .expand-operation:hover svg{fill:var(--expand-operation-svg-arrow-hover-color,#404040)}.swagger-ui select{background:var(--select-background-icon, #f7f7f7 url('data:image/svg+xml;charset=utf-8,') right 10px center no-repeat);border:2px solid var(--select-border-color,#41444e);box-shadow:0 1px 2px 0 var(--select-shadow-color,rgba(0,0,0,.25));color:var(--swagger-main-color,#3b4151);cursor:pointer}.swagger-ui select[multiple]{background:var(--select-background-color,#f7f7f7)}.swagger-ui select:disabled{opacity:.5;cursor:not-allowed}.swagger-ui label{color:var(--swagger-main-color,#3b4151)}.swagger-ui input[type=email],.swagger-ui input[type=file],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text],.swagger-ui textarea{background:var(--input-background-color,#fff);border:1px solid var(--input-border-color,#d9d9d9);color:var(--swagger-main-color,#3b4151)}.swagger-ui input[type=email].invalid,.swagger-ui input[type=file].invalid,.swagger-ui input[type=password].invalid,.swagger-ui input[type=search].invalid,.swagger-ui input[type=text].invalid,.swagger-ui select.invalid,.swagger-ui textarea.invalid{background:var(--input-invalid-background-color,#feebeb);border-color:var(--input-invalid-border-color,#f93e3e);color:var(--input-invalid-color,#3b4151)}.swagger-ui input[disabled],.swagger-ui select[disabled]{background-color:var(--input-disabled-background-color,#fafafa);color:var(--input-disabled-color,#888)}.swagger-ui select[disabled]{border-color:var(--select-disabled-border-color,#888)}.swagger-ui textarea[disabled]{background-color:var(--textarea-disabled-background-color,#41444e);color:var(--textarea-disabled-color,#fff)}.swagger-ui textarea{background:var(--textarea-background-color,hsla(0,0%,100%,.8));color:var(--swagger-main-color,#3b4151)}.swagger-ui textarea:focus{border:2px solid var(--textarea-border-focus-color,#61affe)}.swagger-ui textarea.curl{background:var(--textarea-curl-background-color,#41444e);color:var(--textarea-curl-color,#fff)}.swagger-ui .checkbox{color:var(--checkbox-color,#303030)}.swagger-ui .checkbox p{color:var(--swagger-main-color,#3b4151)}.swagger-ui .checkbox input[type=checkbox]+label>.item{background:var(--checkbox-label-item-background-color,#e8e8e8);box-shadow:0 0 0 2px var(--checkbox-label-item-shadow-color,#e8e8e8)}.swagger-ui .checkbox input[type=checkbox]:checked+label>.item{background:var(--checkbox-label-item-icon, #e8e8e8 url('data:image/svg+xml;charset=utf-8,') 50% no-repeat)}.swagger-ui .dialog-ux .backdrop-ux{background:var(--dialog-backdrop-color,rgba(0,0,0,.8))}.swagger-ui .dialog-ux .modal-ux{background:var(--dialog-background-color,#fff);border:1px solid var(--dialog-border-color,#ebebeb);box-shadow:0 10px 30px 0 var(--dialog-shadow-color,rgba(0,0,0,.2))}.swagger-ui .dialog-ux .modal-ux-content h4,.swagger-ui .dialog-ux .modal-ux-content p,.swagger-ui .dialog-ux .modal-ux-header h3{color:var(--swagger-main-color,#3b4151)}.swagger-ui .dialog-ux .modal-ux-header{border-bottom:1px solid var(--dialog-border-color,#ebebeb)}.swagger-ui .dialog-ux .modal-ux-header button svg{fill:var(--dialog-close-button-icon-color,black)}.swagger-ui .model{color:var(--swagger-main-color,#3b4151)}.swagger-ui .model .deprecated span,.swagger-ui .model .deprecated td{color:var(--model-deprecated-color,#a0a0a0)!important}.swagger-ui .model-toggle{cursor:pointer;-webkit-transition:-webkit-transform .15s ease-in;transition:-webkit-transform .15s ease-in;transition:transform .15s ease-in;transition:transform .15s ease-in,-webkit-transform .15s ease-in;-webkit-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:50% 50%;transform-origin:50% 50%}.swagger-ui .model-toggle.collapsed{-webkit-transform:rotate(0);transform:rotate(0)}.swagger-ui .model-toggle::after{background:var(--model-arrow-icon, url('data:image/svg+xml;charset=utf-8,') 50% no-repeat)}.swagger-ui .model-hint{background:var(--model-hint-background-color,rgba(0,0,0,.7));color:var(--model-hint-color,#ebebeb)}.swagger-ui .model .property{color:var(--model-property-color,#999)}.swagger-ui .model .property.primitive{color:var(--model-property-primitive-color,#6b6b6b)}.swagger-ui table.model tr.property-row .star{color:var(--model-property-required-symbol-color,red)}.swagger-ui .model .external-docs,.swagger-ui table.model tr.description{color:var(--model-property-description,#666)}.swagger-ui table.model tr.extension{color:var(--model-property-extension,#777)}.swagger-ui section.models{border:1px solid var(--model-section-border-color,rgba(59,65,81,.3));border-radius:.5rem;padding:0;margin:0}.swagger-ui section.models.is-open h4{border-bottom:1px solid var(--model-section-border-color,rgba(59,65,81,.3))}.swagger-ui section.models h4{color:var(--model-section-header-color,#606060);padding:0}.swagger-ui section.models h4:hover{background:var(--model-section-header-hover-background-color,rgba(0,0,0,.02))}.swagger-ui section.models h4 svg{fill:var(--expand-model-svg-arrow-color,#707070)}.swagger-ui section.models h4 button.models-control{color:var(--model-section-header-color,#606060);padding:20px}.swagger-ui section.models h5{color:var(--model-section-little-header-color,#707070)}.swagger-ui section.models .model-container{background:var(--model-container-background-color,rgba(0,0,0,.05))}.swagger-ui section.models .model-container:hover{background:var(--model-container-hover-background-color,rgba(0,0,0,.07))}.swagger-ui .model-box{background:var(--model-box-background-color,rgba(0,0,0,.1))}.swagger-ui .model-title{color:var(--model-title-color,#505050)}.swagger-ui .model-deprecated-warning{color:var(--model-deprecated-warning-color,#f93e3e)}.swagger-ui .prop-type{color:var(--model-prop-type-color,#55a)}.swagger-ui .prop-format{color:var(--model-prop-format-color,#606060)}.swagger-ui .scopes h2{color:var(--swagger-main-color,#3b4151)}.swagger-ui .scopes h2 a{color:var(--swagger-info-link,#4990e2)}.swagger-ui .markdown pre,.swagger-ui .renderedMarkdown pre{color:var(--rendered-markdown-pre-color,#000)}.swagger-ui .markdown code,.swagger-ui .renderedMarkdown code{background:var(--rendered-markdown-code-background-color,rgba(0,0,0,.05));color:var(--rendered-markdown-code-color,#9012fe)}.scroll-to-top-wrapper{position:fixed;bottom:0;left:0;right:0;text-align:end;box-sizing:border-box;margin:0 auto;max-width:1460px;padding:0 20px;width:100%;pointer-events:none;z-index:1000}#scroll-to-top-btn{background-color:var(--scroll-to-top-button-background-color,#000000d1);border:1px solid var(--scroll-to-top-button-border-color,#d1c9c9);border-radius:50%;color:#fff;cursor:pointer;width:2.5rem;aspect-ratio:1;backdrop-filter:blur(20px);margin:10px;position:relative;transition:background-color .5s,opacity .6s .2s,translate .8s,scale .2s;opacity:0;translate:0 100px;box-shadow:0 0 10px 0 var(--scroll-to-top-button-shadow-color,rgba(0,0,0,.4))}#scroll-to-top-btn.showBtn{opacity:1;translate:0 0;pointer-events:auto}#scroll-to-top-btn::before{position:absolute;content:'';top:10px;left:12px;right:12px;background-color:var(--scroll-to-top-button-icon-color,#fff);border-radius:1rem;height:2px}#scroll-to-top-btn::after{position:absolute;content:'';top:15px;left:50%;border:solid var(--scroll-to-top-button-icon-color,#fff);border-width:0 2px 2px 0;padding:3px;transform:rotate(-135deg);translate:-50% 4px}#scroll-to-top-btn:focus-within,#scroll-to-top-btn:hover{background-color:var(--scroll-to-top-button-hover-background-color,#0d0d0dba)}#scroll-to-top-btn:focus-within::after,#scroll-to-top-btn:hover::after{animation:bounceUp 1.2s ease-in infinite}#scroll-to-top-btn:active{scale:0.95}@keyframes bounceUp{100%{translate:-50% 4px}25%{translate:-50% 6px}60%{translate:-50% -2px}}@-moz-keyframes bounceUp{100%{translate:-50% 4px}25%{translate:-50% 6px}60%{translate:-50% -2px}}@-webkit-keyframes bounceUp{100%{translate:-50% 4px}25%{translate:-50% 6px}60%{translate:-50% -2px}}@media only screen and (max-width:680px){.swagger-ui .opblock .opblock-summary-description,.swagger-ui .opblock .opblock-summary-operation-id,.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{word-break:break-all}} \ No newline at end of file diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/dark.css b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/dark.css index ce542f8..4addccc 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/dark.css +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/dark.css @@ -103,7 +103,7 @@ --download-contents-color: white; --copy-to-clipboard-background-color: #7d8293; --copy-to-clipboard-icon: url('data:image/svg+xml;charset=utf-8,') 50% no-repeat; - --filter-operation-filter-input-border-color: #d8dde7; + --filter-operation-filter-input-border-color: #909090; --filter-download-failed-color: red; --filter-download-loading-color: #aaa; --table-headers-example-color: #999; diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/dark.min.css b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/dark.min.css index 9d48b52..d8fa929 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/dark.min.css +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/Styles/dark.min.css @@ -1 +1 @@ -ο»Ώ/*Dark Theme https://github.com/teociaps/SwaggerUI.Themes */@media (prefers-color-scheme:dark){:root{--scrollbar-thumb-color:hsl(0, 0%, 45%);--scrollbar-thumb-hover-color:hsl(0, 0%, 55%);--body-background-color:#101010;--swagger-main-color:#ecf0f1;--loading-container-border-color:rgba(200, 200, 200, .1);--loading-container-border-top-color:rgba(255, 255, 255, .6);--errors-wrapper-background-color:rgb(255, 0, 0, .1);--errors-wrapper-border-color:#f93e3e;--errors-wrapper-errors-color:#606060;--topbar-background-color:black;--topbar-pinned-background-color:rgba(0, 0, 0, .3);--topbar-pinned-shadow-color:rgba(0, 0, 0, .6);--topbar-pin-icon-color:white;--topbar-border-color:gray;--topbar-select-border-color:#5b5b5b;--topbar-select-label-color:#dfd9d9;--topbar-download-url-button-background-color:#5b5b5b;--topbar-download-url-button-color:#dfd9d9;--swagger-info-link:#4990e2;--swagger-info-link-hover:#1b78e5;--api-version-background-color:#616161;--api-version-stamp-background-color:#23891f;--api-version-color:#cdcdcd;--auth-wrapper-background-color:black;--auth-wrapper-border-color:#383636;--auth-wrapper-box-shadow-color:#c1c1c1;--auth-container-border-bottom-color:#ebebeb;--auth-container-background-color:#ddd;--auth-container-errors-color:red;--btn-authorize-background-color:transparent;--btn-authorize-border-color:#49cc90;--btn-authorize-font-color:#49cc90;--btn-authorize-svg-fill-color:#49cc90;--opblock-tag-section-border-color:#312c2c;--opblock-border-color:black;--opblock-tag-section-expandcollapse-background-color:rgb(0 0 0 / 45%);--opblock-tag-background-color-hover:rgba(0, 0, 0, .1);--opblock-tag-pinned-background-color-hover:#0e0e0e;--opblock-tag-border-bottom-color:rgba(109, 113, 121, .3);--opblock-shadow-color:rgba(59, 59, 59, .19);--opblock-tabheader-underline-color:rgba(255, 255, 255, .6);--opblock-summary-svg-icons-color:white;--opblock-summary-border-bottom-color:black;--opblock-section-header-background-color:#0000004a;--opblock-section-header-shadow-color:rgba(0, 0, 0, .1);--opblock-summary-method-background-color:black;--opblock-summary-method-color:white;--opblock-summary-method-shadow-color:rgba(0, 0, 0, .1);--opblock-post-background-color:rgba(56, 203, 82, .1);--opblock-post-method-color:#49cc90;--opblock-post-border-color:#49cc90;--opblock-put-background-color:rgba(255, 189, 63, .1);--opblock-put-method-color:#eb9532;--opblock-put-border-color:#eb9532;--opblock-delete-background-color:rgba(255, 4, 4, .1);--opblock-delete-method-color:#c0392b;--opblock-delete-border-color:#c0392b;--opblock-get-background-color:rgb(33, 161, 255, .1);--opblock-get-method-color:#61affe;--opblock-get-border-color:#61affe;--opblock-patch-background-color:rgba(255, 0, 165, .1);--opblock-patch-method-color:#9b59b6;--opblock-patch-border-color:#9b59b6;--opblock-head-background-color:rgba(62, 73, 114, .1);--opblock-head-method-color:#3e4972;--opblock-head-border-color:#3e4972;--opblock-options-background-color:rgba(37, 64, 211, .1);--opblock-options-method-color:#263795;--opblock-options-border-color:#263795;--opblock-deprecated-background-color:hsla(0, 0%, 87%, .1);--opblock-deprecated-method-color:#a59595;--opblock-deprecated-border-color:#a59595;--swagger-tabs-divider-color:gray;--response-undocumented-color:#999;--response-control-media-type-color:green;--opblock-pre-microlight-background-color:#333;--opblock-pre-microlight-color:#ebe3e3;--download-contents-background-color:#7d8293;--download-contents-color:white;--copy-to-clipboard-background-color:#7d8293;--copy-to-clipboard-icon:url('data:image/svg+xml;charset=utf-8,') 50% no-repeat;--filter-operation-filter-input-border-color:#d8dde7;--filter-download-failed-color:red;--filter-download-loading-color:#aaa;--table-headers-example-color:#999;--table-thead-border-bottom-color:rgba(59, 65, 81, .2);--parameter-name-required-symbol-color:red;--parameter-name-required-color:rgba(255, 0, 0, .8);--parameter-in-extension-color:#878585;--parameter-deprecated-color:red;--button-background-color:transparent;--button-border-color:#a1a1a1;--button-shadow-color:rgba(0, 0, 0, .1);--button-shadow-hover-color:rgba(0, 0, 0, .3);--button-cancel-background-color:transparent;--button-cancel-border-color:#ff6060;--button-cancel-color:#ff6060;--button-execute-background-color:#4990e2;--button-execute-border-color:#5fa9ff;--button-execute-color:white;--button-invalid-background-color:#feebeb;--button-invalid-border-color:#f93e3e;--expand-operation-svg-arrow-color:#aaa;--expand-operation-svg-arrow-hover-color:white;--select-background-icon:#0c0c0c url('data:image/svg+xml;charset=utf-8,') right 10px center no-repeat;--select-border-color:#41444e;--select-shadow-color:rgba(0, 0, 0, .25);--input-background-color:#0c0c0c;--input-border-color:#6d6d6d;--input-invalid-background-color:#210101;--input-invalid-border-color:#f93e3e;--input-invalid-color:#f3ebeb;--input-disabled-background-color:#333333;--input-disabled-color:#757575;--select-disabled-border-color:#888;--textarea-disabled-background-color:#2b2b2b;--textarea-disabled-color:#b3b3b3;--textarea-background-color:hsla(0, 0%, 0%, .8);--textarea-border-focus-color:#a5a5a5;--textarea-curl-background-color:#41444e;--textarea-curl-color:#fff;--checkbox-color:#303030;--checkbox-label-item-background-color:#e8e8e8;--checkbox-label-item-shadow-color:#e8e8e8;--checkbox-label-item-icon:#e8e8e8 url('data:image/svg+xml;charset=utf-8,') 50% no-repeat;--dialog-backdrop-color:rgba(0, 0, 0, .8);--dialog-background-color:#161616;--dialog-border-color:#333436;--dialog-shadow-color:rgba(0, 0, 0, .2);--dialog-close-button-icon-color:#fff;--model-deprecated-color:#a0a0a0;--model-arrow-icon:url('data:image/svg+xml;charset=utf-8,') 50% no-repeat;--model-hint-background-color:rgba(0, 0, 0, .7);--model-hint-color:#ebebeb;--model-property-color:#999;--model-property-primitive-color:#676767;--model-property-required-symbol-color:red;--model-property-description:#b9b9b9;--model-property-extension:#8d8a8a;--model-section-border-color:rgba(96, 96, 96, .3);--model-section-header-color:white;--model-section-header-hover-background-color:rgba(0, 0, 0, .02);--expand-model-svg-arrow-color:white;--model-section-little-header-color:#707070;--model-container-background-color:rgba(20, 20, 20, .1);--model-container-hover-background-color:rgba(0, 0, 0, .1);--model-box-background-color:rgba(0, 0, 0, .15);--model-title-color:#bec6cf;--model-deprecated-warning-color:#f93e3e;--model-prop-type-color:#6464db;--model-prop-format-color:#b7b7b7;--rendered-markdown-pre-color:#000;--rendered-markdown-code-background-color:rgba(0, 0, 0, .05);--rendered-markdown-code-color:#9012fe;--scroll-to-top-button-background-color:#00000085;--scroll-to-top-button-hover-background-color:#0d0d0d85;--scroll-to-top-button-border-color:#232323;--scroll-to-top-button-icon-color:#d1cccc;--scroll-to-top-button-shadow-color:rgba(0, 0, 0, .3)}} \ No newline at end of file +ο»Ώ/*Dark Theme https://github.com/teociaps/SwaggerUI.Themes */@media (prefers-color-scheme:dark){:root{--scrollbar-thumb-color:hsl(0, 0%, 45%);--scrollbar-thumb-hover-color:hsl(0, 0%, 55%);--body-background-color:#101010;--swagger-main-color:#ecf0f1;--loading-container-border-color:rgba(200, 200, 200, .1);--loading-container-border-top-color:rgba(255, 255, 255, .6);--errors-wrapper-background-color:rgb(255, 0, 0, .1);--errors-wrapper-border-color:#f93e3e;--errors-wrapper-errors-color:#606060;--topbar-background-color:black;--topbar-pinned-background-color:rgba(0, 0, 0, .3);--topbar-pinned-shadow-color:rgba(0, 0, 0, .6);--topbar-pin-icon-color:white;--topbar-border-color:gray;--topbar-select-border-color:#5b5b5b;--topbar-select-label-color:#dfd9d9;--topbar-download-url-button-background-color:#5b5b5b;--topbar-download-url-button-color:#dfd9d9;--swagger-info-link:#4990e2;--swagger-info-link-hover:#1b78e5;--api-version-background-color:#616161;--api-version-stamp-background-color:#23891f;--api-version-color:#cdcdcd;--auth-wrapper-background-color:black;--auth-wrapper-border-color:#383636;--auth-wrapper-box-shadow-color:#c1c1c1;--auth-container-border-bottom-color:#ebebeb;--auth-container-background-color:#ddd;--auth-container-errors-color:red;--btn-authorize-background-color:transparent;--btn-authorize-border-color:#49cc90;--btn-authorize-font-color:#49cc90;--btn-authorize-svg-fill-color:#49cc90;--opblock-tag-section-border-color:#312c2c;--opblock-border-color:black;--opblock-tag-section-expandcollapse-background-color:rgb(0 0 0 / 45%);--opblock-tag-background-color-hover:rgba(0, 0, 0, .1);--opblock-tag-pinned-background-color-hover:#0e0e0e;--opblock-tag-border-bottom-color:rgba(109, 113, 121, .3);--opblock-shadow-color:rgba(59, 59, 59, .19);--opblock-tabheader-underline-color:rgba(255, 255, 255, .6);--opblock-summary-svg-icons-color:white;--opblock-summary-border-bottom-color:black;--opblock-section-header-background-color:#0000004a;--opblock-section-header-shadow-color:rgba(0, 0, 0, .1);--opblock-summary-method-background-color:black;--opblock-summary-method-color:white;--opblock-summary-method-shadow-color:rgba(0, 0, 0, .1);--opblock-post-background-color:rgba(56, 203, 82, .1);--opblock-post-method-color:#49cc90;--opblock-post-border-color:#49cc90;--opblock-put-background-color:rgba(255, 189, 63, .1);--opblock-put-method-color:#eb9532;--opblock-put-border-color:#eb9532;--opblock-delete-background-color:rgba(255, 4, 4, .1);--opblock-delete-method-color:#c0392b;--opblock-delete-border-color:#c0392b;--opblock-get-background-color:rgb(33, 161, 255, .1);--opblock-get-method-color:#61affe;--opblock-get-border-color:#61affe;--opblock-patch-background-color:rgba(255, 0, 165, .1);--opblock-patch-method-color:#9b59b6;--opblock-patch-border-color:#9b59b6;--opblock-head-background-color:rgba(62, 73, 114, .1);--opblock-head-method-color:#3e4972;--opblock-head-border-color:#3e4972;--opblock-options-background-color:rgba(37, 64, 211, .1);--opblock-options-method-color:#263795;--opblock-options-border-color:#263795;--opblock-deprecated-background-color:hsla(0, 0%, 87%, .1);--opblock-deprecated-method-color:#a59595;--opblock-deprecated-border-color:#a59595;--swagger-tabs-divider-color:gray;--response-undocumented-color:#999;--response-control-media-type-color:green;--opblock-pre-microlight-background-color:#333;--opblock-pre-microlight-color:#ebe3e3;--download-contents-background-color:#7d8293;--download-contents-color:white;--copy-to-clipboard-background-color:#7d8293;--copy-to-clipboard-icon:url('data:image/svg+xml;charset=utf-8,') 50% no-repeat;--filter-operation-filter-input-border-color:#909090;--filter-download-failed-color:red;--filter-download-loading-color:#aaa;--table-headers-example-color:#999;--table-thead-border-bottom-color:rgba(59, 65, 81, .2);--parameter-name-required-symbol-color:red;--parameter-name-required-color:rgba(255, 0, 0, .8);--parameter-in-extension-color:#878585;--parameter-deprecated-color:red;--button-background-color:transparent;--button-border-color:#a1a1a1;--button-shadow-color:rgba(0, 0, 0, .1);--button-shadow-hover-color:rgba(0, 0, 0, .3);--button-cancel-background-color:transparent;--button-cancel-border-color:#ff6060;--button-cancel-color:#ff6060;--button-execute-background-color:#4990e2;--button-execute-border-color:#5fa9ff;--button-execute-color:white;--button-invalid-background-color:#feebeb;--button-invalid-border-color:#f93e3e;--expand-operation-svg-arrow-color:#aaa;--expand-operation-svg-arrow-hover-color:white;--select-background-icon:#0c0c0c url('data:image/svg+xml;charset=utf-8,') right 10px center no-repeat;--select-border-color:#41444e;--select-shadow-color:rgba(0, 0, 0, .25);--input-background-color:#0c0c0c;--input-border-color:#6d6d6d;--input-invalid-background-color:#210101;--input-invalid-border-color:#f93e3e;--input-invalid-color:#f3ebeb;--input-disabled-background-color:#333333;--input-disabled-color:#757575;--select-disabled-border-color:#888;--textarea-disabled-background-color:#2b2b2b;--textarea-disabled-color:#b3b3b3;--textarea-background-color:hsla(0, 0%, 0%, .8);--textarea-border-focus-color:#a5a5a5;--textarea-curl-background-color:#41444e;--textarea-curl-color:#fff;--checkbox-color:#303030;--checkbox-label-item-background-color:#e8e8e8;--checkbox-label-item-shadow-color:#e8e8e8;--checkbox-label-item-icon:#e8e8e8 url('data:image/svg+xml;charset=utf-8,') 50% no-repeat;--dialog-backdrop-color:rgba(0, 0, 0, .8);--dialog-background-color:#161616;--dialog-border-color:#333436;--dialog-shadow-color:rgba(0, 0, 0, .2);--dialog-close-button-icon-color:#fff;--model-deprecated-color:#a0a0a0;--model-arrow-icon:url('data:image/svg+xml;charset=utf-8,') 50% no-repeat;--model-hint-background-color:rgba(0, 0, 0, .7);--model-hint-color:#ebebeb;--model-property-color:#999;--model-property-primitive-color:#676767;--model-property-required-symbol-color:red;--model-property-description:#b9b9b9;--model-property-extension:#8d8a8a;--model-section-border-color:rgba(96, 96, 96, .3);--model-section-header-color:white;--model-section-header-hover-background-color:rgba(0, 0, 0, .02);--expand-model-svg-arrow-color:white;--model-section-little-header-color:#707070;--model-container-background-color:rgba(20, 20, 20, .1);--model-container-hover-background-color:rgba(0, 0, 0, .1);--model-box-background-color:rgba(0, 0, 0, .15);--model-title-color:#bec6cf;--model-deprecated-warning-color:#f93e3e;--model-prop-type-color:#6464db;--model-prop-format-color:#b7b7b7;--rendered-markdown-pre-color:#000;--rendered-markdown-code-background-color:rgba(0, 0, 0, .05);--rendered-markdown-code-color:#9012fe;--scroll-to-top-button-background-color:#00000085;--scroll-to-top-button-hover-background-color:#0d0d0d85;--scroll-to-top-button-border-color:#232323;--scroll-to-top-button-icon-color:#d1cccc;--scroll-to-top-button-shadow-color:rgba(0, 0, 0, .3)}} \ No newline at end of file diff --git a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/ThemeSwitcher.cs b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/ThemeSwitcher.cs index 8bfa66c..cd5528c 100644 --- a/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/ThemeSwitcher.cs +++ b/src/AspNetCore.Swagger.Themes.Common/AspNetCore/Swagger/Themes/ThemeSwitcher.cs @@ -1,4 +1,5 @@ ο»Ώusing Microsoft.AspNetCore.Builder; +using System.Collections.Concurrent; using System.Collections.Frozen; using System.Reflection; @@ -15,9 +16,9 @@ internal static class ThemeSwitcher Theme.Dark, Theme.Light, Theme.Forest, Theme.DeepSea, Theme.Desert, Theme.Futuristic }.ToFrozenSet(); - private static readonly Dictionary s_registeredThemes = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary s_registeredThemes = new(StringComparer.OrdinalIgnoreCase); private static FrozenDictionary s_frozenThemes; - private static readonly HashSet s_scannedTypes = []; + private static readonly ConcurrentDictionary s_scannedTypes = new(); /// /// Configures theme switcher for a given theme and options. @@ -64,14 +65,11 @@ internal static void RegisterTheme(BaseTheme theme, string cssPath, bool isStand { var themeName = theme.GetThemeName(); - if (s_registeredThemes.ContainsKey(themeName)) - return; - - s_registeredThemes[themeName] = new RegisteredTheme( + s_registeredThemes.TryAdd(themeName, new RegisteredTheme( theme, FileProvider.StylesPath + cssPath, isStandalone - ); + )); } /// @@ -82,14 +80,11 @@ internal static void RegisterCustomTheme(string cssFilename, bool isStandalone) var themeName = ExtractThemeName(cssFilename); var cssPath = FileProvider.StylesPath + cssFilename; - if (s_registeredThemes.ContainsKey(themeName)) - return; - - s_registeredThemes[themeName] = new RegisteredTheme( + s_registeredThemes.TryAdd(themeName, new RegisteredTheme( null, cssPath, isStandalone - ); + )); } /// @@ -188,7 +183,7 @@ private static void ScanAssemblyForThemes(Assembly assembly) /// private static void ScanTypeForThemes(Type type) { - if (!s_scannedTypes.Add(type)) + if (!s_scannedTypes.TryAdd(type, 0)) return; var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Static) diff --git a/src/AspNetCore.SwaggerUI.Extensions/AspNetCore.SwaggerUI.Extensions.csproj b/src/AspNetCore.SwaggerUI.Extensions/AspNetCore.SwaggerUI.Extensions.csproj new file mode 100644 index 0000000..393052a --- /dev/null +++ b/src/AspNetCore.SwaggerUI.Extensions/AspNetCore.SwaggerUI.Extensions.csproj @@ -0,0 +1,28 @@ + + + + + + + + AspNetCore.SwaggerUI.Extensions + + Enhance your ASP.NET Core Swagger UI with functional extensions like API Counter. + + + swagger;swagger-ui;swaggerui;swashbuckle;aspnetcore;webapi;openapi;extension;api-counter;operation-count;tag-extensions + + true + + + + + + + + + diff --git a/src/AspNetCore.SwaggerUI.Extensions/AspNetCore/Swagger/Extensions/Filters/AppendOperationCountToTagDescriptionFilter.cs b/src/AspNetCore.SwaggerUI.Extensions/AspNetCore/Swagger/Extensions/Filters/AppendOperationCountToTagDescriptionFilter.cs new file mode 100644 index 0000000..ce60eb9 --- /dev/null +++ b/src/AspNetCore.SwaggerUI.Extensions/AspNetCore/Swagger/Extensions/Filters/AppendOperationCountToTagDescriptionFilter.cs @@ -0,0 +1,100 @@ +#if NET10_0_OR_GREATER +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace AspNetCore.Swagger.Extensions.Filters; + +/// +/// Document filter that appends operation count to tag descriptions in Swagger UI. +/// +/// +/// This filter modifies the OpenAPI document at generation time to include +/// the number of operations (endpoints) in each tag's description. +/// +public sealed class AppendOperationCountToTagDescriptionFilter : IDocumentFilter +{ + private readonly string _messageTemplate; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The message template to use. Must contain {0} placeholder for the count. + /// Default: " (operations: {0})" + /// + /// + /// Thrown when does not contain {0} placeholder. + /// + public AppendOperationCountToTagDescriptionFilter(string messageTemplate = " (operations: {0})") + { + ArgumentException.ThrowIfNullOrWhiteSpace(messageTemplate); + + if (!messageTemplate.Contains("{0}")) + { + throw new ArgumentException("The message template must contain '{0}' placeholder.", nameof(messageTemplate)); + } + + _messageTemplate = messageTemplate; + } + + /// + /// Applies the filter to the OpenAPI document. + /// + /// The OpenAPI document to modify. + /// The document filter context. + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + // Count operations per tag + var tagOperationCount = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var path in swaggerDoc.Paths.Values) + { + foreach (var operation in path.Operations.Values) + { + if (operation?.Tags is null || operation.Tags.Count == 0) + continue; + + foreach (var tag in operation.Tags.Select(t => t.Name)) + { + if (tagOperationCount.TryGetValue(tag, out int value)) + tagOperationCount[tag] = ++value; + else + tagOperationCount[tag] = 1; + } + } + } + + // If no counts found, nothing to do + if (tagOperationCount.Count == 0) + return; + + // Ensure Tags collection exists +#if NET10_0_OR_GREATER + swaggerDoc.Tags ??= new HashSet(); +#else + swaggerDoc.Tags ??= []; +#endif + + // Create tags if they don't exist + foreach (var tagName in tagOperationCount.Keys) + { + if (!swaggerDoc.Tags.Any(t => t.Name.Equals(tagName, StringComparison.OrdinalIgnoreCase))) + { + swaggerDoc.Tags.Add(new OpenApiTag { Name = tagName }); + } + } + + // Append count to tag descriptions + foreach (var tag in swaggerDoc.Tags) + { + if (tagOperationCount.TryGetValue(tag.Name, out var count)) + { + tag.Description ??= string.Empty; + tag.Description += string.Format(_messageTemplate, count); + } + } + } +} \ No newline at end of file diff --git a/src/AspNetCore.SwaggerUI.Extensions/Microsoft/Extensions/DependencyInjection/SwaggerGenOptionsExtensions.cs b/src/AspNetCore.SwaggerUI.Extensions/Microsoft/Extensions/DependencyInjection/SwaggerGenOptionsExtensions.cs new file mode 100644 index 0000000..26b41d5 --- /dev/null +++ b/src/AspNetCore.SwaggerUI.Extensions/Microsoft/Extensions/DependencyInjection/SwaggerGenOptionsExtensions.cs @@ -0,0 +1,35 @@ +using AspNetCore.Swagger.Extensions.Filters; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for configuring Swagger generation with operation count feature. +/// +public static class SwaggerGenOptionsExtensions +{ + /// + /// Appends operation count to tag descriptions in Swagger UI. + /// + /// The Swagger generation options. + /// + /// Optional message template. Must contain {0} placeholder for the count. + /// Default: " (operations: {0})" + /// + /// + /// + /// services.AddSwaggerGen(c => + /// { + /// c.AppendOperationCountToTags(); + /// // Or with custom template: + /// c.AppendOperationCountToTags(" [{0} endpoints]"); + /// }); + /// + /// + public static void AppendOperationCountToTags( + this SwaggerGenOptions options, + string messageTemplate = " (operations: {0})") + { + options.DocumentFilter(messageTemplate); + } +} \ No newline at end of file diff --git a/src/AspNetCore.SwaggerUI.Extensions/package-readme.md b/src/AspNetCore.SwaggerUI.Extensions/package-readme.md new file mode 100644 index 0000000..cd78785 --- /dev/null +++ b/src/AspNetCore.SwaggerUI.Extensions/package-readme.md @@ -0,0 +1,48 @@ +ο»Ώ# AspNetCore.SwaggerUI.Extensions + +### Enhance your ASP.NET Core Swagger UI with functional extensions that go beyond theming! + +**Automatic operation counts**  β€’  **Zero-config setup** β€’ _**...and more coming soon!**_ + +**[Get Started](https://github.com/teociaps/SwaggerUI.Themes/wiki/Getting-Started)** β€’ **[Full Documentation](https://github.com/teociaps/SwaggerUI.Themes/wiki)** + +## πŸš€ Quick Start + +```bash +dotnet add package AspNetCore.SwaggerUI.Extensions +``` + +```csharp +builder.Services.AddSwaggerGen(c => +{ + c.AppendOperationCountToTags(); +}); +``` + +> **Note**: `AddSwaggerGen()` is provided by Swashbuckle.AspNetCore. This package adds convenient extension methods on top of it. + +## ✨ Features + +- **API Counter** - Automatically display operation counts in tag descriptions +- _...more extensions coming soon!_ + +## πŸ“š Basic Usage Examples + +```csharp +builder.Services.AddSwaggerGen(c => +{ + // Append operation count to tag descriptions + c.AppendOperationCountToTags(); + + // Or with a custom template + c.AppendOperationCountToTags(" [{0} endpoints]"); +}); +``` + +## Related Packages + +- **[AspNetCore.SwaggerUI.Themes](https://www.nuget.org/packages/AspNetCore.SwaggerUI.Themes/)** - Runtime theme switching and customization for Swashbuckle + +--- + +#### Discover all the features and customization options in the [documentation](https://github.com/teociaps/SwaggerUI.Themes/wiki)! \ No newline at end of file diff --git a/src/AspNetCore.SwaggerUI.Extensions/readme.txt b/src/AspNetCore.SwaggerUI.Extensions/readme.txt new file mode 100644 index 0000000..c7c9bf2 --- /dev/null +++ b/src/AspNetCore.SwaggerUI.Extensions/readme.txt @@ -0,0 +1,24 @@ +ο»Ώ+===================================================================================+ +| AspNetCore.SwaggerUI.Extensions β€’ RELEASE NOTES - v1.0 | ++===================================================================================+ + +NEW FEATURES +------------------------------------------------------------------------------------- +- API Counter: Automatically append operation counts to tag descriptions + Enable it with: c.AppendOperationCountToTags(); + + +QUICK START +------------------------------------------------------------------------------------- +API Counter: + builder.Services.AddSwaggerGen(c => c.AppendOperationCountToTags()); + + +DOCUMENTATION +------------------------------------------------------------------------------------- + +Full docs: https://github.com/teociaps/SwaggerUI.Themes/wiki +Troubleshooting: https://github.com/teociaps/SwaggerUI.Themes/wiki/Troubleshooting +Repository: https://github.com/teociaps/SwaggerUI.Themes + +===================================================================================== \ No newline at end of file diff --git a/src/AspNetCore.SwaggerUI.Themes/AspNetCore.SwaggerUI.Themes.csproj b/src/AspNetCore.SwaggerUI.Themes/AspNetCore.SwaggerUI.Themes.csproj index c297dd7..abe46e9 100644 --- a/src/AspNetCore.SwaggerUI.Themes/AspNetCore.SwaggerUI.Themes.csproj +++ b/src/AspNetCore.SwaggerUI.Themes/AspNetCore.SwaggerUI.Themes.csproj @@ -1,17 +1,9 @@ ο»Ώ + - - - 3 - 0 - 2 - - $(MajorVersion).$(MinorVersion).$(PatchVersion)$(VersionSuffix) - - AspNetCore.SwaggerUI.Themes diff --git a/src/AspNetCore.SwaggerUI.Themes/Microsoft/AspNetCore/Builder/SwaggerUIOptionsExtensions.cs b/src/AspNetCore.SwaggerUI.Themes/Microsoft/AspNetCore/Builder/SwaggerUIOptionsExtensions.cs index 92a0cf3..891b419 100644 --- a/src/AspNetCore.SwaggerUI.Themes/Microsoft/AspNetCore/Builder/SwaggerUIOptionsExtensions.cs +++ b/src/AspNetCore.SwaggerUI.Themes/Microsoft/AspNetCore/Builder/SwaggerUIOptionsExtensions.cs @@ -57,6 +57,20 @@ public void EnableAllAdvancedOptions(ThemeSwitcherOptions switcherOptions = null public void EnablePinnableTopbar() => options.ConfigObject.AdditionalItems.EnablePinnableTopbar(); + /// + /// Enables the operation filter box, optionally making it pinnable. + /// + /// + /// If , the filter box can be pinned in place. + /// + public void EnableFilter(bool pinnable) + { + options.EnableFilter(); + + if (pinnable) + options.ConfigObject.AdditionalItems.EnablePinnableFilterBar(); + } + /// /// Shows a button to scroll back to the top of the page. /// diff --git a/src/AspNetCore.SwaggerUI.Themes/package-readme.md b/src/AspNetCore.SwaggerUI.Themes/package-readme.md index 6d78629..7c7bc7f 100644 --- a/src/AspNetCore.SwaggerUI.Themes/package-readme.md +++ b/src/AspNetCore.SwaggerUI.Themes/package-readme.md @@ -21,6 +21,9 @@ app.UseSwaggerUI(Theme.Dark); // Or enable runtime theme switcher! app.UseSwaggerUI(Theme.Dark, c => c.EnableThemeSwitcher()); + +// Or enable a pinnable filter box +app.UseSwaggerUI(Theme.Dark, c => c.EnableFilter(pinnable: true)); ``` > **Note**: The `UseSwaggerUI()` method is provided by Swashbuckle.AspNetCore. This package adds convenient overloads to apply themes seamlessly. diff --git a/src/AspNetCore.SwaggerUI.Themes/readme.txt b/src/AspNetCore.SwaggerUI.Themes/readme.txt index a406be4..e6da52a 100644 --- a/src/AspNetCore.SwaggerUI.Themes/readme.txt +++ b/src/AspNetCore.SwaggerUI.Themes/readme.txt @@ -1,57 +1,31 @@ ο»Ώ+===================================================================================+ -| AspNetCore.SwaggerUI.Themes β€’ RELEASE NOTES - v3 | +| AspNetCore.SwaggerUI.Themes β€’ RELEASE NOTES - v3.1 | +===================================================================================+ -BREAKING CHANGES -------------------------------------------------------------------------------------- -- API redesign: Style -> Theme (all classes renamed) -- Classic themes removed (modern themes only) -- .NET 6 & 7 support discontinued - -See migration guide: https://github.com/teociaps/SwaggerUI.Themes/wiki/Migration-v3 - - NEW FEATURES ------------------------------------------------------------------------------------- -- πŸ”₯ Runtime Theme Switcher: Change themes on-the-fly without refreshing the page -- Auto-Discovery: Custom themes automatically available in the theme switcher -- Nested Folder Support: Organize your themes in subfolders -- Standalone Themes: Create CSS-only themes with zero dependencies -- Smart Filename Resolution: Support standard and standalone variants simultaneously -- Minified Stylesheet Support: Optimize load times with minified CSS - - -PLATFORM & DEPENDENCIES -------------------------------------------------------------------------------------- -- .NET 10 support added -- Swashbuckle.AspNetCore updated to v9.0.6 +- Pinnable Filter Bar: Pin the operation filter box in place, just like the topbar + Enable it with: c.EnableFilter(pinnable: true); IMPROVEMENTS ------------------------------------------------------------------------------------- -- Unified modern theme system with consistent defaults -- Advanced features now available on all themes -- Smaller package footprint (optimized assets) -- Improved performance and clarity throughout +- Pinnable topbar now remembers its pinned/unpinned state across page reloads +- Improved theme asset serving performance +- Fixed a thread-safety issue that could affect parallel test execution QUICK START ------------------------------------------------------------------------------------- -Basic theme: - app.UseSwaggerUI(Theme.Dark); - -Enable theme switcher: - app.UseSwaggerUI(Theme.Dark, c => c.EnableThemeSwitcher()); - -All features: - app.UseSwaggerUI(Theme.Dark, c => c.EnableAllAdvancedOptions()); +Pinnable filter bar: + app.UseSwaggerUI(Theme.Dark, c => c.EnableFilter(pinnable: true)); DOCUMENTATION ------------------------------------------------------------------------------------- -Full docs: https://github.com/teociaps/SwaggerUI.Themes/wiki -Migration: https://github.com/teociaps/SwaggerUI.Themes/wiki/Migration-v3 -Repository: https://github.com/teociaps/SwaggerUI.Themes +Full docs: https://github.com/teociaps/SwaggerUI.Themes/wiki +Troubleshooting: https://github.com/teociaps/SwaggerUI.Themes/wiki/Troubleshooting +Repository: https://github.com/teociaps/SwaggerUI.Themes ===================================================================================== \ No newline at end of file diff --git a/src/NSwag.AspNetCore.Extensions/AspNetCore/Swagger/Extensions/Processors/AppendOperationCountToTagDescriptionProcessor.cs b/src/NSwag.AspNetCore.Extensions/AspNetCore/Swagger/Extensions/Processors/AppendOperationCountToTagDescriptionProcessor.cs new file mode 100644 index 0000000..f26cc8e --- /dev/null +++ b/src/NSwag.AspNetCore.Extensions/AspNetCore/Swagger/Extensions/Processors/AppendOperationCountToTagDescriptionProcessor.cs @@ -0,0 +1,94 @@ +using NSwag; +using NSwag.Generation.Processors; +using NSwag.Generation.Processors.Contexts; + +namespace AspNetCore.Swagger.Extensions.Processors; + +/// +/// Document processor that appends operation count to tag descriptions in Swagger UI. +/// +/// +/// This processor modifies the OpenAPI document at generation time to include +/// the number of operations (endpoints) in each tag's description. +/// +public sealed class AppendOperationCountToTagDescriptionProcessor : IDocumentProcessor +{ + private readonly string _messageTemplate; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The message template to use. Must contain {0} placeholder for the count. + /// Default: " (operations: {0})" + /// + /// + /// Thrown when does not contain {0} placeholder. + /// + public AppendOperationCountToTagDescriptionProcessor(string messageTemplate = " (operations: {0})") + { + ArgumentException.ThrowIfNullOrWhiteSpace(messageTemplate); + + if (!messageTemplate.Contains("{0}")) + { + throw new ArgumentException("The message template must contain '{0}' placeholder.", nameof(messageTemplate)); + } + + _messageTemplate = messageTemplate; + } + + /// + /// Processes the OpenAPI document. + /// + /// The document processor context. + public void Process(DocumentProcessorContext context) + { + var document = context.Document; + + // Count operations per tag + var tagOperationCount = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var path in document.Paths.Values) + { + foreach (var operation in path.Values) + { + if (operation?.Tags is null || operation.Tags.Count == 0) + continue; + + foreach (var tag in operation.Tags) + { + if (tagOperationCount.TryGetValue(tag, out int value)) + tagOperationCount[tag] = ++value; + else + tagOperationCount[tag] = 1; + } + } + } + + // If no counts found, nothing to do + if (tagOperationCount.Count == 0) + return; + + // Ensure Tags collection exists + document.Tags ??= new List(); + + // Create tags if they don't exist + foreach (var tagName in tagOperationCount.Keys) + { + if (!document.Tags.Any(t => t.Name.Equals(tagName, StringComparison.OrdinalIgnoreCase))) + { + document.Tags.Add(new NSwag.OpenApiTag { Name = tagName }); + } + } + + // Append count to tag descriptions + foreach (var tag in document.Tags) + { + if (tagOperationCount.TryGetValue(tag.Name, out var count)) + { + tag.Description ??= string.Empty; + tag.Description += string.Format(_messageTemplate, count); + } + } + } +} \ No newline at end of file diff --git a/src/NSwag.AspNetCore.Extensions/Microsoft/Extensions/DependencyInjection/AspNetCoreOpenApiDocumentGeneratorSettingsExtensions.cs b/src/NSwag.AspNetCore.Extensions/Microsoft/Extensions/DependencyInjection/AspNetCoreOpenApiDocumentGeneratorSettingsExtensions.cs new file mode 100644 index 0000000..b299410 --- /dev/null +++ b/src/NSwag.AspNetCore.Extensions/Microsoft/Extensions/DependencyInjection/AspNetCoreOpenApiDocumentGeneratorSettingsExtensions.cs @@ -0,0 +1,36 @@ +using AspNetCore.Swagger.Extensions.Processors; +using NSwag.Generation.AspNetCore; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for configuring OpenAPI document generation with operation count feature. +/// +public static class AspNetCoreOpenApiDocumentGeneratorSettingsExtensions +{ + /// + /// Appends operation count to tag descriptions in Swagger UI. + /// + /// The OpenAPI document generator settings. + /// + /// Optional message template. Must contain {0} placeholder for the count. + /// Default: " (operations: {0})" + /// + /// + /// + /// services.AddOpenApiDocument(c => + /// { + /// c.AppendOperationCountToTags(); + /// // Or with custom template: + /// c.AppendOperationCountToTags(" [{0} endpoints]"); + /// }); + /// + /// + public static void AppendOperationCountToTags( + this AspNetCoreOpenApiDocumentGeneratorSettings settings, + string messageTemplate = " (operations: {0})") + { + settings.DocumentProcessors.Add( + new AppendOperationCountToTagDescriptionProcessor(messageTemplate)); + } +} diff --git a/src/NSwag.AspNetCore.Extensions/NSwag.AspNetCore.Extensions.csproj b/src/NSwag.AspNetCore.Extensions/NSwag.AspNetCore.Extensions.csproj new file mode 100644 index 0000000..2b36d1c --- /dev/null +++ b/src/NSwag.AspNetCore.Extensions/NSwag.AspNetCore.Extensions.csproj @@ -0,0 +1,22 @@ +ο»Ώ + + + + + + + NSwag.AspNetCore.Extensions + + Enhance your ASP.NET Core Swagger UI with functional extensions like API Counter. + + + swagger;swagger-ui;swaggerui;nswag;aspnetcore;webapi;openapi;extension;api-counter;operation-count;tag-extensions + + true + + + + + + + diff --git a/src/NSwag.AspNetCore.Extensions/package-readme.md b/src/NSwag.AspNetCore.Extensions/package-readme.md new file mode 100644 index 0000000..bc10a09 --- /dev/null +++ b/src/NSwag.AspNetCore.Extensions/package-readme.md @@ -0,0 +1,48 @@ +ο»Ώ# NSwag.AspNetCore.Extensions + +### Enhance your ASP.NET Core Swagger UI with functional extensions that go beyond theming! + +**Automatic operation counts**  β€’  **Zero-config setup** β€’ _**...and more coming soon!**_ + +**[Get Started](https://github.com/teociaps/SwaggerUI.Themes/wiki/Getting-Started)** β€’ **[Full Documentation](https://github.com/teociaps/SwaggerUI.Themes/wiki)** + +## πŸš€ Quick Start + +```bash +dotnet add package NSwag.AspNetCore.Extensions +``` + +```csharp +builder.Services.AddOpenApiDocument(c => +{ + c.AppendOperationCountToTags(); +}); +``` + +> **Note**: `AddOpenApiDocument()` is provided by NSwag.AspNetCore. This package adds convenient extension methods on top of it. + +## ✨ Features + +- **API Counter** - Automatically display operation counts in tag descriptions +- _...more extensions coming soon!_ + +## πŸ“š Basic Usage Examples + +```csharp +builder.Services.AddOpenApiDocument(c => +{ + // Append operation count to tag descriptions + c.AppendOperationCountToTags(); + + // Or with a custom template + c.AppendOperationCountToTags(" [{0} endpoints]"); +}); +``` + +## Related Packages + +- **[NSwag.AspNetCore.Themes](https://www.nuget.org/packages/NSwag.AspNetCore.Themes/)** - Runtime theme switching and customization for NSwag + +--- + +#### Discover all the features and customization options in the [documentation](https://github.com/teociaps/SwaggerUI.Themes/wiki)! \ No newline at end of file diff --git a/src/NSwag.AspNetCore.Extensions/readme.txt b/src/NSwag.AspNetCore.Extensions/readme.txt new file mode 100644 index 0000000..20470f7 --- /dev/null +++ b/src/NSwag.AspNetCore.Extensions/readme.txt @@ -0,0 +1,24 @@ +ο»Ώ+===================================================================================+ +| NSwag.AspNetCore.Extensions β€’ RELEASE NOTES - v1.0 | ++===================================================================================+ + +NEW FEATURES +------------------------------------------------------------------------------------- +- API Counter: Automatically append operation counts to tag descriptions + Enable it with: c.AppendOperationCountToTags(); + + +QUICK START +------------------------------------------------------------------------------------- +API Counter: + builder.Services.AddOpenApiDocument(c => c.AppendOperationCountToTags()); + + +DOCUMENTATION +------------------------------------------------------------------------------------- + +Full docs: https://github.com/teociaps/SwaggerUI.Themes/wiki +Troubleshooting: https://github.com/teociaps/SwaggerUI.Themes/wiki/Troubleshooting +Repository: https://github.com/teociaps/SwaggerUI.Themes + +===================================================================================== \ No newline at end of file diff --git a/src/NSwag.AspNetCore.Themes/Microsoft/AspNetCore/Builder/SwaggerUiSettingsExtensions.cs b/src/NSwag.AspNetCore.Themes/Microsoft/AspNetCore/Builder/SwaggerUiSettingsExtensions.cs index 066efbf..b868c5d 100644 --- a/src/NSwag.AspNetCore.Themes/Microsoft/AspNetCore/Builder/SwaggerUiSettingsExtensions.cs +++ b/src/NSwag.AspNetCore.Themes/Microsoft/AspNetCore/Builder/SwaggerUiSettingsExtensions.cs @@ -57,6 +57,31 @@ public void EnableAllAdvancedOptions(ThemeSwitcherOptions switcherOptions = null public void EnablePinnableTopbar() => settings.AdditionalSettings.EnablePinnableTopbar(); + /// + /// Enables the operation filter box. + /// + /// + /// Sets the raw filter key consumed directly by the swagger-ui bundle's own + /// SwaggerUIBundle({...}) initializer, as opposed to the other options here + /// which drive this library's own theme JS. + /// + public void EnableFilter() => + settings.AdditionalSettings.TryAdd("filter", true); + + /// + /// Enables the operation filter box, optionally making it pinnable. + /// + /// + /// If , the filter box can be pinned in place. + /// + public void EnableFilter(bool pinnable) + { + settings.EnableFilter(); + + if (pinnable) + settings.AdditionalSettings.EnablePinnableFilterBar(); + } + /// /// Shows a button to scroll back to the top of the page. /// diff --git a/src/NSwag.AspNetCore.Themes/NSwag.AspNetCore.Themes.csproj b/src/NSwag.AspNetCore.Themes/NSwag.AspNetCore.Themes.csproj index 19caafd..a966f7d 100644 --- a/src/NSwag.AspNetCore.Themes/NSwag.AspNetCore.Themes.csproj +++ b/src/NSwag.AspNetCore.Themes/NSwag.AspNetCore.Themes.csproj @@ -1,17 +1,9 @@ ο»Ώ + - - - 3 - 0 - 2 - - $(MajorVersion).$(MinorVersion).$(PatchVersion)$(VersionSuffix) - - NSwag.AspNetCore.Themes @@ -24,7 +16,7 @@ - + diff --git a/src/NSwag.AspNetCore.Themes/package-readme.md b/src/NSwag.AspNetCore.Themes/package-readme.md index c59e707..c45ee78 100644 --- a/src/NSwag.AspNetCore.Themes/package-readme.md +++ b/src/NSwag.AspNetCore.Themes/package-readme.md @@ -21,6 +21,9 @@ app.UseSwaggerUi(Theme.Dark); // Or enable runtime theme switcher! app.UseSwaggerUi(Theme.Dark, c => c.EnableThemeSwitcher()); + +// Or enable a pinnable filter box +app.UseSwaggerUi(Theme.Dark, c => c.EnableFilter(pinnable: true)); ``` > **Note**: The `UseSwaggerUi()` method is provided by NSwag.AspNetCore. This package adds convenient overloads to apply themes seamlessly. diff --git a/src/NSwag.AspNetCore.Themes/readme.txt b/src/NSwag.AspNetCore.Themes/readme.txt index e087d7e..8f83215 100644 --- a/src/NSwag.AspNetCore.Themes/readme.txt +++ b/src/NSwag.AspNetCore.Themes/readme.txt @@ -1,57 +1,36 @@ ο»Ώ+===================================================================================+ -| NSwag.AspNetCore.Themes β€’ RELEASE NOTES - v3 | +| NSwag.AspNetCore.Themes β€’ RELEASE NOTES - v3.1 | +===================================================================================+ -BREAKING CHANGES -------------------------------------------------------------------------------------- -- API redesign: Style -> Theme (all classes renamed) -- Classic themes removed (modern themes only) -- .NET 6 & 7 support discontinued - -See migration guide: https://github.com/teociaps/SwaggerUI.Themes/wiki/Migration-v3 - - NEW FEATURES ------------------------------------------------------------------------------------- -- πŸ”₯ Runtime Theme Switcher: Change themes on-the-fly without refreshing the page -- Auto-Discovery: Custom themes automatically available in the theme switcher -- Nested Folder Support: Organize your themes in subfolders -- Standalone Themes: Create CSS-only themes with zero dependencies -- Smart Filename Resolution: Support standard and standalone variants simultaneously -- Minified Stylesheet Support: Optimize load times with minified CSS - - -PLATFORM & DEPENDENCIES -------------------------------------------------------------------------------------- -- .NET 10 support added -- NSwag.AspNetCore updated to v14.6.3 +- Filter Box Support: settings.EnableFilter() is now available for NSwag, enabling + Swagger UI's native operation filter box +- Pinnable Filter Bar: Pin the operation filter box in place, just like the topbar + Enable it with: settings.EnableFilter(pinnable: true); IMPROVEMENTS ------------------------------------------------------------------------------------- -- Unified modern theme system with consistent defaults -- Advanced features now available on all themes -- Smaller package footprint (optimized assets) -- Improved performance and clarity throughout +- Pinnable topbar now remembers its pinned/unpinned state across page reloads +- Improved theme asset serving performance +- Fixed a thread-safety issue that could affect parallel test execution QUICK START ------------------------------------------------------------------------------------- -Basic theme: - app.UseSwaggerUi(Theme.Dark); - -Enable theme switcher: - app.UseSwaggerUi(Theme.Dark, c => c.EnableThemeSwitcher()); +Filter box: + app.UseSwaggerUi(Theme.Dark, settings => settings.EnableFilter()); -All features: - app.UseSwaggerUi(Theme.Dark, c => c.EnableAllAdvancedOptions()); +Pinnable filter bar: + app.UseSwaggerUi(Theme.Dark, settings => settings.EnableFilter(pinnable: true)); DOCUMENTATION ------------------------------------------------------------------------------------- -Full docs: https://github.com/teociaps/SwaggerUI.Themes/wiki -Migration: https://github.com/teociaps/SwaggerUI.Themes/wiki/Migration-v3 -Repository: https://github.com/teociaps/SwaggerUI.Themes +Full docs: https://github.com/teociaps/SwaggerUI.Themes/wiki +Troubleshooting: https://github.com/teociaps/SwaggerUI.Themes/wiki/Troubleshooting +Repository: https://github.com/teociaps/SwaggerUI.Themes ===================================================================================== \ No newline at end of file diff --git a/tests/AspNetCore.Swagger.Extensions.Tests/AppendOperationCountToTagDescriptionFilterTests.cs b/tests/AspNetCore.Swagger.Extensions.Tests/AppendOperationCountToTagDescriptionFilterTests.cs new file mode 100644 index 0000000..b6cc1f9 --- /dev/null +++ b/tests/AspNetCore.Swagger.Extensions.Tests/AppendOperationCountToTagDescriptionFilterTests.cs @@ -0,0 +1,233 @@ +using AspNetCore.Swagger.Extensions.Filters; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +#if NET10_0_OR_GREATER +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif +using Shouldly; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text.Json; +using Xunit; + +namespace AspNetCore.Swagger.Extensions.Tests; + +public class AppendOperationCountToTagDescriptionFilterTests +{ + [Fact] + public void Constructor_WithValidTemplate_Succeeds() + { + // Act + var filter = new AppendOperationCountToTagDescriptionFilter(" (operations: {0})"); + + // Assert + filter.ShouldNotBeNull(); + } + + [Fact] + public void Constructor_WithNullTemplate_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => + new AppendOperationCountToTagDescriptionFilter(null!)); + } + + [Fact] + public void Constructor_WithEmptyTemplate_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => + new AppendOperationCountToTagDescriptionFilter("")); + } + + [Fact] + public void Constructor_WithoutPlaceholder_ThrowsArgumentException() + { + // Act & Assert + var exception = Should.Throw(() => + new AppendOperationCountToTagDescriptionFilter(" operations")); + exception.Message.ShouldContain("{0}"); + } + + [Fact] + public void Apply_WithEmptyDocument_DoesNothing() + { + // Arrange + var filter = new AppendOperationCountToTagDescriptionFilter(); + var document = new OpenApiDocument + { + Paths = [] + }; + var context = CreateContext(); + + // Act + filter.Apply(document, context); + + // Assert + // Tags is left untouched when there's nothing to append - its default depends on the + // Microsoft.OpenApi version (empty collection pre-v2, null from v2 onward), and either + // is correct here since the filter never had a reason to allocate one. + (document.Tags is null || document.Tags.Count == 0).ShouldBeTrue(); + } + + [Fact] + public void Apply_WithOperations_AppendsCount() + { + // Arrange + var filter = new AppendOperationCountToTagDescriptionFilter(); + var document = CreateDocumentWithTags(); + var context = CreateContext(); + + // Act + filter.Apply(document, context); + + // Assert + document.Tags.ShouldNotBeEmpty(); + var userTag = document.Tags.FirstOrDefault(t => t.Name == "Users"); + userTag.ShouldNotBeNull(); + userTag.Description.ShouldContain("(operations: 2)"); + } + + [Fact] + public void Apply_WithCustomTemplate_UsesTemplate() + { + // Arrange + var filter = new AppendOperationCountToTagDescriptionFilter(" [{0} endpoints]"); + var document = CreateDocumentWithTags(); + var context = CreateContext(); + + // Act + filter.Apply(document, context); + + // Assert + var userTag = document.Tags.FirstOrDefault(t => t.Name == "Users"); + userTag.ShouldNotBeNull(); + userTag.Description.ShouldContain("[2 endpoints]"); + } + + [Fact] + public void Apply_WithExistingDescription_AppendsCount() + { + // Arrange + var filter = new AppendOperationCountToTagDescriptionFilter(); + var document = CreateDocumentWithTags(); +#if NET10_0_OR_GREATER + document.Tags = new HashSet + { + new() { Name = "Users", Description = "User management" } + }; +#else + document.Tags = new List + { + new() { Name = "Users", Description = "User management" } + }; +#endif + var context = CreateContext(); + + // Act + filter.Apply(document, context); + + // Assert + var userTag = document.Tags.FirstOrDefault(t => t.Name == "Users"); + userTag.ShouldNotBeNull(); + userTag.Description.ShouldBe("User management (operations: 2)"); + } + + [Fact] + public void Apply_WithMultipleTags_CountsCorrectly() + { + // Arrange + var filter = new AppendOperationCountToTagDescriptionFilter(); + var document = new OpenApiDocument + { + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { +#if NET10_0_OR_GREATER + Operations = new Dictionary + { + [HttpMethod.Get] = new() { Tags = new HashSet { new("Users", null) } }, + [HttpMethod.Post] = new() { Tags = new HashSet { new("Users", null) } } + } +#else + Operations = new Dictionary + { + [OperationType.Get] = new() { Tags = new List { new() { Name = "Users" } } }, + [OperationType.Post] = new() { Tags = new List { new() { Name = "Users" } } } + } +#endif + }, + ["/products"] = new OpenApiPathItem + { +#if NET10_0_OR_GREATER + Operations = new Dictionary + { + [HttpMethod.Get] = new() { Tags = new HashSet { new("Products", null) } } + } +#else + Operations = new Dictionary + { + [OperationType.Get] = new() { Tags = new List { new() { Name = "Products" } } } + } +#endif + } + } + }; + var context = CreateContext(); + + // Act + filter.Apply(document, context); + + // Assert + document.Tags.Count.ShouldBe(2); + document.Tags.First(t => t.Name == "Users").Description.ShouldContain("(operations: 2)"); + document.Tags.First(t => t.Name == "Products").Description.ShouldContain("(operations: 1)"); + } + + private static DocumentFilterContext CreateContext() + { + return new DocumentFilterContext( + new List(), + new SchemaGenerator(new SchemaGeneratorOptions(), new JsonSerializerDataContractResolver(new JsonSerializerOptions())), + new SchemaRepository()); + } + + private static OpenApiDocument CreateDocumentWithTags() + { + return new OpenApiDocument + { + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { +#if NET10_0_OR_GREATER + Operations = new Dictionary + { + [HttpMethod.Get] = new() + { + Tags = new HashSet { new("Users", null) } + }, + [HttpMethod.Post] = new() + { + Tags = new HashSet { new("Users", null) } + } + } +#else + Operations = new Dictionary + { + [OperationType.Get] = new() + { + Tags = new List { new() { Name = "Users" } } + }, + [OperationType.Post] = new() + { + Tags = new List { new() { Name = "Users" } } + } + } +#endif + } + } + }; + } +} \ No newline at end of file diff --git a/tests/AspNetCore.Swagger.Extensions.Tests/AppendOperationCountToTagDescriptionProcessorTests.cs b/tests/AspNetCore.Swagger.Extensions.Tests/AppendOperationCountToTagDescriptionProcessorTests.cs new file mode 100644 index 0000000..3f4b3d5 --- /dev/null +++ b/tests/AspNetCore.Swagger.Extensions.Tests/AppendOperationCountToTagDescriptionProcessorTests.cs @@ -0,0 +1,182 @@ +using AspNetCore.Swagger.Extensions.Processors; +using NSwag; +using NSwag.Generation.Processors.Contexts; +using Shouldly; +using Xunit; + +namespace AspNetCore.Swagger.Extensions.Tests; + +public class AppendOperationCountToTagDescriptionProcessorTests +{ + [Fact] + public void Constructor_WithValidTemplate_Succeeds() + { + // Act + var processor = new AppendOperationCountToTagDescriptionProcessor(" (operations: {0})"); + + // Assert + processor.ShouldNotBeNull(); + } + + [Fact] + public void Constructor_WithNullTemplate_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => + new AppendOperationCountToTagDescriptionProcessor(null!)); + } + + [Fact] + public void Constructor_WithEmptyTemplate_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => + new AppendOperationCountToTagDescriptionProcessor("")); + } + + [Fact] + public void Constructor_WithoutPlaceholder_ThrowsArgumentException() + { + // Act & Assert + var exception = Should.Throw(() => + new AppendOperationCountToTagDescriptionProcessor(" operations")); + exception.Message.ShouldContain("{0}"); + } + + [Fact] + public void Process_WithEmptyDocument_DoesNothing() + { + // Arrange + var processor = new AppendOperationCountToTagDescriptionProcessor(); + var document = new OpenApiDocument + { + Tags = [] + }; + var context = CreateContext(document); + + // Act + processor.Process(context); + + // Assert + document.Tags.ShouldBeEmpty(); + } + + [Fact] + public void Process_WithOperations_AppendsCount() + { + // Arrange + var processor = new AppendOperationCountToTagDescriptionProcessor(); + var document = CreateDocumentWithTags(); + var context = CreateContext(document); + + // Act + processor.Process(context); + + // Assert + document.Tags.ShouldNotBeEmpty(); + var userTag = document.Tags.FirstOrDefault(t => t.Name == "Users"); + userTag.ShouldNotBeNull(); + userTag.Description.ShouldContain("(operations: 2)"); + } + + [Fact] + public void Process_WithCustomTemplate_UsesTemplate() + { + // Arrange + var processor = new AppendOperationCountToTagDescriptionProcessor(" [{0} endpoints]"); + var document = CreateDocumentWithTags(); + var context = CreateContext(document); + + // Act + processor.Process(context); + + // Assert + var userTag = document.Tags.FirstOrDefault(t => t.Name == "Users"); + userTag.ShouldNotBeNull(); + userTag.Description.ShouldContain("[2 endpoints]"); + } + + [Fact] + public void Process_WithExistingDescription_AppendsCount() + { + // Arrange + var processor = new AppendOperationCountToTagDescriptionProcessor(); + var document = CreateDocumentWithTags(); + document.Tags = new List + { + new() { Name = "Users", Description = "User management" } + }; + var context = CreateContext(document); + + // Act + processor.Process(context); + + // Assert + var userTag = document.Tags.FirstOrDefault(t => t.Name == "Users"); + userTag.ShouldNotBeNull(); + userTag.Description.ShouldBe("User management (operations: 2)"); + } + + [Fact] + public void Process_WithMultipleTags_CountsCorrectly() + { + // Arrange + var processor = new AppendOperationCountToTagDescriptionProcessor(); + var document = new OpenApiDocument(); + document.Paths.Add("/users", new OpenApiPathItem + { + { + OpenApiOperationMethod.Get, + new OpenApiOperation { Tags = new List { "Users" } } + }, + { + OpenApiOperationMethod.Post, + new OpenApiOperation { Tags = new List { "Users" } } + } + }); + document.Paths.Add("/products", new OpenApiPathItem + { + { + OpenApiOperationMethod.Get, + new OpenApiOperation { Tags = new List { "Products" } } + } + }); + var context = CreateContext(document); + + // Act + processor.Process(context); + + // Assert + document.Tags.Count.ShouldBe(2); + document.Tags.First(t => t.Name == "Users").Description.ShouldContain("(operations: 2)"); + document.Tags.First(t => t.Name == "Products").Description.ShouldContain("(operations: 1)"); + } + + private static DocumentProcessorContext CreateContext(OpenApiDocument document) + { + return new DocumentProcessorContext( + document, + new List(), + new List(), + null!, + null!, + null!); + } + + private static OpenApiDocument CreateDocumentWithTags() + { + var document = new OpenApiDocument(); + document.Paths.Add("/users", new OpenApiPathItem + { + { + OpenApiOperationMethod.Get, + new OpenApiOperation { Tags = new List { "Users" } } + }, + { + OpenApiOperationMethod.Post, + new OpenApiOperation { Tags = new List { "Users" } } + } + }); + return document; + } +} \ No newline at end of file diff --git a/tests/AspNetCore.Swagger.Extensions.Tests/AspNetCore.Swagger.Extensions.Tests.csproj b/tests/AspNetCore.Swagger.Extensions.Tests/AspNetCore.Swagger.Extensions.Tests.csproj new file mode 100644 index 0000000..5423612 --- /dev/null +++ b/tests/AspNetCore.Swagger.Extensions.Tests/AspNetCore.Swagger.Extensions.Tests.csproj @@ -0,0 +1,41 @@ +ο»Ώ + + + + + false + true + $(MSBuildProjectName.Replace(" ", "_")) + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/AspNetCore.Swagger.Extensions.Tests/AspNetCoreOpenApiDocumentGeneratorSettingsExtensionsTests.cs b/tests/AspNetCore.Swagger.Extensions.Tests/AspNetCoreOpenApiDocumentGeneratorSettingsExtensionsTests.cs new file mode 100644 index 0000000..6fdd55a --- /dev/null +++ b/tests/AspNetCore.Swagger.Extensions.Tests/AspNetCoreOpenApiDocumentGeneratorSettingsExtensionsTests.cs @@ -0,0 +1,42 @@ +using AspNetCore.Swagger.Extensions.Processors; +using Microsoft.Extensions.DependencyInjection; +using NSwag.Generation.AspNetCore; +using Shouldly; +using Xunit; + +namespace AspNetCore.Swagger.Extensions.Tests; + +public class AspNetCoreOpenApiDocumentGeneratorSettingsExtensionsTests +{ + [Fact] + public void AppendOperationCountToTags_AddsProcessor() + { + // Arrange + var settings = new AspNetCoreOpenApiDocumentGeneratorSettings(); + + // Act + settings.AppendOperationCountToTags(); + + // Assert + settings.DocumentProcessors.ShouldNotBeEmpty(); + settings.DocumentProcessors.ShouldContain(p => + p.GetType() == typeof(AppendOperationCountToTagDescriptionProcessor)); + } + + [Fact] + public void AppendOperationCountToTags_WithCustomTemplate_AddsProcessorWithTemplate() + { + // Arrange + var settings = new AspNetCoreOpenApiDocumentGeneratorSettings(); + var customTemplate = " [{0} endpoints]"; + + // Act + settings.AppendOperationCountToTags(customTemplate); + + // Assert + settings.DocumentProcessors.ShouldNotBeEmpty(); + var processor = settings.DocumentProcessors + .FirstOrDefault(p => p.GetType() == typeof(AppendOperationCountToTagDescriptionProcessor)); + processor.ShouldNotBeNull(); + } +} \ No newline at end of file diff --git a/tests/AspNetCore.Swagger.Extensions.Tests/SwaggerGenOptionsExtensionsTests.cs b/tests/AspNetCore.Swagger.Extensions.Tests/SwaggerGenOptionsExtensionsTests.cs new file mode 100644 index 0000000..610b7e6 --- /dev/null +++ b/tests/AspNetCore.Swagger.Extensions.Tests/SwaggerGenOptionsExtensionsTests.cs @@ -0,0 +1,43 @@ +using AspNetCore.Swagger.Extensions.Filters; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Swashbuckle.AspNetCore.SwaggerGen; +using Xunit; + +namespace AspNetCore.Swagger.Extensions.Tests; + +public class SwaggerGenOptionsExtensionsTests +{ + [Fact] + public void AppendOperationCountToTags_AddsFilter() + { + // Arrange + var options = new SwaggerGenOptions(); + + // Act + options.AppendOperationCountToTags(); + + // Assert + options.DocumentFilterDescriptors.ShouldNotBeEmpty(); + options.DocumentFilterDescriptors.ShouldContain(d => + d.Type == typeof(AppendOperationCountToTagDescriptionFilter)); + } + + [Fact] + public void AppendOperationCountToTags_WithCustomTemplate_AddsFilterWithTemplate() + { + // Arrange + var options = new SwaggerGenOptions(); + var customTemplate = " [{0} endpoints]"; + + // Act + options.AppendOperationCountToTags(customTemplate); + + // Assert + options.DocumentFilterDescriptors.ShouldNotBeEmpty(); + var descriptor = options.DocumentFilterDescriptors + .FirstOrDefault(d => d.Type == typeof(AppendOperationCountToTagDescriptionFilter)); + descriptor.ShouldNotBeNull(); + descriptor.Arguments.ShouldContain(customTemplate); + } +} \ No newline at end of file diff --git a/tests/AspNetCore.SwaggerUI.Themes.Tests/AdvancedOptionsTests.cs b/tests/AspNetCore.SwaggerUI.Themes.Tests/AdvancedOptionsTests.cs index 49ad878..8e001f7 100644 --- a/tests/AspNetCore.SwaggerUI.Themes.Tests/AdvancedOptionsTests.cs +++ b/tests/AspNetCore.SwaggerUI.Themes.Tests/AdvancedOptionsTests.cs @@ -20,6 +20,20 @@ public void EnablePinnableTopbar_AddsCorrectOption() options[AdvancedOptions.PinnableTopbar].ShouldBe(true); } + [Fact] + public void EnablePinnableFilterBar_AddsCorrectOption() + { + // Arrange + var options = new Dictionary(); + + // Act + options.EnablePinnableFilterBar(); + + // Assert + options.ShouldContainKey(AdvancedOptions.PinnableFilterBar); + options[AdvancedOptions.PinnableFilterBar].ShouldBe(true); + } + [Fact] public void EnableBackToTop_AddsCorrectOption() { @@ -114,12 +128,14 @@ public void Apply_AppliesJsFeaturesCorrectly() // Arrange const string JsContent = $""" const pinnableTopbapFeature = {AdvancedOptions.PinnableTopbarJsPH}; + const pinnableFilterBarFeature = {AdvancedOptions.PinnableFilterBarJsPH}; const backToTopFeature = {AdvancedOptions.BackToTopJsPH}; const expandOrCollapseOpsFeature = {AdvancedOptions.ExpandOrCollapseAllOperationsJsPH}; """; var options = new Dictionary { { AdvancedOptions.PinnableTopbar, true }, + { AdvancedOptions.PinnableFilterBar, true }, { AdvancedOptions.BackToTop, true }, { AdvancedOptions.ExpandOrCollapseAllOperations, true } }; @@ -129,6 +145,7 @@ public void Apply_AppliesJsFeaturesCorrectly() const string ExpectedJsConent = """ const pinnableTopbapFeature = true; + const pinnableFilterBarFeature = true; const backToTopFeature = true; const expandOrCollapseOpsFeature = true; """; @@ -136,6 +153,7 @@ public void Apply_AppliesJsFeaturesCorrectly() // Assert result.ShouldBe(ExpectedJsConent); result.ShouldNotContain(AdvancedOptions.PinnableTopbarJsPH); + result.ShouldNotContain(AdvancedOptions.PinnableFilterBarJsPH); } [Fact] diff --git a/tests/AspNetCore.SwaggerUI.Themes.Tests/AspNetCore.Swagger.Themes.Tests.csproj b/tests/AspNetCore.SwaggerUI.Themes.Tests/AspNetCore.Swagger.Themes.Tests.csproj index 45ffea8..992da6f 100644 --- a/tests/AspNetCore.SwaggerUI.Themes.Tests/AspNetCore.Swagger.Themes.Tests.csproj +++ b/tests/AspNetCore.SwaggerUI.Themes.Tests/AspNetCore.Swagger.Themes.Tests.csproj @@ -20,13 +20,13 @@ - + - + @@ -34,15 +34,17 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all + + diff --git a/tests/AspNetCore.SwaggerUI.Themes.Tests/FileProviderMiddlewareTests.cs b/tests/AspNetCore.SwaggerUI.Themes.Tests/FileProviderMiddlewareTests.cs index 89f1aac..c76c1f6 100644 --- a/tests/AspNetCore.SwaggerUI.Themes.Tests/FileProviderMiddlewareTests.cs +++ b/tests/AspNetCore.SwaggerUI.Themes.Tests/FileProviderMiddlewareTests.cs @@ -114,4 +114,96 @@ public async Task AddGetEndpoint_ShouldHandleCustomContentType() var responseBody = await reader.ReadToEndAsync(); responseBody.ShouldBe(content); } + + [Fact] + public async Task AddGetEndpoint_ShouldResolveEachPathIndependently_WhenMultiplePathsRegisteredOnSameApp() + { + // Arrange + var mockAppBuilder = new MockApplicationBuilder(); + var prefix = $"/test-middleware-multi-{Guid.NewGuid():N}"; + var registrations = Enumerable.Range(0, 6) + .Select(i => ( + Path: $"{prefix}/{i}.css", + Content: $"body {{ /* asset {i} */ }}", + ContentType: i % 2 == 0 ? MimeTypes.Text.Css : MimeTypes.Text.Javascript)) + .ToList(); + + foreach (var (path, content, contentType) in registrations) + { + AddGetEndpoint(mockAppBuilder, path, content, contentType); + } + + var app = mockAppBuilder.Build(); + + // Act & Assert - each registered path resolves to its own content and content-type + foreach (var (path, content, contentType) in registrations) + { + var context = MockApplicationBuilder.CreateHttpContext(path); + await app.Invoke(context); + await context.Response.Body.FlushAsync(); + + context.Response.StatusCode.ShouldBe(200); + context.Response.ContentType.ShouldBe(contentType); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(context.Response.Body); + (await reader.ReadToEndAsync()).ShouldBe(content); + } + + // An unregistered path still falls through to the terminal 404 + var missingContext = MockApplicationBuilder.CreateHttpContext($"{prefix}/unregistered.css"); + await app.Invoke(missingContext); + + missingContext.Response.StatusCode.ShouldBe(404); + } + + [Fact] + public async Task AddGetEndpoint_ShouldNotServePathRegisteredByAnotherAppInstance() + { + // Arrange - two independent apps, each registering its own, distinct path + var appA = new MockApplicationBuilder(); + var appB = new MockApplicationBuilder(); + var prefix = $"/test-middleware-isolation-{Guid.NewGuid():N}"; + var pathOnlyRegisteredByA = $"{prefix}/a.css"; + var pathOnlyRegisteredByB = $"{prefix}/b.css"; + + AddGetEndpoint(appA, pathOnlyRegisteredByA, "content-A"); + AddGetEndpoint(appB, pathOnlyRegisteredByB, "content-B"); + + var builtA = appA.Build(); + + // Act - request appA for a path only appB ever registered + var context = MockApplicationBuilder.CreateHttpContext(pathOnlyRegisteredByB); + await builtA.Invoke(context); + + // Assert - appA never registered this path, so it must not serve appB's content + context.Response.StatusCode.ShouldBe(404); + } + + [Fact] + public async Task AddGetEndpoint_WhenCalledConcurrently_RegistersEveryEndpointExactlyOnce() + { + // Arrange + const int EndpointCount = 50; + var prefix = $"/test-middleware-concurrent-{Guid.NewGuid():N}"; + var registrations = Enumerable.Range(0, EndpointCount) + .Select(i => (Path: $"{prefix}/{i}.css", AppBuilder: new MockApplicationBuilder())) + .ToList(); + + // Act - each registration uses its own builder so the middleware pipeline itself + // isn't mutated concurrently; only the shared static endpoint registry is stressed. + await Should.NotThrowAsync(() => Task.WhenAll( + registrations.Select(r => Task.Run(() => + AddGetEndpoint(r.AppBuilder, r.Path, $"body {{ /* {r.Path} */ }}"))))); + + // Assert + foreach (var (path, appBuilder) in registrations) + { + var app = appBuilder.Build(); + var context = MockApplicationBuilder.CreateHttpContext(path); + await app.Invoke(context); + + context.Response.StatusCode.ShouldBe(200); + } + } } \ No newline at end of file diff --git a/tests/AspNetCore.SwaggerUI.Themes.Tests/SwaggerUIOptionsExtensionsTests.cs b/tests/AspNetCore.SwaggerUI.Themes.Tests/SwaggerUIOptionsExtensionsTests.cs new file mode 100644 index 0000000..0513074 --- /dev/null +++ b/tests/AspNetCore.SwaggerUI.Themes.Tests/SwaggerUIOptionsExtensionsTests.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Builder; +using Shouldly; +using Swashbuckle.AspNetCore.SwaggerUI; +using Xunit; + +namespace AspNetCore.Swagger.Themes.Tests; + +public class SwaggerUIOptionsExtensionsTests +{ + [Fact] + public void EnableFilter_Pinnable_AddsPinnableFilterBarOption() + { + // Arrange + var options = new SwaggerUIOptions(); + + // Act + options.EnableFilter(pinnable: true); + + // Assert + options.ConfigObject.AdditionalItems.ShouldContainKey(AdvancedOptions.PinnableFilterBar); + } + + [Fact] + public void EnableFilter_NotPinnable_DoesNotAddPinnableFilterBarOption() + { + // Arrange + var options = new SwaggerUIOptions(); + + // Act + options.EnableFilter(pinnable: false); + + // Assert + options.ConfigObject.AdditionalItems.ShouldNotContainKey(AdvancedOptions.PinnableFilterBar); + } +} diff --git a/tests/AspNetCore.SwaggerUI.Themes.Tests/ThemeProviderTests.cs b/tests/AspNetCore.SwaggerUI.Themes.Tests/ThemeProviderTests.cs index 91edd37..d00ef80 100644 --- a/tests/AspNetCore.SwaggerUI.Themes.Tests/ThemeProviderTests.cs +++ b/tests/AspNetCore.SwaggerUI.Themes.Tests/ThemeProviderTests.cs @@ -1,13 +1,22 @@ using AspNetCore.Swagger.Themes.Tests.Utilities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Shouldly; +using System.Net; using System.Reflection; +using System.Text.Encodings.Web; using static AspNetCore.Swagger.Themes.FileProvider; namespace AspNetCore.Swagger.Themes.Tests; /// -/// Tests for ThemeProvider functionality using WebApplicationFactory. -/// Tests for non-WebApplication scenarios are in FileProviderMiddlewareTests. +/// Tests for ThemeProvider functionality using a WebApplication host (via WebApplicationFactory +/// or a locally hosted TestServer). Tests for non-WebApplication scenarios are in FileProviderMiddlewareTests. /// public class ThemeProviderTests : IClassFixture> { @@ -177,6 +186,91 @@ public async Task AddGetEndpoint_ShouldReturnStyleContent_WhenWebApplication(Bas (await response.Content.ReadAsStringAsync()).ShouldBeEquivalentTo(styleText); } + [Fact] + public async Task AddGetEndpoint_ShouldResolveEachRegisteredPath_WhenWebApplication() + { + // Arrange - the shared test WebApplication (see Program.cs) registers one path per + // theme via AddGetEndpoint at startup, all served through the app's routing table. + var registeredThemes = new ThemeTestData(); + + // Act & Assert - each theme's style path resolves independently through the same WebApplication instance + foreach (var theme in registeredThemes) + { + var fullPath = StylesPath + theme.FileName; + var expectedContent = GetResourceText(theme.FileName, theme.GetType()); + + var response = await _themeProviderWebApplicationFactory.Client.GetAsync(fullPath); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType.MediaType.ShouldBe(MimeTypes.Text.Css); + (await response.Content.ReadAsStringAsync()).ShouldBeEquivalentTo(expectedContent); + } + + // An unregistered path still falls through to the app's default 404 handling + var missingResponse = await _themeProviderWebApplicationFactory.Client.GetAsync( + $"{StylesPath}unregistered-{Guid.NewGuid():N}.css"); + + missingResponse.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task AddGetEndpoint_ShouldBypassGlobalFallbackAuthorizationPolicy_WhenWebApplication() + { + // Arrange - a WebApplication secured with a global fallback policy requiring an + // authenticated user, mirroring a consumer who secures everything by default + // before calling UseSwaggerUI(). + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services + .AddAuthentication(DenyAllAuthenticationHandler.SchemeName) + .AddScheme(DenyAllAuthenticationHandler.SchemeName, null); + builder.Services.AddAuthorization(options => + options.FallbackPolicy = new AuthorizationPolicyBuilder(DenyAllAuthenticationHandler.SchemeName) + .RequireAuthenticatedUser() + .Build()); + + await using var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + + const string ThemePath = "/styles/auth-bypass-theme.css"; + const string ThemeContent = "body { }"; + AddGetEndpoint(app, ThemePath, ThemeContent); + + // A control endpoint mapped without AllowAnonymous proves the fallback policy is actually enforced + app.MapGet("/secured-control", () => "secured"); + + await app.StartAsync(); + var client = app.GetTestClient(); + + // Act + var themeResponse = await client.GetAsync(ThemePath); + var controlResponse = await client.GetAsync("/secured-control"); + + // Assert - the theme asset bypasses the fallback policy via AllowAnonymous; the control endpoint doesn't + themeResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + (await themeResponse.Content.ReadAsStringAsync()).ShouldBe(ThemeContent); + + controlResponse.StatusCode.ShouldNotBe(HttpStatusCode.OK); + + await app.StopAsync(); + } + + /// + /// Authentication handler that never authenticates the current request, used to + /// exercise a global fallback authorization policy without depending on a real scheme. + /// + private sealed class DenyAllAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) + { + public const string SchemeName = "DenyAll"; + + protected override Task HandleAuthenticateAsync() => + Task.FromResult(AuthenticateResult.NoResult()); + } + [Fact] public void GetResourceText_ShouldFindThemeInSubfolder() { diff --git a/tests/AspNetCore.SwaggerUI.Themes.Tests/ThemeSwitcherTests.cs b/tests/AspNetCore.SwaggerUI.Themes.Tests/ThemeSwitcherTests.cs index 2832b76..f6319b2 100644 --- a/tests/AspNetCore.SwaggerUI.Themes.Tests/ThemeSwitcherTests.cs +++ b/tests/AspNetCore.SwaggerUI.Themes.Tests/ThemeSwitcherTests.cs @@ -484,5 +484,33 @@ public void Scenario_CustomThemeModes_WorkCorrectly() #endregion Integration Scenarios + #region Concurrency Tests + + [Fact] + public async Task RegisterTheme_WhenCalledConcurrently_RegistersEveryThemeExactlyOnce() + { + // Arrange + const int ThemeCount = 50; + var prefix = $"concurrent-{Guid.NewGuid():N}-"; + var themes = Enumerable.Range(0, ThemeCount) + .Select(i => new TestCustomTheme($"{prefix}{i}.css")) + .ToList(); + + // Act + await Should.NotThrowAsync(() => Task.WhenAll( + themes.Select(theme => Task.Run(() => ThemeSwitcher.RegisterTheme(theme, theme.FileName, isStandalone: false))))); + + // Assert + var registeredNames = ThemeSwitcher.GetFilteredThemes(ThemeSwitcherOptions.CustomOnly()) + .Select(rt => rt.Name) + .Where(name => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + registeredNames.Count.ShouldBe(ThemeCount); + registeredNames.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(ThemeCount); + } + + #endregion Concurrency Tests + private class TestCustomTheme(string fileName) : Theme(fileName); } \ No newline at end of file