Created
May 9, 2024 13:05
-
-
Save albertodebortoli/37bd9e91fa4336ca4c38d346d13daf8a to your computer and use it in GitHub Desktop.
Universal Links matching against AASA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"applinks": { | |
"details": [ | |
{ | |
"appIDs": [ | |
"XXXXXXXXXX.com.example.app" | |
], | |
"components": [ | |
{ | |
"/": "/", | |
"?": { | |
"key": "value" | |
}, | |
"#": "anchor" | |
}, | |
{ | |
"/": "/path/?*", | |
"?": { | |
"key1": "?*", | |
"key2": "?*" | |
}, | |
"#": "anchor" | |
}, | |
{ | |
"/": "/path/?*", | |
"?": { | |
"key1": "?*" | |
}, | |
"#": "anchor" | |
} | |
] | |
} | |
] | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"deep_linkable_urls": [ | |
"https://example.com/?key=value#anchor", | |
"https://example.com/path/something/?key1=value#anchor", | |
"https://example.com/path/somethingElse/?key1=value1&key2=value2#anchor" | |
], | |
"non_deep_linkable_urls": [ | |
"https://example.com/#different", | |
"https://example.com/path/something/?key2=value#different", | |
"https://example.com/path/somethingElse/?key1=value#different" | |
] | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// UniversalLinksValidator.swift | |
import Foundation | |
typealias Domain = String | |
struct UniversaLinks: Equatable, Decodable { | |
let deepLinkableUrls: [URL] | |
let nonDeepLinkableUrls: [URL] | |
enum CodingKeys: String, CodingKey { | |
case deepLinkableUrls = "deep_linkable_urls" | |
case nonDeepLinkableUrls = "non_deep_linkable_urls" | |
} | |
} | |
extension AASAContent { | |
struct AppLinks: Decodable, Equatable { | |
let details: Details | |
let substitutionVariables: SubstitutionVariables? | |
typealias Details = [Detail] | |
typealias SubstitutionVariables = [SubstitutionVariableKey: [SubstitutionVariableValue]] | |
typealias SubstitutionVariableKey = String | |
typealias SubstitutionVariableValue = String | |
} | |
} | |
extension AASAContent.AppLinks { | |
struct Detail: Decodable, Equatable { | |
typealias AppId = String | |
let appIDs: [AppId] | |
let components: [Component] | |
} | |
} | |
extension AASAContent.AppLinks.Detail { | |
typealias ComponentValue = String | |
struct Component: Decodable, Equatable { | |
let path: ComponentValue | |
let queries: [String: ComponentValue]? | |
let fragment: ComponentValue? | |
let exclude: Bool? | |
private enum CodingKeys: String, CodingKey { | |
case path = "/" | |
case queries = "?" | |
case fragment = "#" | |
case exclude | |
} | |
} | |
} | |
struct UniversalLinksValidator { | |
enum ValidateUniversalLinkError: Error, Equatable, LocalizedError { | |
case domainMismatch(domain: Domain, universaLink: URL) | |
case unsupportedBundleId(bundleId: String) | |
case unhandledUniversalLink(url: URL) | |
case excludedUniversalLink(url: URL) | |
case incorrectlyHandledUniversalLink(url: URL) | |
var errorDescription: String? { | |
switch self { | |
case .domainMismatch(let domain, let universalLink): | |
return "❌ The host of the universal link '\(universalLink.absoluteString)' differs from '\(domain)'." | |
case .unsupportedBundleId(let bundleId): | |
return "❌ The bundleId '\(bundleId)' is not included in any detail object." | |
case .unhandledUniversalLink(let url): | |
return "❌ The url '\(url)' is not handled by the AASA." | |
case .excludedUniversalLink(let url): | |
return "❌ The url '\(url)' is excluded by the AASA." | |
case .incorrectlyHandledUniversalLink(let url): | |
return "❌ The url '\(url)' is handled by the AASA but it shouldn't." | |
} | |
} | |
} | |
func validateUniversalLinks(_ universaLinks: UniversaLinks, domain: Domain, aasaContent: AASAContent, bundleId: BundleId) throws { | |
let universalLinksValidator = UniversalLinksValidator() | |
try universalLinksValidator.validateUniversalLinksHost( | |
universaLinks: universaLinks, | |
domain: domain | |
) | |
try universalLinksValidator.validateDeepLinkingAllowedOnApp( | |
with: bundleId, | |
appLinkDetails: aasaContent.appLinks.details | |
) | |
let components = universalLinksValidator.components( | |
for: bundleId, | |
appLinkDetails: aasaContent.appLinks.details | |
) | |
try universalLinksValidator.validateDeepLinkingAllowed( | |
for: universaLinks.deepLinkableUrls, | |
domain: domain, | |
components: components, | |
substitutionVariables: aasaContent.appLinks.substitutionVariables ?? [:] | |
) | |
try universalLinksValidator.validateDeepLinkingNotAllowed( | |
for: universaLinks.nonDeepLinkableUrls, | |
domain: domain, | |
components: components, | |
substitutionVariables: aasaContent.appLinks.substitutionVariables ?? [:] | |
) | |
} | |
// MARK: - Private | |
private func validateUniversalLinksHost(universaLinks: UniversaLinks, domain: Domain) throws { | |
for deepLinkableUrl in universaLinks.deepLinkableUrls where deepLinkableUrl.host != domain { | |
throw ValidateUniversalLinkError.domainMismatch(domain: domain, universaLink: deepLinkableUrl) | |
} | |
} | |
private func validateDeepLinkingAllowedOnApp(with bundleId: BundleId, appLinkDetails: AASAContent.AppLinks.Details) throws { | |
let result = appLinkDetails | |
.flatMap { $0.appIDs } | |
.map { BundleId(appId: $0) } | |
.contains(bundleId) | |
if !result { | |
throw ValidateUniversalLinkError.unsupportedBundleId(bundleId: bundleId) | |
} | |
} | |
private func components(for bundleId: BundleId, appLinkDetails: AASAContent.AppLinks.Details) -> [AASAContent.AppLinks.Detail.Component] { | |
appLinkDetails | |
.filter { $0 | |
.appIDs | |
.bundleIds() | |
.contains(bundleId) | |
} | |
.flatMap { $0.components } | |
} | |
private func validateDeepLinkingAllowed(for urls: [URL], domain: Domain, components: [AASAContent.AppLinks.Detail.Component], substitutionVariables: AASAContent.AppLinks.SubstitutionVariables) throws { | |
for url in urls { | |
try validateDeepLinkingAllowed(for: url, domain: domain, components: components, substitutionVariables: substitutionVariables) | |
} | |
} | |
private func validateDeepLinkingNotAllowed(for urls: [URL], domain: Domain, components: [AASAContent.AppLinks.Detail.Component], substitutionVariables: AASAContent.AppLinks.SubstitutionVariables) throws { | |
for url in urls { | |
try validateDeepLinkingNotAllowed(for: url, domain: domain, components: components, substitutionVariables: substitutionVariables) | |
} | |
} | |
private func validateDeepLinkingAllowed(for url: URL, domain: Domain, components: [AASAContent.AppLinks.Detail.Component], substitutionVariables: AASAContent.AppLinks.SubstitutionVariables) throws { | |
for component in components { | |
let urlComponents = makeUrlComponents(for: component, substitutionVariables: substitutionVariables, on: domain) | |
let regEx = try regEx(for: urlComponents) | |
if findMatch(for: url, in: regEx) { | |
if component.exclude != true { | |
return | |
} else { | |
throw ValidateUniversalLinkError.excludedUniversalLink(url: url) | |
} | |
} | |
} | |
throw ValidateUniversalLinkError.unhandledUniversalLink(url: url) | |
} | |
private func validateDeepLinkingNotAllowed(for url: URL, domain: Domain, components: [AASAContent.AppLinks.Detail.Component], substitutionVariables: AASAContent.AppLinks.SubstitutionVariables) throws { | |
for component in components { | |
let urlComponents = makeUrlComponents(for: component, substitutionVariables: substitutionVariables, on: domain) | |
let regEx = try regEx(for: urlComponents) | |
if findMatch(for: url, in: regEx) { | |
if component.exclude == true { | |
return | |
} else { | |
throw ValidateUniversalLinkError.incorrectlyHandledUniversalLink(url: url) | |
} | |
} | |
} | |
} | |
private func makeUrlComponents(for component: AASAContent.AppLinks.Detail.Component, substitutionVariables: AASAContent.AppLinks.SubstitutionVariables, on domain: Domain) -> URLComponents { | |
var urlComponents = URLComponents() | |
urlComponents.scheme = "https" | |
urlComponents.host = domain | |
urlComponents.path = component.path | |
.replaceWithSubstitutionVariables(substitutionVariables) | |
.regEx | |
if let queries = component.queries { | |
urlComponents.queryItems = queries | |
.map { (key: String, value: AASAContent.AppLinks.Detail.ComponentValue) in | |
URLQueryItem( | |
name: key | |
.replaceWithSubstitutionVariables(substitutionVariables) | |
.regEx, | |
value: value | |
.replaceWithSubstitutionVariables(substitutionVariables) | |
.regEx | |
) | |
} | |
// to avoid random failures due to Foundation handling dictionary w/ multiple key/value pairs unpredictibly | |
.sorted(by: { $0.name < $1.name }) | |
} | |
if let fragment = component.fragment { | |
urlComponents.fragment = fragment | |
.replaceWithSubstitutionVariables(substitutionVariables) | |
.regEx | |
} | |
return urlComponents | |
} | |
private func regEx(for urlComponents: URLComponents) throws -> NSRegularExpression { | |
let unescapedUrlString = urlComponents.url! | |
.absoluteString | |
.replacingOccurrences(of: "/?", with: "/\\?") // ? for query parameters | |
.removingPercentEncoding! | |
return try NSRegularExpression(pattern: unescapedUrlString, options: .caseInsensitive) | |
} | |
private func findMatch(for url: URL, in regEx: NSRegularExpression) -> Bool { | |
let searchString = url.absoluteString.removingPercentEncoding! | |
let searchRange = NSRange(location: 0, length: searchString.utf16.count) | |
if let result = regEx.firstMatch( | |
in: searchString, | |
options: [.anchored, .withoutAnchoringBounds], | |
range: searchRange) { | |
return result.range.length == searchRange.length | |
} | |
return false | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment