Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ node_modules

# direnv
.direnv/
.pi/
32 changes: 30 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
lazy val V = _root_.scalafix.sbt.BuildInfo

ThisBuild / tlBaseVersion := "0.55"
ThisBuild / tlBaseVersion := "0.56"
ThisBuild / tlJdkRelease := Some(17)
ThisBuild / githubWorkflowJavaVersions := Seq("25", "17").map(JavaSpec.temurin(_))
ThisBuild / scalaVersion := "3.8.4"
ThisBuild / crossScalaVersions := Seq("3.8.4")
ThisBuild / githubWorkflowScalaVersions := Seq("3.8.4")
Global / onChangedBuildSource := ReloadOnSourceChanges

// sbt-typelevel-ci hardcodes Java 11 (both the job's `javas` matrix and the baked-in
// `matrix.java == 'temurin@11'` cond on its Setup Java step) for its auto-added
// "validate-steward" job, but the scala-steward binary that coursier/setup-action
// installs is now built for a newer JVM (class file version 61 = Java 17), so that job
// fails with UnsupportedClassVersionError. Rebuild the job on Java 17 until the plugin
// catches up.
ThisBuild / githubWorkflowAddedJobs ~= { jobs =>
jobs.map { job =>
if (job.id == "validate-steward")
WorkflowJob(
"validate-steward",
"Validate Steward Config",
WorkflowStep.Checkout ::
WorkflowStep.SetupJava(List(JavaSpec.temurin("17")), false) :::
WorkflowStep.Use(
UseRef.Public("coursier", "setup-action", "v1"),
Map("apps" -> "scala-steward")
) ::
WorkflowStep.Run(List("scala-steward validate-repo-config .scala-steward.conf")) :: Nil,
scalas = List.empty,
javas = List(JavaSpec.temurin("17"))
)
else job
}
}

lazy val root = tlCrossRootProject
.aggregate(
model,
Expand Down Expand Up @@ -117,7 +143,9 @@ lazy val otel4s =
.in(file("otel4s"))
.settings(
moduleName := "clue-otel4s",
libraryDependencies ++= Settings.Libraries.Otel4s.value
libraryDependencies ++=
Settings.Libraries.Otel4s.value ++
Settings.Libraries.MUnit.value
)
.dependsOn(core)

Expand Down
3 changes: 2 additions & 1 deletion core/src/main/scala/clue/FetchClientImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ class FetchClientImpl[F[_]: MonadThrow: Logger, P, S](requestParams: P)(using
operationName: Option[String],
variables: Option[JsonObject],
extensions: Option[JsonObject],
modParams: P => P = identity
modParams: P => P,
descriptor: Option[String] // This is ignored here.
): F[GraphQLResponse[D]] =
backend
.request(
Expand Down
54 changes: 45 additions & 9 deletions core/src/main/scala/clue/clients.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ trait FetchClientWithPars[F[_], P, S] {
operationName: Option[String] = none,
variables: Option[JsonObject] = none,
extensions: Option[JsonObject] = none,
modParams: P => P = identity
modParams: P => P = identity,
descriptor: Option[String] = none
): F[GraphQLResponse[D]]
}

Expand All @@ -44,8 +45,14 @@ case class RequestApplied[
] protected[clue] (
client: FetchClientWithPars[F, P, S],
operation: GraphQLOperation[S],
operationName: Option[String]
operationName: Option[String],
descriptor: Option[String] = none
) {

/** Attaches a tracing-only display name (see `clue.descriptor`). Does not affect execution. */
def withDescriptor(descriptor: String): RequestApplied[F, P, S, V, D] =
copy(descriptor = descriptor.some)

def withInput(variables: V): F[GraphQLResponse[D]] =
withInput(variables, identity)

Expand All @@ -55,14 +62,29 @@ case class RequestApplied[
operationName,
variables.asJsonObject.some,
none,
modParams
modParams,
descriptor
)

def withModParams(modParams: P => P): F[GraphQLResponse[D]] =
client.requestInternal(GraphQLQuery(operation.document), operationName, none, none, modParams)
client.requestInternal(
GraphQLQuery(operation.document),
operationName,
none,
none,
modParams,
descriptor
)

def apply: F[GraphQLResponse[D]] =
client.requestInternal(GraphQLQuery(operation.document), operationName, none, none, identity)
client.requestInternal(
GraphQLQuery(operation.document),
operationName,
none,
none,
identity,
descriptor
)
}

object RequestApplied {
Expand Down Expand Up @@ -94,7 +116,8 @@ trait StreamingClient[F[_], S] extends FetchClientWithPars[F, Unit, S] {
document: GraphQLQuery,
operationName: Option[String] = none,
variables: Option[JsonObject] = none,
extensions: Option[JsonObject] = none
extensions: Option[JsonObject] = none,
descriptor: Option[String] = none
): Resource[F, fs2.Stream[F, GraphQLResponse[D]]]
}

Expand All @@ -106,18 +129,31 @@ case class SubscriptionApplied[
] protected[clue] (
client: StreamingClient[F, S],
subscription: GraphQLOperation[S],
operationName: Option[String] = none
operationName: Option[String] = none,
descriptor: Option[String] = none
) {

/** Attaches a tracing-only display name (see `clue.descriptor`). Does not affect execution. */
def withDescriptor(descriptor: String): SubscriptionApplied[F, S, V, D] =
copy(descriptor = descriptor.some)

def withInput(variables: V): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] =
client.subscribeInternal(
GraphQLQuery(subscription.document),
operationName,
variables.asJsonObject.some,
none
none,
descriptor
)

def apply: Resource[F, fs2.Stream[F, GraphQLResponse[D]]] =
client.subscribeInternal(GraphQLQuery(subscription.document), operationName, none, none)
client.subscribeInternal(
GraphQLQuery(subscription.document),
operationName,
none,
none,
descriptor
)
}

object SubscriptionApplied {
Expand Down
6 changes: 4 additions & 2 deletions core/src/main/scala/clue/websocket/ApolloClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ class ApolloClient[F[_], P, S](
subscription: GraphQLQuery,
operationName: Option[String],
variables: Option[JsonObject],
extensions: Option[JsonObject]
extensions: Option[JsonObject],
descriptor: Option[String] // This is ignored here.
): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] =
subscriptionResource(subscription, operationName, variables, extensions)

Expand All @@ -126,7 +127,8 @@ class ApolloClient[F[_], P, S](
operationName: Option[String],
variables: Option[JsonObject],
extensions: Option[JsonObject],
modParams: Unit => Unit // This is ignored here.
modParams: Unit => Unit, // This is ignored here.
descriptor: Option[String] // This is ignored here.
): F[GraphQLResponse[D]] =
F.async(cb =>
startSubscription[D](document, operationName, variables, extensions)
Expand Down
77 changes: 77 additions & 0 deletions core/src/test/scala/clue/DescriptorSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

package clue

import cats.effect.IO
import cats.effect.Ref
import cats.effect.Resource
import cats.syntax.all.*
import clue.model.GraphQLQuery
import clue.model.GraphQLResponse
import io.circe.Decoder
import io.circe.Json
import io.circe.JsonObject
import munit.CatsEffectSuite

/**
* The descriptor is tracing-only: it never goes on the wire. The only thing that carries it from
* the call site to a tracing middleware is the `descriptor` parameter of `requestInternal` /
* `subscribeInternal`, so these tests capture what a wrapped client actually receives.
*/
class DescriptorSpec extends CatsEffectSuite:

private object Op extends GraphQLOperation.Typed[Unit, JsonObject, Json]:
val document = "query NamedOp { field }"

// A client that records the `descriptor` it was handed and answers with an empty response.
private class Recorder(ref: Ref[IO, Option[Option[String]]]) extends StreamingClient[IO, Unit]:
protected[clue] def requestInternal[D: Decoder](
document: GraphQLQuery,
operationName: Option[String],
variables: Option[JsonObject],
extensions: Option[JsonObject],
modParams: Unit => Unit,
descriptor: Option[String]
): IO[GraphQLResponse[D]] =
ref.set(descriptor.some) *> IO.raiseError(new NoSuchElementException("no data"))

protected[clue] def subscribeInternal[D: Decoder](
document: GraphQLQuery,
operationName: Option[String],
variables: Option[JsonObject],
extensions: Option[JsonObject],
descriptor: Option[String]
): Resource[IO, fs2.Stream[IO, GraphQLResponse[D]]] =
Resource.eval(ref.set(descriptor.some).as(fs2.Stream.empty))

// Runs `f` against a recording client and returns the descriptor it saw. The client's response is
// an error, which is irrelevant here and discarded: only the recorded value is under test.
private def descriptorSeen(f: StreamingClient[IO, Unit] => IO[Unit]): IO[Option[String]] =
for
ref <- IO.ref(Option.empty[Option[String]])
_ <- f(Recorder(ref)).attempt
seen <- ref.get
yield seen.getOrElse(fail("the wrapped client was never called"))

test("withDescriptor reaches requestInternal"):
assertIO(
descriptorSeen(_.request(Op).withDescriptor("MyQuery").withInput(JsonObject.empty).void),
"MyQuery".some
)

test("withDescriptor reaches requestInternal through the no-input path"):
assertIO(descriptorSeen(_.request(Op).withDescriptor("MyQuery").apply.void), "MyQuery".some)

test("a request without a descriptor passes none"):
assertIO(descriptorSeen(_.request(Op).withInput(JsonObject.empty).void), none)

test("withDescriptor reaches subscribeInternal"):
assertIO(
descriptorSeen(
_.subscribe(Op).withDescriptor("MySub").withInput(JsonObject.empty).use_
),
"MySub".some
)

end DescriptorSpec
24 changes: 24 additions & 0 deletions gen/input/src/main/scala/test/LucumaMutation.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

// format: off
/*
rules = [GraphQLGen]
Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"]
*/
package test

import clue.GraphQLOperation
import clue.annotation.GraphQL

@GraphQL
trait LucumaMutation extends GraphQLOperation[LucumaODB] {
val document = """
mutation DeleteAsterism($asterismId: AsterismId!) {
deleteAsterism(asterismId: $asterismId) {
id
existence
}
}"""
}
// format: on
27 changes: 27 additions & 0 deletions gen/input/src/main/scala/test/LucumaSubscription.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

// format: off
/*
rules = [GraphQLGen]
Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"]
*/
package test

import clue.GraphQLOperation
import clue.annotation.GraphQL

@GraphQL
trait LucumaSubscription extends GraphQLOperation[LucumaODB] {
val document = """
subscription AsterismEdit($programId: ProgramId) {
asterismEdit(programId: $programId) {
editType
value {
id
name
}
}
}"""
}
// format: on
27 changes: 27 additions & 0 deletions gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

// format: off
/*
rules = [GraphQLGen]
Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"]
// Opt-out fixture: the default is `true`
Clue.descriptor = false
*/
package test

import clue.GraphQLOperation
import clue.annotation.GraphQL

@GraphQL
trait StarWarsDescriptorQuery extends GraphQLOperation[StarWars] {
override val document: String = """
query ($charId: ID!) {
character(id: $charId) {
id
name
}
}
"""
}
// format: on
Loading
Loading