https://kotlinlang.org logo
#apollo-kotlin
Title
# apollo-kotlin
s

Stylianos Gakis

03/08/2024, 3:38 PM
I got a type like
Copy code
type Flow {
  id: ID!
  currentStep: FlowStep!
  progress: FlowProgress
  context: FlowContext!
}
I wanted to build an interceptor to simply observe any request that may ever return
Flow
, and take the
currentStep
from inside of it to log it for some specific reasons. Does this sound possible?
The generated type is just
Copy code
public class Flow {
  public companion object {
    public val type: ObjectType = ObjectType.Builder(name = "Flow").build()
  }
}
I thought maybe this was a job for “alwaysGenerateTypesMatching” but with no luck. That just updated the builder definition, like
Copy code
public class FlowBuilder(
  customScalarAdapters: CustomScalarAdapters,
) : ObjectBuilder(customScalarAdapters) {
  public var id: String by __fields

  public var currentStep: FlowStepMap by __fields
...
Is there a way for me to be able to do something like
Copy code
class ClaimFlowFinishedApolloInterceptor : ApolloInterceptor {
  override fun <D : Operation.Data> intercept(
    request: ApolloRequest<D>,
    chain: ApolloInterceptorChain,
  ): Flow<ApolloResponse<D>> {
    return chain.proceed(request).onEach { apolloResponse ->
      apolloResponse.data?.safeCast<schema.type.Flow>()?.let {
        it.currentStep.also { log it here }
      }
    }
  }
}
Or am I thinking of this completely wrong somehow?
I feel like I need to somehow tell it that I need it to generate that type for me to be able to access it, but I can’t figure out if I can do that
m

mbonnin

03/08/2024, 3:39 PM
Apollo generates types based on operations, not the schema. The output types from
alwaysGenerateTypesMatching
are very slim and are mainly there to access
__typename
in a type safe way
👍 1
s

Stylianos Gakis

03/08/2024, 3:39 PM
Ah, I do have this https://github.com/HedvigInsurance/android/blob/32165d6c2471b7167ed6e58d6651541186[…]octopus/graphql/claimflow/FragmentClaimFlowStepFragment.graphql
Copy code
fragment ClaimFlowStepFragment on Flow {
  currentStep {
...
So I think that it looks like I can do a cast as
apolloResponse.data?.safeCast<ClaimFlowStepFragment>()?.let {
instead, and then I seem to have access to it 👀 Because on that fragment I am explicitly asking for
currentStep
, so the right type is genrated for me
m

mbonnin

03/08/2024, 3:40 PM
The problem you're going to have is that multiple operation using the same field (but different selections) do not share anything in Kotlin
The only way to share is with fragments
apolloResponse.data?.safeCast<ClaimFlowStepFragment>
This only works with responseBased codegen
(which has a bunch of other limitation, #tradeoffs)
s

Stylianos Gakis

03/08/2024, 3:41 PM
I am already using
com.apollographql.apollo3.compiler.MODELS_RESPONSE_BASED
👀
m

mbonnin

03/08/2024, 3:41 PM
THen you're good to go 🙂
s

Stylianos Gakis

03/08/2024, 3:42 PM
I will absolutely forget that this will break if I ever switch away from it though 😄 What would happen in case I was not?
m

mbonnin

03/08/2024, 3:42 PM
fragments are not interfaces and get generated as separate classes
So you can't cast
data
into a fragment
Actually you cannot cast anything into a fragment
Except the fragment itself
Something else to consider is even with
responseBased
, you can't intercept nested fields
The underlying question is: what happens if an operation does not select
currentStep
?
Copy code
query {
  myFlow {
    progress
    # no currentStep here
  }
}
👍 1
One way I could see this working is at the JSON layer. Write a
JsonReader
implementation that checks
__typename: "Flow"
and logs
currentStep
if present
👀 1
(I don't think there are APIs to customize the JSON reader but we could add that)
s

Stylianos Gakis

03/08/2024, 3:48 PM
So there would simply not be an possible approach to get the same behavior without response_based then right? I think since I’m already using this I doubt we’ll ever change, but just good to know. And yes I totally understand we won’t get the info on nested fields. Our schema for this is something like:
Copy code
flowClaimFooNext(input: FlowClaimFooInput!): Flow!
flowClaimBarNext(input: FlowClaimBarInput!): Flow!
flowClaimBazNext(input: FlowClaimBazInput!): Flow!
and so on for 10+ of them. And in our context all of them try to get the same
...ClaimFlowStepFragment
out of it. So maybe through all those caveats and limitations if all 3 are true: • this being response_based • not nested • has to use the fragment then it should work 😄
💯 1
m

mbonnin

03/08/2024, 3:49 PM
100% under those assumptions it should work
s

Stylianos Gakis

03/08/2024, 3:49 PM
The JsonReader approach sounds interesting, would that be if I tried to re-read the output somehow in the interceptor? At that point I get it as
val data: D?,
, where would I get the opportunity to do the Json reading instead?
m

mbonnin

03/08/2024, 3:51 PM
The
Data
instance is parsed from a
JsonReader
. We could add APIs to customize that
JsonReader
so that side effects like logging are possible
There's no real re-reading, the JSON is parsed only once and the
Data
is constructed from it
s

Stylianos Gakis

03/08/2024, 3:52 PM
Where do you imagine the entry-point would be to add such side-effects/logs? Since the parsing happens before we get the
data
type right?
m

mbonnin

03/08/2024, 3:52 PM
Exactly. Taht would have to be an
ApolloClient
global thing
Or maybe we could customize the
JsonReader
per-
ApolloCall
actually
Sounds like a bit overkill but it's all doable
Copy code
apolloClient.query(myQuery)
            .jsonReader(LoggingJsonReader)
            .execute()
But more probably
Copy code
ApolloClient.Builder()
            .jsonRreader(LoggingJsonReader)
            .serverUrl(...)
            .build()
👀 1
s

Stylianos Gakis

03/08/2024, 3:58 PM
Hmm yeah interesting. For the first proposal, I was trying to do this in an interceptor in this case specifically to avoid having to do this in ~15 places, which would risk if we add one more we forget to add it. For the second that is probably what I would go for in those scenarios. Albeit it would probably be a bit tricky to know how to use the JsonReader to get the right thing out of it. It’s a tricky API to use if you are not super proficient with it 😄 But let me try to get this working with the interceptor as we first mentioned here, and I’ll get back to you to see if it works for me 😊
👍 1
The fact that I am getting this
ClaimFlowStepFragment
back from mutations should not affect the way I write the interceptor I suppose, right?
m

mbonnin

03/08/2024, 4:01 PM
It's all the same code path underneath so should not matter indeed 👍
s

Stylianos Gakis

03/08/2024, 4:04 PM
Hmmmm, the
data
actually, isn’t that always just
Mutation.Data
for me, but I need to go 1 level deep in order to get what I want here? But I can’t quite do that in a generic way right?
m

mbonnin

03/08/2024, 4:05 PM
Mmm right, you'd need
data.flowClaimBazNext
🤔
You can do reflection 😅
s

Stylianos Gakis

03/08/2024, 4:06 PM
Shit 😅
m

mbonnin

03/08/2024, 4:07 PM
That's kind of the issue with typesafety
We have type safety but bypassing the type system becomes problematic...
s

Stylianos Gakis

03/08/2024, 4:08 PM
Me right now:
😂 1
m

mbonnin

03/08/2024, 4:08 PM
Other option ... wait for it ... could be to customize the generated parsers
👀 1
s

Stylianos Gakis

03/08/2024, 4:09 PM
🥸
I feel like I will go back to adding this in those 15 spots 😅
m

mbonnin

03/08/2024, 4:10 PM
Sometimes this is the way [inserts xkcd automation comic]
s

Stylianos Gakis

03/08/2024, 4:12 PM
Haha yeah, I know that I can easily fall into this pit so I will try not to. Now doing this on the JsonReader level does not sound that bad though. Because I am not quite sure what the “customizing the generated parsers” would even look like.
m

mbonnin

03/08/2024, 4:12 PM
You could customize the generated data class to have side effect in their constructor:
Copy code
class FlowClaimBazNext(currentStep: CurrentStep, id: String) {

  init {
    println(currentStep)
  }
}
Next beta has compiler plugins. That gives you the KotlinPoet model to work with (API ref)
s

Stylianos Gakis

03/08/2024, 4:15 PM
In my case I would need to do something more than just println, it would need a dependency to another module in my app to get access to the right thing to call. In fact just to avoid the XY problem. I want to every time I get back one specific step which is considered the “flow was successful” step, which may happen coming from any of the other steps, I want to store that there was a successful flow completion for this flow. I did not want to do this on any other layer since it would introduce issues with double-counting if you come back into the app from a process death and so on
👀 1
So with that said, adding some side effect in the initialization of that model would not exactly make sense either I think. But perhaps I am missing some similar approach instead?
m

mbonnin

03/08/2024, 4:16 PM
(PS: For the
JsonReader
route, you'll need
__typename
so make sure to include it in the fragment or set
addTypename.set("alwaus")
)
Not sure I'm following. You can call symbols into other modules from the models constructors, this is fine
The problem is if you're using the models in Unit tests or something where now the unit tests start logging stuff
Other option ... would be to make the model "traversable" i.e. you could
response.data.visit { if (it) is FlowFragment { doStuff() }}
but that sounds even more involved than all previous versions 😅
Actually, now that I think about it, you could call
data.normalize()
and check the records there for a record with
__typename: Flow
, this is probably the easiest way
Only problem is you pay the normalization price everywhere now
Other option... (and then I stop 😅) You codegen a function that "knows" what queries contain
Flow
and have typesafe code that knows where to look them up
😅 1
Copy code
fun Data.forEachCurrentStep(block: (currentStep) -> Unit) {
  when (this) {
     is FlowClaimBarNext -> block(data.flowClaimBarNext.currentStep)
     is FlowClaimBazNext -> block(data.flowClaimBazNext.currentStep)
     // ....
  }
}
It's not that bad and it's a good
apollo-ast
+
kotlinpoet
exercise + it's all typesafe and very small runtime penalty. If I wanted to automate this, this is probably the route I'll take
Gotta run, let me know what you find!
s

Stylianos Gakis

03/08/2024, 4:31 PM
Aha! This
Copy code
class ClaimInterceptor(
  private val selfServiceCompletedEventManager: SelfServiceCompletedEventManager,
) : ApolloInterceptor {
  override fun <D : Operation.Data> intercept(
    request: ApolloRequest<D>,
    chain: ApolloInterceptorChain,
  ): Flow<ApolloResponse<D>> {
    return chain.proceed(request).onEach {
      it.dataAssertNoErrors.forEachCurrentStep {
        if (it.currentStep.asFlowClaimSuccessStep() != null) {
          selfServiceCompletedEventManager.completedSelfServiceSuccessfully()
        }
      }
    }
  }
}

inline fun <D : Operation.Data> D.forEachCurrentStep(block: (ClaimFlowStepFragment) -> Unit) {
  when (this) {
    is FlowClaimConfirmEmergencyMutation.Data -> block(this.flowClaimConfirmEmergencyNext)
    else -> {}
  }
}
Already looks like it would run. So your suggestion would be to get that
forEachCurrentStep
function auto-generated so that I don’t need to keep track that I am not missing out on any of the possible types?
🎯 1
m

mbonnin

03/08/2024, 4:31 PM
Exactly, parse each operation and traverse the GraphQL AST to determine where the
currentStep
are
👍 1
s

Stylianos Gakis

03/08/2024, 4:31 PM
Thanks a lot for looking into this with me, I will probably (because it’s Friday too) do a quick solution for now, and think about potentially playing more with this! You said that this should only be possible with 4.x right? I will postpone it till we migrate to that if yes
m

mbonnin

03/08/2024, 4:32 PM
You said that this should only be possible with 4.x right?
Actually I think you can do that in v3. You don't need Apollo compiler plugins for this since it does not touch the existing models, it's purely additive
You would need your own Gradle task that parses the operations and generates the
forEachCurrentStep
In 4.x we could add a function in
ApolloCompilerPlugin
to expose the GraphQL AST so you don't pay the parsing price twice but unless you have a looooot of operations, parsing should be too expensive
Really got to run now, I'll think about those
JsonReader
and
ApolloCompilerPlugin.onAst(graphqlAst)
APIs. If you find you need them or anything else, do not hesitate to open an issue
👌 1
s

Stylianos Gakis

03/08/2024, 4:36 PM
I will stop talking now, and read what you said and consider, thanks a lot again!
👍 1
m

mbonnin

03/12/2024, 1:05 PM
s

Stylianos Gakis

03/12/2024, 1:55 PM
Thanks a lot, subscribed to updates! 👀 For the context of my question, I took the easy path of doing the side-effect in the code itself, since I was already calling a mapping function to the response for all cases, I passed in the place where the side effect is supposed to happen and it works, albeit in a very not nice way, but we had to ship this as soon as possible 😅 If anything happens around that issue I will look into how it’d be to update my usage
👍 1
6 Views