Skip to content

Instantly share code, notes, and snippets.

@albertodebortoli
Created May 9, 2024 13:05
Show Gist options
  • Save albertodebortoli/37bd9e91fa4336ca4c38d346d13daf8a to your computer and use it in GitHub Desktop.
Save albertodebortoli/37bd9e91fa4336ca4c38d346d13daf8a to your computer and use it in GitHub Desktop.
Universal Links matching against AASA
{
"applinks": {
"details": [
{
"appIDs": [
"XXXXXXXXXX.com.example.app"
],
"components": [
{
"/": "/",
"?": {
"key": "value"
},
"#": "anchor"
},
{
"/": "/path/?*",
"?": {
"key1": "?*",
"key2": "?*"
},
"#": "anchor"
},
{
"/": "/path/?*",
"?": {
"key1": "?*"
},
"#": "anchor"
}
]
}
]
}
}
// 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