Skip to content

Commit e53e2a6

Browse files
authored
Merge pull request #367 from ably/360-escape-roomName-and-serial
[ECO-5578] Encode room name and serial
2 parents a07e65f + 334a395 commit e53e2a6

File tree

9 files changed

+289
-33
lines changed

9 files changed

+289
-33
lines changed

Sources/AblyChat/ChatAPI.swift

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,21 @@ internal final class ChatAPI {
1515
self.realtime = realtime
1616
}
1717

18+
private func escapePathSegment(_ segment: String) -> String {
19+
segment.encodePathSegment()
20+
}
21+
22+
private func roomUrl(roomName: String, suffix: String = "") -> String {
23+
"\(apiVersionV4)/rooms/\(escapePathSegment(roomName))\(suffix)" // CHA-RST6
24+
}
25+
26+
private func messageUrl(roomName: String, serial: String, suffix: String = "") -> String {
27+
"\(roomUrl(roomName: roomName, suffix: "/messages/"))\(escapePathSegment(serial))\(suffix)"
28+
}
29+
1830
// (CHA-M6) Messages should be queryable from a paginated REST API.
1931
internal func getMessages(roomName: String, params: HistoryParams) async throws(InternalError) -> some PaginatedResult<Message> {
20-
let endpoint = "\(apiVersionV4)/rooms/\(roomName)/messages"
32+
let endpoint = roomUrl(roomName: roomName, suffix: "/messages")
2133
return try await makePaginatedRequest(endpoint, params: params.asQueryItems())
2234
}
2335

