From 7e319114d13baf023a6c63f2bf891123b6be2d30 Mon Sep 17 00:00:00 2001 From: Fumito Ito Date: Fri, 23 Aug 2024 14:36:09 +0900 Subject: [PATCH] Fix problem where some constructors in Template could block the main thread. You can use constructors that pass `URL` when initializing a `Template` object. This constructor can generate a `Template` object from a string that exists not only locally, but also server-side. Since this constructor calls `NSString(contentsOf:encoding)` internally, it could block the calling thread if the given URL is remote. This change adds a constructor for a `Template` object that can be called in async for iOS 15 and above. This constructor allows remote resources to be used without blocking the calling thread. --- Sources/Template.swift | 24 +++++++- Sources/TemplateRepository.swift | 55 +++++++++++++++++- .../TemplateFromMethodsTests.swift | 58 +++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/Sources/Template.swift b/Sources/Template.swift index a590132..fd088e6 100644 --- a/Sources/Template.swift +++ b/Sources/Template.swift @@ -82,7 +82,29 @@ final public class Template { let templateAST = try repository.templateAST(named: templateName) self.init(repository: repository, templateAST: templateAST, baseContext: repository.configuration.baseContext) } - + + /// Creates a template from the contents of a URL. + /// + /// Eventual partial tags in the template refer to sibling templates using + /// the same extension. + /// + /// // `{{>partial}}` in `file://path/to/template.txt` loads `file://path/to/partial.txt`: + /// let template = try! Template(URL: "file://path/to/template.txt") + /// + /// - parameter URL: The URL of the template. + /// - parameter encoding: The encoding of the template resource. + /// - parameter configuration: The configuration for rendering. If the configuration is not specified, `Configuration.default` is used. + /// - throws: MustacheError + @available(iOS 15.0, *) + public convenience init(URL: Foundation.URL, encoding: String.Encoding = .utf8, configuration: Configuration = .default) async throws { + let baseURL = URL.deletingLastPathComponent() + let templateExtension = URL.pathExtension + let templateName = (URL.lastPathComponent as NSString).deletingPathExtension + let repository = TemplateRepository(baseURL: baseURL, templateExtension: templateExtension, encoding: encoding, configuration: configuration) + let templateAST = try await repository.templateAST(named: templateName) + self.init(repository: repository, templateAST: templateAST, baseContext: repository.configuration.baseContext) + } + /// Creates a template from a bundle resource. /// /// Eventual partial tags in the template refer to template resources using diff --git a/Sources/TemplateRepository.swift b/Sources/TemplateRepository.swift index 1c2a093..c6dedc8 100644 --- a/Sources/TemplateRepository.swift +++ b/Sources/TemplateRepository.swift @@ -76,6 +76,14 @@ public protocol TemplateRepositoryDataSource { /// - throws: MustacheError /// - returns: A Mustache template string. func templateStringForTemplateID(_ templateID: TemplateID) throws -> String + + /// Returns the Mustache template string that matches the template ID. + /// + /// - parameter templateID: The template ID of the template. + /// - throws: MustacheError + /// - returns: A Mustache template string. + @available(iOS 15.0, *) + func templateStringForTemplateID(_ templaetID: TemplateID) async throws -> String } /// A template repository represents a set of sibling templates and partials. @@ -344,7 +352,45 @@ final public class TemplateRepository { throw error } } - + + @available(iOS 15.0, *) + func templateAST(named name: String, relativeToTemplateID baseTemplateID: TemplateID? = nil) async throws -> TemplateAST { + guard let dataSource = self.dataSource else { + throw MustacheError(kind: .templateNotFound, message: "Missing dataSource", templateID: baseTemplateID) + } + + guard let templateID = dataSource.templateIDForName(name, relativeToTemplateID: baseTemplateID) else { + if let baseTemplateID = baseTemplateID { + throw MustacheError(kind: .templateNotFound, message: "Template not found: \"\(name)\" from \(baseTemplateID)", templateID: baseTemplateID) + } else { + throw MustacheError(kind: .templateNotFound, message: "Template not found: \"\(name)\"") + } + } + + if let templateAST = templateASTCache[templateID] { + // Return cached AST + return templateAST + } + + let templateString = try await dataSource.templateStringForTemplateID(templateID) + + // Cache an empty AST for that name so that we support recursive + // partials. + let templateAST = TemplateAST() + templateASTCache[templateID] = templateAST + + do { + let compiledAST = try self.templateAST(string: templateString, templateID: templateID) + // Success: update the empty AST + templateAST.updateFromTemplateAST(compiledAST) + return templateAST + } catch { + // Failure: remove the empty AST + templateASTCache.removeValue(forKey: templateID) + throw error + } + } + func templateAST(string: String, templateID: TemplateID? = nil) throws -> TemplateAST { // A Compiler let compiler = TemplateCompiler( @@ -463,6 +509,13 @@ final public class TemplateRepository { func templateStringForTemplateID(_ templateID: TemplateID) throws -> String { return try NSString(contentsOf: URL(string: templateID)!, encoding: encoding.rawValue) as String } + + @available(iOS 15.0, *) + func templateStringForTemplateID(_ templateID: TemplateID) async throws -> String { + let (data, _) = try await URLSession.shared.data(from: URL(string: templateID)!) + + return (NSString(data: data, encoding: encoding.rawValue) ?? "") as String + } } diff --git a/Tests/Public/TemplateTests/TemplateFromMethodsTests/TemplateFromMethodsTests.swift b/Tests/Public/TemplateTests/TemplateFromMethodsTests/TemplateFromMethodsTests.swift index 5660d2b..f7c009c 100644 --- a/Tests/Public/TemplateTests/TemplateFromMethodsTests/TemplateFromMethodsTests.swift +++ b/Tests/Public/TemplateTests/TemplateFromMethodsTests/TemplateFromMethodsTests.swift @@ -274,3 +274,61 @@ class TemplateFromMethodsTests: XCTestCase { } } } + +@available(iOS 15.0, *) +extension TemplateFromMethodsTests { + func testTemplateFromURL() async { + let template = try! await Template(URL: templateURL) + let keyedSubscript = makeKeyedSubscriptFunction("foo") + let rendering = try! template.render(MustacheBox(keyedSubscript: keyedSubscript)) + XCTAssertEqual(valueForStringPropertyInRendering(rendering)!, "foo") + } + + func testParserErrorFromURL() async { + do { + let _ = try await Template(URL: parserErrorTemplateURL) + XCTFail("Expected MustacheError") + } catch let error as MustacheError { + XCTAssertEqual(error.kind, MustacheError.Kind.parseError) + XCTAssertTrue(error.description.range(of: "line 2") != nil) + XCTAssertTrue(error.description.range(of: parserErrorTemplatePath) != nil) + } catch { + XCTFail("Expected MustacheError") + } + + do { + let _ = try await Template(URL: parserErrorTemplateWrapperURL) + XCTFail("Expected MustacheError") + } catch let error as MustacheError { + XCTAssertEqual(error.kind, MustacheError.Kind.parseError) + XCTAssertTrue(error.description.range(of: "line 2") != nil) + XCTAssertTrue(error.description.range(of: parserErrorTemplatePath) != nil) + } catch { + XCTFail("Expected MustacheError") + } + } + + func testCompilerErrorFromURL() async { + do { + let _ = try await Template(URL: compilerErrorTemplateURL) + XCTFail("Expected MustacheError") + } catch let error as MustacheError { + XCTAssertEqual(error.kind, MustacheError.Kind.parseError) + XCTAssertTrue(error.description.range(of: "line 2") != nil) + XCTAssertTrue(error.description.range(of: compilerErrorTemplatePath) != nil) + } catch { + XCTFail("Expected MustacheError") + } + + do { + let _ = try await Template(URL: compilerErrorTemplateWrapperURL) + XCTFail("Expected MustacheError") + } catch let error as MustacheError { + XCTAssertEqual(error.kind, MustacheError.Kind.parseError) + XCTAssertTrue(error.description.range(of: "line 2") != nil) + XCTAssertTrue(error.description.range(of: compilerErrorTemplatePath) != nil) + } catch { + XCTFail("Expected MustacheError") + } + } +}