Skip to content

Membrane Auth Sever: Configurable Response Modes#2013

Merged
predic8 merged 9 commits into
masterfrom
membrane-as-response-modes
Jul 28, 2025
Merged

Membrane Auth Sever: Configurable Response Modes#2013
predic8 merged 9 commits into
masterfrom
membrane-as-response-modes

Conversation

@predic8
Copy link
Copy Markdown
Member

@predic8 predic8 commented Jul 28, 2025

Summary by CodeRabbit

  • New Features

    • Added a utility for parsing comma- and space-separated strings into lists or sets.
    • Introduced configurable response mode negotiation in OAuth2/OpenID Connect authorization service.
    • Added public methods and constants for response mode configuration and retrieval.
  • Bug Fixes

    • Improved consistency and correctness in parsing and normalizing CORS headers and methods.
  • Refactor

    • Replaced and streamlined internal parsing utilities for CORS and response mode handling.
    • Enhanced JSON parsing and logging in the authorization service.
    • Simplified annotation declarations and adjusted field visibility for improved code clarity.
    • Updated CORS header parsing and normalization to use new utility methods.
    • Consolidated imports and standardized utility method usage.
  • Tests

    • Added comprehensive tests for the new string parsing utility and response mode negotiation logic.
    • Updated and improved existing tests for CORS and utility methods.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jul 28, 2025

Walkthrough

This set of changes refactors string parsing and normalization for CORS and OAuth2 modules, introduces new utility classes (StringList, updated CollectionsUtil), removes legacy methods, and enhances the OAuth2 authorization service with configurable response mode negotiation. Associated tests are added or updated to verify these changes, and minor code style and visibility adjustments are included.

Changes

Cohort / File(s) Change Summary
CORS Parsing Refactor
core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsInterceptor.java, core/src/main/java/com/predic8/membrane/core/interceptor/cors/PreflightHandler.java, core/src/test/java/com/predic8/membrane/core/interceptor/cors/CorsInterceptorTest.java
Updated CORS attribute parsing to use StringList.parseToSet and CollectionsUtil.toLowerCaseSet. Tests updated to match new utility usage.
CORS Utility Removal
core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java
Removed parseCommaOrSpaceSeparated, toLowerCaseSet, and join methods from CorsUtil.
String Parsing Utilities
core/src/main/java/com/predic8/membrane/core/util/StringList.java, core/src/test/java/com/predic8/membrane/core/util/StringListTest.java
Introduced new StringList utility class for parsing comma/space-separated strings, with comprehensive unit tests.
Collection Utilities
core/src/main/java/com/predic8/membrane/core/util/CollectionsUtil.java, core/src/test/java/com/predic8/membrane/core/util/CollectionsUtilTest.java
Added toLowerCaseSet and join utility methods; updated test for assertion style.
OAuth2 Authorization Service Enhancement
core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationService.java, core/src/test/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationServiceTest.java
Refactored for explicit response mode negotiation, improved JSON parsing, added logging, new constants, configurable response modes, and new tests.
OAuth2 Service Minor Change
core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/AuthorizationService.java
Changed router field visibility from protected to package-private.
Annotation Import Cleanup
annot/src/main/java/com/predic8/membrane/annot/MCAttribute.java
Simplified annotation imports and method declaration.
Abstract CORS Handler Update
core/src/main/java/com/predic8/membrane/core/interceptor/cors/AbstractCORSHandler.java
Changed initialization of allowedMethodsString to use CollectionsUtil.join.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant MembraneAuthorizationService
    participant OAuth2Server

    Client->>MembraneAuthorizationService: Request login URL
    MembraneAuthorizationService->>OAuth2Server: Fetch .well-known/openid-configuration
    OAuth2Server-->>MembraneAuthorizationService: Return JSON config (response_modes_supported)
    MembraneAuthorizationService->>MembraneAuthorizationService: Parse and negotiate response mode
    MembraneAuthorizationService-->>Client: Return login URL with negotiated response_mode
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

  • Cors refactoring #1810: Refactors CORS parsing logic and removes old utility methods, directly related to the parsing and utility changes here.
  • Cors Refactoring 2 #1990: Refactors CORS handling including removal of CorsUtil parsing methods and updates to CorsInterceptor parsing logic, closely related to this PR's changes.
  • refactor: cors #1976: Refactors CORS parsing to use new utility methods, overlapping with the utility and parsing changes in this PR.

Suggested reviewers

  • rrayst

Poem

