Experimental GraphQL client for Scala and Scala.js.
Either:
- A
FetchClient[F[_], S](supporting queries and mutations), or - A
StreamingClient[F[_], S](supporting queries, mutations and subscriptions).
S is a type denoting the schema. It can be any type, even a phantom type. It's only used to type-match clients and operations.
import clue.*
import cats.effect.IO
sealed trait StarWars
// Scala JVM and Scala.js with http4s Ember client
import org.http4s.ember.client.EmberClientBuilder
EmberClientBuilder
.default[IO]
.build
.use: client =>
given Backend[IO] = Http4sHttpBackend[IO](client)
val fetchClient: FetchClient[IO, StarWars] =
Http4sHttpClient.of[IO, StarWars]("https://starwars.com/graphql")
// Scala JVM with JDK WS client behind http4s
import import org.http4s.jdkhttpclient.JdkWSClient
JdkWSClient
.simple[IO]
.use: client =>
given StreamingBackend[IO] = Http4sWebSocketBackend[IO](client)
val streamingClient: StreamingClient[IO, StarWars] =
Http4sWebSocketClient.of[IO, StarWars]("wss://starwars.com/graphql")
// Scala.js with default fetch/WS client
import clue.js.*
given Backend[IO] = AjaxJSBackend[IO]
val fetchClient: FetchClient[IO, StarWars] =
FetchJsClient.of[IO, StarWars]("https://starwars.com/graphql")
// Streaming doesn't require Apollo, it just follows the Apollo protocol for GraphQL over WS
given StreamingBackend[IO] = WebSocketJsBackend[IO]
val streamingClient: StreamingClient[IO, StarWars] =
ApolloStreamingClient.of[IO, StarWars]("wss://starwars.com/graphql")They must extend GraphQLOperation[S], defining the following members:
val document: GraphQLDocument // built with the `gql` interpolator (see below)
type Variables
type Data
val varEncoder: io.circe.Encoder.AsObject[Variables]
val dataDecoder: io.circe.Decoder[Data]The document is built with the gql string interpolator (import clue.gql) rather than a plain
String/s"...". gql produces the same text at runtime, but its type (GraphQLDocument) can only
be obtained through gql, so every operation goes through the compile-time check that splices its
subqueries correctly (see Subquery variables below). Documents are parsed at
compile time, so write them without margin characters (.stripMargin is not available on a
GraphQLDocument). For a document built by other means, GraphQLDocument.unsafeFromString(...) is
the explicit (check-skipping) escape hatch.
import io.circe._
import io.circe.generic.semiauto._
object CharacterQuery extends GraphQLOperation[StarWars] {
val document = gql"""
query ($$charId: ID!) {
character(id: $$charId) {
id
name
}
}
"""
case class Variables(charId: String)
case class Character(id: String, name: Option[String])
object Character {
implicit val characterDecoder: Decoder[Character] = deriveDecoder[Character]
}
case class Data(character: Option[Character])
val varEncoder: Encoder[Variables] = deriveEncoder[Variables]
val dataDecoder: Decoder[Data] = deriveDecoder[Data]
}Hand-written operations and subqueries (those defined manually, without the code generator) are
validated against the schema by the GraphQLValidate rule, which reuses the same checks as the
generator — no code generation involved. Any definition extending GraphQLOperation[S] /
GraphQLOperation.Typed[S, ...] (checked via its document) or GraphQLSubquery[S] /
GraphQLSubquery.Typed[S, ...] (checked via its subquery) is validated; fields, arguments,
variables and deprecations that don't typecheck against schema S are reported as scalafix
diagnostics. Operations that are generated (annotated with @GraphQL) are validated during
generation.
A subquery declares the GraphQL root type(s) its selection applies to with a
@clue.annotation.GraphQLType annotation:
@GraphQLType("Character")
abstract class FriendFields extends GraphQLSubquery.Typed[StarWars, Json] {
val subquery = gql"{ id name }"
}A subquery's subquery is a GraphQLDocument too, so it is built with gql for the same reason a
document is (see Subquery variables).
Hand-written subqueries may declare multiple types (the selection is validated against each:
@GraphQLType("Human", "Droid")). A subquery processed by the generator (@GraphQL) must declare
exactly one type. @GraphQLType is only valid on a subquery, not on a GraphQLOperation.
When a selection on an interface or union field has inline fragments (or fragment spreads) on its
subtypes, the generator emits a sealed trait for the field, one case class per fragment type
carrying the shared fields plus the fragment's own, and, if the schema has concrete types none of
the fragments cover, a case class Other with just the shared fields. For example, the StarWars
selection
character { __typename id name ... on Human { homePlanet } }(character's type is an interface implemented by Human and Droid) generates roughly:
sealed trait Character { val id: String; val name: Option[String] }
object Character {
case class Human(id: String, name: Option[String], homePlanet: Option[String]) extends Character
case class Other(id: String, name: Option[String]) extends Character // Droid, uncovered
}The selection must include __typename at the same level as the fragments; the generated
decoder switches on it to pick the case class to decode into. Omitting it is a generation error: "Selection on [...] has fragments on subtypes [...] but does not select __typename". It must also
be selected unconditionally: with @skip/@include on it, or on a fragment that encloses it,
generation fails with "... its __typename is selected with @skip/@include". A __typename
inside a fragment on the field's own type (plain grouping, e.g. ... on Character { __typename })
counts, as long as neither it nor the fragment is conditional.
A bare __typename is consumed by the decoder and is not generated as a field. Aliased (e.g.
kind: __typename), it is both the decoder key and a regular String field on the trait and every
case class — the way to keep the raw type name around, e.g. for the types folded into Other.
Fragments whose type conditions overlap on a concrete type the field can return (e.g. an interface
Pilot implemented only by Human, alongside a fragment on Human itself) are rejected: a
response of that concrete type could only ever decode to one of them, silently losing the other's
fields. Merge the overlapping fragments into one (e.g. on the concrete type they share).
A field selected both at the base level and inside a fragment (which GraphQL merges into a single
response value) is likewise generated once, not duplicated in the fragment's case class, as long as
both selections are the same: same type (a top-level @include/@skip doesn't count as different;
the base level's type wins) and, for object fields, the same sub-selection (then the nested class
is generated once, in the enclosing companion). Selecting the same field with different
sub-selections at the two levels is not merged and fails to compile.
Fragments whose type condition is the field's own type or one of its supertypes are plain grouping
(e.g. for @include), not subtypes, and are flattened rather than turned into cases.
A subquery is spliced as the whole selection set of a field (field $Subquery); splicing one inside
a fragment (... on Human $HumanFields) is rejected. A subquery may itself be rooted at an
interface/union (@GraphQLType("Character")) and use fragments in its own selection, in which case
its Data is the sealed trait.
A subquery that references GraphQL variables must declare them in a type VariableDefs member,
written in operation-header syntax (the GraphQL spec's VariablesDefinition). A subquery that
references none omits it — the member is not part of GraphQLSubquery, so there is nothing to write.
It is a declaration only: no runtime value, case class or encoder behind it, unlike an operation's
Variables:
@GraphQLType("Query")
abstract class HeroByEpisode extends GraphQLSubquery.Typed[StarWars, Json] {
type VariableDefs = "($ep: Episode!)"
val subquery = gql"{ hero(episode: $$ep) { name } }"
}($ is escaped as $$ inside gql, as in any interpolated string. The VariableDefs literal is a
plain string, so it is written with a single $.)
The declaration is checked against usage (here $ep feeds hero(episode: Episode!)); a variable
used but not declared, or declared with an incompatible type, is a scalafix error. Declaring a
variable the subquery doesn't use is currently accepted (unused-variable detection is disabled, see
validateParsed). For subqueries processed by the generator (@GraphQL), type VariableDefs is
inferred from usage and emitted automatically when you don't write it; hand-written subqueries
declare it explicitly.
Note that the declaration is what the caller-check below keys on: a subquery that uses a variable
without declaring it reads as "requires nothing" to callers. That omission is an error wherever the
subquery is validated (it needs @GraphQLType and a configured schema), so keep validation enabled
on projects that publish subqueries.
To splice a subquery into an operation and have the operation's variables checked against it, build
the document with the gql interpolator (from clue) instead of s:
trait MyQuery extends GraphQLOperation[StarWars] {
val document = gql"query ($$ep: Episode!) $HeroByEpisode"
}gql produces exactly the string s would, but at compile time it verifies the operation
declares every variable required by each spliced subquery, with a compatible ("usable as") type. It
reads the requirement from the subquery's type VariableDefs directly, so the check works even when
the subquery comes from a dependency jar; a missing or wrong-typed variable is a compile error.
gql documents are parsed at compile time with grackle's
parser, so malformed GraphQL fails compilation; spliced subqueries are represented by a placeholder
selection during that parse. Only a GraphQLSubquery may be spliced into a gql document; anything
else is a compile error, and text built another way goes through GraphQLDocument.unsafeFromString.
The generator's own pre-generation placeholder (a companion object annotated @GraphQLStub, before
the scalafix rule replaces it with a real subquery) is accepted too, since that is the only place
such a placeholder is ever spliced through plain scalac.
A subquery splicing another subquery is checked the same way, against its own VariableDefs instead
of an operation header:
@GraphQLType("Character")
object FriendsWithHero extends GraphQLSubquery.Typed[StarWars, Json] {
type VariableDefs = "($ep: Episode!)" // required: `HeroByEpisode` needs it
val subquery = gql"{ id friends $HeroByEpisode }"
}Because each level has to declare what the level below requires, the requirement reaches the
operation transitively — an operation splicing FriendsWithHero must declare $ep as well. Nesting
is checked one level at a time; no whole-tree analysis is involved.
The schema location is configured (once) in .scalafix.conf:
Clue.schemaDirs = ["path/to/schemas"]With sbt-clue: validation runs automatically on every compile — the plugin decorates the
compile task to run the validation rule after compilation (disable with
clueValidateOnCompile := false), so an invalid hand-written operation/subquery fails the build.
<project>/clueCheck runs the same check on demand (e.g. in CI). The generator
separately validates the annotated sources under src/clue/scala during generation. A
@GraphQL/@GraphQLSchema/@GraphQLStub annotation found outside the clue source directory is
reported as a warning (those are only processed by the generator, so the annotation has no effect
there). (Don't add rules = [GraphQLGen] to .scalafix.conf — a plain scalafixAll would then
expand the annotated generator inputs in place.)
Without the plugin (running scalafix directly), use the validation-only GraphQLValidate rule
(it reads the same Clue config) and run scalafixAll / scalafixCheckAll:
rules = [GraphQLValidate]
Clue.schemaDirs = ["path/to/schemas"]A subquery with no @GraphQLType annotation has no declared root type, so it can't be validated —
this is reported as a warning rather than silently skipped.
fetchClient.request(CharacterQuery)(CharacterQuery.Variables("0001"))
.forEach(println).unsafeRunSync()
# Data(Some(Character("0001", Some("Luke"))))The clue-otel4s module wraps any clue with OpenTelemetry tracing via otel4s.
A SpanKind.Client span is emitted per HTTP request or subscription.
W3C trace context is automatically propagated to the server:
- HTTP requests: the current span's
traceparent(andtracestate) is injected into outgoing request headers, thus client spans can propagate to the server - WebSocket requests:
traceparentis included in theextensionsfield, so the server can create child readingextensions.traceparent.
Some span attributes recorded automatically:
clue.versionhttp.request.methodgraphql.operation.typegraphql.operation.namegraphql.documentclue.response.hasData
And on errors clue.response.hasErrors, clue.response.errorCount, clue.response.errors.
W3C propagation requires the SDK to be configured with W3CTraceContextPropagator.
See https://ochenashko.com/practical-observability-distributed-tracing/#6-cross-service-propagation