Add Cody Pro REST API client library (#62715)

* Add Cody Pro REST API client library

* Expose REST API methods for Subscriptions

* Address PR feedback

* Address even more, great PR feedback

* Add unit tests

* Run 'sg lint', 'bazel run //:configure'

* Fix error from bad merge

* Lint
This commit is contained in:
Chris Smith 2024-05-17 14:58:10 -07:00 committed by GitHub
parent 2a5df059fd
commit a4af00e716
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 588 additions and 79 deletions

View File

@ -229,6 +229,12 @@ ts_project(
"src/cody/management/CodyManagementPage.tsx",
"src/cody/management/SubscriptionStats.tsx",
"src/cody/management/UseCodyInEditorSection.tsx",
"src/cody/management/api/client.ts",
"src/cody/management/api/components/CodyProApiClient.ts",
"src/cody/management/api/hooks/useApiClient.tsx",
"src/cody/management/api/stripeCheckout.ts",
"src/cody/management/api/teamSubscriptions.ts",
"src/cody/management/api/types.ts",
"src/cody/management/subscription/new/CodyProCheckoutForm.tsx",
"src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx",
"src/cody/onboarding/CodyOnboarding.tsx",
@ -1888,6 +1894,7 @@ ts_project(
"src/backend/persistenceMapper.test.ts",
"src/codeintel/ReferencesPanel.mocks.ts",
"src/codeintel/ReferencesPanel.test.tsx",
"src/cody/management/api/hooks/useApiClient.test.tsx",
"src/cody/useCodyIgnore.test.ts",
"src/components/ErrorBoundary.test.tsx",
"src/components/FilteredConnection/FilteredConnection.test.tsx",

View File

@ -0,0 +1,33 @@
# Cody Pro Client API Library
This module contains the Cody Pro REST API client library and associated types.
These are specific to the API used for managing Cody Pro subscriptions, and its associated
microservice backend. For more information, see the `sourcegraph/self-serve-cody` repo which
implements the server-side of this API.
For compatibility, the data types MUST match the Golang definitions in the `internal/api/types`
package. Care must also be taken on the Golang side to avoid breaking changes, such as not
renaming the JSON serialization of data types, only adding new fields, etc.
For any other Sourcegraph backend interactions from the frontend, that should be using the
GraphQL API.
## Usage
The API client is exposed as two React hooks: `useApiClient` and `useApiCaller`.
The `Client` provides a strongly-typed definition of the REST API exposed as a synchronous methods.
However, they just return `Call<Resp>` objects which merely _describe_ the API call to be made.
A separate `Caller` is what actually performs the operation.
⚠️ It's super important to wrap the `Call<Resp>` object's creation in `useMemo`. Otherwise, any time
the calling React comment gets repainted, the object reference passed to `useApiCaller` will change,
leading to additional HTTP requests being made unintentionally!
```ts
// Make the API call to create the Stripe Checkout session.
// Make the API call to create the Stripe Checkout session.
const call = useMemo(() => Client.createStripeCheckoutSession(req), [req.customerEmail, req.showPromoCodeField])
const { loading, error, data } = useApiCaller(call)
```

View File

@ -0,0 +1,103 @@
import type * as types from './types'
// Client provides the metadata for the methods exposed from the Cody Pro API client.
//
// This doesn't _do_ anything, it just returns the metadata for what needs to be done.
// It is used in conjunction with a Caller implementation for actually fetching data.
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
export module Client {
// Subscriptions
export function getCurrentSubscription(): Call<types.Subscription> {
return { method: 'GET', urlSuffix: '/team/current/subscription' }
}
export function getCurrentSubscriptionSummary(): Call<types.SubscriptionSummary> {
return { method: 'GET', urlSuffix: '/team/current/subscription/summary' }
}
export function updateCurrentSubscription(requestBody: types.UpdateSubscriptionRequest): Call<types.Subscription> {
return { method: 'PATCH', urlSuffix: '/team/current/subscription', requestBody }
}
export function getCurrentSubscriptionInvoices(): Call<types.GetSubscriptionInvoicesResponse> {
return { method: 'GET', urlSuffix: '/team/current/subscription/invoices' }
}
export function reactivateCurrentSubscription(
requestBody: types.ReactivateSubscriptionRequest
): Call<types.GetSubscriptionInvoicesResponse> {
return { method: 'POST', urlSuffix: '/team/current/subscription/reactivate', requestBody }
}
// Stripe Checkout
export function createStripeCheckoutSession(
requestBody: types.CreateCheckoutSessionRequest
): Call<types.CreateCheckoutSessionResponse> {
return { method: 'POST', urlSuffix: '/checkout/session', requestBody }
}
}
// Call is the bundle of data necessary for making an API request.
// This is a sort of "meta request" in the same veign as the `gql`
// template tag, see: https://github.com/apollographql/graphql-tag
export interface Call<Resp> {
method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
urlSuffix: string
requestBody?: any
// Unused. This will never be set, it is only to
// pass along the expected response type.
responseBody?: Resp
}
// Caller is a wrapper around an HTTP client. An implementation of this interface
// will be responsible for making API calls to the backend.
export interface Caller {
// call performs the described HTTP request, returning the response body deserialized from
// JSON as `data`, and the full HTTP response object as `response`.
call<Data>(call: Call<Data>): Promise<{ data?: Data; response: Response }>
}
// CodyProApiCaller is an implementation of the Caller interface which issues API calls to
// the current Sourcegraph instance's SSC proxy API endpoint.
export class CodyProApiCaller implements Caller {
// e.g. "https://sourcegraph.com"
private origin: string
constructor() {
this.origin = window.location.origin
}
public async call<Data>(call: Call<Data>): Promise<{ data?: Data; response: Response }> {
let bodyJson: string | undefined
if (call.requestBody) {
bodyJson = JSON.stringify(call.requestBody)
}
const fetchResponse = await fetch(`${this.origin}/.api/ssc/proxy${call.urlSuffix}`, {
// Pass along the "sgs" session cookie to identify the caller.
credentials: 'same-origin',
method: call.method,
body: bodyJson,
})
if (fetchResponse.status >= 200 && fetchResponse.status <= 299) {
const rawBody = await fetchResponse.text()
const typedResp = JSON.parse(rawBody) as Data
return {
data: typedResp,
response: fetchResponse,
}
}
// Otherwise just return the raw response. We rely on the caller
// to confirm that the Response object indicates success, and to
// handle any 4xx or 5xx status codes.
return {
data: undefined,
response: fetchResponse,
}
}
}

View File

@ -0,0 +1,21 @@
import { createContext } from 'react'
import { Caller, CodyProApiCaller } from '../client'
export interface CodyProApiClient {
caller: Caller
}
// Helper for returning a default value, for the API client contacting the local
// Sourcegraph backend for making API calls.
export function defaultCodyProApiClientContext(): CodyProApiClient {
return {
caller: new CodyProApiCaller(),
}
}
// Context for supplying a Cody Pro API client to a React component tree.
//
// The default value will be a functional API client that makes HTTP requests
// to the current Sourcegraph instance's backend.
export const CodyProApiClientContext = createContext<CodyProApiClient>(defaultCodyProApiClientContext())

View File

@ -0,0 +1,138 @@
import React from 'react'
import { renderHook } from '@testing-library/react-hooks'
import { describe, expect, it } from 'vitest'
import { Call, Caller } from '../client'
import { CodyProApiClientContext } from '../components/CodyProApiClient'
import { useApiCaller } from './useApiClient'
// FakeCaller is a testing fake for the Caller interface, for simulating
// making API calls. Only supports one call being made at a time, otherwise
// will fail.
//
// It's hard to do async hook testing correctly. This might be helpful:
// https://react-hooks-testing-library.com/usage/advanced-hooks#async
class FakeCaller implements Caller {
private callInFlight = false
private resolveLastCallFn: any | undefined = undefined
private rejectLastCallFn: any | undefined = undefined
public call<Data>(_: Call<Data>): Promise<{ data?: Data; response: Response }> {
if (this.callInFlight) {
throw new Error('There is already a call in-flight. You must call `reset()`')
}
return new Promise<{ data?: Data; response: Response }>((resolve, reject) => {
this.callInFlight = true
this.resolveLastCallFn = resolve
this.rejectLastCallFn = reject
// We leave the promise in this running state,
// requiring the testcase to call resolveLastCallWith.
})
}
public isCallInFlight(): boolean {
return this.callInFlight
}
public resolveLastCallWith<Data>(result: { data?: Data; response: Response }) {
if (!this.resolveLastCallFn) {
throw new Error('Cannot resolve. There is no call in-flight.')
}
this.resolveLastCallFn(result)
this.reset()
}
public rejectLastCallWith(reason: any) {
if (!this.rejectLastCallFn) {
throw new Error('Cannot reject. There is no call in-flight.')
}
this.rejectLastCallFn(reason)
this.reset()
}
public reset() {
if (!this.callInFlight) {
throw new Error('Cannot reset. There is no call in-flight')
}
this.callInFlight = false
this.resolveLastCallFn = undefined
this.rejectLastCallFn = undefined
}
}
describe('useApiCaller()', () => {
const mockCaller = new FakeCaller()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<CodyProApiClientContext.Provider value={{ caller: mockCaller }}>{children}</CodyProApiClientContext.Provider>
)
// responseStub is a stubbed out Response object.
const responseStub: Response = {
status: 200,
} as any
it('works', async () => {
const call: Call<void> = {
method: 'GET',
urlSuffix: '/test',
}
// Verify the initial state is loading.
const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper })
{
const { loading, error, data } = result.current
expect(loading).toBe(true)
expect(data).toBeUndefined()
expect(error).toBeUndefined()
}
// Resolve the promise that was returned by the API call made by
// the useApiCaller hook.
expect(mockCaller.isCallInFlight()).toBe(true)
mockCaller.resolveLastCallWith({ data: 'some value', response: responseStub })
expect(mockCaller.isCallInFlight()).toBe(false)
// Now we need to kick the React runtime to pick up on the change.
await waitForNextUpdate()
// Verify the updated state has the result from the caller.
{
const { loading, error, data } = result.current
expect(loading).toBe(false)
expect(data).toBe('some value')
expect(error).toBeUndefined()
}
})
it('handles runtime errors', async () => {
const call: Call<void> = {
method: 'GET',
urlSuffix: '/test',
}
// Verify the initial state is loading.
const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper })
{
const { loading, error, data } = result.current
expect(loading).toBe(true)
expect(data).toBeUndefined()
expect(error).toBeUndefined()
}
mockCaller.rejectLastCallWith(new Error('Random Network Error'))
await waitForNextUpdate()
// Verify the error field is set
{
const { loading, error, data } = result.current
expect(loading).toBe(false)
expect(data).toBeUndefined()
expect(error).toBeTruthy()
expect(error?.message).toBe('Random Network Error')
}
})
})

View File

@ -0,0 +1,87 @@
import { useEffect, useState, useContext } from 'react'
import { Call } from '../client'
import { CodyProApiClientContext } from '../components/CodyProApiClient'
export interface ReactFriendlyApiResponse<T> {
loading: boolean
error?: Error
data?: T
response?: Response
}
// useApiCaller will issue a REST API call to the backend, returning the
// loading status and the response JSON object and/or error object as React
// state.
//
// IMPORTANT: In order to avoid the same API request being made multiple times,
// you MUST ensure that the provided call is the same between repains of the
// calling React component. i.e. you pretty much always need to create it via
// `useMemo()`.
export function useApiCaller<Resp>(call: Call<Resp>): ReactFriendlyApiResponse<Resp> {
const { caller } = useContext(CodyProApiClientContext)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | undefined>(undefined)
const [data, setData] = useState<Resp | undefined>(undefined)
const [response, setResponse] = useState<Response | undefined>(undefined)
useEffect(() => {
// `ignore` tracks if we should discard any results, because of any underlying race condition
// in the sequence of API calls. We return a handle to this in the function callback, which
// the React runtime may invoke (setting ignore = true) outside of our view.
// https://react.dev/reference/react/useEffect#fetching-data-with-effects
// https://maxrozen.com/race-conditions-fetching-data-react-with-useeffect
let ignore = false
;(async () => {
try {
const callerResponse = await caller.call(call)
if (ignore) {
return
}
// If we received a 200 response, all is well. We can just return
// the unmarshalled JSON response object as-is.
setLoading(false)
if (callerResponse.response.status >= 200 && callerResponse.response.status <= 299) {
setData(callerResponse.data)
setError(undefined)
setResponse(callerResponse.response)
} else {
// For a 4xx or 5xx response this is where we provide any standardized logic for
// error handling. For example:
//
// - On a 401 response, we need to force-logout the user so they can refresh their
// SAMS access token.
// - On a 500 response, perhaps replace the current UI with a full-page error. e.g.
// http://github.com/500 or http://github.com/501
setData(undefined)
setError(new Error(`unexpected status code: ${callerResponse.response.status}`))
setResponse(callerResponse.response)
// Provide a clearer message. A 401 typically comes from the user's SAMS credentials
// having expired on the backend.
if (callerResponse.response.status === 401) {
setError(new Error('Please log out and log back in.'))
}
}
} catch (error) {
if (ignore) {
return
}
setData(undefined)
setError(error)
setResponse(undefined)
setLoading(false)
}
})()
return () => {
ignore = true
}
}, [call, caller])
return { loading, error, data, response }
}

View File

@ -0,0 +1,25 @@
import { BillingInterval } from './teamSubscriptions'
export interface CreateCheckoutSessionRequest {
interval: BillingInterval
seats: number
customerEmail?: string
showPromoCodeField: boolean
returnUrl?: string
}
export interface CreateCheckoutSessionResponse {
clientSecret: string
}
export interface GetCheckoutSessionResponse {
// The only valid state is "complete". Any other string implies that the
// checkout was not successful, and no new Cody Pro team was registered.
status: string
// The only valid state is "paid" (IFF status is also "complete").
// Anything else means the team/subscription was not registered.
paymentStatus: string
}

View File

@ -0,0 +1,118 @@
export type TeamRole = 'none' | 'member' | 'admin'
// BillingInterval is the subscription's billing cycle. 'daily' is only
// available in the dev environment.
export type BillingInterval = 'daily' | 'monthly' | 'yearly'
// UsdCents is used to wrap any situation involving money, which
// should always be an integer referring to USD cents. e.g.
// 105 corresponds to $1.05.
export type UsdCents = number
export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'other'
export type SubscriptionStatus = 'active' | 'past_due' | 'unpaid' | 'canceled' | 'trailing' | 'other'
export interface Address {
line1: string
line2: string
city: string
state: string
postalCode: string
country: string
}
export interface PaymentMethod {
expMonth: number
expYear: number
last4: string
}
export interface PreviewResult {
dueNow: UsdCents
newPrice: UsdCents
dueDate: Date
}
export interface DiscountInfo {
description: string
expiresAt?: Date
}
export interface Invoice {
date: Date
amountDue: UsdCents
amountPaid: UsdCents
status: InvoiceStatus
periodStart: Date
periodEnd: Date
hostedInvoiceUrl?: string
pdfUrl?: string
}
export interface SubscriptionSummary {
teamId: string
userRole: TeamRole
teamCurrentMembers: number
teamMaxMembers: number
subscriptionStatus: SubscriptionStatus
cancelAtPeriodEnd: boolean
}
export interface Subscription {
createdAt: Date
endedAt?: Date
primaryEmail: string
name: string
address: Address
subscriptionStatus: SubscriptionStatus
cancelAtPeriodEnd: boolean
billingInterval: BillingInterval
discountInfo?: DiscountInfo
currentPeriodStart: Date
currentPeriodEnd: Date
paymentMethod?: PaymentMethod
nextInvoice?: PreviewResult
maxSeats: number
}
export interface CustomerUpdateOptions {
newName?: string
newEmail?: string
newAddress?: Address
newCreditCardToken?: string
}
// Is a discriminated union. Exactly one field should be set at a time.
export interface SubscriptionUpdateOptions {
newSeatCount?: number
newBillingInterval?: BillingInterval
newCancelAtPeriodEnd?: boolean
}
export interface ReactivateSubscriptionRequest {
seatLimit: number
billingInterval: BillingInterval
creditCardToken?: string
}
export interface UpdateSubscriptionRequest {
customerUpdate?: CustomerUpdateOptions
subscriptionUpdate?: SubscriptionUpdateOptions
}
export interface GetSubscriptionInvoicesResponse {
invoices: Invoice[]
continuationToken?: string
}

View File

@ -0,0 +1,4 @@
// Export all of the API types, so consumers we can organize the type definitions
// into smaller files, without consumers needing to care about that organization.
export * from './teamSubscriptions'
export * from './stripeCheckout'

View File

@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react'
import React, { useMemo } from 'react'
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js'
import type { Stripe } from '@stripe/stripe-js'
import { useSearchParams } from 'react-router-dom'
import { H3, Text } from '@sourcegraph/wildcard'
import { H3, LoadingSpinner, Text } from '@sourcegraph/wildcard'
import { requestSSC } from '../../../util'
import { Client } from '../../api/client'
import { useApiCaller } from '../../api/hooks/useApiClient'
import { CreateCheckoutSessionRequest } from '../../api/types'
/**
* CodyProCheckoutForm is essentially an iframe that the Stripe Elements library will
@ -16,88 +18,56 @@ export const CodyProCheckoutForm: React.FunctionComponent<{
stripePromise: Promise<Stripe | null>
customerEmail: string | undefined
}> = ({ stripePromise, customerEmail }) => {
const [clientSecret, setClientSecret] = useState('')
const [errorDetails, setErrorDetails] = useState('')
const [urlSearchParams] = useSearchParams()
// Optionally support the "showCouponCodeAtCheckout" URL query parameter, which is present
// Optionally support the "showCouponCodeAtCheckout" URL query parameter, which, if present,
// will display a "promotional code" element in the Stripe Checkout UI.
const [urlSearchParams] = useSearchParams()
const showPromoCodeField = urlSearchParams.get('showCouponCodeAtCheckout') !== null
// Issue an API call to the backend asking it to create a new checkout session.
// This will update clientSecret/errorDetails asynchronously when the request completes.
useEffect(() => {
// useEffect will not accept a Promise, so we call
// createCheckoutSession and let it run async.
// (And not `await createCheckoutSession` or `return createCheckoutSession`.)
void createCheckoutSession('monthly', showPromoCodeField, customerEmail, setClientSecret, setErrorDetails)
}, [customerEmail, showPromoCodeField, setClientSecret, setErrorDetails])
const options /* unexported EmbeddedCheckoutProviderProps.options */ = {
clientSecret,
}
return (
<div>
{errorDetails && (
<>
<H3>Awe snap!</H3>
<Text>There was an error creating the checkout session: {errorDetails}</Text>
</>
)}
{clientSecret && (
<EmbeddedCheckoutProvider stripe={stripePromise} options={options}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)}
</div>
)
}
// createSessionResponse is the API response returned from the SSC backend when
// we ask it to create a new Stripe Checkout Session.
interface createSessionResponse {
clientSecret: string
}
// createCheckoutSession initiates the API request to the backend to create a Stripe Checkout session.
// Upon completion, the `setClientSecret` or `setErrorDetails` will be called to report the result.
async function createCheckoutSession(
billingInterval: string,
showPromoCodeField: boolean,
customerEmail: string | undefined,
setClientSecret: (arg: string) => void,
setErrorDetails: (arg: string) => void
): Promise<void> {
// e.g. "https://sourcegraph.com"
const origin = window.location.origin
try {
const response = await requestSSC('/checkout/session', 'POST', {
interval: billingInterval,
// Make the API call to create the Stripe Checkout session.
const call = useMemo(() => {
const req: CreateCheckoutSessionRequest = {
interval: 'monthly',
seats: 1,
customerEmail,
showPromoCodeField,
// URL the user is redirected to when the checkout process is complete.
//
// CHECKOUT_SESSION_ID will be replaced by Stripe with the correct value,
// when the user finishes the Stripe-hosted checkout form.
//
// 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 interstitial or welcome prompt, to give various things to sync.
// some prompt, to give the backends an opportunity to sync.
returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`,
})
const responseBody = await response.text()
if (response.status >= 200 && response.status <= 299) {
const typedResp = JSON.parse(responseBody) as createSessionResponse
setClientSecret(typedResp.clientSecret)
} else {
// Pass any 4xx or 5xx directly to the user. We expect the
// server to have properly redacted any sensitive information.
setErrorDetails(responseBody)
}
} catch (error) {
setErrorDetails(`unhandled exception: ${JSON.stringify(error)}`)
return Client.createStripeCheckoutSession(req)
}, [customerEmail, showPromoCodeField])
const { loading, error, data } = useApiCaller(call)
// Show a spinner while we wait for the Checkout session to be created.
if (loading) {
return <LoadingSpinner />
}
// Error page if we aren't able to show the Checkout session.
if (error) {
return (
<div>
<H3>Awe snap!</H3>
<Text>There was an error creating the checkout session: {error.message}</Text>
</div>
)
}
return (
<div>
{data && data.clientSecret && (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret: data.clientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)}
</div>
)
}

View File

@ -24,6 +24,7 @@ import {
} from '../../../../graphql-operations'
import { CodyProIcon } from '../../../components/CodyIcon'
import { USER_CODY_PLAN } from '../../../subscription/queries'
import { defaultCodyProApiClientContext, CodyProApiClientContext } from '../../api/components/CodyProApiClient'
import { CodyProCheckoutForm } from './CodyProCheckoutForm'
@ -72,12 +73,14 @@ const AuthenticatedNewCodyProSubscriptionPage: FunctionComponent<NewCodyProSubsc
</PageHeader>
<Container>
<Elements stripe={stripePromise} options={{ appearance: stripeElementsAppearance }}>
<CodyProCheckoutForm
stripePromise={stripePromise}
customerEmail={authenticatedUser?.emails[0].email || ''}
/>
</Elements>
<CodyProApiClientContext.Provider value={defaultCodyProApiClientContext()}>
<Elements stripe={stripePromise} options={{ appearance: stripeElementsAppearance }}>
<CodyProCheckoutForm
stripePromise={stripePromise}
customerEmail={authenticatedUser?.emails[0].email || ''}
/>
</Elements>
</CodyProApiClientContext.Provider>
</Container>
</Page>
)

View File

@ -17,7 +17,7 @@ instead.
Steps:
1. Request ["CI Secrets Read Access"](https://app.entitle.io/request?data=eyJkdXJhdGlvbiI6Ijg2NDAwIiwianVzdGlmaWNhdGlvbiI6IlJ1bm5pbmcgYmFja2VuZCBpbnRlZ3JhdGlvbiB0ZXN0cyBsb2NhbGx5IiwiYnVuZGxlSWRzIjpbIjA2ZmRkZTIwLWY1Y2MtNDdlMS1iZGYxLWZkZTUwYjhhNDE3MCJdfQ%3D%3D) in Entitle
1. Request "CI Secrets Read Access" in Entitle
2. Run `sg test bazel-backend-integration`
## How to add new tests