Bynder Example
Full walkthrough of a production adapter using a server-side adapter pattern (pre-SDK). Bynder connects to a DAM platform with a REST API, multi-tenant OAuth 2.0 (no PKCE), collections-based folder model, chunked upload with directAccessHelper, and workflow tasks.
Config
{
"serverBaseUrl": "http://localhost:8080",
"Bynder": {
"clientKey": "your-client-id",
"clientSecret": "your-client-secret",
"defaultServerUrl": "https://your-platform.example.com",
"retrieveRevisions": true,
"getAssetMetadata": true,
"maxQuerySize": 250,
"maxMultipleAssetVersions": 5,
"withTasking": true,
"contact": {
"text": "If you experience any problems, please contact support.",
"url": { "link": "https://your-platform.example.com/help/", "email": "support@your-platform.example.com" }
}
}
}Config keys are validated at startup:
const configKeys = [
'Bynder.clientKey',
'Bynder.clientSecret',
'Bynder.defaultServerUrl',
'Bynder.retrieveRevisions',
'Bynder.getAssetMetadata',
'Bynder.maxQuerySize',
'Bynder.maxMultipleAssetVersions',
'Bynder.contact',
'Bynder.withTasking',
]
const missingKeys = configKeys.filter(key => !config.has(key)).join(' and ')
if (missingKeys) {
throw new Error(`Missing configuration: ${missingKeys}`)
}Capabilities
export default {
name: 'Bynder',
version: '0.0.0',
logo,
glyph,
contact,
capabilities: {
category: 'DAM',
assetHashAlgorithm: 'FileAttributes',
assetUploadLimitInMB: 4 * 1024,
directAssetDownload: true,
preferredNavigationMode: 'search',
tasking: config.get('Bynder.withTasking'),
maxMultipleAssetVersions: config.get('Bynder.maxMultipleAssetVersions'),
directAccessHelper: `(function () { /* ... */ })()`,
assetSearch: {
help: {
en: `Search Operators: AND operator: E.g. boston AND hotel ...`
},
autoSearchQuery: ''
},
serverUrl: { defaultUrls: [config.get('Bynder.defaultServerUrl')] },
description: {
de: 'Bynder hilft Marketern, eine konsistente Markenkommunikation zu erreichen.',
en: 'Bynder helps marketers to achieve consistent brand communication.'
},
supportsAddAssetInSearch: true
},
// ... handlers
}Key decisions:
directAssetDownload: true— download URLs are resolved server-side, then redirected to S3 with$NO_AUTH$preferredNavigationMode: 'search'— the plugin opens in search mode by defaulttasking: true— enables task handlers (searchTasks,getTask,getTaskAssets)directAccessHelper— embeds a client-side upload function for chunked uploads directly from the browser, NOT COVERED BY THE SDK YETmaxMultipleAssetVersions— controls how many asset versions can be fetched at oncesupportsAddAssetInSearch— the "Upload" button appears in search results, not just in folders
Authentication — OAuth 2.0 with portal URL (no PKCE)
The platform is multi-tenant — each customer has their own portal URL. The adapter collects the portal URL, validates it, then redirects to OAuth. Unlike Frontify, this adapter uses a client secret and does not use PKCE.
OAuth client setup
const SCOPES = config.get('Bynder.withTasking') ?
'offline current.user:read admin.user:read admin.profile:read asset:read asset:write collection:read collection:write meta.assetbank:read workflow.job:read workflow.preset:read' :
'offline current.user:read admin.user:read asset:read asset:write collection:read collection:write meta.assetbank:read'
const getAuthClient = portalUrl => {
const oauthBaseUrl = `${portalUrl}/v6/authentication/`
return new AuthorizationCode({
client: {
id: config.get('Bynder.clientKey'),
secret: config.get('Bynder.clientSecret')
},
auth: {
tokenHost: oauthBaseUrl,
tokenPath: 'oauth2/token',
revokePath: 'oauth2/revoke',
authorizeHost: oauthBaseUrl,
authorizePath: 'oauth2/auth'
}
})
}Login handler
Auth error / cancel — Handle immediately:
if (authError) {
return sendAccessToken(authError)
}
if (canceled) {
return sendAccessToken()
}No state — Generate state and redirect:
if (!state) {
const urlObject = new URL(locals.endpointUrl)
urlObject.searchParams.set('state', await getStateCodeAsync())
urlObject.searchParams.set('serverUrl', serverUrl)
return sendRedirectUri(urlObject.toString())
}No server URL and no code — Validate the portal URL, then render the server URL form. If the URL fails validation, show an error:
const serverUrlError = serverUrl && !await checkIsBynderPortal(serverUrl)
if (!serverUrl && !code || serverUrlError) {
const urlObject = new URL(locals.endpointUrl)
urlObject.searchParams.set('state', state)
return render(path.join(import.meta.dirname, 'select-serverurl'), {
title: 'Select Bynder Portal',
action: urlObject.toString(),
serverUrl: serverUrl || config.get('Bynder.defaultServerUrl'),
logo,
contact,
error: serverUrlError ? 'Invalid Url (System is not a valid Bynder system).' : undefined
})
}Portal validation calls a known public endpoint:
const checkIsBynderPortal = async serverUrl => {
const apiUrl = new URL('/feeds/media/is-bynder-portal/', serverUrl).toString()
try {
await axios.get(apiUrl)
return true
} catch (err) {
return false
}
}Server URL but no code — Store the server URL in state and redirect to OAuth:
if (!code) {
await setStateAdapterDataAsync(state, { serverUrl })
const authUrl = oauth.authorizeURL({
redirect_uri: `${locals.endpointUrl}`,
scope: SCOPES,
state
})
return sendRedirectUri(authUrl)
}Code received — Exchange for tokens and store resourceEndpoint in adapterData:
const { token } = await oauth.getToken({
code,
redirect_uri: locals.endpointUrl
})
return sendAccessToken(null, token.access_token, token.expires_in, token.refresh_token, undefined, { resourceEndpoint: serverUrl })No brand selection step — unlike Frontify, the token scopes the user to their workspace automatically.
See Login Flow → and Template Examples →.
select-serverurl.ect template
<% extend 'login-template.ect' %>
<p>Please enter the URL to your Bynder portal.</p>
<br>
<form action="<%- @action %>" method="post">
<div class="field">
<label class="label">Bynder portal URL</label>
<div class="control">
<input class="inputfield" type="url" name="serverUrl" value="<%= @serverUrl %>" required>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-link" type="submit">Login</button>
</div>
</div>
</form>checkToken
checkToken: async (locals) => {
try {
const data = await callApi(locals, 'GET', '/api/v4/currentUser/', {})
return send({ adapter: 'Bynder', source: remoteSystemPrefix(locals), user: data.username })
} catch (err) {
return sendError(err, 400)
}
}refreshToken
refreshToken: async (locals) => {
const adapterData = locals.adapterData
if (!adapterData.resourceEndpoint) {
return sendError('Could not refresh token, please re-add the connection.', 400)
}
try {
const oauth = getAuthClient(adapterData.resourceEndpoint)
const accessToken = oauth.createToken({ refresh_token: locals.authPayload })
const { token } = await accessToken.refresh()
if (!token || !token.access_token) {
throw new Error('No access_token found')
}
return sendAccessToken(null, token.access_token, token.expires_in, undefined, undefined, adapterData)
} catch (err) {
return sendError(err, 400)
}
}logout
logout: async (locals, requestData) => {
try {
// Token revocation not implemented by the platform's API endpoint
return sendStatus(200)
} catch (err) {
return sendError(err, 400)
}
}REST API (callApi)
All data is fetched via REST endpoints. The callApi helper builds the URL from the resource endpoint stored at login:
const callApi = async (locals, method, relUrl, params, data) => {
const resourceEndpoint = locals.adapterData.resourceEndpoint
const accessToken = locals.authPayload
const apiUrl = new URL(relUrl, resourceEndpoint).toString()
try {
const res = await axios({
url: apiUrl,
method: method.toLowerCase(),
headers: { Authorization: `Bearer ${accessToken}` },
params: params || {},
data: data ? new URLSearchParams(data) : null
})
return res.data
} catch (err) {
const msg = `${method} ${apiUrl} failed: ${err}`
throw new Error(msg)
}
}Key differences from the GraphQL pattern in Frontify:
- Uses standard HTTP methods (
GET,POST,DELETE) with URL-encoded form data - Passes query parameters via
params - POST data is sent as
URLSearchParams, not JSON
extractAssetData
Maps a single REST API media item to the CI HUB asset schema:
const extractAssetData = (locals, item) => {
let name = (item.name || '').replace(/[/\\:*?"<>|]/g, '_')
const currentExtension = (path.extname(name) || '').toLowerCase().replace('.', '')
if (item.extension && currentExtension !== `${item.extension}`.toLowerCase()) {
name += `.${item.extension}`
}
const downloadUrl = getDownloadUrl(locals, item.id)
const thumbnailUrl = locals.getThumbnailUrl(
downloadUrl, name, ['indd', 'idms'],
item.thumbnails.thul && `${item.thumbnails.thul}$NO_AUTH$`
)
const fileSize = item.fileSize
const modified = new Date(item.dateModified).getTime()
return {
id: item.id,
name,
parentPath: '/Assets',
fileSize,
xSizePx: item.width,
ySizePx: item.height,
created: new Date(item.dateCreated).getTime(),
modified,
mimeType: mime.lookup(name) || 'application/octet-stream',
version: item.version || 1,
caption: item.description && item.description.trim() || undefined,
copyright: item.copyright && item.copyright.trim() || undefined,
title: item.name,
thumbnailUrl,
thumbnailVideoUrl: item.videoPreviewURLs?.[0],
downloadUrl,
downloadHashFileAttributes: JSON.stringify({ fileSize, modified }),
lowresUrl: getDownloadUrl(locals, item.id, '?lowres=1'),
assetDetailsExternalUrl: getAssetDetailsExternalUrl(locals, item.id),
conversions: getConversions(item),
keywords: item.tags,
states: stateProps.filter(entry => item[entry.id]).map(entry => entry.name),
capabilities: defaultAssetCapabilities,
values: getValues(item)
}
}Key differences from Frontify:
downloadUrlpoints to the adapter's own download endpoint (not a direct CDN URL), which then resolves the S3 location and redirectslowresUrlprovides a separate low-resolution download pathnameis sanitized for filesystem-invalid characters and has the extension appended if missingvaluesare computed fromproperty_*metaproperties on the item and a set ofstateProps
values and stateProps
const stateProps = [
{ id: 'watermarked', name: 'Watermarked' },
{ id: 'limited', name: 'Limited' },
{ id: 'archive', name: 'Archived' },
{ id: 'isPublic', name: 'Public' }
]
const getValues = item => stateProps
.filter(prop => item[prop.id])
.map(prop => ({ id: prop.id, name: prop.name, type: 'TEXT', value: '✓' }))
.concat(
Object.entries(item)
.filter(([key, value]) => key.startsWith('property_') && value)
.map(([key, value]) => ({
id: key,
name: `${key[9].toUpperCase()}${key.substring(10)}`,
type: 'TEXT',
value: Array.isArray(value) ? value.join(', ') : value
}))
)Search with facets
The adapter's search is the primary navigation mode. It uses the platform's /api/v4/media/ endpoint with faceted filtering.
getAssets (core search function)
const getAssets = async (locals, requestData, collection, search, page, size) => {
const activeFilters = requestData.query.filters || []
const filterTags = activeFilters.filter(filterId => filterId.startsWith('tag:')).map(filterId => filterId.substring(4))
const filterPropertyNames = activeFilters
.filter(filterId => filterId.startsWith('property:'))
.reduce((acc, filterId) => {
const [, propName, optionId] = filterId.split(':')
const id = `property_${propName}`
if (!acc[id]) { acc[id] = optionId } else { acc[id] += `,${optionId}` }
return acc
}, {})
const orderBy = activeFilters.filter(filterId => filterId.startsWith('orderBy:')).map(filterId => filterId.substring(8)).pop()
const searchOptions = search ? getSearchOptions(locals, search) : null
const query = Object.assign({
limit: size,
page: page + 1,
count: true,
total: true,
orderBy: orderBy || 'dateCreated desc',
includeVersionNumber: true
},
collection ? { collectionId: collection } : {},
searchOptions ? { keyword: searchOptions.keyword } : {},
filterTags.length ? { tags: filterTags.join() } : {},
filterPropertyNames
)
const result = { folders: [], assets: [], filters: [], capabilities: {}, totalAssetsCount: 0 }
const assets = await callApi(locals, 'GET', '/api/v4/media/', query)
result.totalAssetsCount = assets.count.total
// Order by filter
if (orderBy || assets.count.total > 1) {
result.filters.push({
id: 'orderBy',
name: 'Order by',
options: ['dateCreated', 'datePublished', 'dateModified', 'name'].reduce((acc, field) =>
acc.concat(['desc', 'asc'].map(direction => {
const key = `${field} ${direction}`
return {
id: `orderBy:${key}`,
name: `${field} ${direction === 'asc' ? '▲' : '▼'}`,
isActive: orderBy === key
}
})), []
)
})
}
// Facet-based metaproperty filters
result.filters.push(...await getFacetsFilters(locals, assets.count, activeFilters))
// Tag filters from facets
if (Object.keys(assets.count.tags).length) {
result.filters.push({
id: 'tags',
name: 'Tags',
options: Object.entries(assets.count.tags).map(([key, value]) => ({
id: `tag:${key}`,
name: key,
count: value,
isActive: activeFilters.includes(`tag:${key}`)
}))
})
}
// Pagination
if (page * size + assets.media.length < result.totalAssetsCount) {
result.more = page + 1
}
for (const item of assets.media) {
const asset = extractAssetData(locals, item)
result.assets.push(asset)
}
return result
}Facet filters (getFacetsFilters)
Facets come back in the count object of the media response. The adapter resolves metaproperty names to labels by fetching /api/v4/metaproperties and then fetching options for each filterable property:
const getFacetsFilters = async (locals, facets = {}, activeFilters = []) => {
const { 'Bynder.maxFacetFilters': maxFacetFilters = 12 } = locals.getLiveValues()
const facetsFiltered = Object.fromEntries(
Object.entries(facets)
.filter(([key, value]) => key.startsWith('property_') && Object.keys(value).length)
.map(([key, counts]) => [key.substring(9), Object.fromEntries(Object.entries(counts).slice(0, 100))])
)
if (Object.keys(facetsFiltered).length) {
const props = Object.values(
await callApi(locals, 'GET', '/api/v4/metaproperties', { count: false, options: false }) || {}
)
.filter(prop => facetsFiltered[prop.name] && prop.isFilterable && prop.isMainfilter)
.sort((prop1, prop2) => prop1.zindex - prop2.zindex)
.slice(0, maxFacetFilters)
.map(prop => ({ id: prop.id, key: prop.name, name: prop.label, counts: facetsFiltered[prop.name], options: [] }))
if (props.length) {
await Promise.all(props.map(async prop => {
const options = await callApi(locals, 'GET', `/api/v4/metaproperties/${prop.id}/options`, { limit: 1000, page: 1 })
.catch(() => undefined) || []
options
.filter(option => Object.keys(prop.counts).includes(option.name))
.forEach(option => {
const id = `property:${prop.key}:${option.id}`
prop.options.push({
id,
name: option.label,
count: prop.counts[option.name],
isActive: activeFilters.includes(id),
zindex: option.zindex
})
})
prop.options.sort((op1, op2) => op1.zindex - op2.zindex)
}))
return props.filter(prop => prop.options.length)
}
}
return []
}Filter IDs use prefixed patterns:
tag:<tagName>— tag filtersproperty:<propName>:<optionId>— metaproperty filtersorderBy:<field> <direction>— sort options
Folder navigation (collections)
The platform does not have a hierarchical folder structure. The adapter maps collections to a virtual folder tree:
folderId value | What it represents |
|---|---|
root | Top level — shows "Assets" and "Collections" |
assets | All assets (flat listing) |
collections | Collection categories: My Collections, Received, Published, By User |
collections:own | User's own collections |
collections:received | Collections shared with user |
collections:public | Published collections |
collections:by_user | Collections grouped by owner |
collections:<userId> | Collections owned by a specific user |
collectown:<id> | A collection the user owns (can delete) |
collection:<id> | A collection the user doesn't own |
getFolder handler
getFolder: async (locals, requestData) => {
const folderId = requestData.query.folderId || 'root'
const page = Number.parseInt(requestData.query.more || 0, 10)
const size = Math.min(Number.parseInt(requestData.query.size || 50, 10), config.get('Bynder.maxQuerySize'))
let result = {}
try {
if (folderId === 'root') {
result = {
folders: [
{ id: 'assets', name: 'Assets', uploadUrl: '' },
{ id: 'collections', name: 'Collections', uploadUrl: '' }
],
assets: [],
more: undefined,
capabilities: defaultFolderCapabilities
}
} else if (folderId === 'collections') {
result = {
folders: [
{ id: 'collections:own', name: 'My Collections', uploadUrl: '' },
{ id: 'collections:received', name: 'Collections received', uploadUrl: '' },
{ id: 'collections:public', name: 'Published online', uploadUrl: '' },
{ id: 'collections:by_user', name: 'By User', uploadUrl: '' }
],
assets: [],
more: undefined,
capabilities: defaultFolderCapabilities
}
} else if (folderId.startsWith('collections:')) {
// List collections in the category
const user = await callApi(locals, 'GET', '/api/v4/currentUser/', {})
const collections = await getCollections(locals)
result = { folders: [], assets: [], more: undefined, capabilities: defaultFolderCapabilities }
for (const collection of collections) {
if ((folderId === 'collections:own' && collection.userId === user.id) ||
(folderId === 'collections:received' && collection.userId !== user.id) ||
(folderId === 'collections:public' && collection.IsPublic) ||
(folderId === `collections:${collection.userId}`)) {
result.folders.push({
id: folderId === 'collections:own'
? `collectown:${collection.id}`
: `collection:${collection.id}`,
name: `${collection.name} (${collection.collectionCount})`
})
}
}
// Only own collections can be created
if (folderId === 'collections:own') {
result.capabilities = { ...defaultFolderCapabilities, canAddFolder: true }
}
} else {
// List assets in collection or all assets
const collection = folderId.startsWith('collection:') || folderId.startsWith('collectown:')
? folderId.substring(11) : null
result = await getAssets(locals, requestData, collection, null, page, size)
result.capabilities.canDeleteFolder = folderId.startsWith('collectown:')
}
result.id = folderId
return send(result)
} catch (err) {
return sendError(err, 400)
}
}The collectown: prefix distinguishes user-owned collections (which can be deleted) from shared ones.
Upload with directAccessHelper
The adapter supports both server-side and client-side upload. The directAccessHelper capability embeds a client-side JavaScript function that runs in the browser for direct chunked upload.
Server-side upload flow
createAsset: async (locals, requestData) => {
const { name, dataBase64, dataBuffer, parentId } = requestData.body
const buffer = dataBuffer || Buffer.from(dataBase64.split(',')[1], 'base64')
try {
// Get upload endpoint
const endpoint = await callApi(locals, 'GET', '/api/upload/endpoint')
// Initialize upload
const init = await callApi(locals, 'POST', '/api/upload/init', {}, { filename: name })
// Upload file in chunks (5MB each)
const importId = await uploadFile(locals, name, buffer, endpoint, init)
// Save asset
const basename = path.basename(name, path.extname(name))
const asset = await callApi(locals, 'POST', '/api/v4/media/save/', {}, { importId, name: basename })
// Add to collection if parentId is a collection
if (parentId.startsWith('collection:') || parentId.startsWith('collectown:')) {
const collection = parentId.substring(11)
await callApi(locals, 'POST', `/api/v4/collections/${collection}/media/`, {}, {
data: JSON.stringify([asset.mediaid])
})
}
return sendResult(null, { id: asset.mediaid, name })
} catch (err) {
return sendError(`Could not create asset (${err})`, 400)
}
}Chunked upload internals
Files are uploaded in 5MB chunks to an S3-compatible endpoint:
const UPLOAD_CHUNK_SIZE = 1024 * 1024 * 5
const uploadFile = async (locals, name, buffer, endpoint, init) => {
const uploadPath = init.multipart_params.key
const numChunks = Math.ceil(buffer.length / UPLOAD_CHUNK_SIZE)
for (let i = 0; i < numChunks; i++) {
const start = i * UPLOAD_CHUNK_SIZE
const end = Math.min(start + UPLOAD_CHUNK_SIZE, buffer.length)
const chunkData = buffer.subarray(start, end)
await uploadChunk(endpoint, init, uploadPath, numChunks, i + 1, chunkData)
await registerChunk(locals, init, i + 1)
}
const { importId } = await registerUpload(locals, init, name, numChunks)
await awaitUpload(locals, [importId])
return importId
}Upload polling
After upload, the adapter polls until processing completes:
const UPLOAD_POLLING_INTERVAL = 2000
const UPLOAD_POLLING_ATTEMPTS = 60
const awaitUpload = async (locals, importIds) => {
for (let attempt = 0; attempt < UPLOAD_POLLING_ATTEMPTS; attempt++) {
const pollStatus = await callApi(locals, 'GET', '/api/v4/upload/poll/', { items: importIds.join(',') }, {})
if (pollStatus.itemsDone.length === importIds.length) {
return pollStatus.itemsDone
} else if (pollStatus.itemsFailed.length > 0) {
throw new Error(`Processing upload ${importIds} failed`)
}
await new Promise(resolve => setTimeout(resolve, UPLOAD_POLLING_INTERVAL))
}
}updateAsset
Same chunked flow, but saves to the existing asset's endpoint:
updateAsset: async (locals, requestData) => {
const id = requestData.params.assetId
const { name, dataBase64, dataBuffer } = requestData.body
const buffer = dataBuffer || Buffer.from(dataBase64.split(',')[1], 'base64')
const assetId = id.split('::')[0]
try {
const endpoint = await callApi(locals, 'GET', '/api/upload/endpoint', {})
const init = await callApi(locals, 'POST', '/api/upload/init', {}, { filename: name })
const importId = await uploadFile(locals, name, buffer, endpoint, init)
const asset = await callApi(locals, 'POST', `/api/v4/media/${assetId}/save/`, {}, { importId, name })
return sendResult(null, { id: asset.mediaid, name })
} catch (err) {
return sendError(`Could not update asset (${err})`, 400)
}
}directAccessHelper (client-side upload)
The directAccessHelper is a self-contained JavaScript function embedded in the capabilities object. It runs in the browser and performs the same chunked upload flow directly from the client, bypassing the server for file transfer. Key structure:
const createUpdateAsset = async (axios, _payload, _sessionId, data, onProgress, _adapterData, _accessToken, helpers) => {
const totalFileSize = data.file.size || data.file.length
let uploadedSize = 0
const { assetId } = data
const progress = event => onProgress && onProgress((event.loaded + uploadedSize) / totalFileSize)
const callApi = async (method, relUrl, params, body, progress) => {
const apiUrl = new URL(relUrl, helpers.getAdapterData().resourceEndpoint).toString()
const res = await axios({
url: apiUrl,
method: method.toLowerCase(),
headers: { Authorization: 'Bearer '.concat(helpers.getPayload()) },
onUploadProgress: progress,
params: params || {},
data: body ? new URLSearchParams(body) : null
})
return res.data
}
// ... uploadChunk, registerChunk, registerUpload, awaitUpload, saveAsset, updateAsset ...
const endpoint = await callApi('GET', '/api/upload/endpoint')
const init = await callApi('POST', '/api/upload/init', null, { filename: data.name })
const importId = await uploadFile(name, data.file, endpoint, init)
let asset = {}
if (assetId) {
asset = await updateAsset(assetId, importId, name)
} else {
asset = await saveAsset(importId, name)
}
return { sessionId: helpers.getSessionId(), data: { id: asset.mediaid, name: name || '' } }
}
return { createAsset: createUpdateAsset, updateAsset: createUpdateAsset }The helpers object provides getAdapterData(), getPayload(), and getSessionId() to access session context from the client.
See Upload → and Asset Operations →.
Download
The adapter resolves download URLs server-side. Each asset version has media items; the adapter picks the appropriate one:
download: async (locals, requestData) => {
const id = requestData.query.assetId
const parts = id.split('::')
const assetId = parts[0]
const version = parts.length === 2 ? parts[1] : -1
const lowres = requestData.query.lowres === '1'
const noRedirect = requestData.query.noRedirect === 'true'
try {
const downloadUrl = await getDownloadLocation(locals, assetId, version, lowres)
return noRedirect
? sendResult(null, { downloadUrl: downloadUrl && `${downloadUrl}$NO_AUTH$` })
: sendRedirectUri(downloadUrl)
} catch (err) {
return sendError(err, 404)
}
}getDownloadLocation fetches all media items for the asset, filters by version, and picks either the original or a web-sized derivative for low-res requests:
const getDownloadLocation = async (locals, assetId, ver, lowres) => {
const version = Number.parseInt(ver, 10)
const originals = []
const derivates = []
const { mediaItems } = await callApi(locals, 'GET', `/api/v4/media/${assetId}`, { versions: 1 })
for (const item of mediaItems) {
if ((version > -1 && item.version === version) || (version === -1 && item.active === 1)) {
if (item.type === 'original') originals.push(item)
if (item.type === 'web') derivates.push(item)
}
}
derivates.sort((a, b) => a.height - b.height)
const itemId = lowres && derivates.length > 0
? derivates[Math.max(derivates.length - 2, 0)].id
: originals[originals.length - 1].id
const loc = await callApi(locals, 'GET', `/api/v4/media/${assetId}/download/${itemId}`, {})
return loc.s3_file
}deleteAsset
deleteAsset: async (locals, requestData) => {
const id = requestData.params.assetId
const assetId = id.split('::')[0]
try {
await callApi(locals, 'DELETE', `/api/v4/media/${assetId}/`, {})
return sendResult(null)
} catch (err) {
return sendError(`Could not delete asset ${assetId} (${err})`, 403)
}
}getAssetVersions
Iterates mediaItems of type original, sorted by version descending:
const getAssetVersions = async (locals, requestData, assetId) => {
const withMaster = requestData.query.withMaster === 'true'
const asset = await callApi(locals, 'GET', `/api/v4/media/${assetId}`, { versions: true })
const assetInfo = extractAssetData(locals, asset)
const result = { id: assetId, name: '', versions: [] }
result.versions = asset.mediaItems
.filter(item => item.type === 'original')
.sort((a, b) => b.version - a.version)
.map((item, idx) => {
const versionId = `${assetId}::${item.version}`
const version = Object.assign({}, assetInfo, {
masterId: assetId,
id: versionId,
version: item.version,
fileSize: item.size,
xSizePx: item.width,
ySizePx: item.height,
thumbnailUrl: item.thumbnails.thul && `${item.thumbnails.thul}$NO_AUTH$`,
downloadUrl: getDownloadUrl(locals, versionId),
lowresUrl: getDownloadUrl(locals, versionId, '?lowres=1')
})
if (idx) {
version.created = new Date(item.dateCreated).getTime()
version.modified = new Date(item.dateCreated).getTime()
version.downloadHashFileAttributes = JSON.stringify({ fileSize: version.fileSize, modified: version.modified })
}
return version
})
if (!withMaster) {
result.versions.shift()
}
return result
}The assetId::version format is used throughout to reference specific versions. Handlers split on :: to extract the base asset ID.
createFolder / deleteFolder
Only collections can be created/deleted. Folder structures are not supported by the platform:
createFolder: async (locals, requestData) => {
const { parentId, name } = requestData.body
if (parentId === 'collections:own') {
try {
await callApi(locals, 'POST', '/api/v4/collections/', {}, { name, description: '' })
const collections = await getCollections(locals)
let id = ''
for (const collection of collections) {
if (collection.name === name) { id = collection.id }
}
return sendResult(null, { name, id: `collection:${id}`, uploadUrl: '' })
} catch (err) {
return sendError(err, 403)
}
} else {
return sendError('Folder structures are not supported by Bynder.', 403)
}
}deleteFolder: async (locals, requestData) => {
const folderId = requestData.params.folderId
if (folderId.startsWith('collection:') || folderId.startsWith('collectown:')) {
const collection = folderId.substring(11)
await callApi(locals, 'DELETE', `/api/v4/collections/${collection}/`)
return sendResult(null)
} else {
return sendError('Folder structures are not supported by Bynder.', 403)
}
}Tasks — searchTasks, getTask, getTaskAssets
Enabled by tasking: true in capabilities. Uses the platform's workflow API.
searchTasks
searchTasks: async (locals, requestData) => {
try {
const limit = Math.min(Number.parseInt(requestData.query.size || 100, 10), 50)
const [morePart, pageN] = (requestData.query.more || '0').split(':')
const more = parseInt(morePart, 10) || 0
const page = more === 0 ? 1 : pageN
const appliedFilters = requestData.query.filters || []
const groupedFilters = Array.isArray(appliedFilters) && appliedFilters.length > 0 && appliedFilters[0] !== ''
? appliedFilters.reduce((acc, filter) => {
const [key, value] = filter.split(':').map(s => s.trim())
if (!acc[key]) { acc[key] = [] }
acc[key].push(value)
return acc
}, {})
: {}
const jobs = await axios({
method: 'GET',
url: new URL('api/workflow/jobs', locals.adapterData.resourceEndpoint).toString(),
headers: { Authorization: `Bearer ${locals.authPayload}` },
params: { page, limit, ...groupedFilters },
paramsSerializer: params => Object.entries(params)
.map(([key, value]) => Array.isArray(value) ? `${key}=${value.join(',')}` : `${key}=${value}`)
.join('&')
})
const headers = jobs.headers
const result = await transformData(locals, jobs.data, appliedFilters)
result.more = headers['x-pagination-page'] < headers['x-pagination-totalpages']
? `${more + limit}:${parseInt(headers['x-pagination-page'], 10) + 1}`
: undefined
result.totalTasksCount = headers['x-pagination-totalrecords']
return send(result)
} catch (err) {
return sendError(err, 400)
}
}transformData (task mapping)
Transforms workflow jobs into CI HUB tasks and builds status/accountable filters:
const transformJob = (job, getUserFullName) => {
const { id, name, description, deadline, dateCreated, job_previous_stage, job_active_stage, job_next_stage, accountableID } = job
const values = [
job_previous_stage && { id: job_previous_stage.id, name: 'job_previous_stage', type: 'TEXT', value: job_previous_stage.status },
job_active_stage && { id: job_active_stage.id, name: 'job_active_stage', type: 'TEXT', value: job_active_stage.status },
job_next_stage && { id: job_next_stage.id, name: 'job_next_stage', type: 'TEXT', value: job_next_stage.status }
].filter(Boolean)
return {
id,
amountSubtasks: 0,
name,
description,
dueDate: deadline,
created: dateCreated,
assignee: getUserFullName(accountableID),
capabilities: { canAddComment: false, canAddAsset: false },
comments: [],
values,
subTasks: []
}
}
const transformData = async (locals, jobs, appliedFilters) => {
const users = await callApi(locals, 'GET', 'api/workflow/users/')
const getUserFullName = (id) => {
const user = users.find(user => user.ID === id)
return user ? user.fullName : ''
}
const tasks = jobs.map(job => transformJob(job, getUserFullName))
const filters = [
{
id: 'Status',
name: 'Status',
options: ['Approved', 'NeedsChanges', 'Active', 'Cancelled'].map(status => ({
id: `status:${status}`,
name: status,
isActive: appliedFilters.includes(`status:${status}`)
}))
},
{
id: 'accountable',
name: 'accountable',
options: users.map(user => ({
id: `accountable:${user.ID}`,
name: user.fullName,
isActive: appliedFilters.includes(`accountable:${user.ID}`)
}))
}
]
return { tasks, filters, more: new Date().toISOString(), totalTasksCount: tasks.length }
}getTask
getTask: async (locals, requestData) => {
try {
const taskId = requestData.params.taskId
const job = await callApi(locals, 'GET', `api/workflow/jobs/${taskId}`)
const task = await transformSingleJob(locals, job)
if (!task) {
return sendError('Error processing task', 400)
}
return send(task)
} catch (err) {
return sendError(err, 400)
}
}getTaskAssets
getTaskAssets: async (locals, requestData) => {
const taskId = requestData.params.taskId
const result = { assets: [], more: undefined, totalAssetsCount: 0 }
const jobAssets = await callApi(locals, 'GET', `api/workflow/jobs/${taskId}/media`)
const capabilities = {
canDeleteAsset: false,
canUpdateAsset: false,
canLockAsset: false,
canUnlockAsset: false
}
result.assets = transformTaskAssets(locals, jobAssets, capabilities)
return send(result)
}See Search Tasks →, Get Task →, and Get Task Assets →.