CI HUBCI HUB SDK
ExamplesBynder

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 default
  • tasking: 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 YET
  • maxMultipleAssetVersions — controls how many asset versions can be fetched at once
  • supportsAddAssetInSearch — the "Upload" button appears in search results, not just in folders

See Capabilities Reference →.


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:

  • downloadUrl points to the adapter's own download endpoint (not a direct CDN URL), which then resolves the S3 location and redirects
  • lowresUrl provides a separate low-resolution download path
  • name is sanitized for filesystem-invalid characters and has the extension appended if missing
  • values are computed from property_* metaproperties on the item and a set of stateProps

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 filters
  • property:<propName>:<optionId> — metaproperty filters
  • orderBy:<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 valueWhat it represents
rootTop level — shows "Assets" and "Collections"
assetsAll assets (flat listing)
collectionsCollection categories: My Collections, Received, Published, By User
collections:ownUser's own collections
collections:receivedCollections shared with user
collections:publicPublished collections
collections:by_userCollections 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 →.

On this page