|
| 1 | +// Copyright 2022 Google LLC |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +import Foundation |
| 16 | + |
| 17 | +enum StoragePathError: Error { |
| 18 | + case storagePathError(String) |
| 19 | +} |
| 20 | + |
| 21 | +/** |
| 22 | + * Represents a path in GCS, which can be represented as: gs://bucket/path/to/object |
| 23 | + * or http[s]://firebasestorage.googleapis.com/v0/b/bucket/o/path/to/object?token=<12345> |
| 24 | + * This class also includes helper methods to parse those URI/Ls, as well as to |
| 25 | + * add and remove path segments. |
| 26 | + */ |
| 27 | +internal class StoragePath: NSCopying, Equatable { |
| 28 | + // MARK: Class methods |
| 29 | + |
| 30 | + /** |
| 31 | + * Parses a generic string (representing some URI or URL) and returns the appropriate path. |
| 32 | + * @param string String which is parsed into a path. |
| 33 | + * @return Returns an instance of StoragePath. |
| 34 | + * @throws Throws an error if the string is not a valid gs:// URI or http[s]:// URL. |
| 35 | + */ |
| 36 | + static func path(string: String) throws -> StoragePath { |
| 37 | + if string.hasPrefix("gs://") { |
| 38 | + // "gs://bucket/path/to/object" |
| 39 | + return try path(GSURI: string) |
| 40 | + } else if string.hasPrefix("http://") || string.hasPrefix("https://") { |
| 41 | + // "http[s]://firebasestorage.googleapis.com/bucket/path/to/object?signed_url_params" |
| 42 | + return try path(HTTPURL: string) |
| 43 | + } else { |
| 44 | + // Invalid scheme, throw an error! |
| 45 | + throw StoragePathError.storagePathError("Internal error: URL scheme must be one" + |
| 46 | + "of gs://, http://, or https://") |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + /** |
| 51 | + * Parses a gs://bucket/path/to/object URI into a GCS path. |
| 52 | + * @param aURIString gs:// URI which is parsed into a path. |
| 53 | + * @return Returns an instance of StoragePath or nil if one can't be created. |
| 54 | + * @throws Throws an error if the string is not a valid gs:// URI. |
| 55 | + */ |
| 56 | + internal static func path(GSURI aURIString: String) throws -> StoragePath { |
| 57 | + if aURIString.starts(with: "gs://") { |
| 58 | + let bucketObject = aURIString.dropFirst("gs://".count) |
| 59 | + if bucketObject.contains("/") { |
| 60 | + let splitStringArray = bucketObject.split(separator: "/", maxSplits: 1).map(String.init) |
| 61 | + let object = splitStringArray.count == 2 ? splitStringArray[1] : nil |
| 62 | + return StoragePath(with: splitStringArray[0], object: object) |
| 63 | + } else if bucketObject.count > 0 { |
| 64 | + return StoragePath(with: String(bucketObject)) |
| 65 | + } |
| 66 | + } |
| 67 | + throw StoragePathError |
| 68 | + .storagePathError("Internal error: URI must be in the form of " + |
| 69 | + "gs://<bucket>/<path/to/object>") |
| 70 | + } |
| 71 | + |
| 72 | + /** |
| 73 | + * Parses a http[s]://firebasestorage.googleapis.com/v0/b/bucket/o/path/to/object...?token=<12345> |
| 74 | + * URL into a GCS path. |
| 75 | + * @param aURLString http[s]:// URL which is parsed into a path. |
| 76 | + * string which is parsed into a path. |
| 77 | + * @return Returns an instance of StoragePath or nil if one can't be created. |
| 78 | + * @throws Throws an error if the string is not a valid http[s]:// URL. |
| 79 | + */ |
| 80 | + private static func path(HTTPURL aURLString: String) throws -> StoragePath { |
| 81 | + let httpsURL = URL(string: aURLString) |
| 82 | + let pathComponents = httpsURL?.pathComponents |
| 83 | + guard let pathComponents = pathComponents, |
| 84 | + pathComponents.count >= 4, |
| 85 | + pathComponents[1] == "v0", |
| 86 | + pathComponents[2] == "b" else { |
| 87 | + throw StoragePathError.storagePathError("Internal error: URL must be in the form of " + |
| 88 | + "http[s]://<host>/v0/b/<bucket>/o/<path/to/object>[?token=signed_url_params]") |
| 89 | + } |
| 90 | + let bucketName = pathComponents[3] |
| 91 | + |
| 92 | + guard pathComponents.count > 4 else { |
| 93 | + return StoragePath(with: bucketName) |
| 94 | + } |
| 95 | + // Construct object name |
| 96 | + var objectName = pathComponents[5] |
| 97 | + for i in 6 ..< pathComponents.count { |
| 98 | + objectName = "\(objectName)/\(pathComponents[i])" |
| 99 | + } |
| 100 | + return StoragePath(with: bucketName, object: objectName) |
| 101 | + } |
| 102 | + |
| 103 | + // Removes leading and trailing slashes, and compresses multiple slashes |
| 104 | + // to create a canonical representation. |
| 105 | + // Example: /foo//bar///baz//// -> foo/bar/baz |
| 106 | + private static func standardizedPathForString(_ string: String) -> String { |
| 107 | + var output = string |
| 108 | + while true { |
| 109 | + let newOutput = output.replacingOccurrences(of: "//", with: "/") |
| 110 | + if newOutput == output { |
| 111 | + break |
| 112 | + } |
| 113 | + output = newOutput |
| 114 | + } |
| 115 | + return output.trimmingCharacters(in: ["/"]) |
| 116 | + } |
| 117 | + |
| 118 | + // MARK: - Internal Implementations |
| 119 | + |
| 120 | + /** |
| 121 | + * The GCS bucket in the path. |
| 122 | + */ |
| 123 | + internal let bucket: String |
| 124 | + |
| 125 | + /** |
| 126 | + * The GCS object in the path. |
| 127 | + */ |
| 128 | + internal let object: String? |
| 129 | + |
| 130 | + /** |
| 131 | + * Constructs an StoragePath object that represents the given bucket and object. |
| 132 | + * @param bucket The name of the bucket. |
| 133 | + * @param object The name of the object. |
| 134 | + * @return An instance of StoragePath representing the @a bucket and @a object. |
| 135 | + */ |
| 136 | + internal init(with bucket: String, |
| 137 | + object: String? = nil) { |
| 138 | + self.bucket = bucket |
| 139 | + if let object = object { |
| 140 | + self.object = StoragePath.standardizedPathForString(object) |
| 141 | + } else { |
| 142 | + self.object = nil |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + static func == (lhs: StoragePath, rhs: StoragePath) -> Bool { |
| 147 | + return lhs.bucket == rhs.bucket && lhs.object == rhs.object |
| 148 | + } |
| 149 | + |
| 150 | + internal func copy(with zone: NSZone? = nil) -> Any { |
| 151 | + return StoragePath(with: bucket, object: object) |
| 152 | + } |
| 153 | + |
| 154 | + /** |
| 155 | + * Creates a new path based off of the current path and a string appended to it. |
| 156 | + * Note that all slashes are compressed to a single slash, and leading and trailing slashes |
| 157 | + * are removed. |
| 158 | + * @param path String to append to the current path. |
| 159 | + * @return Returns a new instance of StoragePath with the new path appended. |
| 160 | + */ |
| 161 | + internal func child(_ path: String) -> StoragePath { |
| 162 | + if path.count == 0 { |
| 163 | + return copy() as! StoragePath |
| 164 | + } |
| 165 | + var childObject: String |
| 166 | + if let object = object as? NSString { |
| 167 | + childObject = object.appendingPathComponent(path) |
| 168 | + } else { |
| 169 | + childObject = path |
| 170 | + } |
| 171 | + return StoragePath(with: bucket, object: childObject) |
| 172 | + } |
| 173 | + |
| 174 | + /** |
| 175 | + * Creates a new path based off of the current path with the last path segment removed. |
| 176 | + * @return Returns a new instance of StoragePath pointing to the parent path, |
| 177 | + * or nil if the current path points to the root. |
| 178 | + */ |
| 179 | + internal func parent() -> StoragePath? { |
| 180 | + guard let object = object, |
| 181 | + object.count > 0 else { |
| 182 | + return nil |
| 183 | + } |
| 184 | + let parentObject = (object as NSString).deletingLastPathComponent |
| 185 | + return StoragePath(with: bucket, object: parentObject) |
| 186 | + } |
| 187 | + |
| 188 | + /** |
| 189 | + * Creates a new path based off of the root of the bucket. |
| 190 | + * @return Returns a new instance of StoragePath pointing to the root of the bucket. |
| 191 | + */ |
| 192 | + internal func root() -> StoragePath { |
| 193 | + return StoragePath(with: bucket) |
| 194 | + } |
| 195 | + |
| 196 | + /** |
| 197 | + * Returns a GS URI representing the current path. |
| 198 | + * @return Returns a gs://bucket/path/to/object URI representing the current path. |
| 199 | + */ |
| 200 | + internal func stringValue() -> String { |
| 201 | + return "gs://\(bucket)/\(object ?? "")" |
| 202 | + } |
| 203 | +} |
0 commit comments