Skip to content

GroupHQ/group-sync

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

group-sync

Contents

Synopsis

Group Sync manages RSocket connections for users to keep in-sync with the latest group changes using Spring Security RSocket. The service acts as a mediator between the user and group related information posted by other services (such as Group Service). Any requests for group-related info takes place through Group Sync.

Synchronization Flow

The main purpose of Group Sync is to forward events published by Group Service, as well as to publish its own events to an event broker. Once a user establishes an RSocket connection to Group Sync, the service sends the user a stream of public and private events via the request-stream model of the RSocket protocol.

Group Sync maintains its own state of groups. When the first request is received from a user for a stream of group updates, Group Sync fetches the latest group info and keeps an in-memory snapshot of the current group state. This state is returned to the user, followed by any group updates over the lifecycle of the stream. The current group state is kept up-to-date by listening on the same update stream. This strategy allows all users to keep up-to-date with the latest group info from just one group fetch request by Group Sync.

Public and Private Events

Public events involve any successful action that occurs and should be shown to users, such as a group being created or a member joining a group. Public events have any sensitive information stripped off, such as the user ID of the user who made the request. On the other hand, private events are sent to the user who initiated the request, whether successful or unsuccessful. These events include more sensitive data, such as the member ID of the member created for a user when they join the group, allowing them to know which member is theirs and in which group.

Join and Leave Requests

Along with streams of events, Group Sync currently allows users to send requests to join or leave groups, as well as to retrieve a user’s current member. Join and leave requests are published to the event queue to be processed asynchronously. The results of these requests is sent back on both the public and private event streams (note, realized it's not really a good idea to process it asynchronously since an immediate response is expected. will need to change this eventually).

Retrieving Current Member Info

For retrieving a user’s current member, the client uses their RSocket connection to authenticate their request for their current member. A response is returned with the user's member info if they have one. This information includes the current group a user's member is in.

Setting up the Development Environment

Prerequisites

  • Recommended Java 21. Minimum Java 17. Download here
  • An IDE that supports Java. IntelliJ is recommended. A free community edition is available.
  • Git. Download here. For Windows users, install Git using Git For Windows
  • Recommended to install a Bash terminal. Linux and Mac users should have this by default. Windows users can install Git Bash using Git For Windows.
  • Kubeconform (for validating Kubernetes manifests). Download here

Group Sync uses a RabbitMQ event broker. While you can download, configure, and run this service manually, it is highly recommended to use the provided docker-compose file to run it instead, located in the GroupHQ Deployment repository. See the GroupHQ Deployment README for more information.

Once you have your backing services ready, you should be able to run the Group Sync application, either through your IDE or through the docker-compose file in the GroupHQ Deployment repository.

Alternatively, you can run the Group Sync application in a Kubernetes environment. See the GroupHQ Deployment README for instructions on setting that up.

Developing

When developing new features, it's recommended to follow a test-driven development approach using the classicist style of testing. What this means is:

  1. Prioritize writing tests first over code. At the very minimum, write out test cases for the changes you want to make. This will help you think through the design of your changes and catch defects early on.
  2. Avoid excessive mocking. Mocks are useful for isolating your code from external dependencies, but they can also make your tests brittle and hard to maintain. If you find yourself mocking a lot, it may be a sign that the class under test is more suitable for integration testing. If you are mocking out an external service, consider using a Testcontainer for simulating the service as a fake type of test double*.
  3. Write tests, implement, and then most importantly, refactor and review. It's easy to get caught up in messy code to write code that pass tests. Take the time to review your code after implementing a feature.

*When testing the event-messaging system with an event broker, use the Spring Cloud Stream Test Binder. All messaging with the event broker takes place through Spring Cloud Stream. Instead of testing the dependency itself, rely on the Spring Cloud Stream Test Binder to simulate the broker. This will allow you to test the messaging system without having to worry about the sending and receiving of messages. See the GroupSyncSocketIntegrationTest class for an example of this. See the Spring Cloud Stream Test Binder documentation for more information on the test binder.

Checks To Pass

When pushing a commit to any branch, the following checks are run:

  • Code Style: All code must pass the checkstyle rules defined in the config/checkstyle/checkstyle.xml file.
  • Code Quality: All code must pass the PMD rules defined in the config/pmd/* files.
  • Dependency Vulnerability Check: Dependencies with vulnerabilities that meet the specified vulnerability cutoff must be reviewed.
  • Unit Tests: All unit tests must pass.
  • Integration Tests: All integration tests must pass.
  • Manifest Validation: Any changes to Kubernetes manifests under the k8s folder must pass validation.

For code style, quality, and dependency vulnerability checks, you can view a detailed report on these checks once they have completed by navigating to the build/reports directory. You can run these checks with the following commands (These commands are compatible with the bash terminal. If you are using a different terminal, you may need to modify the commands to work in that terminal environment):

Code Style

./gradlew checkstyleMain --stacktrace
./gradlew checkstyleTest --stacktrace

Code Quality

./gradlew pmdMain --stacktrace
./gradlew pmdTest --stacktrace

Dependency Vulnerability Check

These commands are compatible with the bash terminal. If you are using a different terminal, you may need to modify the commands to work with your terminal.

./gradlew dependencyCheckAnalyze --stacktrace -PnvdApiKey="YOUR_NVD_API_KEY"

See here for details on requesting an NVD API key.

Unit Tests

./gradlew testUnit

Integration Tests

./gradlew testIntegration

Manifest Validation

kustomize build k8s/base | kubeconform -strict -summary -output json
kustomize build k8s/overlays/observability | kubeconform -strict -summary -output json

It's recommended to add these commands to your IDE as separate run configurations for quick access. Make sure you do not commit these run configurations to the version control system, especially any that may contain sensitive info (such as the NVD API key for the dependency vulnerability check).

User Automated Tests & Regression Testing

For any features that introduce a new user-facing feature, it's recommended to add automated tests for them to the GroupHQ Continuous Testing Test Suite. For more information on how to write these tests, see the associated READEME of that repository.

When any pull request is opened, a request is sent to the GroupHQ Continuous Testing Proxy Server to run the test suite against the pull request. The length of a test run is expected to vary over time, but it's currently pretty quick at just under 10 minutes for around ~200 tests. Once the test run is complete, the results will be posted to the pull request, including a link to the test results to review if needed.

To learn how to add a new test to the test suite, or run the test suite locally, see the GroupHQ Continuous Testing Test Suite README. It's recommended to validate at least tests relevant to your feature, as well as any new tests added.

RSocket Testing

Group Sync contains integration tests that use utilities provided by Spring libraries to test RSocket functionality. While this is convenient, one can benefit from a deeper understanding on how RSocket requests are actually constructed. For more details on RSocket and how to manually send RSocket requests using the RSocket Client CLI (analogous to using Curl to send HTTP requests), refer to the following sections.


Note: The following sections assume you are using the Windows Command Prompt. If you are using any other terminal, beware that some examples may fail to run due to differences in how your terminal may interpret command syntax.


Introducing the RSocket Client CLI (RSC)

This section requires you to be familiar with the RSocket protocol in the Spring Framework. If you are not, refer to this section of the Spring Framework docs on RSocket:

Spring Framework / Web on Reactive Stack / RSocket

To communicate with an RSocket server, we need a client that supports the RSocket protocol. In a frontend client, this could be done using an RSocket library for the language of choice, but that adds needless complexity. One simple solution to test an RSocket server without having to configure a client is to use the RSocket Client CLI (RSC) tool.

Described as a "curl for RSocket", RSC provides a way to communicate with an RSocket server using the command line, just as a developer would use curl to communicate with an HTTP server. You can view the RSC project on GitHub through this link, which provides installation instructions as well as instructions on how to use the client.

Spring Boot Configuration

Our RSocket server is configured and managed by Spring Boot through the following dependency:

org.springframework.boot:spring-boot-starter-rsocket

This dependency provides default configuration for the RSocket server. It can also "plug in" the RSocket server to an existing WebFlux server if the following properties match:

spring:
  rsocket:
    server:
      mapping-path: "/rsocket"
      transport: "websocket"

Click here more info on Spring Boot's interaction with RSocket.

Assuming our WebFlux server is hosted on http://localhost:9002, then our RSocket server is hosted on ws://localhost:9002/rsocket. This is the URL we'll use to connect to the RSocket server using RSC.

Spring Security RSocket

Spring Security RSocket integrates RSocket connections with Spring Security, allowing them to be authenticated based on the authentication strategy specified. For example, the following applies the simple authentication type to RSocket connections:

@Bean
PayloadSocketAcceptorInterceptor rsocketInterceptor(RSocketSecurity socketSecurity) {
    socketSecurity.authorizePayload(authorize -> authorize
        .anyExchange().authenticated())
        .simpleAuthentication(Customizer.withDefaults());

    return socketSecurity.build();
}

This bean configures all RSocket connections to be authenticated using the simple authentication type on all exchanges. The Simple Authentication Type is similar to HTTP Basic Authentication, but the username and password are not encoded in Base64. Instead, they are sent as cleartext.

When we include a ReactiveAuthenticationManager bean in our Spring Boot application, Spring Security RSocket will use that authentication manager to authenticate RSocket connections. For example, the following provides a custom ReactiveAuthenticationManager bean:

@Bean
public ReactiveAuthenticationManager reactiveAuthenticationManager() {
    return authentication -> {
        final String username = authentication.getName();

        try {
            UUID.fromString(username);
        } catch (IllegalArgumentException e) {
            return Mono.error(new IllegalArgumentException("Invalid username: " + username));
        }

        return Mono.just(new UsernamePasswordAuthenticationToken(
            username,
            "dummy",
            Collections.singletonList(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))
        ));
    };
}

Notice that this authentication manager returns a UsernamePasswordAuthenticationToken without checking the password a user provides. Instead, it checks if the username provided can be parsed to a universal unique identifier (UUID). If so, it returns an Authentication object which allows the user to access the routes. If not, then an error is returned and the user is denied access. This flow fits our use case where we want:

  • All RSocket connections to access any endpoint
  • Authenticate the RSocket connection, which is then authenticated for all subsequent requests the user makes.
  • Integration with Spring Security RSocket provides other security protections (e.g. CSRF protection, locking future routes, etc.) without having to configure them ourselves.

Click here to read more about RSocket in Spring Security.

Testing the RSocket Server

Initial Connection (Setup)

Now that we know how our application is configured, we need to check if it works. Using RSC, we can send the following request to retrieve all group updates as Group Sync consumes them from the event broker. Note that we are sending a "setup" request, which is the first request sent to an RSocket server. This request is used to establish the connection between us and the server. Also note that when using RSC, we'll need to send a setup request for every request, since the client will not save our connection for future requests.

rsc --stream --setupMetadata simple:f315fbb2-028b-4784-8ce5-cc5e4f4c672b:password --setupMetadataMimeType message/x.rsocket.authentication.v0 --route=groups.updates.all ws://localhost:9002/api/rsocket
Detailed Explanation of Command Flags
--interactionModel, --im: InteractionModel (default: REQUEST_RESPONSE)

--request: Shortcut of --im REQUEST_RESPONSE

--fnf: Shortcut of --im FIRE_AND_FORGET

--stream: Shortcut of --im REQUEST_STREAM

--setupMetadata, --sm: Metadata for Setup payload

--setupMetadataMimeType, --smmt: Metadata MimeType for Setup payload (default: application/json)

--route, --r: Enable Routing Metadata Extension

Your terminal should then be in a persistent RSocket connection, receiving events as they come in. It will periodically send KEEP ALIVE messages to the Group Sync server to keep its connection active. If you start up the Group Service application with the Group Demo Loader feature enabled, then you should receive three GROUP_CREATED events in your terminal.

It's important to specify the type of metadata we are sending in the setupMetadataMimeType argument. The type we're sending is message/x.rsocket.authentication.v0. This is the type used for the simple authentication type.

For testing purposes, it's better to include the --debug flag to see the full request along with the --stacktrace flag to see the full stack trace if an error occurs:

rsc --request --sm simple:user:password --smmt message/x.rsocket.authentication.v0 --r=connect --stacktrace --debug ws://localhost:9002/api/rsocket

Notice that we use the shorthands for the flags where applicable.

Types of Requests

You can use the following commands to send RSocket requests to Group Sync. Remember that RSC is stateless; each request results in an independent RSocket connection.

Requests a stream of updates
rsc --stream --sm simple:f315fbb2-028b-4784-8ce5-cc5e4f4c672b:password --smmt message/x.rsocket.authentication.v0 --r=groups.updates.all --stacktrace --debug ws://localhost:9002/api/rsocket
Requests a stream of user-specific updates
rsc --stream --sm simple:f315fbb2-028b-4784-8ce5-cc5e4f4c672b:password --smmt message/x.rsocket.authentication.v0 --r=groups.updates.user --stacktrace --debug ws://localhost:9002/api/rsocket
Requests to join a group
rsc --fnf --sm simple:f315fbb2-028b-4784-8ce5-cc5e4f4c672b:password --smmt message/x.rsocket.authentication.v0 --r=groups.join --data "{ \"eventId\":\"0da7c964-beec-456b-b73a-0b62f1c8699b\", \"aggregateId\":169, \"websocketId\":\"fbe943cc-b3a0-4f2e-921a-2325d64b16c9\", \"createdDate\":\"2023-09-23T19:31:35.086587900Z\", \"username\":\"Klunk\" }" ws://localhost:9002/api/rsocket

Request to leave a group
rsc --fnf --sm simple:f315fbb2-028b-4784-8ce5-cc5e4f4c672b:password --smmt message/x.rsocket.authentication.v0 --r=groups.leave --data "{ \"eventId\":\"fa0fcf99-2aef-4f2a-8173-6e4bee623e2a\", \"aggregateId\":169, \"websocketId\":\"fbe943cc-b3a0-4f2e-921a-2325d64b16c9\", \"createdDate\":\"2023-09-23T19:31:35.086587900Z\", \"memberId\": 3569 }" ws://localhost:9002/api/rsocket

Request to get user's current active member

rsc --request --sm simple:32290501-5681-45f1-a14d-73c29d11d6b7:password --smmt message/x.rsocket.authentication.v0 --r=groups.user.member ws://localhost:9002/api/rsocket

Group Sync Architecture

The following container diagram shows Group Sync's place in the GroupHQ Software System. Shown in the diagram, Group Sync communicates with three downstream services: Group Service and an event broker, while being called by an upstream service, Edge Service (i.e. GroupHQ's API Gateway).

GroupHQ_Demo_Containers_noObservability


Component Diagram

structurizr-1-GroupHQ_GroupSyncService_Components

Example of Event Flow: User Joining a Group

1. Group Sync Publishes a Group Join Request to the Event Broker

structurizr-1-GroupHQ_GroupSyncJoinGroupPart1_Dynamic

2. Group Service Consumes this Request From the Broker And Publishes an OutboxEvent to the Broker

structurizr-1-GroupHQ_GroupSyncJoinGroupPart2_Dynamic

3. Group Sync Consumes this OutboxEvent and Forwards the Event Info to all Connected Users

structurizr-1-GroupHQ_GroupSyncJoinGroupPart3_Dynamic