Skip to content

Commit 508b24c

Browse files
authored
Merge pull request #110 from THEOplayer/feature/ordered-parameters
Add `orderedPreplayParameters` to Uplynk
2 parents 4b93f19 + cee2e25 commit 508b24c

7 files changed

Lines changed: 249 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
- Uplynk
13+
- Added `orderedPreplayParameters` to `UplynkSSAIConfiguration`, which can be used to maintain the order of preplay parameters when making a request.
14+
15+
### Fixed
16+
17+
- Uplynk
18+
- Improved URL encoding for characters such as `%`, `&`, `=`, `+` and `,` to preserve pre-encoded values and prevent server-side double decoding issues.
19+
20+
### Changed
21+
22+
- Uplynk
23+
- When ping feature is not configured, the player will now send an empty string instead of `"&ad.pingc=0"` to prevent unsigned parameters that could break signature validation.
24+
- Deprecated the old `preplayParameters` in favor of the new `orderedPreplayParameters`
25+
1026
## [10.8.0.1] - 2026-01-20
1127

1228
### Fixed

Code/Uplynk/Source/Internal/UplynkSSAIConfiguration+Extensions.swift

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,26 @@
99
import Foundation
1010

1111
extension UplynkSSAIConfiguration {
12-
var drmParameters: String {
13-
contentProtected ? "&manifest=m3u8&rmt=fps" : ""
14-
}
15-
16-
var urlParameters: String {
17-
guard !preplayParameters.isEmpty else {
18-
return ""
19-
}
20-
let joinedParameters = preplayParameters.map {
21-
"\($0.key)=\($0.value)"
22-
}.joined(separator: "&")
23-
24-
return "&\(joinedParameters)"
12+
var drmParameters: [URLQueryItem] {
13+
guard contentProtected else {return []}
14+
return [
15+
URLQueryItem(name: "manifest", value: "m3u8"),
16+
URLQueryItem(name: "rmt", value: "fps")
17+
]
2518
}
2619

2720
var pingFeature: UplynkPingFeature {
2821
UplynkPingFeature(ssaiConfiguration: self)
2922
}
3023

31-
var pingParameters: String {
24+
var pingParameters: [URLQueryItem] {
3225
let pingFeature = pingFeature
33-
if pingFeature == .noPing {
34-
return "&ad.pingc=0"
35-
} else {
36-
return "&ad.pingc=1&ad.pingf=\(pingFeature.rawValue)"
26+
if pingFeature == .noPing { return [] }
27+
else {
28+
return [
29+
URLQueryItem(name: "ad.cping", value: "1"),
30+
URLQueryItem(name: "ad.pingf", value: pingFeature.rawValue.description)
31+
]
3732
}
3833
}
3934

Code/Uplynk/Source/Internal/UplynkSSAIURLBuilder.swift

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Foundation
99

1010
class UplynkSSAIURLBuilder {
1111
private static let DEFAULT_PREFIX: String = "https://content.uplynk.com"
12+
private static let versionQueryItem = CollectionOfOne(URLQueryItem(name: "v", value: "2"))
1213

1314
private let ssaiConfiguration: UplynkSSAIConfiguration
1415

@@ -22,17 +23,24 @@ class UplynkSSAIURLBuilder {
2223

2324
private var urlAssetType: String { ssaiConfiguration.urlAssetType }
2425
private var urlAssetID: String { ssaiConfiguration.urlAssetID }
25-
private var drmParameters: String { ssaiConfiguration.drmParameters }
26-
private var pingParameters: String { ssaiConfiguration.pingParameters }
27-
private var urlParameters: String { ssaiConfiguration.urlParameters }
26+
private var drmParameters: [URLQueryItem] { ssaiConfiguration.drmParameters }
27+
private var pingParameters: [URLQueryItem] { ssaiConfiguration.pingParameters }
28+
private var preplayParameters: [URLQueryItem] { ssaiConfiguration.orderedPreplayParameters.map(URLQueryItem.init) }
2829
private var id: UplynkSSAIConfiguration.ID { ssaiConfiguration.id }
2930

31+
var queryString: String {
32+
let items = Self.versionQueryItem + drmParameters + pingParameters + preplayParameters
33+
var urlBuilder = URLComponents()
34+
urlBuilder.percentEncodedQueryItems = items.map(\.encodedForUplynk)
35+
return urlBuilder.percentEncodedQuery!
36+
}
37+
3038
func buildPreplayVODURL() -> String {
31-
return "\(prefix)/preplay/\(urlAssetID)?v=2\(drmParameters)\(pingParameters)\(urlParameters)"
39+
return "\(prefix)/preplay/\(urlAssetID)?\(queryString)"
3240
}
3341

3442
func buildPreplayLiveURL() -> String {
35-
return "\(prefix)/preplay/\(urlAssetType)/\(urlAssetID)?v=2\(drmParameters)\(pingParameters)\(urlParameters)"
43+
return "\(prefix)/preplay/\(urlAssetType)/\(urlAssetID)?\(queryString)"
3644
}
3745

3846
func buildAssetInfoURLs(
@@ -81,3 +89,15 @@ class UplynkSSAIURLBuilder {
8189
"\(prefix)/session/ping/\(sessionID).json?v=3&pt=\(currentTimeSeconds)"
8290
}
8391
}
92+
93+
extension CharacterSet {
94+
fileprivate static let uplynkUrlQueryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&+=?,"))
95+
}
96+
extension URLQueryItem {
97+
fileprivate var encodedForUplynk: URLQueryItem {
98+
URLQueryItem(
99+
name: name,
100+
value: value?.addingPercentEncoding(withAllowedCharacters: .uplynkUrlQueryValueAllowed)
101+
)
102+
}
103+
}

Code/Uplynk/Source/UplynkSSAIConfiguration.swift

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ public class UplynkSSAIConfiguration: CustomServerSideAdInsertionConfiguration {
2626

2727
public let id: ID
2828
public let prefix: String?
29-
public let preplayParameters: [String: String]
29+
public let orderedPreplayParameters: [(String, String)]
3030
public let assetType: AssetType
3131
public let contentProtected: Bool
3232
public let assetInfo: Bool
3333
public let pingConfiguration: UplynkPingConfiguration
3434
public let playbackURLParameters: [(String, String)]
3535

36+
@available(*, deprecated, message: "Use the initializer with orderedPreplayParameters instead.")
3637
public init(
3738
id: ID,
3839
assetType: AssetType,
@@ -46,10 +47,35 @@ public class UplynkSSAIConfiguration: CustomServerSideAdInsertionConfiguration {
4647
self.id = id
4748
self.assetType = assetType
4849
self.prefix = prefix
49-
self.preplayParameters = preplayParameters
50+
self.orderedPreplayParameters = Array(preplayParameters)
5051
self.contentProtected = contentProtected
5152
self.assetInfo = assetInfo
5253
self.pingConfiguration = uplynkPingConfiguration
5354
self.playbackURLParameters = playbackURLParameters
5455
}
56+
57+
public init(
58+
id: ID,
59+
assetType: AssetType,
60+
orderedPreplayParameters: [(String, String)],
61+
prefix: String? = nil,
62+
contentProtected: Bool = false,
63+
assetInfo: Bool = false,
64+
uplynkPingConfiguration: UplynkPingConfiguration = .init(),
65+
playbackURLParameters: [(String, String)] = []
66+
) {
67+
self.id = id
68+
self.assetType = assetType
69+
self.orderedPreplayParameters = orderedPreplayParameters
70+
self.prefix = prefix
71+
self.contentProtected = contentProtected
72+
self.assetInfo = assetInfo
73+
self.pingConfiguration = uplynkPingConfiguration
74+
self.playbackURLParameters = playbackURLParameters
75+
}
76+
77+
@available(*, deprecated, renamed: "orderedPreplayParameters", message: "Passing preplayParameters as a dictionary is no longer supported. Use orderedPreplayParameters instead.")
78+
var preplayParameters: [String: String] {
79+
Dictionary(orderedPreplayParameters) { left, right in left }
80+
}
5581
}

Code/Uplynk/Tests/Mocks/MockAdBreak.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Foundation
99
import THEOplayerSDK
1010

1111
struct MockAdBreak: AdBreak {
12+
let id: String? = UUID().uuidString
1213
var ads: [Ad] = []
1314
var maxDuration: Int = 0
1415
var maxRemainingDuration: Double = 0
@@ -18,6 +19,7 @@ struct MockAdBreak: AdBreak {
1819
}
1920

2021
struct MockAd: Ad {
22+
let isSlate = false
2123
var adBreak: AdBreak = MockAdBreak()
2224
var companions: [CompanionAd] = []
2325
var type: String = ""

Code/Uplynk/Tests/UplynkSSAIConfigurationURLBuilderTests.swift

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase {
6363
let prefix = "https://content.uplynk.com"
6464
let assetID = "a123"
6565

66-
let validNoPingQueryParameter = "ad.pingc=0"
67-
let validPingQueryParameter = "ad.pingc=1&ad.pingf=\(pingFeature.rawValue)"
66+
let validPingQueryParameter = "ad.cping=1&ad.pingf=\(pingFeature.rawValue)"
6867

6968
let pingConfiguration = switch pingFeature {
7069
case .noPing:
@@ -89,7 +88,7 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase {
8988
let builtPreplayURL = UplynkSSAIURLBuilder(ssaiConfiguration: configurationWithAssetID).buildPreplayVODURL()
9089
switch (pingFeature) {
9190
case .noPing:
92-
XCTAssertTrue(builtPreplayURL.contains(validNoPingQueryParameter))
91+
XCTAssertFalse(builtPreplayURL.contains(validPingQueryParameter), "built PreplayURL should not contain the ping query parameter \(validPingQueryParameter). url: \(builtPreplayURL)")
9392
default:
9493
XCTAssertTrue(builtPreplayURL.contains(validPingQueryParameter))
9594

@@ -139,4 +138,164 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase {
139138
XCTAssertTrue(builtPreplayURL.contains(validPrePlayParameters) ||
140139
builtPreplayURL.contains(anotherValidPrePlayParameters))
141140
}
141+
142+
/// Makes sure Uplynk URLs do not contain the sequence "?&"
143+
/// FIFA raised an issue that the Uplynk backend does not support those kind of URLs.
144+
/// See [THEOSD-16266] [OPTI-1771]
145+
func testEmptyQueryParameter() {
146+
let hasEmptyParameter: (String)->Bool = { $0.contains("?&") }
147+
let faultyURL = "https://content.uplynk.com/preplay/ID?&sig=signature"
148+
XCTAssert(hasEmptyParameter(faultyURL))
149+
150+
let assetID = "a123"
151+
let vodBuilder = UplynkSSAIURLBuilder(
152+
ssaiConfiguration: UplynkSSAIConfiguration(
153+
id: .asset(ids: [assetID]),
154+
assetType: .asset
155+
)
156+
)
157+
let liveBuilder = UplynkSSAIURLBuilder(
158+
ssaiConfiguration: UplynkSSAIConfiguration(
159+
id: .asset(ids: [assetID]),
160+
assetType: .channel
161+
)
162+
)
163+
164+
let preplayVod = vodBuilder.buildPreplayVODURL()
165+
let preplayFaultyLive = vodBuilder.buildPreplayLiveURL()
166+
print(preplayVod)
167+
XCTAssertFalse(hasEmptyParameter(preplayVod))
168+
print(preplayFaultyLive)
169+
XCTAssertFalse(hasEmptyParameter(preplayFaultyLive))
170+
171+
let preplayFaultyVod = liveBuilder.buildPreplayVODURL()
172+
let preplayLive = liveBuilder.buildPreplayLiveURL()
173+
print(preplayFaultyVod)
174+
XCTAssertFalse(hasEmptyParameter(preplayFaultyVod))
175+
print(preplayLive)
176+
XCTAssertFalse(hasEmptyParameter(preplayLive))
177+
}
178+
179+
func testPreplayArray() {
180+
let normalParameter = ("keyA", "valueA")
181+
let specialParameter = ("special","?&+=,%")
182+
let specialEncodedParameter = (specialParameter.0, "%3F%26%2B%3D%2C%25")
183+
let mixedParameters = [normalParameter, specialParameter]
184+
let mixedEncodedParamteres = [normalParameter, specialEncodedParameter]
185+
186+
let configs: [TestConfig] = [
187+
TestConfig(assetType: .asset, preplayArray: [normalParameter], expectedParams: [normalParameter]),
188+
TestConfig(assetType: .asset, preplayArray: [specialParameter], expectedParams: [specialEncodedParameter]),
189+
TestConfig(assetType: .asset, preplayArray: mixedParameters, expectedParams: mixedEncodedParamteres),
190+
TestConfig(assetType: .channel, preplayArray: [normalParameter], expectedParams: [normalParameter]),
191+
TestConfig(assetType: .channel, preplayArray: [specialParameter], expectedParams: [specialEncodedParameter]),
192+
TestConfig(assetType: .channel, preplayArray: mixedParameters, expectedParams: mixedEncodedParamteres)
193+
]
194+
195+
for config in configs {
196+
config.assertUrlContainsPreplayParams()
197+
}
198+
199+
let emptyParams = TestConfig(assetType: .asset, preplayArray: [], expectedParams: [])
200+
XCTAssertFalse(emptyParams.url.contains("&"), "A config without params should not contain an `&` character")
201+
}
202+
203+
func testPreplayParameterOrdering() {
204+
let preplayParams = (0..<20).map { index in
205+
("key\(index)", "value\(index)")
206+
}
207+
let vod = TestConfig(assetType: .asset, preplayArray: preplayParams, expectedParams: []).url
208+
209+
var reversedOrder = Array(preplayParams.reversed())
210+
var lastIndex = vod.startIndex
211+
while let item = reversedOrder.popLast() {
212+
if let range = vod.range(of: item.0) {
213+
let newIndex = range.upperBound
214+
if newIndex < lastIndex {
215+
XCTFail("Parameter \(item.0) is out of order. ExpectedOrder: \(preplayParams.map(\.0)), Actual: \(vod)")
216+
}
217+
lastIndex = newIndex
218+
} else {
219+
XCTFail("Missing parameter: \(item)")
220+
}
221+
}
222+
}
223+
224+
func testUrlWithDrmAndPing() {
225+
let assetID = UplynkSSAIConfiguration.ID.asset(ids: ["a123"])
226+
let extraParams = [
227+
("a", "1"),
228+
("b", "2")
229+
]
230+
let drmAndPingConfig = UplynkSSAIConfiguration(
231+
id: assetID,
232+
assetType: .asset,
233+
orderedPreplayParameters: extraParams,
234+
contentProtected: true,
235+
uplynkPingConfiguration: .init(adImpressions: true, freeWheelVideoViews: true, linearAdData: true)
236+
)
237+
XCTAssertEqual(
238+
UplynkSSAIURLBuilder(ssaiConfiguration: drmAndPingConfig).buildPreplayVODURL(),
239+
"https://content.uplynk.com/preplay/a123.json?v=2&manifest=m3u8&rmt=fps&ad.cping=1&ad.pingf=3&a=1&b=2"
240+
)
241+
242+
let drmConfig = UplynkSSAIConfiguration(
243+
id: assetID,
244+
assetType: .asset,
245+
orderedPreplayParameters: extraParams,
246+
contentProtected: true
247+
)
248+
XCTAssertEqual(
249+
UplynkSSAIURLBuilder(ssaiConfiguration: drmConfig).buildPreplayVODURL(),
250+
"https://content.uplynk.com/preplay/a123.json?v=2&manifest=m3u8&rmt=fps&a=1&b=2"
251+
)
252+
253+
let liveDrmConfig = UplynkSSAIConfiguration(
254+
id: assetID,
255+
assetType: .channel,
256+
orderedPreplayParameters: extraParams,
257+
contentProtected: true
258+
)
259+
XCTAssertEqual(
260+
UplynkSSAIURLBuilder(ssaiConfiguration: liveDrmConfig).buildPreplayLiveURL(),
261+
"https://content.uplynk.com/preplay/channel/a123.json?v=2&manifest=m3u8&rmt=fps&a=1&b=2"
262+
)
263+
}
264+
}
265+
266+
struct TestConfig {
267+
let assetType: UplynkSSAIConfiguration.AssetType
268+
let preplayArray: [(String,String)]
269+
let expectedParams: [(String,String)]
270+
271+
var url: String {
272+
let assetID = UplynkSSAIConfiguration.ID.asset(ids: ["a123"])
273+
let config = UplynkSSAIConfiguration(id: assetID, assetType: assetType, orderedPreplayParameters: preplayArray)
274+
let builder = UplynkSSAIURLBuilder(ssaiConfiguration: config)
275+
switch assetType {
276+
case .asset: return builder.buildPreplayVODURL()
277+
case .channel: return builder.buildPreplayLiveURL()
278+
}
279+
}
280+
281+
func assertUrlContainsPreplayParams() {
282+
let url = self.url
283+
for (key, value) in expectedParams {
284+
let exptectation = "\(key)=\(value)"
285+
if !url.contains(exptectation) {
286+
XCTFail("Generated url (\(url)) does not contain \(exptectation)")
287+
}
288+
}
289+
guard let parsedUrl = URLComponents(string: url) else {
290+
return XCTFail("Could not parse the generated URL \(url)")
291+
}
292+
let parsedQueryItems = parsedUrl.queryItems ?? []
293+
for (key, value) in preplayArray {
294+
guard parsedQueryItems.contains(where: { item in
295+
item.name == key && item.value == value
296+
}) else {
297+
return XCTFail("Generated URL does not contain preplay param \(key)=\(value)")
298+
}
299+
}
300+
}
142301
}

Code/Uplynk/docs/preplay.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ We start by creating an `UplynkSSAIConfiguration` object that describes how to c
3333

3434
- `preplayParameters`: The `preplayParameters` object should have string-key-string-value combinations, which will be used as query parameters for the Preplay API call. Nested objects are not supported.
3535

36+
- `orderedPreplayParameters`: The `orderedPreplayParameters` object should have string-key-string-value combinations, which will be used as query parameters for the Preplay API call. Nested objects are not supported. Unlike `preplayParameters`, `orderedPreplayParameters` preserves the order of parameters while making a request, which is neccessary to prevent unsigned parameters that could break signature validation.
37+
3638
- `contentProtected`: Boolean value which will internally set any necessary content-protection information. No content-protection details have to be specified by the customer.
3739

3840
- **A Preplay request must include all parameters defined within the playback request, hence these parameters must be included in the THEOplayer source**. This request must also include a digital signature if the 'Require a token for playback' option is enabled in the back-end on the corresponding live channel. (See also : [Signing a Playback URL Tutorial](https://docs.uplynk.com/docs/sign-playback-url))
@@ -62,7 +64,7 @@ Ad specific parameters can be passed in the `preplayParameters` argument of the
6264
assetType: ...,
6365
prefix: ...,
6466
preplayParameters: [
65-
// Parameters here should specify the necessary ad parameters for the Preplay API
67+
// Parameters here should specify the necessary ad parameters for the Preplay API. Use `orderedPreplayParameters` instead to pass these in the order given.
6668
"ad.param1": "param_val1",
6769
"ad.param2": "param_val2"
6870
],

0 commit comments

Comments
 (0)