Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Add Cody Pro REST API client library#62715

Merged
chrsmith merged 8 commits into
mainfrom
chrsmith/cody-ssc-api-client
May 17, 2024
Merged

Add Cody Pro REST API client library#62715
chrsmith merged 8 commits into
mainfrom
chrsmith/cody-ssc-api-client

Conversation

@chrsmith

@chrsmith chrsmith commented May 16, 2024

Copy link
Copy Markdown
Contributor

Introduces an API client library for interacting with the Cody Pro-specific REST API.

For the Cody Pro-specific experience, there are a lot of imperative APIs that we need to make available to the frontend. (e.g. adding/removing team members, sending team invites, updating billing details, etc.)

The overhead of adding and maintaining the wiring for all of these APIs can easily get out of hand, very quickly. And yet, if we get any of the minor details wrong (such as the URL suffix for the API, or have a type definition that doesn't perfectly aline with the backend) things break down.

So in order for us to keep our sanity, and have a clear, process for hooking up new REST API endpoints as we add them to the backend, I've added some abstraction layers to cleanly separate the "API type definitions", the "API endpoints", and the "React-specific calling pattern".

All of which so that we can make API calls with a clear pattern:

const client = useApiClient()
const { loading, error, data } = useApiCaller(client.createStripeCheckoutSession(req))

And, relying on strong typing to provide intelligence and catch errors.

image

image

The specific implementation was inspired by the Apollo GraphQL React library, and its useQuery hook.

Design goals

To help frame the implementation, let me first call out the design goals:

Provide a clear place to put the API type definitions

e.g. api/types.ts and related files like api/teamSubscriptions.ts. One of the most error prone parts of wiring up a new REST API is typos in the API type definition. (e.g. the Golang json:"foo" annotations not matching line up with the TypeScript interface fields.

If we wanted to invest a bunch of time here, we could look into a tool like OpenAPI to generate client libraries automatically. (But I've never heard a single good thing about using OpenAPI in production, and honestly don't think we'd need to do it.)

Just having an approximate 1:1 mapping between the TypeScript type definitions and the Golang counterparts will make it much easier to review that things are correct.

Provide an easy way to encode endpoint metadata

In order to call the right API endpoint, there are a few things that you need to supply: the HTTP method, the URL suffix, and the request body. Having the specific ('GET', '/team/subscription') data sprinkled throughout the codebase isn't great. Since the caller of these API endpoints shouldn't need to care about that.

So a goal was to try to put all of that endpoint metadata in a single place (api/client.ts) So that you can define new methods with as little boilerplate as possible. e.g.

    createStripeCheckoutSession(
        requestBody: types.CreateCheckoutSessionRequest
    ): Call<types.CreateCheckoutSessionResponse> {
        return { method: 'POST', urlSuffix: '/checkout/session', requestBody }
    }

Use React idioms

Rather than replicating the same useState() calls for { loading, error, data } on every page, and duplicate the same error handling logic, I wanted to put that into a single place so that we only need to write that code once.

Implementation

So putting that all together, we have the following:

  1. class Client

This is the thing that we update whenever we need to wire up a new API endpoint. Its methods provide the types for the request and response bodies as applicable. (Or may just be left as undefined, or void where applicable.)

getCurrentSubscription(): Call<types.Subscription> {
    return { method: 'GET', urlSuffix: '/team/current/subscription' }
}

The return type for these methods is Call<ResponseBody>. Call<> is just a description of a REST API call to make, but isn't meaningful on its own.

  1. export class CodyProApiCaller implements Caller

This is what will actually make the API calls. It is just a wrapper around fetch:

async call<Resp>(call: Call<Resp>): Promise<{ data?: Resp; response: Response }>
  1. The useApiClient and useApiCaller hooks

The final piece is how the React component can get ahold of the Client and pass any returned Call<> objects to a Caller.

// client defines the API surface area, and how API calls can get made.
const client = useApiClient()

// Create a Call<CreateCheckoutSessionResponse>.
const createCheckoutSessionCallObj = client.createStripeCheckoutSession(req)

// Create the React state and return the loading/error status of the API call,
// or a resolved `CreateCheckoutSessionResponse` object.
const { loading, error, data } = useApiCaller(createCheckoutSessionCallObj)

Example

To illustrate the steps involved with registering some new API endpoints to this client library, the second commit shows what is involved in hooking up our Subscription-related APIs.

  1. Defining the new data types (api/teamSubscriptions.ts) and exporting them from types.ts for convenience.
  2. Adding the new methods to the Client class in client.ts.

From there, we can call the new method using the useApiClient and useApiCaller hooks for a consistent pattern to make API calls with a consistent { loading, error, data } signature.

See https://github.com/sourcegraph/sourcegraph/pull/62715/commits/78d79d6ad26a69f369c078f1019504fa87859f6f.

Test plan

Manually tested. Going through the "New Cody Pro Subscription" flow worked like normal.

@chrsmith chrsmith requested review from a team, taras-yemets and vdavid May 16, 2024 05:04
@cla-bot cla-bot Bot added the cla-signed label May 16, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We must declare call as a useEffect hook dependency (docs). We also get a lint error on line 31: "This hook does not specify all of its dependencies: callbiomelint/correctness/useExhaustiveDependencies".

call value is re-calculated on each render and thus Object.is(prevCall, call) will always return false. For example, client.createStripeCheckoutSession(req) creates a new object on each render.
Including call as a hook dependency will have an undesired side-effect of useEffect being called on each render.
To avoid it, we can move the call definition outside of the component (it's value is stable anyway).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's value is stable anyway

correcting myself here: not always stable, see https://github.com/sourcegraph/sourcegraph/pull/62715#discussion_r1603288864

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

How could I write a unit test or Storybook test to confirm that we only make these API calls only when needed. Which may be more frequently than the component lifetime, e.g. if you navigate to/from the same page, we may want to refresh the data every time the page is visible. (But not on each paint.)

Comment on lines 25 to 42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useApiCaller argument doesn't depend either on component's state or props. As this argument should be included as a hook dependency (https://github.com/sourcegraph/sourcegraph/pull/62715#discussion_r1603095355), to avoid unnecessary hook calls, we should probably define argument value outside the component.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sorry, actually it depends on customerEmail prop and showPromoCodeField value.
In this case, to avoid re-creating req on each render we should consider wrapping it with useMemo().

Comment on lines 26 to 29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current approach work totally fine, I just want to mention the alternative one.

loading, error, data and response are not independent values IMO. E.g., we shouldn't have data and error defined and isLoading set to true at the same time (optimistic UI updates seem to be outside of the scope given the PR description).

The way these values are updated looks to me like a good use case for useReducer hook.
For example, we can maintain state as a single object:

interface State<T> {
    loading: boolean
    error: Error // or whatever type of an error we want
    data: T
    response: unknown // TODO: replace with actual type
}

and 4 actions that may change it (let's say "call_init", "call_error", "call_success"). This article has an example of a similar approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I find returning loading, data, error super-clunky too.

I wouldn't have a problem with using a different way to represent this. But personally I'd like to err on the side of having a "dumber but easier to understand" abstraction, than something fancier, but more difficult to use correctly.

Why don't we just keep the "loading, data, error" pattern as-is for now. But once we get this in-place, we can look into improving it with something that requires less tedium.

Since once we streamline our ability to "make API calls", we then would want to streamline how we "handle the responses". (e.g. cleaning up how we deal with loading or error states.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what Taras means is not related to what the hook returns (the interface would remain the same), I think his suggestion was related to how the hook could store its state internally. It's no big deal, though, as the hook is currently pretty simple. I agree it might scale better later with useReducer—that said, I don't think I ever actually used useReducer, and we have two uses of it in the monorepo, so useState is usually sufficient.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should consider returning a cleanup function not to suffer from race conditions (note on ignore variable in the docs).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Definitely not a scenario I had considered, so thanks for pointing that out.

Please double check I added the check right. Essentially I need to gate all interactions with setState() on !ignore?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, this is neat, TIL, @taras-yemets. I think the fix is correct.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup function should be returned by the hook setup function.

useEffect(() => {
  let ignore = false  

  ;(() => {
    // fetch data and set state if `ignore` is still `false`, etc.
  })()

  // cleanup
  return () => {
    ignore = true
  }
}, [...dependencies])  

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, true, I overlooked that the return was inside the async function. 🙈

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we plan to use response in React components?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully not... but there are situations where we may need to know those details, which is why I left it in the response type.

Essentially the issue is when we get a 4xx response. Usually, it will be a 400 and we can maybe just parse the response body as JSON and assume it is something like { "error": "human-friendly description", "details": ... }.

But we need the response to be available for dealing with more obscure situations, like 401/403. e.g. today we get a 401 error if your SAMS credentials have expired. 🤞 we can "deal with that" within the userApiCaller hook. But I cannot say for certain that we would never want to access that data elsewhere.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
updateCurretSubscription(requestBody: types.UpdateSubscriptionRequest): Call<types.Subscription> {
updateCurrentSubscription(requestBody: types.UpdateSubscriptionRequest): Call<types.Subscription> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doh, good catch. Fixed.

Comment on lines 14 to 17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be able to use context value we need to wrap a component tree with the context provider (docs). I couldn't find this wrapper so I expect useContext calls will error out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm surprised that it worked to... but it totally does!

If you omit the <CodyProApiContext.Provider>...</CodyProApiContext.Provider> it will return the "default value" that is provided here.

I'll update the React components to make this explicit. (Since that's clearly better than having an implicit thing... but at least on my machine, it worked without needing to wrap the component tree.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it works without a provider and we need only the default context value, maybe we can use them as create a class instance next to its declaration and export it? They are stable anyway.

export const client = new Client()
export const caller = new CodyProApiCaller()

Then, we can just import them into React components. What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I wonder if we really need classes here 🤔
Do they add any additional value compared to:

export const getSomeCodyProApiCallArgs = (param): Call<SomeType> => {
    // Not stable, will need to memorize in component.
    // Or, if `param` is not reactive, `getSomeCodyProApiCallArgs` can be called outside of component and be stable.
    return {
    // ... some fields
    param
  }
} 

export function callCodyProApi<T, R> = (call: Call<T>): Promise<{ data?: R; response: Response }> {
  // do stuff
}

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then, we can just import them into React components. What do you think?

I'm inclined to check what we have now in as-is, but I would be more than happy to refactor it as we find a better or clearer way to represent the data.

e.g. for the GET API calls, we could side-step the memoization requirement by just exporting a constant. (Since the is no request body.)

export const listCurrentTeamMembersCall = { ... }

export const getMemoizedUpdateSubscriptionSettingsCall(req: ReqBody) = {
     return useMemo(() => ..., [req])
}

That might be an improvement? I would want to wire up more API calls to get a better sense for what impact it would have on the ergonomics.

Also, I wonder if we really need classes here 🤔

I'm not wed to using a class, but one thing that we would be more difficult to do, is having some additional piece of data associated with each call. (e.g. wiring in a "request ID" or some custom header.) Having all of the "get Call<T> objects" be routed through the same class instance makes that a lot easier. (But perhaps that's not a requirement we'd ever have.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a non-blocking comment. Feel free to leave as it is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to call useApiClient to be able to use some Client method which result is used as useApiCaller argument. Given that Client is stateless and its methods are pure functions, I wonder what is the advantage of providing Client instance as a context value compared to making its methids static and using them directly, e.g.,

const { loading, error, data } = useApiCaller(Client.createStripeCheckoutSession(req))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what is the advantage of providing Client instance as a context value compared to making its methods static and using them directly, e.g.

I certainly can't think of any. I like your way better, which also has the nice property of maybe enabling us to cache/memoize the returned Call<T> objects, too!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Resp sounds to me like a shorthand for Response. As we use it to type the response body and later we return data of type Resp, maybe Data or Result will be more descriptive, WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, these are all confusing.

Personally, I like the type parameter name as Resp (for Response type). But the fact that the object returned has response: Response, and that is referring to an entirely different thing is... a good sign something is wrong.

data: Data and a doc comment clarifying the two seems like a good call.

Comment on lines 83 to 92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want need response text if the status code is not 2xx. Thus we can move reading response text inside the if block.

We are not sure that response text is a valid JSON string. We may need to wrap response text parsing with try/catch block:

if (fetchResponse.status >= 200 && fetchResponse.status <= 299) {
    try {
        // read response as JSON
        const typedResp = await fetchResponse.json() as Resp 
        return {
            data: typedResp,
            response: fetchResponse,
        }
    } catch (e) {
        // handle error
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on not waiting on reading the response body on a non-2xx response. 👍

We are not sure that response text is a valid JSON string. We may need to wrap response text parsing with try/catch block:

I don't think we should do that, but please double check my logic. (Since I'm not sure if it is a good idea.)

The problem is that in the Caller, it isn't clear what should be done in the case of any sort of error. (e.g. if the response body fails to unmarshall as JSON, or if the server returns a 5xx.)

So without any clear way to handle errors that crop up, the only choice we have is to:

  1. Extend the function's return type to include an optional error, e.g.: { response: Response, data?: Data, error?: Error }.
  2. Just let it fail, and require the caller to handle the error instead.

To keep things simpler, I went with option #2. So in the React hook we have:

   try {
       const callerResponse = await caller.call(call)
       ...
   } catch (err) {
      setData(undefined)
      setError(err)
      setResponse(undefined)
      setLoading(false)
   }

And I believe that is "better", in that we do return the error value to the caller. But without needing to complicate the function signature of the Caller interface.

e.g. if there is a failure in unmarshalling the JSON, it will be surfaced to the React component and they will need to respond to it. (Showing some error message in the UI.)

Does that make sense? Does it sound reasonable? Or should we just add an error?: Error field to the object returned from call<Data>(call: Call<Data>): Promise<{ data?: Data; response: Response }>?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Call type definition and usage resemble a subset of RequestInit + resource path. I don't know if "Call" is a descriptive enough name. However, I don't have a better alternative.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I wasn't aware of RequestInit. Taking a look though, it is definitely a lot fancier than what we need to do. (And so using that type would probably just lead to trouble, since it would look like we support features that we don't, such as specifying custom request headers.)

Also, I agree Call is not a good name. But I too couldn't come up with a better alternative. Some of the ones I rejected were as follows, LMK if any of those stand out as strictly better.

  • ApiRequest
  • ApiRequestStub
  • RequestMetadata
  • CallMetadata

... though I don't feel too bad about having Call here, because in-practice I don't expect it to ever really matter. e.g. it is never needed when you use the API client library. It's just the "thunk-like-thing" that is returned, and passed to the hook that actually performs the HTTP request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it's not great and that it's good enough. It's a pretty easy rename later, so let's just leave it for now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find CHECKOUT_SESSION_ID definition? Where do we get it from?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stripe will automatically find/replace that value when the checkout is complete.
https://docs.stripe.com/payments/checkout/custom-success-page#modify-the-success-url

So the flow is:

  1. We render the Stripe iframe, with the clientSecret of XXX which tells Stripe what the configuration settings are.
  2. When the checkout is complete, Stripe will redirect the user to "returnUrl" but with the actual CHECKOUT_SESSION_ID
  3. On the page that receives that page, we then use that session_id URL query parameter, to load the Checkout Session results from Stripe and see if the checkout was complete or not.

That third step isn't in the Sourcegraph.com code yet. But if you look in the SSC UI, that's what the WelcomePageComponent does.

Comment on lines 50 to 72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can there be a case when we have both error and data.clientSecret?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, or at least we should try hard to make sure that cannot happen.

So maybe a cleaner (?) way to represent this would be something like:

{
    kind: 'http-response-body-as-json' | 'error'
    result: T | Error
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's good as-is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion was to return early as we do for loading, e.g.

if (loading) {
    return <LoadingSpinner />
}

if (error) {
    return (
        <div>
            <H3>Awe snap!</H3>
            <Text>There was an error creating the checkout session: {error.message}</Text>
        </div>
    )
}

if (data?.clientSecret) {
    return (
        <div>
            <EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret: data.clientSecret }}>
                <EmbeddedCheckout />
            </EmbeddedCheckoutProvider>
        </div>
    )
}

return null

But if we are sure that we won't get truthy values for both error and data.clientSecret, it's good as-is.

@taras-yemets taras-yemets left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the motivation and overall approach! Great work, Chris!
Left a few comments about implementation details inline.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use import type to allow compiler safely drop imports and not load these modules at runtime.

@chrsmith chrsmith left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed review @taras-yemets ! You definitely caught a lot of subtle issues that I was unaware of. Please take another look.

  • Remove the need to putting a Client instance in the CodyProApiClientContext, and instead just use static methods.
  • Updated the pattern to require useMemo() to avoid unintentionally resending the same API request.
  • Returned a function object the useEffect hook within useApiCaller, so (hopefully) it will handle data races and things... I think?

So now the pattern looks something like:

// Make the API call to create the Stripe Checkout session.
const call = useMemo(() => client.createStripeCheckoutSession(req), [])
const { loading, error, data } = useApiCaller(call)

Things seem to be working OK when running locally, but a couple of things stand out:

Is there some way we can avoid the need to memoize the Call<T>?

I understand why it's important to have a stable reference to the Call<T>. I'm just wondering if we can somehow remove the use of useMemo and push the "memoize the return values" to the Client class and its static methods. (But doing so in some way that makes sense.)

I was thinking about something along the lines of:

// Lookup of known calls we have memoized. Keyed by:
// `${call.method}:${call.urlSuffix}`
let memoizedCalls = new Map<string, Call>()
function memoize(c: Call): Call {
    ...
}

    ...
    static updateCurrentSubscription(requestBody: types.UpdateSubscriptionRequest): Call<types.Subscription> {
        return { method: 'PATCH', urlSuffix: '/team/current/subscription', requestBody }
    }

However, when I thought more about it, we would need to maybe only want to memoize GET HTTP calls, since anything else wouldn't be idempotent? But this isn't about idempotency, but rather "state change management"...

And then I realized I should just ask you if you had any ideas :D

How can I test this thing?

Given the importance of making sure we have clarity around when API calls get made, and that we are properly returning HTTP status codes and things, do you have any suggestions for how we can test this client library?

Should I just try to create a component specific for verifying lifecycle hooks, and a Storybook entry for that? Or is there an example in the repo where we have "unit tests" for React components, that deal with async data loading, verifying DOM elements, etc.?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I wasn't aware of RequestInit. Taking a look though, it is definitely a lot fancier than what we need to do. (And so using that type would probably just lead to trouble, since it would look like we support features that we don't, such as specifying custom request headers.)

Also, I agree Call is not a good name. But I too couldn't come up with a better alternative. Some of the ones I rejected were as follows, LMK if any of those stand out as strictly better.

  • ApiRequest
  • ApiRequestStub
  • RequestMetadata
  • CallMetadata

... though I don't feel too bad about having Call here, because in-practice I don't expect it to ever really matter. e.g. it is never needed when you use the API client library. It's just the "thunk-like-thing" that is returned, and passed to the hook that actually performs the HTTP request.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doh, good catch. Fixed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, these are all confusing.

Personally, I like the type parameter name as Resp (for Response type). But the fact that the object returned has response: Response, and that is referring to an entirely different thing is... a good sign something is wrong.

data: Data and a doc comment clarifying the two seems like a good call.

Comment on lines 83 to 92

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on not waiting on reading the response body on a non-2xx response. 👍

We are not sure that response text is a valid JSON string. We may need to wrap response text parsing with try/catch block:

I don't think we should do that, but please double check my logic. (Since I'm not sure if it is a good idea.)

The problem is that in the Caller, it isn't clear what should be done in the case of any sort of error. (e.g. if the response body fails to unmarshall as JSON, or if the server returns a 5xx.)

So without any clear way to handle errors that crop up, the only choice we have is to:

  1. Extend the function's return type to include an optional error, e.g.: { response: Response, data?: Data, error?: Error }.
  2. Just let it fail, and require the caller to handle the error instead.

To keep things simpler, I went with option #2. So in the React hook we have:

   try {
       const callerResponse = await caller.call(call)
       ...
   } catch (err) {
      setData(undefined)
      setError(err)
      setResponse(undefined)
      setLoading(false)
   }

And I believe that is "better", in that we do return the error value to the caller. But without needing to complicate the function signature of the Caller interface.

e.g. if there is a failure in unmarshalling the JSON, it will be surfaced to the React component and they will need to respond to it. (Showing some error message in the UI.)

Does that make sense? Does it sound reasonable? Or should we just add an error?: Error field to the object returned from call<Data>(call: Call<Data>): Promise<{ data?: Data; response: Response }>?

Comment on lines 14 to 17

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm surprised that it worked to... but it totally does!

If you omit the <CodyProApiContext.Provider>...</CodyProApiContext.Provider> it will return the "default value" that is provided here.

I'll update the React components to make this explicit. (Since that's clearly better than having an implicit thing... but at least on my machine, it worked without needing to wrap the component tree.)

Comment on lines 26 to 29

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I find returning loading, data, error super-clunky too.

I wouldn't have a problem with using a different way to represent this. But personally I'd like to err on the side of having a "dumber but easier to understand" abstraction, than something fancier, but more difficult to use correctly.

Why don't we just keep the "loading, data, error" pattern as-is for now. But once we get this in-place, we can look into improving it with something that requires less tedium.

Since once we streamline our ability to "make API calls", we then would want to streamline how we "handle the responses". (e.g. cleaning up how we deal with loading or error states.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Definitely not a scenario I had considered, so thanks for pointing that out.

Please double check I added the check right. Essentially I need to gate all interactions with setState() on !ignore?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

How could I write a unit test or Storybook test to confirm that we only make these API calls only when needed. Which may be more frequently than the component lifetime, e.g. if you navigate to/from the same page, we may want to refresh the data every time the page is visible. (But not on each paint.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what is the advantage of providing Client instance as a context value compared to making its methods static and using them directly, e.g.

I certainly can't think of any. I like your way better, which also has the nice property of maybe enabling us to cache/memoize the returned Call<T> objects, too!

Comment on lines 50 to 72

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, or at least we should try hard to make sure that cannot happen.

So maybe a cleaner (?) way to represent this would be something like:

{
    kind: 'http-response-body-as-json' | 'error'
    result: T | Error
}

@vdavid

vdavid commented May 17, 2024

Copy link
Copy Markdown
Contributor

@chrsmith, I mentioned in yesterday's call that we prefer modules to classes in our TypeScript codebase, but I didn't find our reasoning for that at the time. I did now: it's here.
I don't see a good reason why this logic couldn't be implemented with TypeScript modules, so I suggest refactoring the classes into modules. Cody/Copilot could probably do 90% of the job. Let me know if you need help—or I can do it if needed.

Comment on lines 25 to 27

@vdavid vdavid May 17, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can definitely type these more strongly. This works:

Suggested change
const [error, setError] = useState<any>(undefined)
const [data, setData] = useState<any>(undefined)
const [response, setResponse] = useState<any>(undefined)
const [error, setError] = useState<Error | undefined>(undefined)
const [data, setData] = useState<Resp | undefined>(undefined)
const [response, setResponse] = useState<Response | undefined>(undefined)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for creating all these types, @chrsmith. What's your plan to ensure that any engineer touching the back end will sync these types?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great question. I think this is a situation where we will "add process as needed", rather than trying to formalize something up-front.

It shouldn't be too difficult to remember "if you add APIs to the backend, you probably want to add them to the frontend too". Since ultimately, we won't be able to use those backend APIs unless we export them to the frontend.

So I don't believe this will be a major problem. But if we do find ourselves unintentionally making breaking changes, or not doing a good job keeping these in-sync, we will need to add some sort of unit/integration tests to ensure that we are intentional and know what we are doing when touching these files. (But to start with, let us try erring on the side of trust. #WCGW.)

@vdavid vdavid May 17, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To further avoid confusion

Suggested change
// Optionally support the "showCouponCodeAtCheckout" URL query parameter, which if present
// Optionally support the "showCouponCodeAtCheckout" URL query parameter, which, if present,

(I made the mistake of correcting if to is earlier because I and my spellchecker both thought this was a typo.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@vdavid vdavid May 17, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interstitial? (sorry, non-native English speaker here, just the word "intersitular" doesn't seem to exist, so guessing.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might not be a real world :P To break it down, it's "inter-site-ular", but the concept is just where you have a "transition" page of some sort.

e.g. the thing some websites show when linking to some unknown 3rd party like:
image

I reworded the comment to just say this more plainly:

        // BUG: Due to the race conditions between Stripe, the SSC backend,
        // and Sourcegraph.com, immediately loading the Dashboard page isn't
        // going to show the right data reliably. We will need to instead show
        // some prompt, to give the backends an opportunity to sync.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`,
//
// "{CHECKOUT_SESSION_ID}" is a magic string that Stripe will replace with the actual checkout session ID.
returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

@vdavid vdavid May 17, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Resp short for ResponseType? Could it be CallType, ResponseType, or just T like we usually do with TypeScript generics? I'd probably go with T

@vdavid vdavid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving to unblock, but I suggest refactoring the classes to modules before merging. (See)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const call = useMemo(() => Client.createStripeCheckoutSession(req), [])
const call = useMemo(() => Client.createStripeCheckoutSession(req), [req])

Same as https://github.com/sourcegraph/sourcegraph/pull/62715#discussion_r1603095355: we must include req as a hook dependency.
req object is re-cretated on each render. So either we memoize it too, e.g.

const req = useMemo(() => ({
        // ... other fields
        customerEmail,
        showPromoCodeField,
    }), 
    [customerEmail, showPromoCodeField] // reactive dependencies - when they change we want `req` value to be recalculated
)

or create req inside a memoized call, e.g.,

const call = useMemo(() =>
    Client.createStripeCheckoutSession({
        // ... other fields
        customerEmail,
        showPromoCodeField,
    }),
    [customerEmail, showPromoCodeField]
) 

I prefer the latter as req is only used once and there's no additional value in definitin it as a separate const.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Fixed. And yeah, I agree the second format where we construct the request object too as part of the memoization process is probably the easier to understand.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: string may be the sufficient type for the error field. An empty string means no error; non-empty may be presented to the user as-is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the type to be Error | undefined, since otherwise we cannot say for certain what is being caught when we have the exception handler, right?

TBH I don't have a good sense for how to "properly" think about Error and runtime exceptions in JavaScript. So I'm happy to do whatever you think we should be doing 😅 .

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we can return early in case of ingore (this line) and after processing 2xx result (line 48).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines 23 to 25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: I understand why it's important to have a stable reference to the Call. I'm just wondering if we can somehow remove the use of useMemo and push the "memoize the return values" to the Client class and its static methods. (But doing so in some way that makes sense.) - https://github.com/sourcegraph/sourcegraph/pull/62715#pullrequestreview-2061728371

const currentSubscriptionArgs = {
  method: 'GET',
  urlSuffix: '/team/current/subscription'
}

class Client {
    static getCurrentSubscription(): Call<types.Subscription> {
        return currentSubscriptionArgs
    }
}

Now the reference to the currentSubscriptionArgs is stable and args inside MyComponent don't need a useMemo wrapper.

const MyComponent = () => {
  const args = Client.getCurrentSubscription()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, when I thought more about it, we would need to maybe only want to memoize GET HTTP calls, since anything else wouldn't be idempotent?

You're right, the above approach won't work for cases when args depend on some reactive value. In these case we would benefit from useMemo hook, e.g.:

const MyComponent = (props) => {
  const args = useMemo(() => Client.getSubscriptionById(id), [props.id])
}

@taras-yemets

Copy link
Copy Markdown
Contributor

Re: How can I test this thing?

@chrsmith, I think unit tests should be sufficient. We use the renderHook utility across the codebase (docs).

@taras-yemets taras-yemets left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! Thank you, Chris!

We can address the rest of comments (if we find them reasonable), add tests, etc. in the follow-up PRs.

@chrsmith chrsmith force-pushed the chrsmith/cody-ssc-api-client branch from 868e2ef to 26f47e6 Compare May 17, 2024 20:47
@chrsmith

Copy link
Copy Markdown
Contributor Author

Thanks @vdavid, @taras-yemets for the detailed code review! Y'all caught a lot of important defects in the code, and the end result seems a lot better.

  • I addressed the feedback you had in the is last round, e.g. converting class Client into module Client
  • Fixing the totally incorrect usage of that loading field in the hook set up function.
  • Added a couple of unit tests to sanity check things.

I'll do some more manual testing on my machine, but then check this in. I'll then update the recently added Team management pages to use this to (🤞) simplify/clean up the code there. And also validate that this is a net improvement.

@chrsmith chrsmith merged commit a4af00e into main May 17, 2024
@chrsmith chrsmith deleted the chrsmith/cody-ssc-api-client branch May 17, 2024 21:58
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants