Skip to content

Latest commit

 

History

History

sgv2-graphqlapi

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 

Stargate GraphQL API

This project provides the Stargate GraphQL API - an HTTP service that gives access to data stored in a Cassandra cluster using auto-generated GraphQL interface. (As part of Stargate V2, this service was extracted from the monolithic Stargate V1 Coordinator as a separate microservice.)

The project depends on the sgv2-quarkus-common module, which provides common functionality used by all Stargate V2 APIs.

All issues related to this project are marked with the stargate-v2 and either GraphQL CQL-first or GraphQL schema-first.

Table of Contents

Concepts

Shared concepts

Please read the Stargate V2 Shared Concepts in order to get basic concepts shared between all V2 API implementations.

GraphQL concepts

GraphQL paths

Here is a brief functional overview of the paths supported by the GraphQL service:

  • /graphql-schema exposes DDL operations (describe, create table, etc). It can be used for any keyspace (most operations take a keyspace argument).
  • for each keyspace, there is a /graphql/<keyspace> service for DML operations (read and insert data, etc). Initially, its GraphQL schema is automatically generated from the CQL data model. We call this the CQL-first model.
  • /graphql-admin allows users to deploy their own GraphQL schemas. Stargate will alter the CQL schema to match what they want to map. We call this the GraphQL-first or schema-first model. Once a GraphQL-first schema has been deployed in this manner, it replaces the CQL-first one: /graphql/<keyspace> now uses the custom schema, and the generated schema is not available anymore.

GraphQL Java primer

We rely extensively on GraphQL Java. Before delving further into the Stargate code, it can be helpful to have a basic understanding of that library:

Random random = new Random();
GraphQLSchema schema =
    GraphQLSchema.newSchema() // (1)
        .query(
            GraphQLObjectType.newObject()
                .name("Query")
                .field(
                    GraphQLFieldDefinition.newFieldDefinition()
                        .name("random")
                        .type(Scalars.GraphQLInt)
                        .build())
                .build())
        .codeRegistry( // (2)
            GraphQLCodeRegistry.newCodeRegistry()
                .dataFetcher(
                    FieldCoordinates.coordinates("Query", "random"),
                    (DataFetcher<Integer>) environment -> random.nextInt())
                .build())
        .build();

GraphQL graphql = GraphQL.newGraphQL(schema).build(); // (3)

ExecutionResult result = graphql.execute("{ random }");
System.out.println(result.getData().toString()); // prints {random=1384094011}
  1. newSchema() provides a DSL to create the GraphQL schema programmatically. This example will produce:

    type Query {
      random: Int
    }
  2. The code registry provides the logic to execute queries at runtime. It is broken down into data fetchers. Each fetcher handles a field, identified by its coordinates in the schema.

  3. Finally, we turn the GraphQLSchema into an executable GraphQL.

HTTP layer

Bridge connectivity

The service accesses the Stargate persistence via the gRPC bridge. The bridge client is obtained via StargateRequestInfo (imported from sgv2-quarkus-common), which creates it from the auth token and (optional) tenant ID passed as HTTP request headers.

Note that we don't use the gRPC client generated by Quarkus (StargateBridge) directly; instead, we rewrap it in a synchronous / future-based interface: StargateBridgeClient. This was done out of convenience to simplify the migration of existing code. In the future, it might be possible to make all the fetcher code asynchronous and use the Quarkus client (but there will still be some wrapping required because graphql-java only knows of to deal with futures).

Resources
GraphQL services

They are implemented as REST resources. They all extend GraphqlResourceBase, which handles the various ways to query GraphQL over HTTP: POST vs GET, arguments in the query string vs the body, multipart, etc.

The only thing that changes across subclasses is how we obtain the GraphQL object to query (see GraphQL layer below), and whether multipart is supported.

StargateGraphqlContext allows us to inject state that will be available later in the data fetchers (see CassandraFetcher). In particular, this is how we pass the reference to the bridge client. The context also handles batching, which will be described below.

Other resources

PlaygroundResource exposes the GraphQL playground (an in-browser client, served from a static HTML file). FilesResource provides downloadable versions of users' custom schemas, and CQL directive definitions.

GraphQL layer

GraphqlCache provides the GraphQL instances used by the HTTP layer. The lifecycle of those objects varies depending on the service:

CQL-first
DDL (/graphql-schema)

The DDL service never changes, we only need to cache one instance. The schema is built by DdlSchemaBuilder.

The data fetchers are in the package cqlfirst.ddl.fetchers. Their implementation is pretty straightforward: translate the GraphQL operations into CQL queries for the persistence layer.

Our fetchers are generally loosely typed, a lot of them return raw data as a Map<String, Object>. But there is a case where that doesn't work, and that's when one of the inner fields can be parameterized:

query {
  keyspace(name: "library") {
    books: table(name: "books") { columns { name } }
    authors: table(name: "authors") { columns { name } }
  }
}

In that situation, there is no way to return both tables with a Map<String, Object>, because the map keys are the actual field types, not their aliases. The solution is to use a strongly-typed DTO, such as KeyspaceDto. (In hindsight, it would be better to use the strongly-typed approach whenever possible, that can be done as a housekeeping task in the future but is not high priority.)

DML (/graphql/<keyspace>)

We cache one GraphQL per keyspace.

The schema is built by DmlSchemaBuilder. This time things are a bit more dynamic, we read from a CqlKeyspaceDescribe and generate the schema accordingly. For example if there is a "User" table, we'll have a User GraphQL type, an insertUser operation, etc.

The schema often references the same types over and over. For example, each time a table can be queried by an int, the query operation will have an IntFilterInput argument that defines various search operators (eq, lte, etc). In the builder code, every occurrence of a given type must be represented by the same GraphQLType instance, so we must keep track of the types we have generated so far, and reuse them if they appear again. This is handled by FieldTypeCache and its subclasses.

We name our GraphQL types and fields after CQL tables and columns. But GraphQL identifiers have more restrictive naming rules, so we sometimes need to convert names. This is covered by NameMapping, which also caches the results.

CQL also has a wider range of primitive types. CqlScalar defines a number of custom GraphQL scalars to match the CQL types that have no direct equivalent. See the *Coercing classes for the details of the type coercion rules.

The data fetchers are in the package cqlfirst.dml.fetchers. There is one fetcher per type of query, it gets initialized with the CqlTable that this particular operation was created for.

In order to adapt to CQL changes in real time, GraphqlCache re-fetches the CqlKeyspaceDescribe from the bridge on every call to getDml. If the "hash" field is different, this means that the CQL schema has changed, and we need to regenerate a new GraphQL instance. That logic can be observed in the GraphqlHolder internal class in GraphqlCache.

It's also worth noting that both versions of the API share the same cache entry: if a GraphQL-first schema was deployed for this keyspace, we use it, otherwise we generate a CQL-first schema. Again, see GraphqlHolder.

GraphQL-first
Admin (/graphql-admin)

The admin service never changes, we only need to cache one instance. The schema is built by AdminSchemaBuilder.

The data fetchers are in the package graphqlfirst.fetchers.admin. Most of the fetchers are trivial, with the exception of DeploySchemaFetcherBase: this is where we process a custom GraphQL schema received from the user.

  • we first check if a previous custom schema was deployed for this keyspace. This is stored in a table stargate_graphql.schema_source. We also use a lightweight transaction as a rudimentary concurrency control mechanism to ensure that two deployments cannot run concurrently. This is all handled by SchemaSourceDao.
  • SchemaProcessor parses the user's GraphQL schema, and builds:
    • MappingModel, a representation of the equivalent CQL model. This starts in MappingModel.build(), and then branches out to *ModelBuilder helper classes for each kind of GraphQL element (types, operations, etc).
    • the GraphQL instance. We use the user's schema directly (it is valid if we got this far), and MappingModel generates the data fetchers.
  • CassandraMigrator checks if that theoretical CQL model matches the actual contents of the database. If it doesn't, there are various user-configurable migration strategies to handle the differences: error out, or try to alter the CQL model. In the latter case, the check returns a list of migration queries, that we can now execute.
  • finally, we insert the new version in stargate_graphql.schema_source. The new GraphQL instance is sent to GraphqlCache, which starts to serve it to its clients.

We provide a set of CQL directives that allow users to control certain aspects of their mapping (for example, use a different table name than the inferred default). They are defined in CqlDirectives, and referenced throughout the model-building code. You can also download a text version from a running Stargate instance at graphql-files/cql_directives.graphql.

Deployed (/graphql/<keyspace>)

Once a user has deployed their own GraphQL schema, it replaces the CQL-first schema for that keyspace. As already mentioned, GraphqlCache contains logic to determine which variant to load.

There are two ways that a GraphQL-first GraphQL instance can be created:

  • if the deploy operation just happened in this Stargate process, then DeploySchemaFetcherBase already has the GraphQL, and puts it directly in the cache with GraphqlCache.putDml.
  • if the deployment happened via another Stargate instance, or if Stargate just restarted, we need to reload the schema from stargate_graphql.schema_source. This uses a simplified version of the deployment process described in the previous section, see GraphqlCache.GraphqlHolder.

Unlike CQL-first, GraphQL-first does not react to external CQL schema changes: the user is supposed to have full control over the GraphQL schema, so we can't just change it behind their back. So our assumption is that once users go GraphQL-first, they will evolve their CQL schema via successive deploy operations, not by directly altering the database. If things get out of sync, there are no guarantees: GraphqlCache will detect the changes (via the hash mechanism) and try to regenerate the schema-first GraphQL, but that might fail.

The data fetchers are in the package graphqlfirst.fetchers.deployed. They rely on the representation that was built by MappingModel. For example, the query fetcher relies on a query model that defines the target table, which parameters of the GraphQL operation will be mapped to WHERE clauses, etc. This part of the code is quite tedious because our mapping rules are very flexible: there are many different ways to define operations and map the results. The best way to get familiar with a fetcher is to look at the integration tests and trace their execution.

Batching

Batching is a cross-cutting concern, both CQL-first and GraphQL-first support the @atomic directive to indicate that a set of mutations must be executed as a single CQL batch:

mutation @atomic {
  ... // mutations
}

StargateGraphqlContext (via its inner class BatchContext) provides coordination services that allows independent fetchers to accumulate queries, and track which is the last one that must execute the batch.

Deployment

Running the Service

The GraphQL API service is packaged as a Docker image available on Docker Hub. For examples of container-based deployments, see the docker-compose or helm folders.

The GraphQL API is also packaged as an uber-jar as part of Stargate v2 releases, along with the accompanying start-graphqlapi.sh script.

The startup script can also be used to start the uber-jar locally, along with other options documented in the Development Guide.

Configuration properties

There are two main configuration property prefixes used, stargate. and quarkus..

The quarkus. properties are defined by the Quarkus framework, and the complete list of available properties can be found on the All configuration options page. In addition, the related guide of each Quarkus extension used in the project provides an overview of the available config options.

The stargate. properties are defined by this project itself and by the sgv2-quarkus-common configuration. The properties are defined by dedicated config classes annotated with the @ConfigMapping. The list of currently available properties is documented in the Configuration Guide.

Development guide

This project uses Quarkus, the Supersonic Subatomic Java Framework. If you want to learn more about Quarkus, please visit its website.

It's recommended that you install Quarkus CLI in order to have a better development experience. See CLI Tooling for more information.

Warning This project uses Java 17, please ensure that you have the target JDK installed on your system.

Running the application in dev mode

Before being able to run the application, make sure you install the root ../apis/pom.xml:

cd ../
./mvnw install -DskipTests

To run your application in dev mode with live coding enabled, use the command:

../mvnw quarkus:dev

Note
Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/star\ gate/dev/.

Debugging

In development mode, Quarkus starts by default with debug mode enabled, listening to port 5005 without suspending the JVM. You can attach the debugger at any point, the simplest option would be from IntelliJ using Run -> Attach to Process.

If you wish to debug from the start of the application, start with -Ddebug=client and create a debug configuration in a Listen to remote JVM mode.

See Debugging for more information.

Running integration tests

Warning
You need to build the coordinator docker image(s) first. In the Stargate repo directory /apis run:

../mvnw clean install -P dse -DskipTests && ./build_docker_images.sh

Integration tests are using the Testcontainers library in order to set up all needed dependencies, a Stargate coordinator and a Cassandra data store. They are separated from the unit tests and are running as part of the integration-test and verify Maven phases:

../mvnw integration-test

Data store selection

Depending on the active profile, integration tests will target different Cassandra version as the data store. The available profiles are:

The required profile can be activated using the -P option:

../mvnw integration-test -P cassandra-311

Running from IDE

Warning
You need to build the coordinator docker image(s) first. In the Stargate repo directory /apis run:

../mvnw clean install -P dse -DskipTests && ./build_docker_images.sh

Running integration tests from an IDE is supported out of the box. The tests will use the Cassandra 4.0 as the data store by default. Running a test with a different version of the data store or the Stargate coordinator requires changing the run configuration and specifying the following system properties:

  • testing.containers.cassandra-image - version of the Cassandra docker image to use, for example: cassandra:4.0.4
  • testing.containers.stargate-image - version of the Stargate coordinator docker image to use, for example: stargateio/coordinator-4_0:v2.0.0-ALPHA-10-SNAPSHOT (must be V2 coordinator for the target data store)
  • testing.containers.cluster-version - version of the cluster, for example: 4.0 (should be one of 3.11, 4.0 or 6.8)
  • testing.containers.cluster-dse - optional and only needed if DSE is used

Executing against a running application

The integration tests can also be executed against an already running instance of the application. This can be achieved by setting the quarkus.http.test-host system property when running the tests. You'll most likely need to specify the authentication token to use in the tests, by setting the stargate.int-test.auth-token system property.

./mvnw verify -DskipUnitTests -Dquarkus.http.test-host=1.2.3.4 -Dquarkus.http.test-port=4321 -Dstargate.int-test.auth-token=[AUTH_TOKEN]

Skipping integration tests

You can skip the integration tests during the maven build by disabling the int-tests profile using the -DskipITs property:

../mvnw verify -DskipITs

Skipping unit tests

Alternatively you may want to run only integration tests but not unit tests (especially when changing integration tests). This can be achieved using the command:

../mvnw verify -DskipUnitTests

Troubleshooting failure to run ITs

If your Integration Test run fails with some generic, non-descriptive error like:

[ERROR]   CollectionsResourceIntegrationTest » Runtime java.lang.reflect.InvocationTargetException

here are some things you should try:

  • Make sure your Docker Engine has enough resources. For example following have been observed:
    • Docker Desktop defaults of 2 gigabytes of memory on Mac are not enough: try at least 4

Packaging and running the application

The application can be packaged using:

../mvnw package

This command produces the quarkus-run.jar file in the target/quarkus-app/ directory. Be aware that it’s not an über-jar as the dependencies are copied into the target/quarkus-app/lib/ directory.

The application is now runnable using java -jar target/quarkus-app/quarkus-run.jar.

If you want to build an über-jar, execute the following command:

../mvnw package -Dquarkus.package.type=uber-jar

The application, packaged as an über-jar, is now runnable using java -jar target/*-runner.jar.

Creating a native executable

Coming soon.

Creating a Docker image

You can create a Docker image named io.stargate/docsapi using:

../mvnw clean package -Dquarkus.container-image.build=true

Or, if you want to create a native-runnable Docker image named io.stargate/docsapi-native using:

../mvnw clean package -Pnative -Dquarkus.native.container-build=true -Dquarkus.container-image.build=true

If you want to learn more about building container images, please consult Container images.

Testing

Manually

The easiest way is to use the built-in GraphQL playground at http://localhost:8080/playground.

You can then interact with the various GraphQL schemas by entering their URL in the playground's address bar, for example http://localhost:8080/graphql/{keyspace}.

Quarkus Extensions

This project uses various Quarkus extensions, modules that run on top of a Quarkus application. You can list, add and remove the extensions using the quarkus ext command.

Note Please check the shared extensions introduced by the sgv2-quarkus-common project.

quarkus-arc

Related guide

The Quarkus DI solution.

quarkus-container-image-docker

Related guide

The project uses Docker for building the Container images. Properties for Docker image building are defined in the pom.xml file. Note that under the hood, the generated Dockerfiles under src/main/docker are used when building the images. When updating the Quarkus version, the Dockerfiles must be re-generated.

quarkus-smallrye-health

Related guide

The extension setups the health endpoints under /stargate/health.

quarkus-smallrye-openapi

Related guide

This project disables the OpenAPI and SwaggerUI, due to the availability of the GraphQL Playground.