A rabbit hops through code so neat,
Parsing strings with nimble feet.
CORS and OAuth2, now refined,
Utilities and tests aligned.
Response modes chosen with great care—
Bugs and chaos? None to spare!
🐇✨ Code is crisp, and all is fair.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch membrane-as-response-modes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@membrane-ci-server
Copy link
Copy Markdown

This pull request needs "/ok-to-test" from an authorized committer.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
core/src/main/java/com/predic8/membrane/core/util/CollectionsUtil.java (1)

35-59: Consider removing commented code

The commented parseCommaOrSpaceSeparated method should be removed since it's been superseded by the StringList utility class. Commented code can create confusion and maintenance overhead.

-//    /**
-//     * Parses a string into a list of trimmed, non-empty string tokens.
-//     *
-//     * <p>This method supports two parsing modes:</p>
-//     * <ul>
-//     *   <li><strong>Comma-separated:</strong> Standard HTTP header format (e.g., "GET, POST, PUT")</li>
-//     *   <li><strong>Space-separated:</strong> Alternative format (e.g., "GET POST PUT")</li>
-//     * </ul>
-//     *
-//     * <p>The method automatically trims whitespace from each token and excludes empty values,
-//     * making it robust against various formatting inconsistencies.</p>
-//     *
-//     * @param value the string to parse. Can be null or empty.
-//     * @return a non-null list of parsed tokens. Returns an empty list if input is null/empty.
-//
-//     * @apiNote This dual parsing behavior is intended for flexibility in configuration formats.
-//     * For strict HTTP header compliance, consider using comma-only separation.
-//     * @since 6.2.0
-//     */
-//    public static @NotNull Set<String> parseCommaOrSpaceSeparated(String value) {
-//        return stream(value.split("\\s*,\\s*|\\s+"))
-//                .map(String::trim)
-//                .filter(s -> !s.isEmpty())
-//                .collect(toSet());
-//    }
core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationService.java (2)

152-173: Excellent JSON parsing refactoring with a minor typo.

The switch to JsonNode with path().asText() provides safer field extraction and better null handling. The added logging is helpful for debugging.

Fix the typo in the log message:

-        log.debug("Aggreed on response mode: {}", responseMode);
+        log.debug("Agreed on response mode: {}", responseMode);

175-177: Add null check for defensive programming.

The method should handle null input gracefully.

 private static List<String> convertToListOfStrings(ObjectMapper mapper, JsonNode json) {
+    if (json == null || json.isNull()) {
+        return null;
+    }
     return mapper.convertValue(json, new TypeReference<>() {});
 }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e0792b5 and fa14006.

