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
51 changes: 51 additions & 0 deletions packages/fetch-links/links/authLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type {UnionToIntersection} from 'type-fest'
import {openIntProxyLink, OpenIntProxyLinkOptions} from './openIntProxyLink.js'
import {mergeHeaders, modifyRequest} from '../modifyRequestResponse.js'
import type {Link} from '../link.js'

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}

type Indexify<T> = T & Record<string, undefined>
type AllUnionKeys<T> = keyof UnionToIntersection<{[K in keyof T]: undefined}>
type NonDiscriminatedUnion<T> = {
[K in AllUnionKeys<T> & string]: Indexify<T>[K]
}

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

if (auth.openInt) {
return openIntProxyLink(auth.openInt, baseUrl)
}

const headers = {
['authorization']: auth.oauth?.accessToken
? `Bearer ${auth.oauth.accessToken}`
: auth?.bearer
? `Bearer ${auth.bearer}`
: auth?.basic
? `Basic ${btoa(`${auth.basic?.username}:${auth.basic?.password}`)}`
: '',
} 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
}
}
3 changes: 2 additions & 1 deletion packages/fetch-links/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"dependencies": {},
"devDependencies": {
"axios": "^1.6.2",
"concurrently": "^8.2.2"
"concurrently": "^8.2.2",
"type-fest": "^4.28.0"
},
"publishConfig": {
"access": "public"
Expand Down
29 changes: 28 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,26 @@ 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 1 elements by default', () => {
const client = createClient()
expect(client.links.length).toBe(1)
})

test('expect links array to have 2 elements when auth is present', () => {
const client = createClient({
auth: {basic: {username: 'user', password: 'pass'}},
})
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({
auth: {basic: {username: 'user', password: 'pass'}},
links: (defaultLinks) => [...defaultLinks, fetchLink()],
})
expect(client.links.length).toBe(3)
})
18 changes: 13 additions & 5 deletions packages/runtime/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import {
fetchLink,
type HTTPMethod,
type Link,
authLink, ClientAuthOptions
} from '@opensdks/fetch-links'
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 interface ClientOptions extends _ClientOptions {
links?: Link[] | ((defaultLinks: Link[]) => Link[])
auth?: ClientAuthOptions
}

export type OpenAPIClient<Paths extends {}> = ReturnType<
Expand All @@ -25,13 +28,18 @@ 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 = [
...(clientOptions.auth
? [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
Loading
Loading