mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 18:11:48 +00:00
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:
parent
2a5df059fd
commit
a4af00e716
@ -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",
|
||||
|
||||
33
client/web/src/cody/management/api/README.md
Normal file
33
client/web/src/cody/management/api/README.md
Normal 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)
|
||||
```
|
||||
103
client/web/src/cody/management/api/client.ts
Normal file
103
client/web/src/cody/management/api/client.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
138
client/web/src/cody/management/api/hooks/useApiClient.test.tsx
Normal file
138
client/web/src/cody/management/api/hooks/useApiClient.test.tsx
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
87
client/web/src/cody/management/api/hooks/useApiClient.tsx
Normal file
87
client/web/src/cody/management/api/hooks/useApiClient.tsx
Normal 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 }
|
||||
}
|
||||
25
client/web/src/cody/management/api/stripeCheckout.ts
Normal file
25
client/web/src/cody/management/api/stripeCheckout.ts
Normal 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
|
||||
}
|
||||
118
client/web/src/cody/management/api/teamSubscriptions.ts
Normal file
118
client/web/src/cody/management/api/teamSubscriptions.ts
Normal 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
|
||||
}
|
||||
4
client/web/src/cody/management/api/types.ts
Normal file
4
client/web/src/cody/management/api/types.ts
Normal 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'
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user