Skip to content

Commit

Permalink
Fix problem where some constructors in Template could block the main …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
fumito-ito committed Aug 23, 2024
1 parent 1be6c51 commit 7e31911
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 2 deletions.
24 changes: 23 additions & 1 deletion Sources/Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion Sources/TemplateRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

0 comments on commit 7e31911

Please sign in to comment.