Skip to content

Commit

Permalink
feat: Implement passthrough endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
tonyxiao committed Nov 13, 2023
1 parent 9982704 commit bb20f92
Show file tree
Hide file tree
Showing 10 changed files with 180 additions and 22 deletions.
33 changes: 30 additions & 3 deletions apps/web/pages/api/trpc/[...trpc].ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import '@usevenice/app-config/register.node'

import * as trpcNext from '@trpc/server/adapters/next'
import type {TRPCError} from '@trpc/server'
import type {NextApiHandler} from 'next'

import {contextFactory} from '@usevenice/app-config/backendConfig'
import type {Id} from '@usevenice/cdk'
import type {RouterContext} from '@usevenice/engine-backend'
import {parseWebhookRequest} from '@usevenice/engine-backend'
import {fromMaybeArray} from '@usevenice/util'
import {fromMaybeArray, HTTPError} from '@usevenice/util'

import {appRouter} from '@/lib-server/appRouter'
import {respondToCORS, serverGetViewer} from '@/lib-server/server-helpers'

/** https://trpc.io/docs/server/error-handling */
const HTTP_CODE_TO_TRPC_CODE = {
400: 'BAD_REQUEST',
401: 'UNAUTHORIZED',
403: 'FORBIDDEN',
404: 'NOT_FOUND',
408: 'TIMEOUT',
409: 'CONFLICT',
412: 'PRECONDITION_FAILED',
413: 'PAYLOAD_TOO_LARGE',
405: 'METHOD_NOT_SUPPORTED',
422: 'UNPROCESSABLE_CONTENT',
429: 'TOO_MANY_REQUESTS',
499: 'CLIENT_CLOSED_REQUEST',
500: 'INTERNAL_SERVER_ERROR',
} satisfies Record<number, TRPCError['code']>

export const createContext: Parameters<
typeof trpcNext.createNextApiHandler
>[0]['createContext'] = async ({req, res}): Promise<RouterContext> => {
Expand All @@ -26,8 +44,17 @@ export const createContext: Parameters<

export const onError: Parameters<
typeof trpcNext.createNextApiHandler
>[0]['onError'] = ({error}) => {
console.warn('error', error)
>[0]['onError'] = ({error, path}) => {
// Force passthrough the HTTP error code.
if (path === 'passthrough' && error.cause instanceof HTTPError) {
const newCode =
HTTP_CODE_TO_TRPC_CODE[
error.cause.code as keyof typeof HTTP_CODE_TO_TRPC_CODE
]
Object.assign(error, {code: newCode ?? error.code})
} else {
console.warn('error', error)
}
}

const handler = trpcNext.createNextApiHandler({
Expand Down
62 changes: 59 additions & 3 deletions integrations/integration-plaid/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,24 @@ import {CountryCode, Products} from 'plaid'

import type {IntegrationServer} from '@usevenice/cdk'
import {shouldSync} from '@usevenice/cdk'
import type {DurationObjectUnits, IAxiosError} from '@usevenice/util'
import {DateTime, R, RateLimit, Rx, rxjs} from '@usevenice/util'
import type {
DurationObjectUnits,
IAxiosError,
InfoFromPaths} from '@usevenice/util';
import {
DateTime,
makeOpenApiClient,
R,
RateLimit,
Rx,
rxjs,
safeJSONParse,
} from '@usevenice/util'

import type {plaidSchemas} from './def'
import {helpers as def} from './def'
import {inferPlaidEnvFromToken} from './plaid-utils'
import type {paths} from './plaid.gen'
import {makePlaidClient, zWebhook} from './PlaidClient'

export const plaidServerIntegration = {
Expand Down Expand Up @@ -463,7 +476,50 @@ export const plaidServerIntegration = {
console.warn('[plaid] Unhandled webhook', webhook)
return {resourceUpdates: []}
},
} satisfies IntegrationServer<typeof plaidSchemas>
newInstance: ({config, settings}) => {
const env = inferPlaidEnvFromToken(settings.accessToken)
// https://plaid.com/docs/api/#api-host
return makeOpenApiClient<InfoFromPaths<paths>>({
baseUrl: `https://${env}.plaid.com`,
headers: {
'PLAID-CLIENT-ID': config.clientId,
'PLAID-SECRET': config.clientSecret,
},
middleware: (url, init) => {
if (init?.method?.toLowerCase() === 'post') {
const body =
typeof init?.body === 'string' ? safeJSONParse(init.body) : {}
if (typeof body === 'object') {
return [
url,
{
...init,
body: JSON.stringify({
...body,
access_token: settings.accessToken,
}),
headers: {
...init.headers,
'Content-Type': 'application/json',
},
},
]
}
}
return [url, init]
},
})
},
passthrough: (instance, input) =>
instance._request(input.method, input.path, {
header: input.headers,
query: input.query,
bodyJson: input.body,
}),
} satisfies IntegrationServer<
typeof plaidSchemas,
ReturnType<typeof makeOpenApiClient<InfoFromPaths<paths>>>
>

// Per client limit.
// TODO: Account for different rate limits for sandbox vs development & prduction
Expand Down
6 changes: 6 additions & 0 deletions packages/cdk/integration.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
OpenDialogFn,
ResourceUpdate,
WebhookReturnType,
zPassthroughInput,
} from './providers.types'
import type {AccountingMethods, ZAccounting} from './verticals/accounting'
import type {InvestmentMethods, ZInvestment} from './verticals/investment'
Expand Down Expand Up @@ -264,6 +265,11 @@ export interface IntegrationServer<
) => MaybePromise<void>
}) => TInstance

