diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index aa93fca..954392c 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -120,6 +120,7 @@ jobs: uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.9 needs: install-swiftly with: + api_breakage_check_allowlist_path: "api-breakages.txt" api_breakage_check_container_image: "swift:${{ needs.install-swiftly.outputs.current_version }}-noble" docs_check_enabled: false format_check_container_image: "swift:${{ needs.install-swiftly.outputs.current_version }}-noble" diff --git a/Sources/JSONLD/Algorithms/ExpansionAlgorithm.swift b/Sources/JSONLD/Algorithms/ExpansionAlgorithm.swift index f8ca577..8e107e7 100644 --- a/Sources/JSONLD/Algorithms/ExpansionAlgorithm.swift +++ b/Sources/JSONLD/Algorithms/ExpansionAlgorithm.swift @@ -13,7 +13,7 @@ struct ExpansionAlgorithm { static func run( _ input: Input, - loader: any JSONLDDocumentLoader + loader: (any JSONLDDocumentLoader)? ) async throws(JSONLDError) -> JSONLDValues { _ = input.normative diff --git a/Sources/JSONLD/Context/ContextResolver.swift b/Sources/JSONLD/Context/ContextResolver.swift index 6656d35..a710609 100644 --- a/Sources/JSONLD/Context/ContextResolver.swift +++ b/Sources/JSONLD/Context/ContextResolver.swift @@ -78,17 +78,11 @@ struct ContextResolver { ) } - let result = await loader.load(url: resolvedIRI) - let remoteDocument: RemoteDocument = - switch result { - case .success(let doc): - doc - case .failure(let error): - throw .code( - .loadingRemoteContextFailed, - debugInfo: .init(url: resolvedIRI, message: String(describing: error)) - ) - } + let remoteDocument = try await RemoteDocument.load( + url: resolvedIRI, + using: loader, + requestProfile: RemoteDocument.contextProfile + ) guard case .object(let object) = remoteDocument.document, let innerContext = object[.context] @@ -101,11 +95,15 @@ struct ContextResolver { var subContext = activeContext subContext.baseIRI = remoteDocument.documentURL + let previousBaseIRI = activeContext.baseIRI + let previousOriginalBaseIRI = activeContext.originalBaseIRI activeContext = try await self.process( contexts: remoteContext, activeContext: subContext, remoteContexts: updatedRemoteContexts ) + activeContext.baseIRI = previousBaseIRI + activeContext.originalBaseIRI = previousOriginalBaseIRI case .contextDefinition(let definition): try self.apply(contextDefinition: definition, to: &activeContext) diff --git a/Sources/JSONLD/Context/Contexts.swift b/Sources/JSONLD/Context/Contexts.swift index 62bdb6c..9babdff 100644 --- a/Sources/JSONLD/Context/Contexts.swift +++ b/Sources/JSONLD/Context/Contexts.swift @@ -53,11 +53,7 @@ extension Contexts: ExpressibleByNilLiteral { extension Contexts: ExpressibleByStringLiteral { /// Creates a context literal from an IRI string. public init(stringLiteral value: String) { - do { - self = .single(try .init(iri: value)) - } catch { - preconditionFailure("Invalid @context literal: \(error)") - } + self = .single(.init(iri: value)) } } @@ -75,6 +71,21 @@ extension Contexts: ExpressibleByDictionaryLiteral { } } +extension Contexts { + static func + (lhs: Self, rhs: Self) -> Self { + switch (lhs, rhs) { + case (.null, .null): .null + case (.null, let context), (let context, .null): context + + case (.single(let lhsElement), .single(let rhsElement)): .array([lhsElement, rhsElement]) + + case (.array(let lhsElements), .single(let rhsElement)): .array(lhsElements + [rhsElement]) + case (.single(let lhsElement), .array(let rhsElements)): .array([lhsElement] + rhsElements) + case (.array(let lhsElements), .array(let rhsElements)): .array(lhsElements + rhsElements) + } + } +} + extension Contexts { /// A single `@context` element. public enum Element: CustomJSONValueConvertible, Equatable, Sendable { @@ -87,11 +98,7 @@ extension Contexts { extension Contexts.Element: ExpressibleByStringLiteral { /// Creates a context element literal from an IRI string. public init(stringLiteral value: String) { - do { - try self.init(iri: value) - } catch { - preconditionFailure("Invalid @context literal: \(error)") - } + self.init(iri: value) } } @@ -111,7 +118,7 @@ extension Contexts.Element { } } - init(iri value: String) throws(JSONLDError) { + init(iri value: String) { self = if value.contains(":") { .absoluteIRI(value) @@ -130,7 +137,7 @@ extension Contexts.Element { self = switch jsonValue { case .object(let jsonObject): try .init(from: jsonObject) - case .string(let value): try .init(iri: value) + case .string(let value): .init(iri: value) default: throw .code(.invalidLocalContext) } } diff --git a/Sources/JSONLD/Core/RemoteDocumentLoader.swift b/Sources/JSONLD/Core/RemoteDocumentLoader.swift index 45aac7e..b6f63af 100644 --- a/Sources/JSONLD/Core/RemoteDocumentLoader.swift +++ b/Sources/JSONLD/Core/RemoteDocumentLoader.swift @@ -1,55 +1,259 @@ // Copyright 2026 kPherox // SPDX-License-Identifier: Apache-2.0 -/// A structure representing a remote document and its associated metadata. +public import Foundation + +struct RemoteDocument: Sendable { + let documentURL: String + let document: JSONValue + let contentType: String? + let profile: String? + let contextURL: String? + + private init( + documentURL: String, + document: JSONValue, + contentType: String? = nil, + profile: String? = nil, + contextURL: String? = nil + ) { + self.documentURL = documentURL + self.document = document + self.contentType = contentType + self.profile = contentType + self.contextURL = contextURL + } + + static let contextProfile = "http://www.w3.org/ns/json-ld#context" + + static func load( + url: String, + using loader: any JSONLDDocumentLoader, + requestProfile: String? = nil, + failureCode: JSONLDError.Code = .loadingRemoteContextFailed + ) async throws(JSONLDError) -> Self { + let result = await loader.load(url: url, requestProfile: requestProfile) + let response: RemoteDocumentResponse = + switch result { + case .success(let response): + response + case .failure(let error): + throw .code( + failureCode, + debugInfo: .init(url: url, message: String(describing: error)) + ) + } + + if !response.isJSON { + let alternateLinks = response.linkHeaders.filter({ $0.relations.contains("alternate") }) + if let alternate = alternateLinks.first, alternate.type == "application/ld+json" { + return try await Self.load( + url: Self.resolveIRI(alternate.target, against: response.documentURL), + using: loader, + requestProfile: requestProfile, + failureCode: failureCode + ) + } + } + + return try Self.fromResponse(response, failureCode: failureCode) + } + + static func fromResponse( + _ response: RemoteDocumentResponse, + failureCode: JSONLDError.Code = .loadingDocumentFailed + ) throws(JSONLDError) -> Self { + let documentURL = response.documentURL + guard response.isJSON else { + throw .code(failureCode, debugInfo: .init(url: documentURL)) + } + + let mediaType = response.mediaType + let contextLinks = response.linkHeaders.filter { $0.relations.contains(Self.contextProfile) } + let contextURL: String? = + if mediaType == "application/ld+json" { + nil + } else if contextLinks.count <= 1 { + contextLinks.first.flatMap { Self.resolveIRI($0.target, against: documentURL) } + } else { + throw .code(.multipleContextLinkHeaders, debugInfo: .init(url: documentURL)) + } + + do { + let document = try JSONDecoder().decode(JSONValue.self, from: response.body) + return Self( + documentURL: documentURL, + document: document, + contentType: mediaType, + profile: response.profile, + contextURL: contextURL + ) + } catch { + throw .code( + failureCode, + debugInfo: .init(url: documentURL, message: String(describing: error)) + ) + } + } + + private static func resolveIRI(_ iri: String, against baseIRI: String) -> String { + guard let baseURL = URL(string: baseIRI), + let resolvedURL = URL(string: iri, relativeTo: baseURL) + else { + return iri + } + return resolvedURL.absoluteString + } +} + +extension CharacterSet { + static let doubleQuote = Self(charactersIn: "\"") +} + +/// A raw HTTP response loaded by a JSON-LD document loader. /// -/// This structure encapsulates the result of a document loading operation, -/// providing the JSON content along with metadata required by the JSON-LD Processing Algorithms. -public struct RemoteDocument: Sendable { - /// The final URL of the document after any redirects. +/// The loader is responsible for fetching bytes and response metadata only. JSON-LD-specific +/// interpretation, such as content type checks and `Link` header processing, is performed by +/// the JSON-LD processor. +public struct RemoteDocumentResponse: Sendable { + /// A parsed HTTP `Link` header value from a remote document response. /// - /// This URL is used as the base IRI for the document if no `@base` is specified - /// within the document itself. + /// JSON-LD uses `Link` headers to discover alternate JSON-LD documents and external contexts. + public struct LinkHeader: Sendable { + let target: String + let relations: [String] + let type: String? + + init?(_ value: Substring) { + let parts = value.split(separator: ";").map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + + self.init(target: parts.first, parameter: parts.dropFirst()) + } + + init?(_ value: String) { + let parts = value.split(separator: ";").map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + + self.init(target: parts.first, parameter: parts.dropFirst()) + } + + private init?(target targetPart: String?, parameter parameterParts: ArraySlice) { + guard let targetPart, targetPart.hasPrefix("<"), targetPart.hasSuffix(">") else { + return nil + } + + self.target = String(targetPart.dropFirst().dropLast()) + self.relations = parameterParts.compactMap { parameter -> [String]? in + let pair = parameter.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2, pair[0].lowercased() == "rel" else { + return nil + } + return pair[1].trimmingCharacters(in: .doubleQuote) + .split(separator: " ") + .map(String.init) + }.flatMap { $0 } + self.type = + parameterParts.compactMap { parameter -> String? in + let pair = parameter.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2, pair[0].lowercased() == "type" else { + return nil + } + return pair[1].trimmingCharacters(in: .doubleQuote) + }.first + } + } + + /// The final URL of the document after any redirects. public let documentURL: String - /// The content of the document as a `JSONValue`. - /// - /// For JSON-LD documents, this should be the parsed JSON structure. - public let document: JSONValue + /// The raw response body. + public let body: Data - /// The content type of the document, if available. - /// - /// This helps the processor determine how to handle the document (e.g., as `application/ld+json`). + /// The HTTP `Content-Type` header value, if available. public let contentType: String? - /// The URL of an associated JSON-LD context. - /// - /// This is typically retrieved from an HTTP `Link` header with the - /// `http://www.w3.org/ns/json-ld#context` relation. - public let contextURL: String? + /// The HTTP `Link` header values, if available. + public let linkHeaders: [LinkHeader] + + var parsedContentType: (type: String, parameter: [String: String])? { + guard let contentType = self.contentType else { return nil } + let parts = contentType.split(separator: ";").map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard let mediaType = parts.first?.lowercased() else { return nil } + return ( + type: mediaType, + parameter: .init( + uniqueKeysWithValues: parts.dropFirst().compactMap { + let pair = $0.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2 else { return nil } + return (pair[0].lowercased(), pair[1].trimmingCharacters(in: .doubleQuote)) + } + ) + ) + } + + var mediaType: String? { + self.parsedContentType?.type + } + + var profile: String? { + self.parsedContentType?.parameter["profile"] + } + + var isJSON: Bool { + guard let mediaType = self.mediaType else { return false } + return mediaType == "application/json" + || mediaType == "application/ld+json" + || mediaType.hasPrefix("application/") + && mediaType.hasSuffix("+json") + } - /// Creates a remote document with metadata. + /// Creates a raw remote document response. public init( documentURL: String, - document: JSONValue, + body: Data, contentType: String? = nil, - contextURL: String? = nil + linkHeaders: String? = nil ) { self.documentURL = documentURL - self.document = document + self.body = body self.contentType = contentType - self.contextURL = contextURL + self.linkHeaders = linkHeaders?.split(separator: ",").compactMap(LinkHeader.init(_:)) ?? [] + } + + /// Creates a raw remote document response. + public init( + documentURL: String, + body: Data, + contentType: String? = nil, + linkHeaders: [String] + ) { + self.documentURL = documentURL + self.body = body + self.contentType = contentType + self.linkHeaders = linkHeaders.flatMap { + $0.split(separator: ",").compactMap(LinkHeader.init(_:)) + } } } /// A protocol for loading remote documents and contexts. /// -/// Implementations of this protocol provide the networking logic (e.g., using `URLSession` or `AsyncHTTPClient`) -/// allowing the JSON-LD processor to remain independent of specific networking stacks. +/// Implementations provide HTTP transport (e.g. `URLSession` or `AsyncHTTPClient`) while the +/// JSON-LD module remains responsible for interpreting response headers and the response body. public protocol JSONLDDocumentLoader: Sendable { - /// Loads a document from the specified URL. + /// Loads a document from the specified URL with a requested profile. /// - /// - Parameter url: The URL of the document to load. - /// - Returns: A `Result` containing either a `RemoteDocument` on success or an `Error` on failure. - func load(url: String) async -> Result + /// - Parameters: + /// - url: The URL of the document to load. + /// - requestProfile: The preferred media type profile, if any. + /// - Returns: A `Result` containing either the raw HTTP response on success or an `Error` on failure. + func load( + url: String, + requestProfile: String? + ) async -> Result } diff --git a/Sources/JSONLD/Processor.swift b/Sources/JSONLD/Processor.swift index 90dd84f..c5bb546 100644 --- a/Sources/JSONLD/Processor.swift +++ b/Sources/JSONLD/Processor.swift @@ -9,7 +9,7 @@ import Foundation /// It maintains configuration settings like the document loader. public class JSONLDProcessor { /// The loader used to resolve remote documents and contexts. - public var loader: any JSONLDDocumentLoader = DefaultLoader() + public var loader: (any JSONLDDocumentLoader)? /// Creates a JSON-LD processor. public init() {} @@ -211,40 +211,39 @@ public class JSONLDProcessor { expandContext: Contexts? = nil, normative: Bool = true ) async throws(JSONLDError) -> JSONLDDocument { - let result = await self.loader.load(url: url) - let remoteDocument: RemoteDocument = - switch result { - case .success(let doc): - doc - case .failure(let error): - throw .code( - .loadingRemoteContextFailed, - debugInfo: .init(url: url, message: String(describing: error)) - ) - } + guard let loader = self.loader else { + throw .code( + .loadingDocumentFailed, + debugInfo: .init(url: url, message: "document loader is not configured") + ) + } + + let remoteDocument = try await RemoteDocument.load( + url: url, + using: loader, + failureCode: .loadingDocumentFailed + ) let document = try JSONLDDocument(from: remoteDocument.document) + let remoteContext: Contexts? = + if let contextURL = remoteDocument.contextURL { + if let expandContext { + .single(.init(iri: contextURL)) + expandContext + } else { + .single(.init(iri: contextURL)) + } + } else { + expandContext + } return try await self.expand( document, - expandContext: expandContext, + expandContext: remoteContext, baseIRI: remoteDocument.documentURL, normative: normative ) } } -private struct DefaultLoader: JSONLDDocumentLoader { - func load(url: String) async -> Result { - // TODO: Implementation of a default loader using URLSession or AsyncHTTPClient. - .failure( - JSONLDError.code( - .loadingRemoteContextFailed, - debugInfo: .init(url: url, message: "default loader is not implemented") - ) - ) - } -} - extension JSONLDProcessor { private func resolveActiveContext( context: Contexts, diff --git a/Tests/JSONLDTestSuiteTests/DocumentLoader+Test.swift b/Tests/JSONLDTestSuiteTests/DocumentLoader+Test.swift index a561f7f..e24df0b 100644 --- a/Tests/JSONLDTestSuiteTests/DocumentLoader+Test.swift +++ b/Tests/JSONLDTestSuiteTests/DocumentLoader+Test.swift @@ -10,18 +10,51 @@ struct TestDocumentLoader: JSONLDDocumentLoader { case unknown(error: Swift.Error) } - func load(url: String) async -> Result { + var optionsByURL: [String: RemoteDocumentTest.Options] = [:] + + func load( + url: String, + requestProfile: String? + ) async -> Result { let base = "https://w3c.github.io/json-ld-api/tests/" guard url.hasPrefix(base) else { return .failure(Error.unknown(url: url)) } + let relativePath = String(url.dropFirst(base.count)) + let options = self.optionsByURL[url] + let documentURL = + options?.redirectTo + .flatMap { URL(string: $0, relativeTo: URL(string: base))?.absoluteString } + ?? url + let documentPath = String(documentURL.dropFirst(base.count)) do { - let document = try TestCaseLoader.load(relativePath, type: JSONValue.self) - return .success(RemoteDocument(documentURL: url, document: document)) + let body = try TestCaseLoader.loadData(documentPath) + return .success( + RemoteDocumentResponse( + documentURL: documentURL, + body: body, + contentType: options?.contentType ?? Self.contentType(for: relativePath), + linkHeaders: options?.httpLink ?? [] + ) + ) } catch let error { return .failure(Error.unknown(error: error)) } } + + private static func contentType(for path: String) -> String? { + if path.hasSuffix(".json") { + "application/json" + } else if path.hasSuffix(".jsonld") { + "application/ld+json" + } else if path.hasSuffix(".html") { + "text/html" + } else if path.hasSuffix(".nq") { + "application/n-quads" + } else { + nil + } + } } diff --git a/Tests/JSONLDTestSuiteTests/RemoteDocumentTests.swift b/Tests/JSONLDTestSuiteTests/RemoteDocumentTests.swift new file mode 100644 index 0000000..02aca0e --- /dev/null +++ b/Tests/JSONLDTestSuiteTests/RemoteDocumentTests.swift @@ -0,0 +1,68 @@ +// Copyright 2026 kPherox +// SPDX-License-Identifier: Apache-2.0 + +import Testing + +@testable import JSONLD + +@Suite( + .disabled( + if: TestCaseLoader.remoteDocumentTestsManifest == nil, + "Missing remote document test manifest" + ) +) +struct RemoteDocumentTests { + @Test( + "[Remote document] Positive Evaluation Test with processingMode 1.0", + arguments: TestCaseLoader.remoteDocumentTestsPositiveCases(version: .v1p0) + ) + func positiveEvaluationTestOneZero(testCase: RemoteDocumentTest.PositiveCase) async throws { + let manifestBase = "https://w3c.github.io/json-ld-api/tests/" + let documentIRI = manifestBase + testCase.input + let processor = JSONLDProcessor() + processor.loader = TestDocumentLoader(optionsByURL: [documentIRI: testCase.options]) + + let actual = try await processor.expand(url: documentIRI) + let expect = try TestCaseLoader.load( + testCase.expectFilename, + type: JSONLDDocument.self + ) + #expect(actual.jsonValue == expect.jsonValue) + } + + @Test( + "[Remote document] Positive Evaluation Test with processingMode 1.1", + .disabled("Unsupported JSON-LD 1.1"), + arguments: TestCaseLoader.remoteDocumentTestsPositiveCases(version: .v1p1) + ) + func positiveEvaluationTestOneOne(testCase: RemoteDocumentTest.PositiveCase) {} + + @Test( + "[Remote document] Negative Evaluation Test with processingMode 1.0", + arguments: TestCaseLoader.remoteDocumentTestsNegativeCases(version: .v1p0) + ) + func negativeEvaluationTestOneZero(testCase: RemoteDocumentTest.NegativeCase) async throws { + guard let expectError = JSONLDError.Code(rawValue: testCase.expectErrorCode) else { + Issue.record( + "Missing JSONLDError case for expected error code: '\(testCase.expectErrorCode)'" + ) + return + } + + let manifestBase = "https://w3c.github.io/json-ld-api/tests/" + let documentIRI = manifestBase + testCase.input + let processor = JSONLDProcessor() + processor.loader = TestDocumentLoader(optionsByURL: [documentIRI: testCase.options]) + + await #expect(throws: JSONLDError.code(expectError)) { + _ = try await processor.expand(url: documentIRI) + } + } + + @Test( + "[Remote document] Negative Evaluation Test with processingMode 1.1", + .disabled("Unsupported JSON-LD 1.1"), + arguments: TestCaseLoader.remoteDocumentTestsNegativeCases(version: .v1p1) + ) + func negativeEvaluationTestOneOne(testCase: RemoteDocumentTest.NegativeCase) {} +} diff --git a/Tests/JSONLDTestSuiteTests/TestCase.swift b/Tests/JSONLDTestSuiteTests/TestCase.swift index b9fcfbc..eee032b 100644 --- a/Tests/JSONLDTestSuiteTests/TestCase.swift +++ b/Tests/JSONLDTestSuiteTests/TestCase.swift @@ -74,3 +74,15 @@ struct FlattenTest { typealias PositiveCase = PositiveEvalutionTest typealias NegativeCase = NegativeEvalutionTest } + +enum RemoteDocumentTest { + struct Options { + private(set) var contentType: String? = nil + private(set) var httpLink: [String] = [] + private(set) var redirectTo: String? = nil + private(set) var httpStatus: Int? = nil + } + + typealias PositiveCase = PositiveEvalutionTest + typealias NegativeCase = NegativeEvalutionTest +} diff --git a/Tests/JSONLDTestSuiteTests/TestCaseLoader.swift b/Tests/JSONLDTestSuiteTests/TestCaseLoader.swift index 2e0c41c..be4c220 100644 --- a/Tests/JSONLDTestSuiteTests/TestCaseLoader.swift +++ b/Tests/JSONLDTestSuiteTests/TestCaseLoader.swift @@ -20,6 +20,11 @@ enum TestCaseLoader { } struct Option: Decodable { + enum CodingKeys: String, CodingKey { + case processingMode, specVersion, base, compactArrays, compactToRelative, expandContext + case normative, contentType, httpLink, redirectTo, httpStatus, processorFeature + } + let processingMode: JsonLdVersion? let specVersion: JsonLdVersion? let base: String? @@ -27,6 +32,43 @@ enum TestCaseLoader { let compactToRelative: Bool? let expandContext: String? let normative: Bool? + let contentType: String? + let httpLink: [String] + let redirectTo: String? + let httpStatus: Int? + let processorFeature: String? + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.processingMode = try container.decodeIfPresent( + JsonLdVersion.self, + forKey: .processingMode + ) + self.specVersion = try container.decodeIfPresent(JsonLdVersion.self, forKey: .specVersion) + self.base = try container.decodeIfPresent(String.self, forKey: .base) + self.compactArrays = try container.decodeIfPresent(Bool.self, forKey: .compactArrays) + self.compactToRelative = try container.decodeIfPresent( + Bool.self, + forKey: .compactToRelative + ) + self.expandContext = try container.decodeIfPresent(String.self, forKey: .expandContext) + self.normative = try container.decodeIfPresent(Bool.self, forKey: .normative) + self.contentType = try container.decodeIfPresent(String.self, forKey: .contentType) + self.httpLink = + if let links = try? container.decode([String].self, forKey: .httpLink) { + links + } else if let link = try container.decodeIfPresent(String.self, forKey: .httpLink) { + [link] + } else { + [] + } + self.redirectTo = try container.decodeIfPresent(String.self, forKey: .redirectTo) + self.httpStatus = try container.decodeIfPresent(Int.self, forKey: .httpStatus) + self.processorFeature = try container.decodeIfPresent( + String.self, + forKey: .processorFeature + ) + } } let id: String @@ -48,6 +90,14 @@ enum TestCaseLoader { [.v1p0, .v1p1] } } + + var requiresHTMLScriptExtraction: Bool { + guard let processorFeature = self.option?.processorFeature else { + return false + } + + return processorFeature == "HTML Script Extraction" + } } let sequence: [Sequence] @@ -81,10 +131,22 @@ enum TestCaseLoader { self.flatteningTestsManifest?.sequence ?? [] } + static var remoteDocumentTestsManifest: Manifest? { + try? self.load("remote-doc-manifest.jsonld") + } + + private static var remoteDocumentTestsCases: [Manifest.Sequence] { + self.remoteDocumentTestsManifest?.sequence ?? [] + } + static func load(_ name: String, type: T.Type = T.self) throws -> T { try Util.loadFixture(name, from: self.testCasePath, type: type) } + static func loadData(_ name: String) throws -> Data { + try Util.loadFixtureData(name, from: self.testCasePath) + } + static func loadContexts(_ name: String?) throws -> Contexts? { guard let name else { return nil } let jsonValue: JSONValue = try self.load(name, type: JSONValue.self) @@ -231,4 +293,65 @@ enum TestCaseLoader { } } } + + static func remoteDocumentTestsPositiveCases( + version: JsonLdVersion + ) + -> [RemoteDocumentTest.PositiveCase] + { + self.remoteDocumentTestsCases.compactMap { seq in + if seq.type.contains("jld:PositiveEvaluationTest"), + seq.type.contains("jld:ExpandTest"), + seq.processingModes.contains(version), + // #tla02 don't requires HTML Script extraction + !seq.requiresHTMLScriptExtraction || seq.id == "#tla02", + // #t0013 requires HTML Script extraction but missing information + seq.id != "#t0013", + let expect = seq.expect + { + RemoteDocumentTest.PositiveCase( + meta: .init(id: seq.id, name: seq.name), + input: seq.input, + expectFilename: expect, + options: .init( + contentType: seq.option?.contentType, + httpLink: seq.option?.httpLink ?? [], + redirectTo: seq.option?.redirectTo, + httpStatus: seq.option?.httpStatus + ) + ) + } else { + nil + } + } + } + + static func remoteDocumentTestsNegativeCases( + version: JsonLdVersion + ) + -> [RemoteDocumentTest.NegativeCase] + { + self.remoteDocumentTestsCases.compactMap { seq in + if seq.type.contains("jld:NegativeEvaluationTest"), + seq.type.contains("jld:ExpandTest"), + seq.processingModes.contains(version), + !seq.requiresHTMLScriptExtraction, + let expectErrorCode = seq.expectErrorCode + { + RemoteDocumentTest.NegativeCase( + meta: .init(id: seq.id, name: seq.name), + input: seq.input, + expectErrorCode: expectErrorCode, + options: .init( + contentType: seq.option?.contentType, + httpLink: seq.option?.httpLink ?? [], + redirectTo: seq.option?.redirectTo, + httpStatus: seq.option?.httpStatus + ) + ) + } else { + nil + } + } + } } diff --git a/Tests/JSONLDTestSuiteTests/Util.swift b/Tests/JSONLDTestSuiteTests/Util.swift index 39a2fa4..24b1f7a 100644 --- a/Tests/JSONLDTestSuiteTests/Util.swift +++ b/Tests/JSONLDTestSuiteTests/Util.swift @@ -8,15 +8,16 @@ enum Util { case missingFixture(String) } + static func loadFixtureData(_ name: String, from url: URL? = nil) throws -> Data { + try Data(contentsOf: self.findResourceURL(for: name, from: url)) + } + static func loadFixture( _ name: String, from url: URL? = nil, type: T.Type = T.self ) throws -> T { - try JSONDecoder().decode( - type, - from: Data(contentsOf: self.findResourceURL(for: name, from: url)) - ) + try JSONDecoder().decode(type, from: Self.loadFixtureData(name, from: url)) } private static func findResourceURL(for name: String, from url: URL?) throws(Error) -> URL { diff --git a/Tests/JSONLDTests/ContextResolverTests.swift b/Tests/JSONLDTests/ContextResolverTests.swift new file mode 100644 index 0000000..b0d7948 --- /dev/null +++ b/Tests/JSONLDTests/ContextResolverTests.swift @@ -0,0 +1,89 @@ +// Copyright 2026 kPherox +// SPDX-License-Identifier: Apache-2.0 + +import Foundation +import Testing + +@testable import JSONLD + +struct ContextResolverTests { + @Test( + "Expect `loading remote context failed` error to load remote context without document loader" + ) + func loadRemoteContextWithoutLoader() async { + await #expect(throws: JSONLDError.code(.loadingRemoteContextFailed)) { + _ = try await ContextResolver(loader: nil).process( + contexts: "https://example.com/context", + activeContext: .empty + ) + } + } + + @Test("Load remote context with document loader") + func loadRemoteContext() async throws { + let loader = RecordingDocumentLoader( + response: .success( + RemoteDocumentResponse( + documentURL: "https://example.com/context", + body: #"{"@context":{"term":"https://example.com/term"}}"#.data(using: .utf8)!, + contentType: "application/ld+json" + ) + ) + ) + + _ = try await ContextResolver(loader: loader).process( + contexts: "https://example.com/context", + activeContext: .empty + ) + + let requests = await loader.requests() + #expect( + requests == [ + .init( + url: "https://example.com/context", + requestProfile: "http://www.w3.org/ns/json-ld#context" + ) + ] + ) + } + + @Test("Remote context invalid json") + func loadInvalidJSONRemoteContext() async { + let loader = RecordingDocumentLoader( + response: .success( + RemoteDocumentResponse( + documentURL: "https://example.com/context", + body: #"{"@context":"#.data(using: .utf8)!, + contentType: "application/ld+json" + ) + ) + ) + + await #expect(throws: JSONLDError.code(.loadingRemoteContextFailed)) { + _ = try await ContextResolver(loader: loader).process( + contexts: "https://example.com/context", + activeContext: .empty + ) + } + } + + @Test("Remote context missing `@context` field") + func loadRemoteContextMissingContextField() async { + let loader = RecordingDocumentLoader( + response: .success( + RemoteDocumentResponse( + documentURL: "https://example.com/context", + body: #"{"term":"https://example.com/term"}"#.data(using: .utf8)!, + contentType: "application/ld+json" + ) + ) + ) + + await #expect(throws: JSONLDError.code(.invalidRemoteContext)) { + _ = try await ContextResolver(loader: loader).process( + contexts: "https://example.com/context", + activeContext: .empty + ) + } + } +} diff --git a/Tests/JSONLDTests/JSONLDProcessorTests.swift b/Tests/JSONLDTests/JSONLDProcessorTests.swift new file mode 100644 index 0000000..0eb76b5 --- /dev/null +++ b/Tests/JSONLDTests/JSONLDProcessorTests.swift @@ -0,0 +1,17 @@ +// Copyright 2026 kPherox +// SPDX-License-Identifier: Apache-2.0 + +import Testing + +@testable import JSONLD + +struct JSONLDProcessorTests { + @Test("Expect `loading document failed` error to load without document loader") + func loadRemoteDocumentWithoutLoader() async { + let processor = JSONLDProcessor() + + await #expect(throws: JSONLDError.code(.loadingDocumentFailed)) { + _ = try await processor.expand(url: "https://example.com/document") + } + } +} diff --git a/Tests/JSONLDTests/JSONLDTests.swift b/Tests/JSONLDTests/JSONLDTests.swift deleted file mode 100644 index b71f275..0000000 --- a/Tests/JSONLDTests/JSONLDTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2026 kPherox -// SPDX-License-Identifier: Apache-2.0 - -import Testing - -@testable import JSONLD - -@Suite("JSONLD Unit Tests") -struct JSONLDTests {} diff --git a/Tests/JSONLDTests/RecordingDocumentLoader.swift b/Tests/JSONLDTests/RecordingDocumentLoader.swift new file mode 100644 index 0000000..72c6d4b --- /dev/null +++ b/Tests/JSONLDTests/RecordingDocumentLoader.swift @@ -0,0 +1,32 @@ +// Copyright 2026 kPherox +// SPDX-License-Identifier: Apache-2.0 + +import JSONLD + +actor RecordingDocumentLoader: JSONLDDocumentLoader { + struct Request: Equatable { + let url: String + let requestProfile: String? + } + + private let response: Result + private var recordedRequests: [Request] = [] + + init(response: Result) { + self.response = response + } + + func load( + url: String, + requestProfile: String? + ) async -> Result< + RemoteDocumentResponse, any Error + > { + self.recordedRequests.append(.init(url: url, requestProfile: requestProfile)) + return self.response + } + + func requests() -> [Request] { + self.recordedRequests + } +} diff --git a/api-breakages.txt b/api-breakages.txt new file mode 100644 index 0000000..2834a62 --- /dev/null +++ b/api-breakages.txt @@ -0,0 +1,9 @@ +API breakage: func JSONLDDocumentLoader.load(url:) has return type change from Swift.Result to Swift.Result +API breakage: func JSONLDDocumentLoader.load(url:) has been renamed to func load(url:requestProfile:) +API breakage: var JSONLDProcessor.loader has declared type change from any JSONLD.JSONLDDocumentLoader to (any JSONLD.JSONLDDocumentLoader)? +API breakage: accessor JSONLDProcessor.loader.Get() has return type change from any JSONLD.JSONLDDocumentLoader to (any JSONLD.JSONLDDocumentLoader)? +API breakage: accessor JSONLDProcessor.loader.Set() has parameter 0 type change from any JSONLD.JSONLDDocumentLoader to (any JSONLD.JSONLDDocumentLoader)? +API breakage: var RemoteDocument.document has been removed +API breakage: var RemoteDocument.contextURL has been removed +API breakage: constructor RemoteDocument.init(documentURL:document:contentType:contextURL:) has been removed +API breakage: struct RemoteDocument has been renamed to struct RemoteDocumentResponse