Files
speckle-server/packages/server/modules/acc/clients/autodesk.ts
T
2025-08-05 21:48:03 +01:00

311 lines
8.1 KiB
TypeScript

/* eslint-disable camelcase */
import type { AccTokens } from '@speckle/shared/acc'
import type { AccRegion } from '@/modules/acc/domain/constants'
import { AccRegions } from '@/modules/acc/domain/constants'
import type { ModelDerivativeServiceDesignManifest } from '@/modules/acc/domain/types'
import {
getAutodeskIntegrationClientId,
getAutodeskIntegrationClientSecret
} from '@/modules/shared/helpers/envHelper'
import { logger } from '@/observability/logging'
import { isObjectLike } from 'lodash-es'
import { z } from 'zod'
import crypto from 'crypto'
const invokeJsonRequest = async <T>(params: {
url: string
token: string
method?: RequestInit['method']
body?: URLSearchParams | Record<string, unknown>
headers?: Record<string, string>
}) => {
const { url, method = 'get', body, headers = {}, token } = params
const response = await fetch(url, {
method,
headers: {
'Content-Type':
body instanceof URLSearchParams
? 'application/x-www-form-urlencoded'
: 'application/json',
Authorization: `Bearer ${token}`,
...headers
},
body:
body && body instanceof URLSearchParams
? body
: isObjectLike(body)
? JSON.stringify(body)
: undefined
})
return (await response.json()) as T
}
interface BuildAuthorizeUrlOptions {
clientId: string
redirectUri: string
codeChallenge: string
scopes: string[]
}
interface ExchangeCodeOptions {
code: string
codeVerifier: string
clientId: string
clientSecret: string
redirectUri: string
}
const AccTokens = z.object({
access_token: z.string(),
refresh_token: z.string(),
token_type: z.string(),
id_token: z.string(),
expires_in: z.number()
})
export const generateCodeVerifier = () => {
const codeVerifier = crypto.randomBytes(32).toString('base64url')
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url')
return { codeVerifier, codeChallenge }
}
export const buildAuthorizeUrl = ({
clientId,
redirectUri,
codeChallenge,
scopes
}: BuildAuthorizeUrlOptions) => {
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
return `https://developer.api.autodesk.com/authentication/v2/authorize?${params.toString()}`
}
export const exchangeCodeForTokens = async ({
code,
codeVerifier,
clientId,
clientSecret,
redirectUri
}: ExchangeCodeOptions): Promise<AccTokens> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
code,
code_verifier: codeVerifier
})
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
body: params,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
)
const data = await response.json()
return AccTokens.parse(data)
}
export const exchangeRefreshTokenForTokens = async (args: {
refresh_token: string
}): Promise<AccTokens> => {
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: getAutodeskIntegrationClientId(),
client_secret: getAutodeskIntegrationClientSecret(),
refresh_token: args.refresh_token
})
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
}
)
const data = await response.json()
return AccTokens.parse(data)
}
type AutodeskIntegrationTokenData = {
access_token: string
token_type: string
expires_in: number
}
/**
* Fetch a valid token for server-side operations as our custom integration
*/
export const getToken = async (): Promise<AutodeskIntegrationTokenData> => {
const clientId = getAutodeskIntegrationClientId()
const clientSecret = getAutodeskIntegrationClientSecret()
const token = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'data:read account:read viewables:read'
}),
headers: {
Authorization: `Basic ${token}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
}
)
const data = await response.json()
return z
.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number()
})
.parse(data)
}
/**
* Get base Autodesk API endpoint for a given region.
* NOTE: This has been true for all endpoints used so far but should be validated for any new ones!
*/
const getRegionUrl = (region: AccRegion): string => {
switch (region) {
case AccRegions.EMEA:
return 'https://developer.api.autodesk.com/modelderivative/v2/regions/eu'
default:
return 'https://developer.api.autodesk.com/modelderivative/v2'
}
}
const getApiUrl = (path: string, region: AccRegion): string => {
return `${getRegionUrl(region)}${path}`
}
/**
* Encode a urn string in the modified url-safe base64 format expected by the Autodesk API
*/
const encodeUrn = (urn: string): string => {
return btoa(urn).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}
type AccWebhookConfig = {
// The (unencoded) ACC folder urn to subscribe to. Event will fire for all files in this folder.
rootProjectFolderUrn: string
// The Speckle endpoint to hit when the given event fires.
callbackUrl: string
// The ACC webhook event to subscribe to. You can register an event to a given callback url only once.
event: 'dm.version.added'
// The region where the request is executed
region: AccRegion
}
/**
* Register relevant webhook callbacks for integration with a given ACC project.
* @see https://aps.autodesk.com/en/docs/webhooks/v1/reference/http/webhooks/systems-system-events-event-hooks-POST/
* @returns null if webhook already exists
*/
export const tryRegisterAccWebhook = async (
webhook: AccWebhookConfig
): Promise<string | null> => {
const { rootProjectFolderUrn, callbackUrl, event, region } = webhook
const tokenData = await getToken()
const response = await fetch(
`https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'Content-Type': 'application/json',
'x-ads-region': region
},
body: JSON.stringify({
callbackUrl,
scope: {
folder: rootProjectFolderUrn
}
})
}
)
if (response.ok && response.status === 201) {
const webhookId = response.headers.get('Location')?.split('/').at(-1)
if (!webhookId) {
logger.info({ location: response.headers.get('Location') })
throw new Error('Webhook created but failed to parse id')
}
return webhookId
}
const e = await response.json().catch(() => null)
const isConflict =
response.status === 409 &&
e?.code === 'CONFLICT_ERROR' &&
e?.detail?.includes('Failed to save duplicate webhooks scope')
if (isConflict) {
logger.warn('Webhook already exists. Skipping registration.')
return null
}
throw new Error(`Webhook registration failed: ${JSON.stringify(e, null, 2)}`)
}
/**
* Get a manifest of the available derivative assets for a given design urn
* @see https://aps.autodesk.com/en/docs/model-derivative/v2/reference/http/manifest/urn-manifest-GET/
*/
export const getManifestByUrn = async (
params: {
urn: string
region: AccRegion
},
context: {
token: string
}
): Promise<ModelDerivativeServiceDesignManifest> => {
const { urn, region = AccRegions.EMEA } = params
const { token } = context
const encodedUrn = encodeUrn(urn)
const url = getApiUrl(`/designdata/${encodedUrn}/manifest`, region)
return await invokeJsonRequest({
url,
token,
method: 'GET'
})
}