@@ -43,7 +55,7 @@ internal final class ChatAPI {
4355
// (CHA-M3) Messages are sent to Ably via the Chat REST API, using the send method.
4456
// (CHA-M3a) When a message is sent successfully, the caller shall receive a struct representing the Message in response (as if it were received via Realtime event).
4557
internal func sendMessage(roomName: String, params: SendMessageParams) async throws(InternalError) -> Message {
46-
let endpoint = "\(apiVersionV4)/rooms/\(roomName)/messages"
58+
let endpoint = roomUrl(roomName: roomName, suffix: "/messages")
4759
var body: [String: JSONValue] = ["text": .string(params.text)]
4860

4961
// (CHA-M3b) A message may be sent without metadata or headers. When these are not specified by the user, they must be omitted from the REST payload.
@@ -62,7 +74,7 @@ internal final class ChatAPI {
6274
// (CHA-M8) A client must be able to update a message in a room.
6375
// (CHA-M8a) A client may update a message via the Chat REST API by calling the update method.
6476
internal func updateMessage(roomName: String, with modifiedMessage: Message, details: OperationDetails?) async throws(InternalError) -> Message {
65-
let endpoint = "\(apiVersionV4)/rooms/\(roomName)/messages/\(modifiedMessage.serial)"
77+
let endpoint = messageUrl(roomName: roomName, serial: modifiedMessage.serial)
6678
var body: [String: JSONValue] = [:]
6779
let messageObject: [String: JSONValue] = [
6880
"text": .string(modifiedMessage.text),
@@ -90,7 +102,7 @@ internal final class ChatAPI {
90102
// (CHA-M9) A client must be able to delete a message in a room.
91103
// (CHA-M9a) A client may delete a message via the Chat REST API by calling the delete method.
92104
internal func deleteMessage(roomName: String, message: Message, details: OperationDetails?) async throws(InternalError) -> Message {
93-
let endpoint = "\(apiVersionV4)/rooms/\(roomName)/messages/\(message.serial)/delete"
105+
let endpoint = messageUrl(roomName: roomName, serial: message.serial, suffix: "/delete")
94106
var body: [String: JSONValue] = [:]
95107

96108
if let description = details?.description {
@@ -107,7 +119,7 @@ internal final class ChatAPI {
107119
}
108120

109121
internal func getOccupancy(roomName: String) async throws(InternalError) -> OccupancyData {
110-
let endpoint = "\(apiVersionV4)/rooms/\(roomName)/occupancy"
122+
let endpoint = roomUrl(roomName: roomName, suffix: "/occupancy")
111123
return try await makeRequest(endpoint, method: "GET")
112124
}
113125

@@ -118,7 +130,7 @@ internal final class ChatAPI {
118130
throw ChatError.messageReactionInvalidMessageSerial.toInternalError()
119131
}
120132

121-
let endpoint = "\(apiVersionV4)/rooms/\(roomName)/messages/\(messageSerial)/reactions"
133+
let endpoint = messageUrl(roomName: roomName, serial: messageSerial, suffix: "/reactions")
122134

123135
let ablyCocoaBody: [String: Any] = [
124136
"type": params.type.rawValue,
@@ -136,7 +148,7 @@ internal final class ChatAPI {
136148
throw ChatError.messageReactionInvalidMessageSerial.toInternalError()
137149
}
138150

139-
let endpoint = "\(apiVersionV4)/rooms/\(roomName)/messages/\(messageSerial)/reactions"
151+
let endpoint = messageUrl(roomName: roomName, serial: messageSerial, suffix: "/reactions")
140152

141153
var httpParams: [String: String] = [
142154
"type": params.type.rawValue,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Foundation
2+
3+
internal extension String {
4+
// Source: https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
5+
// i.e. segment = unreserved / pct-encoded / sub-delims / ":" / "@", where
6+
// unreserved = ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~
7+
// pct-encoded = %XX
8+
// sub-delims = !$&'()*+,;=
9+
func encodePathSegment() -> String {
10+
let allowedSet = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@")
11+
guard let escaped = addingPercentEncoding(withAllowedCharacters: allowedSet) else {
12+
fatalError("String '\(self)' can't be percent encoded.")
13+
}
14+
return escaped
15+
}
16+
}

Tests/AblyChatTests/ChatAPITests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ struct ChatAPITests {
4646

4747
// Then
4848
let expectedMessage = Message(
49-
serial: "3446456",
49+
serial: "123456789-000@123456789:000",
5050
action: .messageCreate,
5151
clientID: "mockClientId",
5252
text: "hello",
5353
metadata: [:],
5454
headers: [:],
55-
version: .init(serial: "3446456", timestamp: Date(timeIntervalSince1970: 1_631_840_000)),
55+
version: .init(serial: "123456789-000@123456789:000", timestamp: Date(timeIntervalSince1970: 1_631_840_000)),
5656
timestamp: Date(timeIntervalSince1970: 1_631_840_000),
5757
reactions: .empty,
5858
)

Tests/AblyChatTests/DefaultMessageReactionsTests.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ struct DefaultMessageReactionsTests {
1212
// @spec CHA-MR11b
1313
// @spec CHA-MR11b1
1414
// @spec CHA-MR11b2
15+
// @specOneOf(5/6) CHA-RST6 - Escaping room name for API send/delete reaction
1516
@Test
1617
func sendAndDeleteReactionForMessage() async throws {
1718
// Given
@@ -20,7 +21,7 @@ struct DefaultMessageReactionsTests {
2021
}
2122
let chatAPI = ChatAPI(realtime: realtime)
2223
let channel = MockRealtimeChannel(initialState: .attached)
23-
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basketball", logger: TestLogger())
24+
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basket/ball", logger: TestLogger())
2425

2526
// When
2627
let message = try await defaultMessages.send(withParams: .init(text: "a joke"))
@@ -32,7 +33,7 @@ struct DefaultMessageReactionsTests {
3233
matching: "request(_:path:params:body:headers:)",
3334
arguments: [
3435
"method": "POST",
35-
"path": "/chat/v4/rooms/basketball/messages/\(message.serial)/reactions",
36+
"path": "/chat/v4/rooms/basket%2Fball/messages/123456789-000@123456789:000/reactions",
3637
"body": [
3738
"name": "😆",
3839
"type": "reaction:multiple.v1",
@@ -46,7 +47,7 @@ struct DefaultMessageReactionsTests {
4647
matching: "request(_:path:params:body:headers:)",
4748
arguments: [
4849
"method": "DELETE",
49-
"path": "/chat/v4/rooms/basketball/messages/\(message.serial)/reactions",
50+
"path": "/chat/v4/rooms/basket%2Fball/messages/123456789-000@123456789:000/reactions",
5051
"body": [:],
5152
"params": [
5253
"name": "😆",
@@ -353,7 +354,7 @@ struct DefaultMessageReactionsTests {
353354
matching: "request(_:path:params:body:headers:)",
354355
arguments: [
355356
"method": "POST",
356-
"path": "/chat/v4/rooms/basketball/messages/\(message.serial)/reactions",
357+
"path": "/chat/v4/rooms/basketball/messages/123456789-000@123456789:000/reactions",
357358
"body": [
358359
"name": "😆",
359360
"type": "reaction:multiple.v1",
@@ -392,7 +393,7 @@ struct DefaultMessageReactionsTests {
392393
matching: "request(_:path:params:body:headers:)",
393394
arguments: [
394395
"method": "POST",
395-
"path": "/chat/v4/rooms/basketball/messages/\(message.serial)/reactions",
396+
"path": "/chat/v4/rooms/basketball/messages/123456789-000@123456789:000/reactions",
396397
"body": [
397398
"name": "😆",
398399
"type": "reaction:distinct.v1",

Tests/AblyChatTests/DefaultMessagesTests.swift

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ struct DefaultMessagesTests {
99
// @spec CHA-M3a
1010
// @spec CHA-M3b
1111
// @spec CHA-M3f
12+
// @specOneOf(1/6) CHA-RST6 - Escaping room name for API send message
1213
@Test
1314
func sendMessage() async throws {
1415
// Given
@@ -35,7 +36,7 @@ struct DefaultMessagesTests {
3536
}
3637
let chatAPI = ChatAPI(realtime: realtime)
3738
let channel = MockRealtimeChannel(initialState: .attached)
38-
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basketball", logger: TestLogger())
39+
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basket/ball", logger: TestLogger())
3940

4041
// When
4142
let sentMessage = try await defaultMessages.send(withParams: .init(text: "hey", metadata: ["key1": "val1"], headers: ["key2": "val2"]))
@@ -52,12 +53,13 @@ struct DefaultMessagesTests {
5253
#expect(sentMessage.timestamp == Date(timeIntervalSince1970: 1_631_840_000_000 / 1000))
5354
#expect(realtime.callRecorder.hasRecord(
5455
matching: "request(_:path:params:body:headers:)",
55-
arguments: ["method": "POST", "path": "/chat/v4/rooms/basketball/messages", "body": ["text": "hey", "metadata": ["key1": "val1"], "headers": ["key2": "val2"]], "params": [:], "headers": [:]],
56+
arguments: ["method": "POST", "path": "/chat/v4/rooms/basket%2Fball/messages", "body": ["text": "hey", "metadata": ["key1": "val1"], "headers": ["key2": "val2"]], "params": [:], "headers": [:]],
5657
))
5758
}
5859

5960
// @spec CHA-M8a
6061
// @spec CHA-M8b
62+
// @specOneOf(2/6) CHA-RST6 - Escaping room name for API update message
6163
@Test
6264
func updateMessage() async throws {
6365
// Given
@@ -66,7 +68,7 @@ struct DefaultMessagesTests {
6668
MockHTTPPaginatedResponse(
6769
items: [
6870
[
69-
"serial": "0",
71+
"serial": "123456789-000@123456789:000",
7072
"version": [
7173
"serial": "1",
7274
"metadata": ["key": "val"],
@@ -88,17 +90,17 @@ struct DefaultMessagesTests {
8890
}
8991
let chatAPI = ChatAPI(realtime: realtime)
9092
let channel = MockRealtimeChannel(initialState: .attached)
91-
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basketball", logger: TestLogger())
93+
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basket/ball", logger: TestLogger())
9294

93-
let sentMessage = try Message(jsonObject: ["serial": "0", "version": ["serial": "0"], "text": .string(text), "clientId": "0", "action": "message.create", "metadata": [:], "headers": [:]]) // arbitrary
95+
let sentMessage = try Message(jsonObject: ["serial": "123456789-000@123456789:000", "version": ["serial": "123456789-000@123456789:000"], "text": .string(text), "clientId": "0", "action": "message.create", "metadata": [:], "headers": [:]]) // arbitrary
9496

9597
// When
9698
var newMessage = sentMessage
9799
newMessage.text = text + "!" // see https://github.com/ably/ably-chat-swift/issues/333
98100
let updatedMessage = try await defaultMessages.update(newMessage: newMessage, details: .init(description: "add exclamation", metadata: ["key": "val"]))
99101

100102
// Then
101-
#expect(updatedMessage.serial == "0")
103+
#expect(updatedMessage.serial == "123456789-000@123456789:000")
102104
#expect(updatedMessage.action == .messageUpdate)
103105
#expect(updatedMessage.text == "hey!")
104106
#expect(updatedMessage.clientID == "clientId")
@@ -110,12 +112,13 @@ struct DefaultMessagesTests {
110112
#expect(updatedMessage.timestamp == Date(timeIntervalSince1970: 1_631_840_000_000 / 1000))
111113
#expect(realtime.callRecorder.hasRecord(
112114
matching: "request(_:path:params:body:headers:)",
113-
arguments: ["method": "PUT", "path": "/chat/v4/rooms/basketball/messages/\(sentMessage.serial)", "body": ["message": ["text": "hey!", "metadata": [:], "headers": [:]], "description": "add exclamation", "metadata": ["key": "val"]], "params": [:], "headers": [:]],
115+
arguments: ["method": "PUT", "path": "/chat/v4/rooms/basket%2Fball/messages/123456789-000@123456789:000", "body": ["message": ["text": "hey!", "metadata": [:], "headers": [:]], "description": "add exclamation", "metadata": ["key": "val"]], "params": [:], "headers": [:]],
114116
))
115117
}
116118

117119
// @spec CHA-M9a
118120
// @spec CHA-M9b
121+
// @specOneOf(3/6) CHA-RST6 - Escaping room name for API delete message
119122
@Test
120123
func deleteMessage() async throws {
121124
// Given
@@ -124,10 +127,10 @@ struct DefaultMessagesTests {
124127
MockHTTPPaginatedResponse(
125128
items: [
126129
[
127-
"serial": "0",
130+
"serial": "123456789-000@123456789:000",
128131
"action": "message.delete",
129132
"version": [
130-
"serial": "1",
133+
"serial": "123456789-000@123456789:000",
131134
"timestamp": 1_631_840_030_000,
132135
"clientId": "clientId2",
133136
],
@@ -144,16 +147,16 @@ struct DefaultMessagesTests {
144147
}
145148
let chatAPI = ChatAPI(realtime: realtime)
146149
let channel = MockRealtimeChannel(initialState: .attached)
147-
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basketball", logger: TestLogger())
150+
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basket/ball", logger: TestLogger())
148151

149-
let sentMessage = try Message(jsonObject: ["serial": "0", "version": ["serial": "0"], "text": .string(text), "clientId": "0", "action": "message.create", "metadata": ["key": "val"], "headers": [:]]) // arbitrary
152+
let sentMessage = try Message(jsonObject: ["serial": "123456789-000@123456789:000", "version": ["serial": "123456789-000@123456789:000"], "text": .string(text), "clientId": "0", "action": "message.create", "metadata": ["key": "val"], "headers": [:]]) // arbitrary
150153

151154
// When
152155
let deletedMessage = try await defaultMessages.delete(message: sentMessage, details: nil)
153156

154157
// Then
155-
#expect(deletedMessage.serial == "0")
156-
#expect(deletedMessage.version.serial == "1")
158+
#expect(deletedMessage.serial == "123456789-000@123456789:000")
159+
#expect(deletedMessage.version.serial == "123456789-000@123456789:000")
157160
#expect(deletedMessage.version.timestamp == Date(timeIntervalSince1970: 1_631_840_030_000 / 1000))
158161
#expect(deletedMessage.version.clientID == "clientId2")
159162
#expect(deletedMessage.action == .messageDelete)
@@ -163,7 +166,7 @@ struct DefaultMessagesTests {
163166
#expect(deletedMessage.timestamp == Date(timeIntervalSince1970: 1_631_840_000_000 / 1000))
164167
#expect(realtime.callRecorder.hasRecord(
165168
matching: "request(_:path:params:body:headers:)",
166-
arguments: ["method": "POST", "path": "/chat/v4/rooms/basketball/messages/\(sentMessage.serial)/delete", "body": [:], "params": [:], "headers": [:]],
169+
arguments: ["method": "POST", "path": "/chat/v4/rooms/basket%2Fball/messages/123456789-000@123456789:000/delete", "body": [:], "params": [:], "headers": [:]],
167170
))
168171
}
169172

@@ -239,6 +242,7 @@ struct DefaultMessagesTests {
239242
}
240243

241244
// @spec CHA-M5a
245+
// @specOneOf(4/6) CHA-RST6 - Escaping room name for API get messages
242246
@Test
243247
func subscriptionPointIsChannelSerialWhenUnderlyingRealtimeChannelIsAttached() async throws {
244248
// Given
@@ -251,14 +255,14 @@ struct DefaultMessagesTests {
251255
properties: ARTChannelProperties(attachSerial: nil, channelSerial: channelSerial),
252256
initialState: .attached,
253257
)
254-
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basketball", logger: TestLogger())
258+
let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "basket/ball", logger: TestLogger())
255259
let subscription = defaultMessages.subscribe()
256260
_ = try await subscription.historyBeforeSubscribe(withParams: .init())
257261

258262
// Then: subscription point is the current channelSerial of the realtime channel
259263
#expect(realtime.callRecorder.hasRecord(
260264
matching: "request(_:path:params:body:headers:)",
261-
arguments: ["method": "GET", "path": "/chat/v4/rooms/basketball/messages", "body": [:], "params": ["direction": "backwards", "fromSerial": "\(channelSerial)"], "headers": [:]],
265+
arguments: ["method": "GET", "path": "/chat/v4/rooms/basket%2Fball/messages", "body": [:], "params": ["direction": "backwards", "fromSerial": "\(channelSerial)"], "headers": [:]],
262266
))
263267
}
264268

Tests/AblyChatTests/DefaultRoomOccupancyTests.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Testing
66
struct DefaultRoomOccupancyTests {
77
// @spec CHA-O3
88
// @spec CHA-O7b
9+
// @specOneOf(6/6) CHA-RST6 - Escaping room name for API get occupancy
910
@Test
1011
func occupancyGet() async throws {
1112
// Given
@@ -24,7 +25,7 @@ struct DefaultRoomOccupancyTests {
2425
let defaultOccupancy = DefaultOccupancy(
2526
channel: channel,
2627
chatAPI: chatAPI,
27-
roomName: "basketball",
28+
roomName: "basket/ball",
2829
logger: TestLogger(),
2930
options: .init(enableEvents: true),
3031
)
@@ -38,6 +39,17 @@ struct DefaultRoomOccupancyTests {
3839

3940
let currentOccupancy = defaultOccupancy.current
4041
#expect(currentOccupancy == nil)
42+
43+
#expect(realtime.callRecorder.hasRecord(
44+
matching: "request(_:path:params:body:headers:)",
45+
arguments: [
46+
"method": "GET",
47+
"path": "/chat/v4/rooms/basket%2Fball/occupancy",
48+
"body": [:],
49+
"params": [:],
50+
"headers": [:],
51+
],
52+
))
4153
}
4254

4355
// @specUntested CHA-O4e - We chose to implement this failure with an idiomatic fatalError instead of throwing, but we can’t test this.

0 commit comments

Comments
 (0)