Custom CDN

By default, files are uploaded to Stream’s CDN, but you can also use your own CDN. Currently, there are two options to provide your custom file uploading logic.

  • Providing a CDNClient implementation. This is the simplest one and it is useful if you are only interested in changing the CDN URL and do not want to update the attachment payload.

  • Providing an AttachmentUploader implementation. This one can be used for more fine-grain control, since you can change not only the URL but the attachment payload as well.

You should only pick 1 of the 2 options provided. Since using an AttachmentUploader will override the custom CDNClient implementation.

CDN Client

In case you simply want to change the URL, here is an example of a custom CDNClient implementation.

// Example of your Upload API
protocol UploadFileAPI {
    func uploadFile(data: Data, progress: ((Double) -> Void)?, completion: (@escaping (FileDetails) -> Void))
}
protocol FileDetails {
    var url: URL { get }
    var thumbnailURL: URL { get }
}

final class CustomCDNClient: CDNClient {
    /// Example, max 100 MB. Required by CDNClient protocol.
    static var maxAttachmentSize: Int64 { 100 * 1024 * 1024 }

    let uploadFileApi: UploadFileAPI

    init(uploadFileApi: UploadFileAPI) {
        self.uploadFileApi = uploadFileApi
    }

    func uploadAttachment(
        _ attachment: AnyChatMessageAttachment,
        progress: ((Double) -> Void)?,
        completion: @escaping (Result<UploadedFile, Error>
    ) -> Void) {
        // The local file url is present in attachment.uploadingState.
        guard let uploadingState = attachment.uploadingState,
              let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
          return completion(.failure(ClientError("Failed to upload attachment with id: \(attachment.id)")))
        }

        uploadFileApi.uploadFile(data: fileData, progress: progress) { file in
            let uploadedFile = UploadedFile(url: file.url, thumbnailURL: file.thumbnailURL)
            completion(.success(uploadedFile))
        }
    }

}

Then, you can set your custom implementation in the ChatClientConfig:

var config = ChatClientConfig(apiKeyString: apiKeyString)
config.customCDNClient = CustomCDNClient(uploadFileApi: YourUploadFileApi())

In case your API does not support progress updates, you can simple ignore the argument.

Firebase

You can also use FirebaseStorage as your custom CDN. There is a Swift Package called StreamFirebaseCDN, built by the community to facilitate this, which can be found here.

Attachment Uploader

If you require changing more details of the attachment, in case your upload file API supports more features, you can provide a custom AttachmentUploader.

// Example of your Upload API
protocol UploadFileAPI {
    func uploadFile(data: Data, progress: ((Double) -> Void)?, completion: (@escaping (FileDetails) -> Void))
}
protocol FileDetails {
    var name: String { get set }
    var url: URL { get }
    var thumbnailURL: URL { get set }
    var codec: String { get set }
}

final class CustomUploader: AttachmentUploader {

    private let attachmentUpdater = AnyAttachmentUpdater()

    let uploadFileApi: UploadFileAPI

    init(uploadFileApi: UploadFileAPI) {
        self.uploadFileApi = uploadFileApi
    }

    func upload(
        _ attachment: AnyChatMessageAttachment,
        progress: ((Double) -> Void)?,
        completion: @escaping (Result<UploadedAttachment, Error>) -> Void
    ) {
        // The local file url is present in attachment.uploadingState.
        guard let uploadingState = attachment.uploadingState,
              let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
          return completion(.failure(ClientError("Failed to upload attachment with id: \(attachment.id)")))
        }

        uploadFileApi.uploadFile(data: fileData, progress: progress) { [weak self] file in
            var uploadedAttachment = UploadedAttachment(
                attachment: attachment,
                remoteURL: file.url
            )

            // Update the image payload, in case the attachment is an image.
            self?.attachmentUpdater.update(
                &uploadedAttachment.attachment,
                forPayload: ImageAttachmentPayload.self
            ) { payload in
                payload.title = file.name
                payload.extraData = [
                    "thumbnailUrl": .string(file.thumbnailURL)
                ]
            }

            // Update the audio payload, in case the attachment is a audio.
            self?.attachmentUpdater.update(
                &uploadedAttachment.attachment,
                forPayload: AudioAttachmentPayload.self
            ) { payload in
                payload.title = file.name
                payload.extraData = [
                    "thumbnailUrl": .string(file.thumbnailURL),
                    "codec": .string(file.codec)
                ]
            }

            completion(.success(uploadedAttachment))
        }
    }
}

The AnyAttachmentUpdater is a helper component provided by Stream, to make it easier to update the underlying payload of a type-erased attachment. You should pass a reference of the attachment with & and say which payload to update depending on what type is the attachment.

Finally, you should set your custom implementation in the ChatClientConfig:

var config = ChatClientConfig(apiKeyString: apiKeyString)
config.customAttachmentUploader = CustomUploader(uploadFileApi: YourUploadFileApi())

Image CDN

While the options above are for uploading files to a CDN, the SDK also supports customizing how images are loaded from a CDN through the ImageCDN protocol. This is particularly useful for:

  • Signed URLs: Adding authentication parameters to image requests.
  • Custom Headers: Adding additional HTTP headers.
  • Image Resizing: Leveraging your CDN’s resizing capabilities to load optimized image sizes.
  • CDN Migration: Redirecting image requests to a different CDN host.

The ImageCDN protocol has two methods:

protocol ImageCDN {
    func urlRequest(forImageUrl url: URL, resize: ImageResize?) -> URLRequest
    func cachingKey(forImageUrl url: URL) -> String
}
  • urlRequest(forImageUrl:resize:): This method is called when an image is loaded from the CDN. It allows you to modify the URL request before it is sent to the CDN.
  • cachingKey(forImageUrl:): This method is called when an image is cached. It allows you to modify the caching key for the image. It is important to remove any dynamic parameters of the URL, otherwise the key will always be different and the image will not be cached.

Signed URLs

Here’s an example of implementing ImageCDN to add signature parameters to image URLs:

final class SignedImageCDN: ImageCDN {
    private let signingService: ImageSigningService

    init(signingService: ImageSigningService) {
        self.signingService = signingService
    }

    func urlRequest(forImageUrl url: URL, resize: ImageResize?) -> URLRequest {
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return URLRequest(url: url)
        }

        // Add signature parameters
        let signature = signingService.sign(url: url)
        var queryItems = components.queryItems ?? []
        queryItems.append(URLQueryItem(name: "signature", value: signature.token))
        queryItems.append(URLQueryItem(name: "expires", value: String(signature.expiresAt)))

        components.queryItems = queryItems

        return URLRequest(url: components.url ?? url)
    }

    func cachingKey(forImageUrl url: URL) -> String {
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return url.absoluteString
        }

        // Remove signature and expiry parameters from cache key
        // so that the same image can be cached regardless of signature
        let filteredItems = components.queryItems?.filter {
            $0.name != "signature" && $0.name != "expires"
        }
        components.queryItems = filteredItems?.isEmpty == true ? nil : filteredItems

        return components.string ?? url.absoluteString
    }
}

Image Resizing

If your custom CDN supports image resizing like the Stream CDN, you can provide a custom implementation to handle the resize parameters:

final class CustomResizeCDN: ImageCDN {
    private let cdnHost = "your-cdn.com"

    func urlRequest(forImageUrl url: URL, resize: ImageResize?) -> URLRequest {
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
              let host = components.host, host.contains(cdnHost) else {
            return URLRequest(url: url)
        }

        // Apply resize parameters if provided
        guard let resize = resize else {
            return URLRequest(url: url)
        }

        let scale = UIScreen.main.scale
        var queryItems = components.queryItems ?? []

        // Add your CDN's specific resize parameters
        queryItems.append(URLQueryItem(
            name: "width",
            value: String(format: "%.0f", resize.width * scale)
        ))
        queryItems.append(URLQueryItem(
            name: "height",
            value: String(format: "%.0f", resize.height * scale)
        ))
        queryItems.append(URLQueryItem(
            name: "fit",
            value: resize.mode.value
        ))

        components.queryItems = queryItems
        return URLRequest(url: components.url ?? url)
    }

    func cachingKey(forImageUrl url: URL) -> String {
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
              let host = components.host, host.contains(cdnHost) else {
            return url.absoluteString
        }

        // Keep resize parameters in the cache key so different sizes are cached separately
        let persistedParameters = ["width", "height", "fit"]
        let filteredItems = components.queryItems?.filter {
            persistedParameters.contains($0.name)
        }
        components.queryItems = filteredItems?.isEmpty == true ? nil : filteredItems

        return components.string ?? url.absoluteString
    }
}

Configuration

To use your custom ImageCDN implementation, you need to provide it in the Components configuration or the Utils object depending on the UI framework you are using:

// For UIKit:
var components = Components.default
components.imageCDN = CustomImageCDN()

// For SwiftUI:
let utils = Utils(
    imageCDN: CustomImageCDN()
)
let streamChat = StreamChat(chatClient: chatClient, utils: utils)

The ImageCDN protocol differs slightly between UIKit and SwiftUI.

The main differences are:

  • The urlRequest(url:) method does not have a resize parameter.
  • A separate thumbnailURL(originalURL:preferredSize:) method handles image resizing.

When implementing ImageCDN for SwiftUI, you should implement the resize logic in the thumbnailURL method instead of in the urlRequest method.