📒 Files selected for processing (12)
  • annot/src/main/java/com/predic8/membrane/annot/MCAttribute.java (1 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsInterceptor.java (3 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java (0 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/PreflightHandler.java (2 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/AuthorizationService.java (1 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationService.java (8 hunks)
  • core/src/main/java/com/predic8/membrane/core/util/CollectionsUtil.java (2 hunks)
  • core/src/main/java/com/predic8/membrane/core/util/StringList.java (1 hunks)
  • core/src/test/java/com/predic8/membrane/core/interceptor/cors/CorsInterceptorTest.java (1 hunks)
  • core/src/test/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationServiceTest.java (1 hunks)
  • core/src/test/java/com/predic8/membrane/core/util/CollectionsUtilTest.java (1 hunks)
  • core/src/test/java/com/predic8/membrane/core/util/StringListTest.java (1 hunks)
💤 Files with no reviewable changes (1)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java
🔇 Additional comments (26)
annot/src/main/java/com/predic8/membrane/annot/MCAttribute.java (3)

16-19: LGTM! Clean import consolidation.

The wildcard import for annotation classes and static imports for frequently used constants improve readability without affecting functionality.


24-25: LGTM! Proper use of static imports.

Using static imports for METHOD and RUNTIME makes the annotation declarations more concise and readable.


28-28: LGTM! Redundant modifier removed.

Correctly removed the redundant public modifier since interface methods are implicitly public.

core/src/test/java/com/predic8/membrane/core/util/CollectionsUtilTest.java (1)

46-46: LGTM! Consistent assertion style.

Using the static import for assertIterableEquals is consistent with other assertions in the file and improves readability.

core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/AuthorizationService.java (1)

58-62: router field visibility change is safe

A search for subclasses of AuthorizationService and any direct accesses to router shows:

  • No classes outside com.predic8.membrane.core.interceptor.oauth2.authorizationservice extend or reference the router field.
  • The only direct assignment in MembraneAuthorizationServiceTest.java resides in the same package.

Changing router from protected to package-private does not introduce breaking changes.

core/src/test/java/com/predic8/membrane/core/interceptor/cors/CorsInterceptorTest.java (1)

480-480: LGTM! Proper adaptation to utility refactoring.

The explicit call to CollectionsUtil.toLowerCaseSet correctly reflects the refactoring where utility methods were moved from CorsUtil to CollectionsUtil.

core/src/main/java/com/predic8/membrane/core/interceptor/cors/PreflightHandler.java (2)

25-29: LGTM! Proper import updates for utility refactoring.

The import changes correctly reflect the refactoring where parsing utilities were moved to StringList and CollectionsUtil. The wildcard import for ResponseHeaderBuilder is appropriate given multiple members are used.


92-92: PreflightHandler update is safe: parseToSet matches prior behavior

The parseToSet shortcut delegates to:

parse(value, LinkedHashSet::new)

and parse itself uses

value.split("\\s*,\\s*|\\s+")

which covers both comma- and whitespace-separation, identical to the old parseCommaOrSpaceSeparated logic. Existing tests in StringListTest confirm deduplication and correct splitting. No further changes are needed.

core/src/main/java/com/predic8/membrane/core/util/CollectionsUtil.java (2)

19-19: LGTM: Clean static import addition

The static import for Collectors improves code readability in the toLowerCaseSet method.


61-71: LGTM: Correct implementation of case normalization utility

The toLowerCaseSet method correctly converts all strings to lowercase and returns a new set, maintaining immutability of the input set.

core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsInterceptor.java (3)

241-241: LGTM: Consistent parsing logic migration

The migration to StringList.parseToSet maintains the same parsing behavior while using the centralized utility.


266-266: LGTM: Correct header normalization

The combination of StringList.parseToSet and CollectionsUtil.toLowerCaseSet properly handles header parsing and case normalization, which is appropriate since HTTP headers are case-insensitive.


291-291: LGTM: Consistent header processing

Same correct pattern as the setHeaders method - parsing followed by case normalization for HTTP headers.

core/src/test/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationServiceTest.java (1)

19-106: LGTM: Comprehensive test coverage

The test class provides good coverage of response mode negotiation scenarios, including edge cases like empty lists and no matches. The mock setup is appropriate for testing the service in isolation.

core/src/test/java/com/predic8/membrane/core/util/StringListTest.java (1)

15-71: LGTM: Comprehensive test coverage

The test class provides excellent coverage of the StringList utility, including:

  • Edge cases (null, blank input)
  • Different separator types (comma, space, mixed)
  • Collection type variations (list, set, custom)
  • Deduplication behavior for sets

The nested test structure improves readability and organization.

core/src/main/java/com/predic8/membrane/core/util/StringList.java (3)

13-15: LGTM: Proper utility class design

The final class with private constructor follows the standard utility class pattern, preventing instantiation and inheritance.


34-41: LGTM: Robust parsing implementation

The implementation correctly handles:

  • Null input (converted to empty string)
  • Regex pattern \\s*,\\s*|\\s+ properly matches comma-separated OR space-separated values
  • Trimming and filtering empty strings prevents malformed tokens
  • Generic collection factory provides flexibility

44-51: LGTM: Well-designed convenience methods

Both convenience methods provide appropriate defaults:

  • parseToList returns ArrayList for general use cases
  • parseToSet returns LinkedHashSet to preserve insertion order while eliminating duplicates

The choice of LinkedHashSet over HashSet is particularly good for maintaining predictable iteration order.

core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationService.java (8)

16-30: LGTM! Well-organized imports.

The consolidated imports properly group related classes and include necessary dependencies for the new functionality.


34-45: Well-structured constants for response mode configuration.

Good design choices:

  • Proper use of constants for response modes
  • Smart fallback strategy excluding form_post which requires server-side support
  • Clear documentation and appropriate use of @NotNull annotation

63-69: Appropriate field declarations for response mode support.

The default priority order (form_post, query, fragment) aligns with security best practices, as form_post provides better protection against token leakage.


93-95: Good refactoring to use constant.

Using the constant improves maintainability and consistency.


109-116: Clean method signatures.

Removing unnecessary exception declarations improves the API clarity since these methods don't perform operations that throw checked exceptions.

Also applies to: 143-150


179-189: Well-implemented response mode negotiation.

Excellent implementation with:

  • Proper fallback handling for servers that don't advertise response modes
  • Clear priority-based selection
  • Informative error message for configuration issues
  • Package-private visibility for testability

200-211: Simplified and correct response mode handling.

Good improvement - always including the negotiated response mode makes the behavior more predictable and spec-compliant.


303-312: Excellent configuration method with clear documentation.

The JavaDoc provides a helpful example and clearly explains the preference ordering. The MCAttribute annotation properly integrates with Membrane's configuration system.

rrayst and others added 5 commits July 28, 2025 21:08
…2/authorizationservice/MembraneAuthorizationServiceTest.java

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
…2/authorizationservice/MembraneAuthorizationServiceTest.java

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@rrayst
Copy link
Copy Markdown
Member

rrayst commented Jul 28, 2025

/ok-to-test

Comment thread core/src/main/java/com/predic8/membrane/core/util/CollectionsUtil.java Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationService.java (2)

109-116: Minor: Consider adding IOException to method signatures.

The removal of declared exceptions from method signatures is fine if the methods truly don't throw checked exceptions, but ensure this doesn't hide potential issues.


314-316: Consider adding setter for responseMode.

The getter for responseMode is exposed but there's no corresponding setter. Consider whether this field should be configurable or if it should remain read-only as a result of negotiation.

If responseMode should remain read-only (recommended), consider adding documentation to clarify this:

+/**
+ * @return The negotiated response mode determined during initialization.
+ * This value is read-only and determined by negotiating between configured
+ * preferences and server capabilities.
+ */
 public String getResponseMode() {
     return responseMode;
 }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6b7486a and 946e135.

📒 Files selected for processing (7)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/AbstractCORSHandler.java (2 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsInterceptor.java (3 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java (0 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/PreflightHandler.java (2 hunks)
  • core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationService.java (8 hunks)
  • core/src/main/java/com/predic8/membrane/core/util/CollectionsUtil.java (2 hunks)
  • core/src/test/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationServiceTest.java (1 hunks)
💤 Files with no reviewable changes (1)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java
✅ Files skipped from review due to trivial changes (1)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/AbstractCORSHandler.java
🚧 Files skipped from review as they are similar to previous changes (4)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/PreflightHandler.java
  • core/src/main/java/com/predic8/membrane/core/util/CollectionsUtil.java
  • core/src/test/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationServiceTest.java
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsInterceptor.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Automated tests
  • GitHub Check: Analyze (java)
🔇 Additional comments (9)
core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/MembraneAuthorizationService.java (9)

16-26: LGTM! Clean import organization.

The new imports are properly organized and necessary for the implemented functionality including Jackson for JSON processing, annotations for configuration, and logging.


34-44: Well-structured constants and defaults.

The constants for response modes and default fallback list are well-defined. The use of @NotNull annotations on the list elements is a good practice for null safety.


63-68: Good design with configurable priority order.

The responseModesSupported field with default priority order (form_post > query > fragment) follows security best practices by preferring form_post, which is more secure than query parameters for sensitive data.


94-94: Use constant for consistency.

The well-known configuration path construction now uses the defined constant, which improves maintainability.


143-150: Method visibility change is appropriate.

Making adjustScope() private is good encapsulation since it's only used internally.


154-173: Robust JSON parsing with proper error handling.

The refactoring to use Jackson's JsonNode with path().asText() is safer than direct field access and provides better null handling. The logging for response mode negotiation provides good visibility into the process.


179-188: Solid negotiation logic with proper error handling.

The response mode negotiation follows a clear priority-based selection algorithm. The error message in the ConfigurationException provides good debugging information. The logic correctly handles the case where the server offers no response modes by falling back to defaults.


299-312: Well-designed configuration API with proper documentation.

The getter/setter pair for responseModesSupported is well-implemented:

  • The getter uses CollectionsUtil.join() for consistent string representation
  • The setter uses StringList.parseToList() for flexible input parsing
  • Good documentation with examples and default values
  • Proper @MCAttribute annotation for configuration binding

209-209: No risk of null responseMode in getLoginURL

responseMode is set unconditionally in init() via negotiateResponseMode(...), which falls back to defaults and throws if nothing matches.
– All callers (e.g. the OAuth2 interceptors) invoke init() before any getLoginURL() call.
– Tests cover both default and custom modes, proving responseMode is never null.

@predic8 predic8 merged commit 3711af6 into master Jul 28, 2025
5 checks passed
@predic8 predic8 deleted the membrane-as-response-modes branch July 28, 2025 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants