diff --git a/README.markdown b/README.markdown index f93d526ae..42dd5abfa 100644 --- a/README.markdown +++ b/README.markdown @@ -34,8 +34,49 @@ There are many ways that this application could be built; we ask that you build Please modify `README.md` to add: 1. Instructions on how to build/run your application + +Before building/running this Swift, please read [Perfect: A Server Side Swift](perfect.org) for installation guide. + +For Xcode 9.0+ (macOS), or Swift 4.0+ (Ubuntu 16.04) + +``` +$ cd csvsql +$ swift run +``` + +The server will run at [http://localhost:8181](http://localhost:8181). + +If success, you can upload the sample CSV and try "summary" on the web page. +To stop the demo server, simply just click [Stop the demo server](http://localhost:8181/halt), as on the same html page. + +⚠️**Alternatively**⚠️, the easiest way to build & run this demo is [docker](docker.com) + +``` +$ docker run -it -v $PWD/csvsql:/home -w /home -p 8181:8181 rockywei/swift:4.0 /bin/bash -c "swift run" +``` + 1. A paragraph or two about what you are particularly proud of in your implementation, and why. +Nothing specially, it is a very common demo for a typical Perfect backend, which doesn't include other powerful Perfect features such as AI / Machine Learning / Live Messaging / Big Data Mining: + +``` swift +/// a Perfect Web Server prefers a pure json style scheme. +/// By such a design, the web server can apply such an architecture: +/// - model.swift, a pure data model file to serve data model +/// - main.swift, a http server route controller +/// - index.html, a static html page to view the data +``` + +The solution took about 3 hours: + +- Document reading. +- Prototyping. +- Final implementation. +- Testing. +- API documentation, which was taken over 90 minutes. + + + ## Submission Instructions 1. Fork this project on github. You will need to create an account if you don't already have one. @@ -52,8 +93,26 @@ Please modify `README.md` to add: Evaluation of your submission will be based on the following criteria. 1. Did you follow the instructions for submission? + +- Yes + + 1. Did you document your build/deploy instructions and your explanation of what you did well? + +- Yes. + 1. Were models/entities and other components easily identifiable to the reviewer? + +- Yes. All codes have been well documented in the source. + 1. What design decisions did you make when designing your models/entities? Why (i.e. were they explained?) + +- Yes, Perfect prefers a pure json backend style. + 1. Did you separate any concerns in your application? Why or why not? + +It is too small a demo so couldn't including other important/scaling features in hours, typically, such as file size control, malicious detection, ORM, google-protocol buffer for huge data trunk traffic, OAuth and JWT token control, legacy single sign-on such as SPNEGO, or distribution storage / indexing, full text searching, etc. + 1. Does your solution use appropriate datatypes for the problem as described? + +- Yes, however, SQLite is a simplified implementation of ANSI SQL, so it should be a bit complicated if production. diff --git a/csvsql/.gitignore b/csvsql/.gitignore new file mode 100644 index 000000000..dd79e1145 --- /dev/null +++ b/csvsql/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +*.resolved +*.pins + diff --git a/csvsql/Package.swift b/csvsql/Package.swift new file mode 100644 index 000000000..a2253222c --- /dev/null +++ b/csvsql/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:4.0 +/// a Perfect Web Server prefers a pure json style scheme. +/// By such a design, the web server can apply such an architecture: +/// - model.swift, a pure data model file to serve data model +/// - main.swift, a http server route controller +/// - index.html, a static html page to view the data + +import PackageDescription + +let package = Package( + name: "csvsql", + dependencies: [ + .package(url: "https://github.com/yaslab/CSV.swift.git", from: "2.1.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-HTTPServer.git", from: "3.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", from: "3.0.0") + ], + targets: [ + .target( + name: "csvsql", + dependencies: ["CSV", "PerfectHTTPServer", "PerfectSQLite"]), + ] +) diff --git a/csvsql/Sources/csvsql/main.swift b/csvsql/Sources/csvsql/main.swift new file mode 100644 index 000000000..d4b1d035b --- /dev/null +++ b/csvsql/Sources/csvsql/main.swift @@ -0,0 +1,135 @@ +/// main.swift +/// se-challenge-expenses solution in Swift +/// by Xiaoquan (Rockford) Wei, Feb 26, 2018 + +/// a Perfect Web Server prefers a pure json style scheme. +/// By such a design, the web server can apply such an architecture: +/// - model.swift, a pure data model file to serve data model +/// - main.swift, a http server route controller +/// - index.html, a static html page to view the data + + +/// Perfect is Server Side Swift +import PerfectLib + +/// importing essential components from Perfect Web Server +import PerfectHTTP +import PerfectHTTPServer + +/// other essential libraries from Apple Inc., such as json +import Foundation + +/* This demo setup 5 different routes to handle the HTTP requests + - /upload: allow user to upload a CSV file + - /record: return database records in json format + - /summary: return an expense summary in months + - /halt: **only for demo purpose**, to stop the server after demo + - others: for static files +*/ + +/// uploader handler +/// - parameter request: upload request in method POST, **MUST BE** a CSV file +/// - parameter response: upload response, return a json with error message, if failed +func handlerUpload(request: HTTPRequest, response: HTTPResponse) { + let err: String + + // look for uploaded files + if let uploads = request.postFileUploads, + let source = uploads.first { + do { + + // this is only for a demo, clean the database to avoid duplicated record with the same testing data + let _ = unlink(ExpenseModel.databasePath) + + // convert the uploaded file into database backbone + let _ = try ExpenseModel(csvSourcePath: source.tmpFileName, sqlitePath: ExpenseModel.databasePath) + err = "" + } catch { + err = "\(error)" + } + } else { + debugPrint(request.postBodyString ?? "") + err = "no uploads" + } + response.setHeader(.contentType, value: "text/json") + .setBody(string: "{\"error\": \"\(err)\"}\n") + .completed() +} + +/// record handler +/// - parameter request: upload request, with no parameters required. +/// - parameter response: upload response in json data +func handlerRecord(request: HTTPRequest, response: HTTPResponse) { + var body = "" + do { + // load the imported data by some default settings. + let e = try ExpenseModel(sqlitePath: ExpenseModel.databasePath) + let rec = try e.fetch(limit: 100) + + // turn the result into json + let json = JSONEncoder() + let data = try json.encode(rec) + body = String(bytes: data, encoding: .utf8) ?? "{\"error\": \"json failure\"}\n" + } catch { + body = "{\"error\": \"\(error)\"}\n" + } + response.setHeader(.contentType, value: "text/json") + .setBody(string: body) + .completed() +} + +/// report summary handler +/// - parameter request: upload request, with no parameters required. +/// - parameter response: upload response in json data +func handlerSummary(request: HTTPRequest, response: HTTPResponse) { + var body = "" + do { + + // load a summary report + let e = try ExpenseModel(sqlitePath: ExpenseModel.databasePath) + let report = try e.summary() + + // turn the result into json + let json = JSONEncoder() + let data = try json.encode(report) + body = String(bytes: data, encoding: .utf8) ?? "{\"error\": \"json failure\"}\n" + } catch { + body = "{\"error\": \"\(error)\"}\n" + } + response.setHeader(.contentType, value: "text/json") + .setBody(string: body) + .completed() +} + +/// easy route to stop the web server +/// - parameter request: upload request, with no parameters required. +func handlerHalt(request: HTTPRequest, response: HTTPResponse) { + exit(0) +} + + +/// configure the above routes to the server. +let confData = [ + "servers": [ + [ + "name":"localhost", + "port":8181, + "routes":[ + ["method":"post", "uri":"/upload", "handler":handlerUpload], + ["method":"get", "uri":"/record", "handler":handlerRecord], + ["method":"get", "uri":"/summary", "handler":handlerSummary], + ["method":"get", "uri":"/halt", "handler": handlerHalt], + ["method":"get", "uri":"/**", + "handler": PerfectHTTPServer.HTTPHandler.staticFiles, + "documentRoot":"./webroot"], + ] + ] + ] +] + +/// start the web server. +do { + try HTTPServer.launch(configurationData: confData) +} catch { + fatalError("\(error)") +} diff --git a/csvsql/Sources/csvsql/model.swift b/csvsql/Sources/csvsql/model.swift new file mode 100644 index 000000000..3d8fac200 --- /dev/null +++ b/csvsql/Sources/csvsql/model.swift @@ -0,0 +1,246 @@ +/// model.swift +/// se-challenge-expenses solution in Swift +/// by Xiaoquan (Rockford) Wei, Feb 26, 2018 + +/// a Perfect Web Server prefers a pure json style scheme. +/// By such a design, the web server can apply such an architecture: +/// - model.swift, a pure data model file to serve data model +/// - main.swift, a http server route controller +/// - index.html, a static html page to view the data + + +/// CSV is an open source library for Swift to read / write +import CSV + +/// PerfectSQLite is better than the Apple/Swift native SQLite because Perfect can run on Linux as well. +import PerfectSQLite + +/// other essential libraries from Apple Inc., such as DateFormatter +import Foundation + +/// Financial Expense Model, which defines +/// 1) Expense Record Model, i.e., the rows of CSV file, and mapping it into SQL +/// 2) Expense Report Model +public class ExpenseModel { + + /// a general definition of common errors + public enum Exception: Error { + + /// error with a possible reason + case reason(String) + } + + /// A summary record, grouped by month + public struct ReportRecord: Encodable { + public let month: String + public let preTax: Double + public let taxAmount: Double + + /// the only way to initialize such a report is from the relational database + public init(rec: SQLiteStmt) { + month = rec.columnText(position: 0) + preTax = rec.columnDouble(position: 1) + taxAmount = rec.columnDouble(position: 2) + } + } + + /// An expense record, according to the CSV definition + public struct Record: Encodable { + public let date: Date + public let category: String + public let employeeName: String + public let employeeAddress: String + public let description: String + public let preTax: Double + public let taxName: String + public let taxAmount: Double + + /// constructor from a CSV row + /// - parameter csvRow: an array of column data in text + /// - parameter parserFormat: reference to parse the date field + /// - parameter blanks: utility reference to trim string + /// - throws: Exception + public init(csvRow: [String], parserFormat: DateFormatter, blanks: CharacterSet) throws { + guard let dt = csvRow.first, csvRow.count == 8, + let timestamp = parserFormat.date(from: dt) else { + throw Exception.reason("unexpected row") + } + date = timestamp + category = csvRow[1] + employeeName = csvRow[2] + employeeAddress = csvRow[3] + description = csvRow[4] + preTax = Double(csvRow[5].trimmingCharacters(in: blanks)) ?? 0.0 + taxName = csvRow[6] + taxAmount = Double(csvRow[7].trimmingCharacters(in: blanks)) ?? 0.0 + } + + /// constructor from a database record + /// - parameter rec: a row of database record + /// - parameter normalizedFormat: reference to parse the date field (only for SQLite) + /// - throws: Exception + public init(rec: SQLiteStmt, normalizedFormat: DateFormatter) throws { + guard let dt = normalizedFormat.date(from: rec.columnText(position: 0)) else { + throw Exception.reason("unexpected date format") + } + date = dt + category = rec.columnText(position: 1) + employeeName = rec.columnText(position: 2) + employeeAddress = rec.columnText(position: 3) + description = rec.columnText(position: 4) + preTax = rec.columnDouble(position: 5) + taxName = rec.columnText(position: 6) + taxAmount = rec.columnDouble(position: 7) + } + + /// a validator for verifying if the CSV is expected + /// - parameter fieldNames: an array of string to represent the CSV header fields + public static func validate(fieldNames: [String]) throws { + let standardFields = ["date", "category", "employee name", "employee address", + "expense description", "pre-tax amount", "tax name", "tax amount"] + guard standardFields == fieldNames else { + throw Exception.reason("Invalid fields") + } + } + + /// table creation sql statement + /// - parameter table: table name, optional + /// - returns: sql statement to create the table + public static func create(table: String = "expense") -> String { + return """ + CREATE TABLE IF NOT EXISTS \(table)(dt TEXT, cat TEXT, + name TEXT, address TEXT, description TEXT, + amount FLOAT, taxName TEXT, tax FLOAT) + """ + } + + /// static method to maintain the insert statement + public static func fieldNames() -> String { + return "dt, cat, name, address, description, amount, taxName, tax" + } + + /// bind a record into insert statement + /// - parameter insert: the sqlite insert statement + /// - parameter normalizedFormat: a utility date formatter + public func bind(insert: SQLiteStmt, normalizedFormat: DateFormatter) throws { + try insert.bind(position: 1, normalizedFormat.string(from: self.date)) + try insert.bind(position: 2, category) + try insert.bind(position: 3, employeeName) + try insert.bind(position: 4, employeeAddress) + try insert.bind(position: 5, description) + try insert.bind(position: 6, preTax) + try insert.bind(position: 7, taxName) + try insert.bind(position: 8, taxAmount) + } + } + + /// database reference handler + private let db: SQLite + + /// date parser for the example CSV file + private let parserFormat = DateFormatter() + + /// date formatter for SQL + private let normalizedFormat = DateFormatter() + + /// demo database file path + public static let databasePath = "/tmp/tax.db" + + /// database will be closed automatically once released + deinit { + db.close() + } + + /// open an existing database + /// - parameter sqlitePath: sqlite database path + /// - throws: Exception + public init(sqlitePath: String) throws { + parserFormat.dateFormat = "MM/dd/yy" + normalizedFormat.dateFormat = "yyyy-MM-dd" + db = try SQLite(sqlitePath) + } + + /// setup a database by the input CSV + /// - parameter csvSourcePath: CSV source file path + /// - parameter sqlitePath: sqlite database path + /// - throws: Exception + public init(csvSourcePath: String, sqlitePath: String) throws { + + // initialize the date formatters + parserFormat.dateFormat = "MM/dd/yy" + normalizedFormat.dateFormat = "yyyy-MM-dd" + + // load the source + guard let source = InputStream(fileAtPath: csvSourcePath) else { + throw Exception.reason("CSV loading failure") + } + + // close the source file once done. + defer { + source.close() + } + + // load the source content with explicity header row requirement + let csv = try CSVReader(stream: source, hasHeaderRow: true) + guard let headers = csv.headerRow else { + throw Exception.reason("invalid CSV header") + } + + // testing if the CSV file is valid + try Record.validate(fieldNames: headers) + + // open the sql database + db = try SQLite(sqlitePath) + + // perform table creation if need + try db.execute(statement: Record.create(table: "expense")) + let blanks = CharacterSet(charactersIn: " \t\n\r") + + // iterate all rows in the csv file + while let row = csv.next() { + + // translate the data into record + let rec = try Record(csvRow: row, parserFormat: parserFormat, blanks: blanks) + + // then save the record into database + let sql = "INSERT INTO expense(\(Record.fieldNames())) VALUES(?, ?, ?, ?, ?, ?, ?, ?)" + try db.execute(statement: sql) { stmt in + try rec.bind(insert: stmt, normalizedFormat: normalizedFormat) + } + } + } + + /// fetch raw data from the database + /// - parameter where: where clause, not implemented in this demo. + /// - parameter limit: limit clause + /// - parameter by: by clause for pagination purpose, not implemented in this demo + /// - throws: Exception + /// - returns: an array of Records, json encodable. + public func fetch(where: String = "", limit: Int, by: Int = 0) throws -> [Record] { + var result: [Record] = [] + try db.forEachRow(statement: "SELECT * FROM expense LIMIT \(limit)") { rec, _ in + let r = try Record(rec: rec, normalizedFormat: self.normalizedFormat) + result.append(r) + } + return result + } + + /// a summary of the current expenses + /// - parameter where: where clause, not implemented in this demo. + /// - throws: Exception + /// - returns: an array of Report Records, json encodable. + public func summary(where: String = "") throws -> [ReportRecord] { + let sql = """ + SELECT strftime('%Y-%m',date(dt)) as month, + sum(amount) as total, + sum(tax) as taxAmount + FROM expense GROUP BY month + """ + var result: [ReportRecord] = [] + try db.forEachRow(statement: sql) { rec, _ in + let r = ReportRecord(rec: rec) + result.append(r) + } + return result + } +} diff --git a/csvsql/webroot/index.html b/csvsql/webroot/index.html new file mode 100644 index 000000000..f3310dca8 --- /dev/null +++ b/csvsql/webroot/index.html @@ -0,0 +1,99 @@ + + + CSV To SQL DEMO + + + +

CSV To SQL Demo

+

+ + +

+

STOP THE DEMO SERVER

+
+

+ + +
datepre taxamount
+
+

+ + + + +
datecategoryemployee nameemployee addressexpense descriptionpre taxtax nameamount
+