Add Cody Pro REST API client library#62715
Conversation
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
it's value is stable anyway
correcting myself here: not always stable, see https://github.com/sourcegraph/sourcegraph/pull/62715#discussion_r1603288864
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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().
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
We should consider returning a cleanup function not to suffer from race conditions (note on ignore variable in the docs).
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Ahh, this is neat, TIL, @taras-yemets. I think the fix is correct.
There was a problem hiding this comment.
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])
There was a problem hiding this comment.
Ah, true, I overlooked that the return was inside the async function. 🙈
There was a problem hiding this comment.
Do we plan to use response in React components?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
| updateCurretSubscription(requestBody: types.UpdateSubscriptionRequest): Call<types.Subscription> { | |
| updateCurrentSubscription(requestBody: types.UpdateSubscriptionRequest): Call<types.Subscription> { |
There was a problem hiding this comment.
Doh, good catch. Fixed.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
}
?
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
This is a non-blocking comment. Feel free to leave as it is.
There was a problem hiding this comment.
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))
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
}
}
There was a problem hiding this comment.
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:
- Extend the function's return type to include an optional error, e.g.:
{ response: Response, data?: Data, error?: Error }. - 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 }>?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
ApiRequestApiRequestStubRequestMetadataCallMetadata
... 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I couldn't find CHECKOUT_SESSION_ID definition? Where do we get it from?
There was a problem hiding this comment.
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:
- We render the Stripe iframe, with the
clientSecretof XXX which tells Stripe what the configuration settings are. - When the checkout is complete, Stripe will redirect the user to "returnUrl" but with the actual CHECKOUT_SESSION_ID
- On the page that receives that page, we then use that
session_idURL 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.
There was a problem hiding this comment.
Can there be a case when we have both error and data.clientSecret?
There was a problem hiding this comment.
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
}There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
I agree with the motivation and overall approach! Great work, Chris!
Left a few comments about implementation details inline.
There was a problem hiding this comment.
We use import type to allow compiler safely drop imports and not load these modules at runtime.
chrsmith
left a comment
There was a problem hiding this comment.
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
Clientinstance in theCodyProApiClientContext, 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
useEffecthook withinuseApiCaller, 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.?
There was a problem hiding this comment.
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.
ApiRequestApiRequestStubRequestMetadataCallMetadata
... 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.
There was a problem hiding this comment.
Doh, good catch. Fixed.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- Extend the function's return type to include an optional error, e.g.:
{ response: Response, data?: Data, error?: Error }. - 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 }>?
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
}|
@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. |
There was a problem hiding this comment.
You can definitely type these more strongly. This works:
| 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) |
There was a problem hiding this comment.
Thanks for creating all these types, @chrsmith. What's your plan to ensure that any engineer touching the back end will sync these types?
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
To further avoid confusion
| // 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.)
There was a problem hiding this comment.
interstitial? (sorry, non-native English speaker here, just the word "intersitular" doesn't seem to exist, so guessing.)
There was a problem hiding this comment.
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:
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.There was a problem hiding this comment.
| 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}`, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
| 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 😅 .
There was a problem hiding this comment.
nit: we can return early in case of ingore (this line) and after processing 2xx result (line 48).
There was a problem hiding this comment.
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()
}
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
868e2ef to
26f47e6
Compare
|
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'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. |
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:
And, relying on strong typing to provide intelligence and catch errors.
The specific implementation was inspired by the Apollo GraphQL React library, and its
useQueryhook.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.tsand related files likeapi/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 Golangjson:"foo"annotations not matching line up with the TypeScriptinterfacefields.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.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:
class ClientThis 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
voidwhere applicable.)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.export class CodyProApiCaller implements CallerThis is what will actually make the API calls. It is just a wrapper around
fetch:useApiClientanduseApiCallerhooksThe final piece is how the React component can get ahold of the
Clientand pass any returnedCall<>objects to aCaller.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.
api/teamSubscriptions.ts) and exporting them fromtypes.tsfor convenience.Clientclass inclient.ts.From there, we can call the new method using the
useApiClientanduseApiCallerhooks 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.