Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(openIntProxyLink): Adding Openint proxy link #39

Merged
merged 15 commits into from
Dec 4, 2024
39 changes: 39 additions & 0 deletions packages/fetch-links/links/authLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
ClientAuthOptions,
mergeHeaders,
modifyRequest,
openIntProxyLink,
} from '@opensdks/runtime'
import {Link} from '../link.js'

export function authLink(auth: ClientAuthOptions, baseUrl: string): Link {
if (!auth) {
// No Op
return (req, next) => next(req)
}

if (auth.openInt) {

Check failure on line 15 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'openInt' does not exist on type 'ClientAuthOptions'.

Check failure on line 15 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'openInt' does not exist on type 'ClientAuthOptions'.
return openIntProxyLink(auth.openInt, baseUrl)

Check failure on line 16 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'openInt' does not exist on type 'ClientAuthOptions'.

Check failure on line 16 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'openInt' does not exist on type 'ClientAuthOptions'.
}

const headers = {
['authorization']: auth.oauth?.accessToken

Check failure on line 20 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'oauth' does not exist on type 'ClientAuthOptions'.

Check failure on line 20 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'oauth' does not exist on type 'ClientAuthOptions'.
? `Bearer ${auth.oauth.accessToken}`

Check failure on line 21 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'oauth' does not exist on type 'ClientAuthOptions'.

Check failure on line 21 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'oauth' does not exist on type 'ClientAuthOptions'.
: auth?.bearer

Check failure on line 22 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'bearer' does not exist on type 'ClientAuthOptions'.

Check failure on line 22 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'bearer' does not exist on type 'ClientAuthOptions'.
? `Bearer ${auth.bearer}`

Check failure on line 23 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'bearer' does not exist on type 'ClientAuthOptions'.

Check failure on line 23 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'bearer' does not exist on type 'ClientAuthOptions'.
: auth?.basic

Check failure on line 24 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'basic' does not exist on type 'ClientAuthOptions'.

Check failure on line 24 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'basic' does not exist on type 'ClientAuthOptions'.
? `Basic ${btoa(`${auth.basic?.username}:${auth.basic?.password}`)}`

Check failure on line 25 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'basic' does not exist on type 'ClientAuthOptions'.

Check failure on line 25 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'basic' does not exist on type 'ClientAuthOptions'.

Check failure on line 25 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'basic' does not exist on type 'ClientAuthOptions'.

Check failure on line 25 in packages/fetch-links/links/authLink.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Property 'basic' does not exist on type 'ClientAuthOptions'.
: '',
} satisfies HeadersInit

return async (req, next) => {
req.headers.delete('authorization')
const res = await next(
modifyRequest(req, {
headers: mergeHeaders(req.headers, headers, {}),
body: req.body,
}),
)
return res
}
}
2 changes: 2 additions & 0 deletions packages/fetch-links/links/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {Link} from '../link.js'
export * from './axiosLink.js'
export * from './corsLink.js'
export * from './oauthLink.js'
export * from './openIntProxyLink.js'
export * from './authLink.js'
// codegen:end

// MARK: Built-in links
Expand Down
120 changes: 120 additions & 0 deletions packages/fetch-links/links/openIntProxyLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {mergeHeaders, modifyRequest} from '../index.js'
import type {Link} from '../link.js'

export type OpenIntProxyLinkOptions = {
token?: string
apiKey?: string
resourceId?: string
endUserId?: string
connectorName?: string
}

// TODO: Use something like this to validate and generate the types too
// const AuthClientOptionsSchema = z.union([
// z.object({
// openInt: z.object({
// // Add OpenIntProxyLinkOptions fields here
// }).optional()
// }),
// z.object({
// oauth: z.object({
// accessToken: z.string(),
// refreshToken: z.string().optional(),
// expiresAt: z.number().optional()
// }).optional()
// }),
// z.object({
// basic: z.object({
// username: z.string(),
// password: z.string()
// }).optional()
// }),
// z.object({
// bearer: z.string().optional()
// })
// ])

export function validateOpenIntProxyLinkOptions(
options: OpenIntProxyLinkOptions,
): boolean {
const {token, apiKey, resourceId, endUserId, connectorName} = options ?? {}

const hasToken = !!token
const hasApiKey = !!apiKey
const hasResourceId = !!resourceId
const hasEndUserId = !!endUserId
const hasConnectorName = !!connectorName

const expectsAuthProxy =
hasToken || hasApiKey || hasResourceId || hasEndUserId || hasConnectorName
if (
expectsAuthProxy &&
!(
(hasToken && hasResourceId) ||
(hasToken && hasConnectorName) ||
(hasApiKey && hasResourceId) ||
(hasApiKey && hasEndUserId && hasConnectorName)
)
) {
throw new Error(
'Invalid configuration for proxy authentication. You must provide one of the following combinations: ' +
'1) token AND resourceId, ' +
'2) token AND connectorName, ' +
'3) apiKey AND resourceId, ' +
'4) apiKey AND endUserId AND connectorName, ' +
'or none of these options and instead authenticate directly.',
)
}
return expectsAuthProxy
}

interface OpenIntProxyHeaders {
Copy link

Choose a reason for hiding this comment

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

The OpenIntProxyHeaders interface is defined twice in this file. Consider removing the duplicate definition to avoid confusion.

authorization?: `Bearer ${string}`
'x-apikey'?: string
'x-resource-id'?: string
'x-resource-end-user-id'?: string
'x-resource-connector-name'?: string
}

function removeEmptyHeaders(headers: OpenIntProxyHeaders): HeadersInit {
return Object.fromEntries(
Object.entries(headers).filter(([_, value]) => value && value !== ''),
) satisfies HeadersInit
}

export function openIntProxyLink(
opts: OpenIntProxyLinkOptions,
baseUrl: string,
): Link {
validateOpenIntProxyLinkOptions(opts)
const {apiKey, token, resourceId, endUserId, connectorName} = opts

const headers = removeEmptyHeaders({
['x-apikey']: apiKey || '',
['authorization']: token ? `Bearer ${token}` : undefined,
['x-resource-id']: resourceId || '',
['x-resource-end-user-id']: endUserId || '',
['x-resource-connector-name']: connectorName || '',
}) satisfies HeadersInit

return async (req, next) => {
// TODO: Check if we are already proxying and throw an error if so
// if (req.url.includes(proxyUrl)) {
// // Was previously necessary as link called twice leading to /api/proxy/api/proxy/?
// return next(req)
// }
const proxyUrl = 'https://api.openint.dev/proxy'

// Remove the authorization header because it will be overwritten by the proxy anyways
// TODO: Think about using non-standard header for auth to avoid this maybe?
req.headers.delete('authorization')
const res = await next(
modifyRequest(req, {
url: req.url.replace(baseUrl, proxyUrl),
headers: mergeHeaders(req.headers, headers, {}),
body: req.body,
}),
)
return res
}
}
21 changes: 20 additions & 1 deletion packages/runtime/createClient.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {createFormUrlEncodedBodySerializer} from '@opensdks/runtime'
import {
createClient,
createFormUrlEncodedBodySerializer,
fetchLink,
} from '@opensdks/runtime'

test('application/x-www-form-urlencoded defaut dot style', () => {
const formUrlEncodedBodySerializer = createFormUrlEncodedBodySerializer({})
Expand Down Expand Up @@ -50,3 +54,18 @@ test('application/x-www-form-urlencoded bracket style', () => {
'account=acct_111222&components[account_onboarding][enabled]=true&components[nested][0]=hello&components[nested][1][key]=world',
)
})

test('expect links array to have 2 elements by default', () => {
const client = createClient()
expect(client.links.length).toBe(2)
})

test('expect override to be possible for links', () => {
const client0 = createClient({links: []})
expect(client0.links.length).toBe(0)

const client = createClient({
links: (defaultLinks) => [...defaultLinks, fetchLink()],
})
expect(client.links.length).toBe(3)
})
25 changes: 20 additions & 5 deletions packages/runtime/createClient.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
import type {BodySerializer, FetchOptions, FetchResponse} from 'openapi-fetch'
import _createClient from 'openapi-fetch'
import type {PathsWithMethod} from 'openapi-typescript-helpers'
import type {OpenIntProxyLinkOptions} from '@opensdks/fetch-links'
import {
applyLinks,
fetchLink,
type HTTPMethod,
type Link,
} from '@opensdks/fetch-links'
import {authLink} from '../fetch-links/links/authLink.js'
import {HTTPError} from './HTTPError.js'
import {flattenNestedObject, FlattenOptions} from './utils.js'
import type {FlattenOptions} from './utils.js'
import {flattenNestedObject} from './utils.js'

type _ClientOptions = NonNullable<Parameters<typeof _createClient>[0]>

export type ClientAuthOptions =
| {openInt?: OpenIntProxyLinkOptions}
/** to be passed as Authorization header as a bearer token, Should handle automatic refreshing */
| {oauth?: {accessToken: string; refreshToken?: string; expiresAt?: number}}
| {basic?: {username: string; password: string}}
/** non oauth / directly specifying bearer token */
| {bearer?: string}

export interface ClientOptions extends _ClientOptions {
links?: Link[] | ((defaultLinks: Link[]) => Link[])
auth?: ClientAuthOptions
}

export type OpenAPIClient<Paths extends {}> = ReturnType<
Expand All @@ -25,13 +37,16 @@ export type OpenAPIClient<Paths extends {}> = ReturnType<
// to get a list of servers and all that?
// Really do feel that they should be generated as well..

export const defaultLinks = [fetchLink()]

export function createClient<Paths extends {}>({
links: _links = defaultLinks,
links: _links,
...clientOptions
}: ClientOptions = {}) {
const links = typeof _links === 'function' ? _links(defaultLinks) : _links
const defaultLinks = [
authLink(clientOptions.auth ?? {}, clientOptions.baseUrl ?? ''),
fetchLink(),
]
const links =
typeof _links === 'function' ? _links(defaultLinks) : _links ?? defaultLinks

const customFetch: typeof fetch = (url, init) =>
applyLinks(new Request(url, init), links)
Expand Down
46 changes: 44 additions & 2 deletions sdks/sdk-qbo/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import {initSDK} from '@opensdks/runtime'
import qboSdkDef from './index.js'

const realmId = process.env['QBO_REALM_ID']!
/** TODO: Setup qbo access + refresh token on CI */
const accessToken = process.env['QBO_ACCESS_TOKEN']!
const maybeTest = realmId ? test : test.skip

maybeTest('get QBO company', async () => {
maybeTest('get QBO company directly with access token', async () => {
const qbo = initSDK(qboSdkDef, {
realmId,
envName: 'sandbox',
accessToken: process.env['QBO_ACCESS_TOKEN']!,
auth: {oauth: {accessToken}},
})

const res = await qbo.GET('/companyinfo/{id}', {
Expand All @@ -19,3 +21,43 @@ maybeTest('get QBO company', async () => {
expect(res.response.status).toEqual(200)
expect(res.data.CompanyInfo.CompanyName).toEqual('Sandbox Company_US_1')
})

const resourceId = process.env['QBO_RESOURCE_ID']!
const apiKey = process.env['OPENINT_API_KEY']!
maybeTest(
'get QBO company via proxy with api key and resource id',
async () => {
const qbo = initSDK(qboSdkDef, {
realmId,
envName: 'sandbox',
auth: {openInt: {apiKey, resourceId}},
})

const res = await qbo.GET('/companyinfo/{id}', {
params: {path: {id: realmId}},
})

expect(res.response.status).toEqual(200)
expect(res.data.CompanyInfo.CompanyName).toEqual('Sandbox Company_US_1')
},
)

const connectorName = 'qbo'
const token = process.env['OPENINT_CUSTOMER_AUTH_TOKEN']!
maybeTest(
'get QBO company via proxy with token and connector name',
async () => {
const qbo = initSDK(qboSdkDef, {
realmId,
envName: 'sandbox',
auth: {openInt: {token, connectorName}},
})

const res = await qbo.GET('/companyinfo/{id}', {
params: {path: {id: realmId}},
})

expect(res.response.status).toEqual(200)
expect(res.data.CompanyInfo.CompanyName).toEqual('Sandbox Company_US_1')
},
)
4 changes: 1 addition & 3 deletions sdks/sdk-qbo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export type QBOSDKTypes = SDKTypes<
qboOasTypes,
ClientOptions & {
realmId: string
accessToken: string
envName: 'sandbox' | 'production'
}
>
Expand All @@ -21,15 +20,14 @@ export type QBOSDKTypes = SDKTypes<
export const qboSdkDef = {
types: {} as QBOSDKTypes,
oasMeta: qboOasMeta,
createClient: (ctx, {realmId, accessToken, envName, ...options}) => {
createClient: (ctx, {realmId, envName, ...options}) => {
const client = ctx.createClient({
...options,
baseUrl: qboOasMeta.servers
.find((s) => s.description === envName)
?.url?.replace('{realmId}', realmId),
// TODO: Should probably extract this server name substitution into a generic function for interacting with oas
headers: {
authorization: `Bearer ${accessToken}`,
accept: 'application/json',
...options.headers,
},
Expand Down
Loading