passthrough?: (
instance: TInstance,
input: z.infer<typeof zPassthroughInput>,
) => unknown

verticals?: {
[v in keyof T['_verticals']]: v extends keyof Verticals
? Verticals<TDef, TInstance>[v]['methods']
Expand Down
8 changes: 8 additions & 0 deletions packages/cdk/providers.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,11 @@ export interface WebhookReturnType<
body: Record<string, unknown>
}
}

export const zPassthroughInput = z.object({
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']),
path: z.string(),
query: z.record(z.unknown()).optional(),
headers: z.record(z.unknown()).optional(),
body: z.record(z.unknown()).optional(),
})
22 changes: 21 additions & 1 deletion packages/engine-backend/router/_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {initTRPC, TRPCError} from '@trpc/server'
import type {OpenApiMeta} from 'trpc-openapi'

import {getExtEndUserId, hasRole} from '@usevenice/cdk'
import {HTTPError} from '@usevenice/util'

import type {RouterContext} from '../context'

Expand All @@ -23,7 +24,26 @@ export const trpc = initTRPC
.context<RouterContext>()
.meta<RouterMeta>()
// For client side to be able to import runtime schema from server side also
.create({allowOutsideOfServer: true})
.create({
allowOutsideOfServer: true,
errorFormatter(opts) {
const {shape, error} = opts
if (!(error.cause instanceof HTTPError)) {
return shape
}
return {
...shape,
data: error.cause.response
? {
...error.cause.response,
// Renaming body to be nicer. otherwise we end up with data.data
data: undefined,
body: error.cause.response.data,
}
: shape.data,
}
},
})

export const publicProcedure = trpc.procedure

Expand Down
28 changes: 26 additions & 2 deletions packages/engine-backend/router/resourceRouter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
import {zId} from '@usevenice/cdk'
import {TRPCError} from '@trpc/server'

import {zId, zPassthroughInput} from '@usevenice/cdk'
import {Rx, rxjs, z} from '@usevenice/util'

import {protectedProcedure, trpc} from './_base'
import {protectedProcedure, remoteProcedure, trpc} from './_base'

export {type inferProcedureInput} from '@trpc/server'

export const resourceRouter = trpc.router({
// TODO: maybe we should allow resourceId to be part of the path rather than only in the headers
passthrough: remoteProcedure
.meta({openapi: {method: 'POST', path: '/passthrough'}})
.input(zPassthroughInput)
.output(z.any())
.mutation(async ({input, ctx}) => {
if (!ctx.remote.provider.passthrough) {
throw new TRPCError({
code: 'NOT_IMPLEMENTED',
message: `${ctx.remote.providerName} does not implement passthrough`,
})
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const instance = ctx.remote.provider.newInstance?.({
config: ctx.remote.config,
settings: ctx.remote.settings,
onSettingsChange: () => {}, // not implemented
})
return await ctx.remote.provider.passthrough(instance, input)
}),

sourceSync: protectedProcedure
.meta({openapi: {method: 'POST', path: '/resources/{id}/source_sync'}})
.input(z.object({id: zId('reso'), state: z.record(z.unknown()).optional()}))
Expand Down
20 changes: 12 additions & 8 deletions packages/util/http/makeHttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type HttpClientOptions = RequestInit & {
bearerToken?: string
basic?: {username: string; password: string}
}

middleware?: (...args: Parameters<typeof fetch>) => Parameters<typeof fetch>
fetch?: typeof fetch
URL?: typeof URL
}
Expand All @@ -58,6 +58,7 @@ export function makeHttpClient(options: HttpClientOptions) {
URL = globalThis.URL,
baseUrl,
auth,
middleware = (url, init) => [url, init],
...defaults
} = options

Expand Down Expand Up @@ -106,12 +107,15 @@ export function makeHttpClient(options: HttpClientOptions) {
// NOTE: Implement proxyAgent as a middleware
// This way we can transparently use reverse proxies also in addition to forward proxy
// as well as just simple in-app logging.
return _fetch(url, {
...defaults,
method,
headers,
body,
}).then(async (res) => {

return _fetch(
...middleware(url, {
...defaults,
method,
headers,
body,
}),
).then(async (res) => {
const text = await res.text()
const json = safeJSONParse(text)
if (res.status < 200 || res.status >= 300) {
Expand All @@ -136,7 +140,7 @@ export function makeHttpClient(options: HttpClientOptions) {
request(method.toUpperCase() as Uppercase<typeof method>, path, input),
])

return {...methods, request, _fetch}
return {...methods, request, _request: request, _fetch}
}

// TODO: build url from utils?
Expand Down
1 change: 1 addition & 0 deletions packages/util/http/makeOpenApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function makeOpenApiClient<Info extends OpenApiInfo>(
input: Get<Info[M][Path], 'input'>,
) => Promise<Get<Info[M][Path], 'output'>>
_fetch: NonNullable<HttpClientOptions['fetch']>
_request: ReturnType<typeof makeHttpClient>['_request']
}
}

Expand Down
12 changes: 12 additions & 0 deletions patches/[email protected]
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
diff --git a/dist/adapters/node-http/core.js b/dist/adapters/node-http/core.js
index ad1b3e5d8fc9d5a9dcdcb5c719f2a3a66fc5f29a..d55f2fcff198112d7faca321d886f60eb1514fcc 100644
--- a/dist/adapters/node-http/core.js
+++ b/dist/adapters/node-http/core.js
@@ -122,6 +122,7 @@ const createOpenApiNodeHttpHandler = (opts) => {
const statusCode = (_h = (_g = meta === null || meta === void 0 ? void 0 : meta.status) !== null && _g !== void 0 ? _g : errors_1.TRPC_ERROR_CODE_HTTP_STATUS[error.code]) !== null && _h !== void 0 ? _h : 500;
const headers = (_j = meta === null || meta === void 0 ? void 0 : meta.headers) !== null && _j !== void 0 ? _j : {};
const body = {
+ ...errorShape, // Pass the formatting through...
message: isInputValidationError
? 'Input validation failed'
: (_l = (_k = errorShape === null || errorShape === void 0 ? void 0 : errorShape.message) !== null && _k !== void 0 ? _k : error.message) !== null && _l !== void 0 ? _l : 'An error occurred',
diff --git a/dist/types.d.ts b/dist/types.d.ts
index d93e38e020d89bef3e615c44fe687b10c8874417..956b349a0dda132bcdd7c255e8ae9946f41ccffd 100644
--- a/dist/types.d.ts
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 comment on commit bb20f92

@vercel
Copy link

@vercel vercel bot commented on bb20f92 Nov 14, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

venice – ./

usevenice.vercel.app
venice-venice.vercel.app
app.venice.is
venice-git-production-venice.vercel.app

Please sign in to comment.