diff --git a/README.md b/README.md index 514395c..b5f98c9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ In your `Package.swift` file, add the following ~~~~swift -.package(url: "https://github.com/vapor-community/stripe-provider.git", from: "2.1.2") +.package(url: "https://github.com/vapor-community/stripe-provider.git", from: "2.2.0") ~~~~ Register the config and the provider to your Application @@ -54,7 +54,8 @@ And you can always check the documentation to see the required paramaters for sp * [x] Customers * [x] Disputes * [ ] Events -* [ ] File Uploads +* [x] File Links +* [x] File Uploads * [x] Payouts * [x] Refunds * [x] Tokens diff --git a/Sources/Stripe/API/Helpers/Endpoints.swift b/Sources/Stripe/API/Helpers/Endpoints.swift index ac269e0..6da11d3 100644 --- a/Sources/Stripe/API/Helpers/Endpoints.swift +++ b/Sources/Stripe/API/Helpers/Endpoints.swift @@ -9,196 +9,120 @@ import Foundation internal let APIBase = "https://api.stripe.com/" +internal let FilesAPIBase = "https://files.stripe.com/" internal let APIVersion = "v1/" internal enum StripeAPIEndpoint { - /** - BALANCE - This is an object representing your Stripe balance. You can retrieve it to see the balance - currently on your Stripe account. You can also retrieve a list of the balance history, which - contains a list of transactions that contributed to the balance - (e.g., charges, transfers, and so forth). The available and pending amounts for each currency - are broken down further by payment source types. - */ + // MARK: - BALANCE case balance case balanceHistory case balanceHistoryTransaction(String) - /** - CHARGES - To charge a credit or a debit card, you create a charge object. You can retrieve and refund - individual charges as well as list all charges. Charges are identified by a unique random ID. - */ + // MARK: - CHARGES case charges case charge(String) case captureCharge(String) - /** - CUSTOMERS - Customers allow you to perform recurring charges and track multiple charges that are - associated with the same customer. The API allows you to create, delete, and update your customers. - You can retrieve individual customers as well as a list of all your customers. - */ + // MARK: - CUSTOMERS case customers case customer(String) case customerSources(String) case customerDetachSources(String,String) case customerDiscount(String) - /** - TOKENS - Tokenization is the process Stripe uses to collect sensitive card or bank account details, - or personally identifiable information (PII), directly from your customers in a secure manner. - A Token representing this information is returned to your server to use. You should use Checkout, - Elements, or Stripe mobile libraries to perform this process, client-side. This ensures that no - sensitive card data touches your server and allows your integration to operate in a PCI compliant way. - */ + // MARK: - TOKENS case tokens case token(String) - /** - REFUNDS - Refund objects allow you to refund a charge that has previously been created but not yet refunded. - Funds will be refunded to the credit or debit card that was originally charged. The fees you were - originally charged are also refunded. - */ + // MARK: - REFUNDS case refunds case refund(String) - /** - COUPONS - A coupon contains information about a percent-off or amount-off discount you might want to - apply to a customer. Coupons may be applied to invoices or orders. - */ + // MARK: - COUPONS case coupons case coupon(String) - /** - PLANS - A subscription plan contains the pricing information for different products and feature levels on your site. - */ + // MARK: - PLANS case plans case plan(String) - /** - SOURCES - Source objects allow you to accept a variety of payment methods. They represent a customer's payment instrument - and can be used with the Stripe API just like a card object: once chargeable, they can be charged, or attached - to customers. - */ + // MARK: - SOURCES case sources case source(String) - /** - SUBSCRIPTION ITEMS - Subscription items allow you to create customer subscriptions with more than one plan, making it easy to represent - complex billing relationships. - */ + // MARK: - SUBSCRIPTION ITEMS case subscriptionItem case subscriptionItems(String) - /** - SUBSCRIPTIONS - Subscriptions allow you to charge a customer's card on a recurring basis. A subscription ties a customer to a - particular plan you've created. - */ + // MARK: - SUBSCRIPTIONS case subscription case subscriptions(String) case subscriptionDiscount(String) - /** - ACCOUNTS - This is an object representing your Stripe account. You can retrieve it to see properties on the account like its - current e-mail address or if the account is enabled yet to make live charges. - */ + // MARK: - ACCOUNTS case account case accounts(String) case accountsReject(String) case accountsLoginLink(String) - /** - DISPUTES - A dispute occurs when a customer questions your charge with their bank or credit card company. - */ + // MARK: - DISPUTES case dispute case disputes(String) case closeDispute(String) - /** - SKUS - Stores representations of stock keeping units. SKUs describe specific product variations. - */ + // MARK: - SKUS case sku case skus(String) - /** - PRODUCTS - Store representations of products you sell in product objects, used in conjunction with SKUs. - */ + // MARK: - PRODUCTS case product case products(String) - /** - ORDERS - The purchase of previously defined products - */ + // MARK: - ORDERS case order case orders(String) case ordersPay(String) case ordersReturn(String) - /** - RETURNS - A return represents the full or partial return of a number of order items. - */ + // MARK: - RETURNS case orderReturn case orderReturns(String) - /** - INVOICES - Invoices are statements of what a customer owes for a particular billing period, including subscriptions, - invoice items, and any automatic proration adjustments if necessary. - */ + // MARK: - INVOICES case invoices case invoice(String) case payInvoice(String) case invoiceLines(String) case upcomingInvoices - /** - INVOICE ITEMS - Sometimes you want to add a charge or credit to a customer but only actually charge the customer's card at - the end of a regular billing cycle. This is useful for combining several charges to minimize per-transaction - fees or having Stripe tabulate your usage-based billing totals. - */ + // MARK: - INVOICE ITEMS case invoiceItems case invoiceItem(String) - - /** - EPHEMERAL KEYS - */ + // MARK: - EPHEMERAL KEYS case ephemeralKeys case ephemeralKey(String) - /** - TRANSFERS - A Transfer object is created when you move funds between Stripe accounts as part of Connect. - */ + // MARK: - TRANSFERS case transfer case transfers(String) case transferReversal(String) case transfersReversal(String,String) - /** - PAYOUTS - A Payout object is created when you receive funds from Stripe, or when you initiate a payout to either a bank account or debit card of a connected Stripe account. - */ + // MARK: - PAYOUTS case payout case payouts(String) case payoutsCancel(String) + // MARK: - FILE LINKS + case fileLink + case fileLinks(String) + + // MARK: - FILE UPLOAD + case file + case files(String) + var endpoint: String { switch self { case .balance: return APIBase + APIVersion + "balance" @@ -280,6 +204,12 @@ internal enum StripeAPIEndpoint { case .payout: return APIBase + APIVersion + "payouts" case .payouts(let id): return APIBase + APIVersion + "payouts/\(id)" case .payoutsCancel(let id): return APIBase + APIVersion + "payouts/\(id)/cancel" + + case .fileLink: return APIBase + APIVersion + "file_links" + case .fileLinks(let id): return APIBase + APIVersion + "file_links/\(id)" + + case .file: return FilesAPIBase + APIVersion + "files" + case .files(let id): return FilesAPIBase + APIVersion + "files/\(id)" } } } diff --git a/Sources/Stripe/API/Routes/FileLinkRoutes.swift b/Sources/Stripe/API/Routes/FileLinkRoutes.swift new file mode 100644 index 0000000..beed218 --- /dev/null +++ b/Sources/Stripe/API/Routes/FileLinkRoutes.swift @@ -0,0 +1,109 @@ +// +// FileLinkRoutes.swift +// Stripe +// +// Created by Andrew Edwards on 9/14/18. +// + +import Vapor + +public protocol FileLinkRoutes { + /// Creates a new file link object. + /// + /// - Parameters: + /// - file: The ID of the file. + /// - expires: A future timestamp after which the link will no longer be usable. + /// - metadata: Set of key-value pairs that you can attach to an object. + /// - Returns: Returns the file link object if successful, and returns an error otherwise. + func create(file: String, expires: Date?, metadata: [String: String]?) throws -> Future + + /// Retrieves the file link with the given ID. + /// + /// - Parameter link: The identifier of the file link to be retrieved. + /// - Returns: Returns a file link object if a valid identifier was provided, and returns an error otherwise. + func retrieve(link: String) throws -> Future + + /// Updates an existing file link object. Expired links can no longer be updated + /// + /// - Parameters: + /// - link: The ID of the file link. + /// - expires: A future timestamp after which the link will no longer be usable, or `now` to expire the link immediately. + /// - metadata: Set of key-value pairs that you can attach to an object. + /// - Returns: Returns the file link object if successful, and returns an error otherwise. + func update(link: String, expires: Any?, metadata: [String: String]?) throws -> Future + + + /// Returns a list of file links. + /// + /// - Parameter filter: A dictionary that contains the filters. More info [here](https://stripe.com/docs/api/curl#list_file_links). + /// - Returns: A `FileLinkList`. + func listAll(filter: [String: Any]?) throws -> Future +} + +extension FileLinkRoutes { + public func create(file: String, expires: Date? = nil, metadata: [String: String]? = nil) throws -> Future { + return try create(file: file, expires: expires, metadata: metadata) + } + + public func retrieve(link: String) throws -> Future { + return try retrieve(link: link) + } + + public func update(link: String, expires: Any? = nil, metadata: [String: String]? = nil) throws -> Future { + return try update(link: link, expires: expires, metadata: metadata) + } + + public func listAll(filter: [String: Any]? = nil) throws -> Future { + return try listAll(filter: filter) + } +} + +public struct StripeFileLinkRoutes: FileLinkRoutes { + private let request: StripeRequest + + init(request: StripeRequest) { + self.request = request + } + + public func create(file: String, expires: Date?, metadata: [String: String]?) throws -> Future { + var body: [String: Any] = [:] + if let expires = expires { + body["expires_at"] = Int(expires.timeIntervalSince1970) + } + + if let metadata = metadata { + metadata.forEach { body["metadata[\($0)]"] = $1} + } + return try request.send(method: .POST, path: StripeAPIEndpoint.fileLink.endpoint, body: body.queryParameters) + } + + public func retrieve(link: String) throws -> Future { + return try request.send(method: .GET, path: StripeAPIEndpoint.fileLinks(link).endpoint) + } + + public func update(link: String, expires: Any?, metadata: [String: String]?) throws -> Future { + var body: [String: Any] = [:] + + if let expires = expires as? Date { + body["expires_at"] = Int(expires.timeIntervalSince1970) + } + + if let expires = expires as? String { + body["expires_at"] = expires + } + + if let metadata = metadata { + metadata.forEach { body["metadata[\($0)]"] = $1} + } + return try request.send(method: .POST, path: StripeAPIEndpoint.fileLinks(link).endpoint, body: body.queryParameters) + } + + public func listAll(filter: [String: Any]?) throws -> Future { + var queryParams = "" + if let filter = filter { + queryParams = filter.queryParameters + } + + return try request.send(method: .GET, path: StripeAPIEndpoint.fileLink.endpoint, query: queryParams) + } +} diff --git a/Sources/Stripe/API/Routes/FileRoutes.swift b/Sources/Stripe/API/Routes/FileRoutes.swift new file mode 100644 index 0000000..f0fc8eb --- /dev/null +++ b/Sources/Stripe/API/Routes/FileRoutes.swift @@ -0,0 +1,101 @@ +// +// FileRoutes.swift +// Stripe +// +// Created by Andrew Edwards on 9/15/18. +// + +import Vapor + +public protocol FileRoutes { + /// Uploads a file to Stripe + /// + /// - Parameters: + /// - file: A file to upload. + /// - purpose: The purpose of the uploaded file. + /// - filename: The name of the file. + /// - type: The MIME type of the file. + /// - Returns: The file object. + func upload(file: Data, purpose: FilePurpose, filename: String, type: FileType) throws -> Future + + /// Retrieves the details of an existing file object. Supply the unique file upload ID from a file creation request, and Stripe will return the corresponding transfer information. + /// + /// - Parameter id: The identifier of the file upload to be retrieved. + /// - Returns: Returns a file upload object if a valid identifier was provided, and returns an error otherwise. + func retrieve(id: String) throws -> Future + + /// Returns a list of the files that you have uploaded to Stripe. The file uploads are returned sorted by creation date, with the most recently created file uploads appearing first. + /// + /// - Parameter filter: A dictionary that contains the filters. More info [here](https://stripe.com/docs/api/curl#list_file_uploads). + /// - Returns: A `FileUploadList` + func listAll(filter: [String: Any]?) throws -> Future +} + +extension FileRoutes { + public func upload(file: Data, purpose: FilePurpose, filename: String, type: FileType) throws -> Future { + return try upload(file: file, purpose: purpose, filename: filename, type: type) + } + + public func retrieve(id: String) throws -> Future { + return try retrieve(id: id) + } + + public func listAll(filter: [String: Any]? = nil) throws -> Future { + return try listAll(filter: filter) + } +} + +public struct StripeFileRoutes: FileRoutes { + private let request: StripeRequest + + init(request: StripeRequest) { + self.request = request + } + + public func upload(file: Data, purpose: FilePurpose, filename: String, type: FileType) throws -> Future { + var headers: HTTPHeaders = [:] + var body: Data = Data() + + guard let fileType = MediaType.fileExtension(type.rawValue) else { + throw StripeUploadError.unsupportedFileType + } + + // Fordm data structure found here. + // https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4 + + let boundary = "Stripe-Vapor-\(UUID().uuidString)" + headers.replaceOrAdd(name: .contentType, value: MediaType(type: "multipart", + subType: "form-data", + parameters: ["boundary": boundary]).description) + body.append(("\r\n--\(boundary)\r\n").data(using: .utf8)!) + + body.append(("Content-Disposition: form-data; name=\"purpose\"\"\r\n\r\n").data(using: .utf8)!) + + body.append(("\(purpose.rawValue)").data(using: .utf8)!) + + body.append(("\r\n--\(boundary)\r\n").data(using: .utf8)!) + + body.append(("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n").data(using: .utf8)!) + + body.append(("Content-Type: \(fileType.description)\r\n\r\n").data(using: .utf8)!) + + body.append(file) + + body.append(("\r\n--\(boundary)--").data(using: .utf8)!) + + return try request.send(method: .POST, path: StripeAPIEndpoint.file.endpoint, body: body, headers: headers) + } + + public func retrieve(id: String) throws -> Future { + return try request.send(method: .GET, path: StripeAPIEndpoint.files(id).endpoint) + } + + public func listAll(filter: [String: Any]?) throws -> Future { + var queryParams = "" + if let filter = filter { + queryParams = filter.queryParameters + } + + return try request.send(method: .GET, path: StripeAPIEndpoint.file.endpoint, query: queryParams) + } +} diff --git a/Sources/Stripe/API/StripeRequest.swift b/Sources/Stripe/API/StripeRequest.swift index 59b51b5..4a61a8b 100644 --- a/Sources/Stripe/API/StripeRequest.swift +++ b/Sources/Stripe/API/StripeRequest.swift @@ -12,11 +12,11 @@ import HTTP public protocol StripeRequest: class { func serializedResponse(response: HTTPResponse, worker: EventLoop) throws -> Future - func send(method: HTTPMethod, path: String, query: String, body: String, headers: HTTPHeaders) throws -> Future + func send(method: HTTPMethod, path: String, query: String, body: LosslessHTTPBodyRepresentable, headers: HTTPHeaders) throws -> Future } public extension StripeRequest { - public func send(method: HTTPMethod, path: String, query: String = "", body: String = "", headers: HTTPHeaders = [:]) throws -> Future { + public func send(method: HTTPMethod, path: String, query: String = "", body: LosslessHTTPBodyRepresentable = HTTPBody(string: ""), headers: HTTPHeaders = [:]) throws -> Future { return try send(method: method, path: path, query: query, body: body, headers: headers) } @@ -63,17 +63,16 @@ public class StripeAPIRequest: StripeRequest { self.testApiKey = testApiKey } - public func send(method: HTTPMethod, path: String, query: String, body: String, headers: HTTPHeaders) throws -> Future { + public func send(method: HTTPMethod, path: String, query: String, body: LosslessHTTPBodyRepresentable, headers: HTTPHeaders) throws -> Future { var finalHeaders: HTTPHeaders = .stripeDefault - headers.forEach { finalHeaders.replaceOrAdd(name: $0.name, value: $0.value) } - // Get the appropiate API key based on the environment and if the test key is present let apiKey = self.httpClient.container.environment == .development ? (self.testApiKey ?? self.apiKey) : self.apiKey finalHeaders.add(name: .authorization, value: "Bearer \(apiKey)") + headers.forEach { finalHeaders.replaceOrAdd(name: $0.name, value: $0.value) } return httpClient.send(method, headers: finalHeaders, to: "\(path)?\(query)") { (request) in - request.http.body = HTTPBody(string: body) + request.http.body = body.convertToHTTPBody() }.flatMap(to: SM.self) { (response) -> Future in return try self.serializedResponse(response: response.http, worker: self.httpClient.container.eventLoop) } diff --git a/Sources/Stripe/Errors/StripeError.swift b/Sources/Stripe/Errors/StripeError.swift index f6edd67..afb79f4 100644 --- a/Sources/Stripe/Errors/StripeError.swift +++ b/Sources/Stripe/Errors/StripeError.swift @@ -12,6 +12,39 @@ import Vapor Error object https://stripe.com/docs/api#errors */ + +public enum StripeUploadError: Error, Debuggable { + case unsupportedFileType + + public var localizedDescription: String { + return "Unsupported file type used for file upload." + } + + public var identifier: String { + return "file-upload-error" + } + + public var reason: String { + return localizedDescription + } + + public var possibleCauses: [String] { + return ["Unsupported file type used for file upload."] + } + + public var suggestedFixes: [String] { + return ["Use one of the following supported filetypes for uploads.", + "CSV", + "DOCX", + "GIF", + "JPEG", + "PDF", + "PNG", + "XLS", + "XLSX"] + } +} + public struct StripeError: StripeModel, Error, Debuggable { public var identifier: String { return self.error.type.rawValue diff --git a/Sources/Stripe/Models/Files/FileLink.swift b/Sources/Stripe/Models/Files/FileLink.swift new file mode 100644 index 0000000..5b92ba9 --- /dev/null +++ b/Sources/Stripe/Models/Files/FileLink.swift @@ -0,0 +1,42 @@ +// +// FileLink.swift +// Stripe +// +// Created by Andrew Edwards on 9/14/18. +// + +import Vapor + +/// To share the contents of a File object with non-Stripe users, you can create a FileLink. FileLinks contain a URL that can be used to retrieve the contents of the file without authentication. +public struct StripeFileLink: StripeModel { + /// Unique identifier for the object. + public var id: String + /// String representing the object’s type. Objects of the same type share the same value. + public var object: String + /// Time at which the object was created. Measured in seconds since the Unix epoch. + public var created: Date? + /// Whether this link is already expired. + public var expired: Bool? + /// Time at which the link expires. + public var expiresAt: Date? + /// The file object this link points to. + public var file: String? + /// Has the value true if the object exists in live mode or the value false if the object exists in test mode. + public var livemode: Bool? + /// Set of key-value pairs that you can attach to an object. + public var metadata: [String: String] + /// The publicly accessible URL to download the file. + public var url: String? + + private enum CodingKeys: String, CodingKey { + case id + case object + case created + case expired + case expiresAt = "expires_at" + case file + case livemode + case metadata + case url + } +} diff --git a/Sources/Stripe/Models/Files/FileLinkList.swift b/Sources/Stripe/Models/Files/FileLinkList.swift new file mode 100644 index 0000000..4a0886a --- /dev/null +++ b/Sources/Stripe/Models/Files/FileLinkList.swift @@ -0,0 +1,22 @@ +// +// FileLinkList.swift +// Stripe +// +// Created by Andrew Edwards on 9/15/18. +// + +public struct FileLinkList: StripeModel { + public var object: String + public var hasMore: Bool? + public var totalCount: Int? + public var url: String? + public var data: [StripeFileLink]? + + private enum CodingKeys: String, CodingKey { + case object + case hasMore = "has_more" + case totalCount = "total_count" + case url + case data + } +} diff --git a/Sources/Stripe/Models/Files/FileUpload.swift b/Sources/Stripe/Models/Files/FileUpload.swift new file mode 100644 index 0000000..0c37598 --- /dev/null +++ b/Sources/Stripe/Models/Files/FileUpload.swift @@ -0,0 +1,50 @@ +// +// FileUpload.swift +// Stripe +// +// Created by Andrew Edwards on 9/15/18. +// + +import Vapor + +/// There are various times when you’ll want to upload files to Stripe (for example, when uploading dispute evidence). This can be done by creating a File Upload object. When you upload a file, the API responds with a file upload token and other information about the upload. The token can then be used to retrieve a File Upload object. +public struct StripeFileUpload: StripeModel { + /// Unique identifier for the object. + public var id: String + /// String representing the object’s type. Objects of the same type share the same value. + public var object: String + /// Time at which the object was created. Measured in seconds since the Unix epoch. + public var created: Date? + /// A filename for the file, suitable for saving to a filesystem. + public var filename: String? + /// A list of file links. + public var links: FileLinkList? + /// The purpose of the uploaded file. + public var purpose: FilePurpose? + /// The size in bytes of the file upload object. + public var size: Int? + /// The type of the file returned. + public var type: FileType? + /// A read-only URL where the uploaded file can be accessed. Will be nil if the purpose of the uploaded file is identity_document. Also nil if retrieved with the publishable API key. + public var url: String? +} + +public enum FilePurpose: String, Content { + case businessLogo = "business_logo" + case customerSignature = "customer_signature" + case disputeEvidence = "dispute_evidence" + case identityDocument = "identity_document" + case pciDocument = "pci_document" + case taxDocumentUserUpload = "tax_document_user_upload" +} + +public enum FileType: String, Content { + case csv + case docx + case gif + case jpg + case pdf + case png + case xls + case xlsx +} diff --git a/Sources/Stripe/Models/Files/FileUploadList.swift b/Sources/Stripe/Models/Files/FileUploadList.swift new file mode 100644 index 0000000..7f2782d --- /dev/null +++ b/Sources/Stripe/Models/Files/FileUploadList.swift @@ -0,0 +1,22 @@ +// +// FileUploadList.swift +// Stripe +// +// Created by Andrew Edwards on 9/15/18. +// + +public struct FileUploadList: StripeModel { + public var object: String + public var hasMore: Bool? + public var totalCount: Int? + public var url: String? + public var data: [StripeFileUpload]? + + private enum CodingKeys: String, CodingKey { + case object + case hasMore = "has_more" + case totalCount = "total_count" + case url + case data + } +} diff --git a/Sources/Stripe/Provider/Provider.swift b/Sources/Stripe/Provider/Provider.swift index 103cd36..dfe23c8 100644 --- a/Sources/Stripe/Provider/Provider.swift +++ b/Sources/Stripe/Provider/Provider.swift @@ -66,6 +66,8 @@ public final class StripeClient: Service { public var transfer: TransferRoutes public var transferReversals: TransferReversalRoutes public var payouts: PayoutRoutes + public var fileLinks: FileLinkRoutes + public var files: FileRoutes internal init(apiKey: String, testKey: String?, client: Client) { let apiRequest = StripeAPIRequest(httpClient: client, apiKey: apiKey, testApiKey: testKey) @@ -92,5 +94,7 @@ public final class StripeClient: Service { transfer = StripeTransferRoutes(request: apiRequest) transferReversals = StripeTransferReversalRoutes(request: apiRequest) payouts = StripePayoutRoutes(request: apiRequest) + fileLinks = StripeFileLinkRoutes(request: apiRequest) + files = StripeFileRoutes(request: apiRequest) } } diff --git a/Tests/StripeTests/FileTests.swift b/Tests/StripeTests/FileTests.swift new file mode 100644 index 0000000..81ffa69 --- /dev/null +++ b/Tests/StripeTests/FileTests.swift @@ -0,0 +1,124 @@ +// +// FileTests.swift +// StripeTests +// +// Created by Andrew Edwards on 9/15/18. +// + +import XCTest +@testable import Stripe +@testable import Vapor + +class FileTests: XCTestCase { + let fileLinkString = """ +{ + "id": "link_1DAf602eZvKYlo2CwXzohqY4", + "object": "file_link", + "created": 1537023004, + "expired": false, + "expires_at": null, + "file": "file_1CcHwQ2eZvKYlo2CS8LDX4wK", + "livemode": false, + "metadata": { + }, + "url": "https://files.stripe.com/links/fl_test_iBHkHOhKU7YuwN7wXjKGOhcw" +} +""" + + func testFileLinkParsedProperly() throws { + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + let body = HTTPBody(string: fileLinkString) + var headers: HTTPHeaders = [:] + headers.replaceOrAdd(name: .contentType, value: MediaType.json.description) + let request = HTTPRequest(headers: headers, body: body) + let fileLink = try decoder.decode(StripeFileLink.self, from: request, maxSize: 65_536, on: EmbeddedEventLoop()) + + fileLink.do { (link) in + XCTAssertEqual(link.id, "link_1DAf602eZvKYlo2CwXzohqY4") + XCTAssertEqual(link.object, "file_link") + XCTAssertEqual(link.created, Date(timeIntervalSince1970: 1537023004)) + XCTAssertEqual(link.expired, false) + XCTAssertEqual(link.expiresAt, nil) + XCTAssertEqual(link.file, "file_1CcHwQ2eZvKYlo2CS8LDX4wK") + XCTAssertEqual(link.livemode, false) + XCTAssertEqual(link.metadata, [:]) + XCTAssertEqual(link.url, "https://files.stripe.com/links/fl_test_iBHkHOhKU7YuwN7wXjKGOhcw") + }.catch { (error) in + XCTFail("\(error.localizedDescription)") + } + } + catch { + XCTFail("\(error.localizedDescription)") + } + } + + let fileUploadString = """ +{ + "id": "file_1CcHwQ2eZvKYlo2CS8LDX4wK", + "object": "file_upload", + "created": 1528830846, + "filename": "icon1.png", + "links": { + "object": "list", + "data": [ + { + "id": "link_1DAf5z2eZvKYlo2CScuVuZnv", + "object": "file_link", + "created": 1537023003, + "expired": false, + "expires_at": null, + "file": "file_1CcHwQ2eZvKYlo2CS8LDX4wK", + "livemode": false, + "metadata": { + }, + "url": "https://files.stripe.com/links/fl_test_980v1485SYKS1DVGCO5SFd7d" + } + ], + "has_more": true, + "url": "/v1/file_links?file=file_1CcHwQ2eZvKYlo2CS8LDX4wK" + }, + "purpose": "business_logo", + "size": 9676, + "type": "png", + "url": "https://files.stripe.com/files/f_test_F3TKCoF1vHGS0B5EmdyH1sUn" +} +""" + + func testFileUploadParsedProperly() throws { + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + let body = HTTPBody(string: fileUploadString) + var headers: HTTPHeaders = [:] + headers.replaceOrAdd(name: .contentType, value: MediaType.json.description) + let request = HTTPRequest(headers: headers, body: body) + let fileUpload = try decoder.decode(StripeFileUpload.self, from: request, maxSize: 65_536, on: EmbeddedEventLoop()) + + fileUpload.do { (upload) in + XCTAssertEqual(upload.id, "file_1CcHwQ2eZvKYlo2CS8LDX4wK") + XCTAssertEqual(upload.object, "file_upload") + XCTAssertEqual(upload.created, Date(timeIntervalSince1970: 1528830846)) + XCTAssertEqual(upload.filename, "icon1.png") + XCTAssertEqual(upload.purpose, .businessLogo) + XCTAssertEqual(upload.size, 9676) + XCTAssertEqual(upload.type, .png) + XCTAssertEqual(upload.url, "https://files.stripe.com/files/f_test_F3TKCoF1vHGS0B5EmdyH1sUn") + + XCTAssertEqual(upload.links?.object, "list") + XCTAssertEqual(upload.links?.hasMore, true) + XCTAssertEqual(upload.links?.url, "/v1/file_links?file=file_1CcHwQ2eZvKYlo2CS8LDX4wK") + XCTAssertEqual(upload.links?.data?.count, 1) + + }.catch { (error) in + XCTFail("\(error.localizedDescription)") + } + } + catch { + XCTFail("\(error.localizedDescription)") + } + } +}