| 1 | import { describe, it } from "@effect/vitest" |
| 2 | import { deepStrictEqual, strictEqual } from "@effect/vitest/utils" |
| 3 | import { escapeToken, formatUriFragment, parseUriFragment, unescapeToken } from "effect/JsonPointer" |
| 4 | |
| 5 | describe("JsonPointer", () => { |
| 6 | describe("escapeToken", () => { |
| 7 | describe("basic escaping", () => { |
| 8 | it("escapes tilde (~) to ~0", () => { |
| 9 | deepStrictEqual(escapeToken("a~b"), "a~0b") |
| 10 | deepStrictEqual(escapeToken("~"), "~0") |
| 11 | deepStrictEqual(escapeToken("~~"), "~0~0") |
| 12 | }) |
| 13 | |
| 14 | it("escapes forward slash (/) to ~1", () => { |
| 15 | deepStrictEqual(escapeToken("a/b"), "a~1b") |
| 16 | deepStrictEqual(escapeToken("/"), "~1") |
| 17 | deepStrictEqual(escapeToken("//"), "~1~1") |
| 18 | }) |
| 19 | |
| 20 | it("escapes both tilde and slash in same string", () => { |
| 21 | deepStrictEqual(escapeToken("a~b/c"), "a~0b~1c") |
| 22 | deepStrictEqual(escapeToken("path/to~key"), "path~1to~0key") |
| 23 | deepStrictEqual(escapeToken("~a/b~c/d~"), "~0a~1b~0c~1d~0") |
| 24 | }) |
| 25 | }) |
| 26 | |
| 27 | describe("order of operations", () => { |
| 28 | it("replaces ~ before / to prevent double-escaping", () => { |
| 29 | const token = "a~b/c" |
| 30 | const result = escapeToken(token) |
| 31 | deepStrictEqual(result, "a~0b~1c") |
| 32 | deepStrictEqual(result.includes("~0~1"), false) |
| 33 | deepStrictEqual(result.includes("~1~0"), false) |
| 34 | }) |
| 35 | |
| 36 | it("handles ~ followed by / correctly", () => { |
| 37 | deepStrictEqual(escapeToken("a~/b"), "a~0~1b") |
| 38 | }) |
| 39 | |
| 40 | it("handles / followed by ~ correctly", () => { |
| 41 | deepStrictEqual(escapeToken("a/~b"), "a~1~0b") |
| 42 | }) |
| 43 | |
| 44 | it("handles consecutive special characters", () => { |
| 45 | deepStrictEqual(escapeToken("~~"), "~0~0") |
| 46 | deepStrictEqual(escapeToken("//"), "~1~1") |
| 47 | deepStrictEqual(escapeToken("~/"), "~0~1") |
| 48 | deepStrictEqual(escapeToken("/~"), "~1~0") |
| 49 | }) |
| 50 | }) |
| 51 | |
| 52 | describe("edge cases", () => { |
| 53 | it("returns empty string unchanged", () => { |
| 54 | deepStrictEqual(escapeToken(""), "") |
| 55 | strictEqual(escapeToken(""), "") |
| 56 | }) |
| 57 | |
| 58 | it("returns input unchanged when no special characters", () => { |
| 59 | deepStrictEqual(escapeToken("abc"), "abc") |
| 60 | deepStrictEqual(escapeToken("hello world"), "hello world") |
| 61 | deepStrictEqual(escapeToken("123"), "123") |
| 62 | }) |
| 63 | |
| 64 | it("handles strings with only tildes", () => { |
| 65 | deepStrictEqual(escapeToken("~~~"), "~0~0~0") |
| 66 | deepStrictEqual(escapeToken("a~b~c"), "a~0b~0c") |
| 67 | }) |
| 68 | |
| 69 | it("handles strings with only slashes", () => { |
| 70 | deepStrictEqual(escapeToken("///"), "~1~1~1") |
| 71 | deepStrictEqual(escapeToken("a/b/c"), "a~1b~1c") |
| 72 | }) |
| 73 | |
| 74 | it("handles multiple occurrences of same character", () => { |
| 75 | deepStrictEqual(escapeToken("a~b~c~d"), "a~0b~0c~0d") |
| 76 | deepStrictEqual(escapeToken("a/b/c/d"), "a~1b~1c~1d") |
| 77 | }) |
| 78 | |
| 79 | it("handles special sequences that could be confused", () => { |
| 80 | deepStrictEqual(escapeToken("~01"), "~001") |
| 81 | deepStrictEqual(escapeToken("~10"), "~010") |
| 82 | deepStrictEqual(escapeToken("~00"), "~000") |
| 83 | deepStrictEqual(escapeToken("~11"), "~011") |
| 84 | }) |
| 85 | }) |
| 86 | |
| 87 | describe("unicode and other characters", () => { |
| 88 | it("passes through unicode characters unchanged", () => { |
| 89 | deepStrictEqual(escapeToken("héllo"), "héllo") |
| 90 | deepStrictEqual(escapeToken("δΈη"), "δΈη") |
| 91 | deepStrictEqual(escapeToken("π"), "π") |
| 92 | }) |
| 93 | |
| 94 | it("passes through other special characters unchanged", () => { |
| 95 | deepStrictEqual(escapeToken("a.b"), "a.b") |
| 96 | deepStrictEqual(escapeToken("a-b"), "a-b") |
| 97 | deepStrictEqual(escapeToken("a_b"), "a_b") |
| 98 | deepStrictEqual(escapeToken("a@b"), "a@b") |
| 99 | deepStrictEqual(escapeToken("a#b"), "a#b") |
| 100 | deepStrictEqual(escapeToken("a$b"), "a$b") |
| 101 | deepStrictEqual(escapeToken("a%b"), "a%b") |
| 102 | deepStrictEqual(escapeToken("a&b"), "a&b") |
| 103 | deepStrictEqual(escapeToken("a*b"), "a*b") |
| 104 | deepStrictEqual(escapeToken("a+b"), "a+b") |
| 105 | deepStrictEqual(escapeToken("a=b"), "a=b") |
| 106 | deepStrictEqual(escapeToken("a?b"), "a?b") |
| 107 | deepStrictEqual(escapeToken("a!b"), "a!b") |
| 108 | }) |
| 109 | |
| 110 | it("handles mixed unicode and special characters", () => { |
| 111 | deepStrictEqual(escapeToken("héllo~world/test"), "héllo~0world~1test") |
| 112 | deepStrictEqual(escapeToken("δΈη/π~key"), "δΈη~1π~0key") |
| 113 | }) |
| 114 | }) |
| 115 | |
| 116 | describe("immutability", () => { |
| 117 | it("does not mutate input string", () => { |
| 118 | const original = "a~b/c" |
| 119 | const originalCopy = "a~b/c" |
| 120 | escapeToken(original) |
| 121 | deepStrictEqual(original, originalCopy) |
| 122 | strictEqual(original, originalCopy) |
| 123 | }) |
| 124 | |
| 125 | it("returns a different token value when escaping changes the input", () => { |
| 126 | const input = "a~b" |
| 127 | const result = escapeToken(input) |
| 128 | strictEqual(result === input, false) |
| 129 | deepStrictEqual(result, "a~0b") |
| 130 | }) |
| 131 | }) |
| 132 | |
| 133 | describe("RFC 6901 examples", () => { |
| 134 | it("handles examples from documentation", () => { |
| 135 | deepStrictEqual(escapeToken("name/alias"), "name~1alias") |
| 136 | deepStrictEqual(escapeToken("path/to~key"), "path~1to~0key") |
| 137 | }) |
| 138 | }) |
| 139 | }) |
| 140 | |
| 141 | describe("unescapeToken", () => { |
| 142 | describe("basic unescaping", () => { |
| 143 | it("unescapes ~0 to ~", () => { |
| 144 | deepStrictEqual(unescapeToken("a~0b"), "a~b") |
| 145 | deepStrictEqual(unescapeToken("~0"), "~") |
| 146 | deepStrictEqual(unescapeToken("~0~0"), "~~") |
| 147 | }) |
| 148 | |
| 149 | it("unescapes ~1 to /", () => { |
| 150 | deepStrictEqual(unescapeToken("a~1b"), "a/b") |
| 151 | deepStrictEqual(unescapeToken("~1"), "/") |
| 152 | deepStrictEqual(unescapeToken("~1~1"), "//") |
| 153 | }) |
| 154 | |
| 155 | it("unescapes both ~0 and ~1 in same string", () => { |
| 156 | deepStrictEqual(unescapeToken("a~0b~1c"), "a~b/c") |
| 157 | deepStrictEqual(unescapeToken("path~1to~0key"), "path/to~key") |
| 158 | deepStrictEqual(unescapeToken("~0a~1b~0c~1d~0"), "~a/b~c/d~") |
| 159 | }) |
| 160 | }) |
| 161 | |
| 162 | describe("order of operations", () => { |
| 163 | it("replaces ~1 before ~0 to prevent incorrect decoding", () => { |
| 164 | const token = "a~1b~0c" |
| 165 | const result = unescapeToken(token) |
| 166 | deepStrictEqual(result, "a/b~c") |
| 167 | }) |
| 168 | |
| 169 | it("handles ~01 sequence correctly (unescapes to ~1)", () => { |
| 170 | deepStrictEqual(unescapeToken("~01"), "~1") |
| 171 | }) |
| 172 | |
| 173 | it("handles ~10 sequence correctly (unescapes to /0)", () => { |
| 174 | deepStrictEqual(unescapeToken("~10"), "/0") |
| 175 | }) |
| 176 | |
| 177 | it("handles ~00 sequence correctly (unescapes to ~0)", () => { |
| 178 | deepStrictEqual(unescapeToken("~00"), "~0") |
| 179 | }) |
| 180 | |
| 181 | it("handles ~11 sequence correctly (unescapes to /1)", () => { |
| 182 | deepStrictEqual(unescapeToken("~11"), "/1") |
| 183 | }) |
| 184 | }) |
| 185 | |
| 186 | describe("edge cases", () => { |
| 187 | it("returns empty string unchanged", () => { |
| 188 | deepStrictEqual(unescapeToken(""), "") |
| 189 | strictEqual(unescapeToken(""), "") |
| 190 | }) |
| 191 | |
| 192 | it("returns input unchanged when no escaped sequences", () => { |
| 193 | deepStrictEqual(unescapeToken("abc"), "abc") |
| 194 | deepStrictEqual(unescapeToken("hello world"), "hello world") |
| 195 | deepStrictEqual(unescapeToken("123"), "123") |
| 196 | }) |
| 197 | |
| 198 | it("handles strings with only ~0 sequences", () => { |
| 199 | deepStrictEqual(unescapeToken("~0~0~0"), "~~~") |
| 200 | deepStrictEqual(unescapeToken("a~0b~0c"), "a~b~c") |
| 201 | }) |
| 202 | |
| 203 | it("handles strings with only ~1 sequences", () => { |
| 204 | deepStrictEqual(unescapeToken("~1~1~1"), "///") |
| 205 | deepStrictEqual(unescapeToken("a~1b~1c"), "a/b/c") |
| 206 | }) |
| 207 | |
| 208 | it("handles multiple occurrences of same sequence", () => { |
| 209 | deepStrictEqual(unescapeToken("a~0b~0c~0d"), "a~b~c~d") |
| 210 | deepStrictEqual(unescapeToken("a~1b~1c~1d"), "a/b/c/d") |
| 211 | }) |
| 212 | |
| 213 | it("decodes escaped sequences at token boundaries and suffixes", () => { |
| 214 | deepStrictEqual(unescapeToken("~0"), "~") |
| 215 | deepStrictEqual(unescapeToken("~1"), "/") |
| 216 | deepStrictEqual(unescapeToken("a~0"), "a~") |
| 217 | deepStrictEqual(unescapeToken("a~1"), "a/") |
| 218 | }) |
| 219 | }) |
| 220 | |
| 221 | describe("unicode and other characters", () => { |
| 222 | it("passes through unicode characters unchanged", () => { |
| 223 | deepStrictEqual(unescapeToken("héllo"), "héllo") |
| 224 | deepStrictEqual(unescapeToken("δΈη"), "δΈη") |
| 225 | deepStrictEqual(unescapeToken("π"), "π") |
| 226 | }) |
| 227 | |
| 228 | it("passes through other special characters unchanged", () => { |
| 229 | deepStrictEqual(unescapeToken("a.b"), "a.b") |
| 230 | deepStrictEqual(unescapeToken("a-b"), "a-b") |
| 231 | deepStrictEqual(unescapeToken("a_b"), "a_b") |
| 232 | deepStrictEqual(unescapeToken("a@b"), "a@b") |
| 233 | }) |
| 234 | |
| 235 | it("handles mixed unicode and escaped sequences", () => { |
| 236 | deepStrictEqual(unescapeToken("héllo~0world~1test"), "héllo~world/test") |
| 237 | deepStrictEqual(unescapeToken("δΈη~1π~0key"), "δΈη/π~key") |
| 238 | }) |
| 239 | }) |
| 240 | |
| 241 | describe("immutability", () => { |
| 242 | it("does not mutate input string", () => { |
| 243 | const original = "a~0b~1c" |
| 244 | const originalCopy = "a~0b~1c" |
| 245 | unescapeToken(original) |
| 246 | deepStrictEqual(original, originalCopy) |
| 247 | strictEqual(original, originalCopy) |
| 248 | }) |
| 249 | |
| 250 | it("returns a different token value when unescaping changes the input", () => { |
| 251 | const input = "a~0b" |
| 252 | const result = unescapeToken(input) |
| 253 | strictEqual(result === input, false) |
| 254 | deepStrictEqual(result, "a~b") |
| 255 | }) |
| 256 | }) |
| 257 | |
| 258 | describe("RFC 6901 examples", () => { |
| 259 | it("handles examples from documentation", () => { |
| 260 | deepStrictEqual(unescapeToken("name~1alias"), "name/alias") |
| 261 | deepStrictEqual(unescapeToken("path~1to~0key"), "path/to~key") |
| 262 | }) |
| 263 | }) |
| 264 | }) |
| 265 | |
| 266 | describe("parseUriFragment", () => { |
| 267 | it("parses the document root", () => { |
| 268 | deepStrictEqual(parseUriFragment(""), []) |
| 269 | deepStrictEqual(parseUriFragment("#"), []) |
| 270 | }) |
| 271 | |
| 272 | it("decodes percent-encoding before splitting tokens", () => { |
| 273 | deepStrictEqual(parseUriFragment("#/definitions/a%20b"), ["definitions", "a b"]) |
| 274 | deepStrictEqual(parseUriFragment("#%2Fdefinitions%2FValue"), ["definitions", "Value"]) |
| 275 | deepStrictEqual(parseUriFragment("#/definitions/a%25b"), ["definitions", "a%b"]) |
| 276 | deepStrictEqual(parseUriFragment("#/definitions/caf%C3%A9"), ["definitions", "café"]) |
| 277 | }) |
| 278 | |
| 279 | it("distinguishes encoded separators from escaped slashes", () => { |
| 280 | deepStrictEqual(parseUriFragment("#/$defs/Box%2Fproperties%2Fvalue"), [ |
| 281 | "$defs", |
| 282 | "Box", |
| 283 | "properties", |
| 284 | "value" |
| 285 | ]) |
| 286 | deepStrictEqual(parseUriFragment("#/$defs/Box~1properties~1value"), [ |
| 287 | "$defs", |
| 288 | "Box/properties/value" |
| 289 | ]) |
| 290 | deepStrictEqual(parseUriFragment("#/$defs/Box%7E1properties%7E1value"), [ |
| 291 | "$defs", |
| 292 | "Box/properties/value" |
| 293 | ]) |
| 294 | }) |
| 295 | |
| 296 | it("rejects invalid fragments", () => { |
| 297 | strictEqual(parseUriFragment("/a"), undefined) |
| 298 | strictEqual(parseUriFragment("#/%"), undefined) |
| 299 | strictEqual(parseUriFragment("#/%FF"), undefined) |
| 300 | strictEqual(parseUriFragment("#/a b"), undefined) |
| 301 | strictEqual(parseUriFragment("#/a#b"), undefined) |
| 302 | strictEqual(parseUriFragment("#/a\\b"), undefined) |
| 303 | strictEqual(parseUriFragment("#/café"), undefined) |
| 304 | strictEqual(parseUriFragment(`#/${String.fromCharCode(0xd800)}`), undefined) |
| 305 | strictEqual(parseUriFragment("#/a~2b"), undefined) |
| 306 | strictEqual(parseUriFragment("#/a~"), undefined) |
| 307 | }) |
| 308 | }) |
| 309 | |
| 310 | describe("formatUriFragment", () => { |
| 311 | it("formats the document root", () => { |
| 312 | strictEqual(formatUriFragment([]), "#") |
| 313 | }) |
| 314 | |
| 315 | it("applies JSON Pointer escaping before URI percent-encoding", () => { |
| 316 | strictEqual( |
| 317 | formatUriFragment(["$defs", "a b", "a%b", "a/b", "café", "a#b"]), |
| 318 | "#/$defs/a%20b/a%25b/a~1b/caf%C3%A9/a%23b" |
| 319 | ) |
| 320 | }) |
| 321 | |
| 322 | it("round trips path tokens", () => { |
| 323 | const paths = [ |
| 324 | [], |
| 325 | [""], |
| 326 | ["$defs", "Rate%"], |
| 327 | ["a/b", "c~d"], |
| 328 | ["!$&'()*+,;=:@?"], |
| 329 | ["café", "δΈη", "π"] |
| 330 | ] |
| 331 | for (const path of paths) { |
| 332 | deepStrictEqual(parseUriFragment(formatUriFragment(path)), path) |
| 333 | } |
| 334 | }) |
| 335 | |
| 336 | it("throws for unpaired surrogates", () => { |
| 337 | const path = [String.fromCharCode(0xd800)] |
| 338 | let error: unknown |
| 339 | try { |
| 340 | formatUriFragment(path) |
| 341 | } catch (cause) { |
| 342 | error = cause |
| 343 | } |
| 344 | strictEqual(error instanceof URIError, true) |
| 345 | }) |
| 346 | }) |
| 347 | |
| 348 | describe("round-trip", () => { |
| 349 | it("unescapeToken(escapeToken(token)) === token for basic cases", () => { |
| 350 | const cases = [ |
| 351 | "abc", |
| 352 | "a~b", |
| 353 | "a/b", |
| 354 | "a~b/c", |
| 355 | "path/to~key", |
| 356 | "name/alias", |
| 357 | "~", |
| 358 | "/", |
| 359 | "~~", |
| 360 | "//", |
| 361 | "~/", |
| 362 | "/~" |
| 363 | ] |
| 364 | |
| 365 | for (const token of cases) { |
| 366 | const escaped = escapeToken(token) |
| 367 | const unescaped = unescapeToken(escaped) |
| 368 | deepStrictEqual(unescaped, token, `Failed for token: ${token}`) |
| 369 | } |
| 370 | }) |
| 371 | |
| 372 | it("unescapeToken(escapeToken(token)) === token for edge cases", () => { |
| 373 | const cases = [ |
| 374 | "", |
| 375 | "~01", |
| 376 | "~10", |
| 377 | "~00", |
| 378 | "~11", |
| 379 | "a~01b", |
| 380 | "a~10b", |
| 381 | "a~00b", |
| 382 | "a~11b" |
| 383 | ] |
| 384 | |
| 385 | for (const token of cases) { |
| 386 | const escaped = escapeToken(token) |
| 387 | const unescaped = unescapeToken(escaped) |
| 388 | deepStrictEqual(unescaped, token, `Failed for token: ${token}`) |
| 389 | } |
| 390 | }) |
| 391 | |
| 392 | it("unescapeToken(escapeToken(token)) === token for unicode", () => { |
| 393 | const cases = [ |
| 394 | "héllo", |
| 395 | "δΈη", |
| 396 | "π", |
| 397 | "héllo~world/test", |
| 398 | "δΈη/π~key" |
| 399 | ] |
| 400 | |
| 401 | for (const token of cases) { |
| 402 | const escaped = escapeToken(token) |
| 403 | const unescaped = unescapeToken(escaped) |
| 404 | deepStrictEqual(unescaped, token, `Failed for token: ${token}`) |
| 405 | } |
| 406 | }) |
| 407 | |
| 408 | it("unescapeToken(escapeToken(token)) === token for complex sequences", () => { |
| 409 | const cases = [ |
| 410 | "a~b/c~d/e", |
| 411 | "path/to~key/value", |
| 412 | "~a/b~c/d~", |
| 413 | "a~/b/c~d", |
| 414 | "a/~b/c~d" |
| 415 | ] |
| 416 | |
| 417 | for (const token of cases) { |
| 418 | const escaped = escapeToken(token) |
| 419 | const unescaped = unescapeToken(escaped) |
| 420 | deepStrictEqual(unescaped, token, `Failed for token: ${token}`) |
| 421 | } |
| 422 | }) |
| 423 | |
| 424 | it("handles multiple round-trips", () => { |
| 425 | const token = "a~b/c~d" |
| 426 | let current = token |
| 427 | |
| 428 | for (let i = 0; i < 5; i++) { |
| 429 | current = escapeToken(current) |
| 430 | current = unescapeToken(current) |
| 431 | deepStrictEqual(current, token) |
| 432 | } |
| 433 | }) |
| 434 | }) |
| 435 | }) |