diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c4320 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..54782e3 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Vatifier.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Vatifier.xcscheme new file mode 100644 index 0000000..559459d --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Vatifier.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..3c5e468 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,142 @@ +{ + "object": { + "pins": [ + { + "package": "async-http-client", + "repositoryURL": "https://github.com/swift-server/async-http-client.git", + "state": { + "branch": null, + "revision": "037b70291941fe43de668066eb6fb802c5e181d2", + "version": "1.1.1" + } + }, + { + "package": "async-kit", + "repositoryURL": "https://github.com/vapor/async-kit.git", + "state": { + "branch": null, + "revision": "635a259c57ba682b60bebf005c52a92cfafc73f5", + "version": "1.1.0" + } + }, + { + "package": "console-kit", + "repositoryURL": "https://github.com/vapor/console-kit.git", + "state": { + "branch": null, + "revision": "7a97a5ea7fefe61cf2c943242113125b0f396a98", + "version": "4.1.0" + } + }, + { + "package": "routing-kit", + "repositoryURL": "https://github.com/vapor/routing-kit.git", + "state": { + "branch": null, + "revision": "e7f2d5bd36dc65a9edb303541cb678515a7fece3", + "version": "4.1.0" + } + }, + { + "package": "swift-backtrace", + "repositoryURL": "https://github.com/swift-server/swift-backtrace.git", + "state": { + "branch": null, + "revision": "f2fd8c4845a123419c348e0bc4b3839c414077d5", + "version": "1.2.0" + } + }, + { + "package": "swift-crypto", + "repositoryURL": "https://github.com/apple/swift-crypto.git", + "state": { + "branch": null, + "revision": "d67ac68d09a95443303e9d6e37b34e7ba101d5f1", + "version": "1.0.1" + } + }, + { + "package": "swift-log", + "repositoryURL": "https://github.com/apple/swift-log.git", + "state": { + "branch": null, + "revision": "74d7b91ceebc85daf387ebb206003f78813f71aa", + "version": "1.2.0" + } + }, + { + "package": "swift-metrics", + "repositoryURL": "https://github.com/apple/swift-metrics.git", + "state": { + "branch": null, + "revision": "708b960b4605abb20bc55d65abf6bad607252200", + "version": "2.0.0" + } + }, + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "c5fa0b456524cd73dc3ddbb263d4f46c20b86ca3", + "version": "2.17.0" + } + }, + { + "package": "swift-nio-extras", + "repositoryURL": "https://github.com/apple/swift-nio-extras.git", + "state": { + "branch": null, + "revision": "7cd24c0efcf9700033f671b6a8eaa64a77dd0b72", + "version": "1.5.1" + } + }, + { + "package": "swift-nio-http2", + "repositoryURL": "https://github.com/apple/swift-nio-http2.git", + "state": { + "branch": null, + "revision": "c8f952dbc37fe60def17eb15e2c90787ce6ee78a", + "version": "1.12.1" + } + }, + { + "package": "swift-nio-ssl", + "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", + "state": { + "branch": null, + "revision": "10e0e17dd47b594c3d864a063f343d716e33e5c1", + "version": "2.7.3" + } + }, + { + "package": "SwiftyXMLParser", + "repositoryURL": "https://github.com/yahoojapan/SwiftyXMLParser.git", + "state": { + "branch": null, + "revision": "9d82653e535a13a518b411934c0a5c0c84406c22", + "version": "5.2.0" + } + }, + { + "package": "vapor", + "repositoryURL": "https://github.com/vapor/vapor.git", + "state": { + "branch": null, + "revision": "6dfdb3445308c40280bf1c071fe14c1c92f98a0d", + "version": "4.7.1" + } + }, + { + "package": "websocket-kit", + "repositoryURL": "https://github.com/vapor/websocket-kit.git", + "state": { + "branch": null, + "revision": "021edd1ca55451ad15b3e84da6b4064e4b877b34", + "version": "2.1.0" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8df888e --- /dev/null +++ b/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version:5.2 + +import PackageDescription + +let package = Package( + name: "vatifier", + platforms: [ + .macOS(.v10_15) + ], + products: [ + .library( + name: "Vatifier", + targets: ["Vatifier"]), + ], + dependencies: [ + .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), + .package(url: "https://github.com/yahoojapan/SwiftyXMLParser.git", from: "5.2.0") + ], + targets: [ + .target( + name: "Vatifier", + dependencies: [ + .product(name: "Vapor", package: "vapor"), + .product(name: "SwiftyXMLParser", package: "SwiftyXMLParser") + ]), + .testTarget( + name: "VatifierTests", + dependencies: ["Vatifier", .product(name: "XCTVapor", package: "vapor")]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2ec42d --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Vatifier + +![Swift](http://img.shields.io/badge/swift-5.2-brightgreen.svg) +![Vapor](http://img.shields.io/badge/vapor-4.0-brightgreen.svg) + +### Vatifier is a Vapor helper for verifying VAT numbers via the [VIES service](https://ec.europa.eu/taxation_customs/vies/) + +## Usage +Add the following line to your `Package.swift` +~~~~swift +.package(url: "https://github.com/vapor-community/vatifier.git", from: "1.0.0") + +.product(name: "Vatifier", package: "vatifier") +~~~~ + +Add this line to your `configure.swift` file: +~~~~swift +import Vatifier + +app.vatifier.use(.VIES) +~~~~ + +You can now verify VAT numbers from `Application` or `Request` +~~~~swift +app.vatifier.verify("47458714", country: "DK") +req.vatifier.verify("47458714", country: "DK") +~~~~ + +If the API request was successfull you will have a `VATVerificationResponse` which contains an `isValid` boolean and optional `name` and `address` properties. If the API request failed, the future will be in an error state and a `VIESError` will be returned. diff --git a/Sources/Vatifier/Client.swift b/Sources/Vatifier/Client.swift new file mode 100644 index 0000000..c633154 --- /dev/null +++ b/Sources/Vatifier/Client.swift @@ -0,0 +1,6 @@ +import Vapor + +public protocol VatifierClient { + func hopped(to eventLoop: EventLoop) -> VatifierClient + func verify(_ vatNumber: String, country: Country) -> EventLoopFuture +} diff --git a/Sources/Vatifier/Models/Country.swift b/Sources/Vatifier/Models/Country.swift new file mode 100644 index 0000000..50064e1 --- /dev/null +++ b/Sources/Vatifier/Models/Country.swift @@ -0,0 +1,40 @@ +public enum Country: String, ExpressibleByStringLiteral { + case austria = "AT" + case belgium = "BE" + case bulgaria = "BG" + case croatia = "HR" + case cyprus = "CY" + case czechRepublic = "CZ" + case denmark = "DK" + case estonia = "EE" + case finland = "FI" + case france = "FR" + case germany = "DE" + case hungary = "HU" + case ireland = "IE" + case italy = "IT" + case latvia = "LV" + case lithuania = "LT" + case luxembourg = "LU" + case malta = "MT" + case netherlands = "NL" + case poland = "PL" + case portugal = "PT" + case romania = "RO" + case slovakia = "SK" + case slovenia = "SI" + case spain = "ES" + case sweden = "SE" + case unitedKingdom = "GB" + case invalid + + public init(stringLiteral value: String) { + if let country = Country(rawValue: value) { + self = country + } else { + self = .invalid + } + } +} + + diff --git a/Sources/Vatifier/Models/VATVerificationResponse.swift b/Sources/Vatifier/Models/VATVerificationResponse.swift new file mode 100644 index 0000000..dd319b3 --- /dev/null +++ b/Sources/Vatifier/Models/VATVerificationResponse.swift @@ -0,0 +1,7 @@ +import Vapor + +public struct VATVerificationResponse: Content { + public let isValid: Bool + public let name: String? + public let address: String? +} diff --git a/Sources/Vatifier/Models/VIESError.swift b/Sources/Vatifier/Models/VIESError.swift new file mode 100644 index 0000000..d906ea5 --- /dev/null +++ b/Sources/Vatifier/Models/VIESError.swift @@ -0,0 +1,80 @@ +import Vapor + +public enum VIESError: DebuggableError, Equatable { + case failedToParseResponse + case invalidInput + case serviceUnavailable + case msUnavailable + case msMaxConcurrentRequests + case timeout + case serverBusy + case invalidRequesterInfo + case unknown(String) + + public var identifier: String { + switch self { + case .failedToParseResponse: + return "failedToParseReponse" + case .invalidInput: + return "invalidInput" + case .serviceUnavailable: + return "serviceUnavailable" + case .msUnavailable: + return "msUnavailable" + case .msMaxConcurrentRequests: + return "msMaxConcurrentRequests" + case .timeout: + return "timeout" + case .serverBusy: + return "serverBusy" + case .invalidRequesterInfo: + return "invalidRequesterInfo" + case .unknown(let fault): + return fault + } + } + + public var reason: String { + switch self { + case .failedToParseResponse: + return "Failed to parse the XML response from VIES" + case .invalidInput: + return "The provided CountryCode is invalid or the VAT number is empty" + case .serviceUnavailable: + return "The VIES VAT service is unavailable, please try again later" + case .msUnavailable: + return "The VAT database of the requested member country is unavailable, please try again later" + case .msMaxConcurrentRequests: + return "The VAT database of the requested member country has had too many requests, please try again later" + case .timeout: + return "The request to VAT database of the requested member country has timed out, please try again later" + case .serverBusy: + return "The service cannot process your request, please try again later" + case .invalidRequesterInfo: + return "The requester info is invalid" + case .unknown(let fault): + return "Unknown error from VIES, fault string: \(fault)" + } + } + + init(faultString: String) { + switch faultString { + case "INVALID_INPUT": + self = .invalidInput + case "SERVICE_UNAVAILABLE": + self = .serviceUnavailable + case "MS_UNAVAILABLE": + self = .msUnavailable + case "MS_MAX_CONCURRENT_REQ": + self = .msMaxConcurrentRequests + case "TIMEOUT": + self = .timeout + case "SERVER_BUSY": + self = .serverBusy + case "INVALID_REQUESTER_INFO": + self = .invalidRequesterInfo + default: + self = .unknown(faultString) + } + } +} diff --git a/Sources/Vatifier/VIESClient.swift b/Sources/Vatifier/VIESClient.swift new file mode 100644 index 0000000..4f87068 --- /dev/null +++ b/Sources/Vatifier/VIESClient.swift @@ -0,0 +1,104 @@ +import Vapor +import SwiftyXMLParser + +public struct VIESClient: VatifierClient { + public enum Environment { + case production + case testing + + var apiURL: String { + switch self { + case .production: + return "https://ec.europa.eu/taxation_customs/vies/services/checkVatService" + case .testing: + return "https://ec.europa.eu/taxation_customs/vies/services/checkVatTestService" + } + } + } + + private let client: Client + private let environment: Environment + + init(client: Client, environment: Environment) { + self.client = client + self.environment = environment + } + + public func hopped(to eventLoop: EventLoop) -> VatifierClient { + VIESClient(client: self.client.delegating(to: eventLoop), environment: self.environment) + } + + public func verify(_ vatNumber: String, country: Country) -> EventLoopFuture { + guard country != .invalid else { + return client.eventLoop.future(error: VIESError.invalidInput) + } + + let soapBody = VIESClient.soapBodyTemplate + .replacingOccurrences(of: "%COUNTRY%", with: country.rawValue) + .replacingOccurrences(of: "%VATNUMBER%", with: vatNumber) + + var buffer = ByteBufferAllocator().buffer(capacity: soapBody.utf8.count) + buffer.writeString(soapBody) + + let headers = HTTPHeaders([ + ("Content-Type", "application/xml"), + ("Cache-Control", "no-cache") + ]) + + let request = ClientRequest(method: .POST, url: URI(string: environment.apiURL), headers: headers, body: buffer) + + return client.send(request) + .flatMap { response in + guard let buffer = response.body else { + return self.client.eventLoop.future(error: VIESError.failedToParseResponse) + } + + do { + let responseXML = try XML.parse(String(buffer: buffer)) + + if let faultString = responseXML["soap:Envelope", "soap:Body", "soap:Fault", "faultstring"].text { + return self.client.eventLoop.future(error: VIESError(faultString: faultString)) + } + + let fields = responseXML["soap:Envelope", "soap:Body", "checkVatResponse"] + + guard + let isValidString = fields["valid"].text, + let isValid = Bool(isValidString) + else { + return self.client.eventLoop.future(error: VIESError.failedToParseResponse) + } + + var address: String? = nil + var name: String? = nil + + if let xmlName = fields["name"].text, xmlName != "---" { + name = xmlName + } + + if let xmlAddress = fields["address"].text, xmlAddress != "---" { + address = xmlAddress + } + + return self.client.eventLoop.future(VATVerificationResponse(isValid: isValid, name: name, address: address)) + } catch XMLError.failToEncodeString { + return self.client.eventLoop.future(error: VIESError.failedToParseResponse) + } catch { + return self.client.eventLoop.future(error: error) + } + } + } +} + +extension VIESClient { + static let soapBodyTemplate = """ + + + + %COUNTRY% + %VATNUMBER% + + + + """ +} diff --git a/Sources/Vatifier/Vatifier+Vapor.swift b/Sources/Vatifier/Vatifier+Vapor.swift new file mode 100644 index 0000000..a8794cf --- /dev/null +++ b/Sources/Vatifier/Vatifier+Vapor.swift @@ -0,0 +1,84 @@ +import Vapor + +extension Application { + public var vatifier: Vatifier { + .init(app: self) + } + + public struct Vatifier { + let app: Application + + init(app: Application) { + self.app = app + } + + public struct Provider { + public static func VIES(environment: VIESClient.Environment) -> Self { + .init { + $0.vatifier.use { + VIESClient(client: $0.client, environment: environment) + } + } + } + + public static var VIES: Self { + .VIES(environment: .production) + } + + let run: ((Application) -> Void) + + public init(_ run: @escaping ((Application) -> Void)) { + self.run = run + } + } + + private final class Storage { + var make: ((Application) -> VatifierClient)? + init() { } + } + + private struct Key: StorageKey { + typealias Value = Storage + } + + private var storage: Storage { + if app.storage[Key.self] == nil { + app.storage[Key.self] = .init() + } + + return app.storage[Key.self]! + } + + var client: VatifierClient { + guard let makeClient = storage.make else { + fatalError("Vatifier not configured, use: app.vatifier.use(.VIES)") + } + + return makeClient(app) + } + + public func use(_ factory: @escaping ((Application) -> VatifierClient)) { + self.storage.make = factory + } + + public func use(_ provider: Provider) { + provider.run(app) + } + } +} + +extension Application.Vatifier: VatifierClient { + public func hopped(to eventLoop: EventLoop) -> VatifierClient { + self.client.hopped(to: eventLoop) + } + + public func verify(_ vatNumber: String, country: Country) -> EventLoopFuture { + self.client.verify(vatNumber, country: country) + } +} + +extension Request { + public var vatifier: VatifierClient { + application.vatifier.client.hopped(to: self.eventLoop) + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..8f384d3 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import VatifierTests + +var tests = [XCTestCaseEntry]() +tests += VatifierTests.allTests() +XCTMain(tests) diff --git a/Tests/VatifierTests/VatifierTests.swift b/Tests/VatifierTests/VatifierTests.swift new file mode 100644 index 0000000..d097980 --- /dev/null +++ b/Tests/VatifierTests/VatifierTests.swift @@ -0,0 +1,72 @@ +import XCTest +import XCTVapor +import Vatifier + +final class VatifierTests: XCTestCase { + var app: Application! + + override func setUpWithError() throws { + app = Application(.testing) + } + + func testValidVATNumber() throws { + app.vatifier.use(.VIES) + let result = try app.vatifier.verify("47458714", country: "DK").wait() + XCTAssertTrue(result.isValid) + } + + func testInvalidVATNumber() throws { + app.vatifier.use(.VIES) + let result = try app.vatifier.verify("123", country: .denmark).wait() + XCTAssertFalse(result.isValid) + XCTAssertNil(result.name) + XCTAssertNil(result.address) + } + + func testInvalidCountry() throws { + app.vatifier.use(.VIES) + XCTAssertThrowsError(try app.vatifier.verify("47458714", country: "ASDF").wait(), "") { error in + XCTAssertEqual(error as? VIESError, VIESError.invalidInput) + } + } + + func testVIESReturnsInvalidInput() throws { + app.vatifier.use(.VIES(environment: .testing)) + + XCTAssertThrowsError(try app.vatifier.verify("201", country: .denmark).wait(), "") { error in + XCTAssertEqual(error as? VIESError, VIESError.invalidInput) + } + } + + func testVIESReturnsInvalidRequesterInfo() throws { + app.vatifier.use(.VIES(environment: .testing)) + + XCTAssertThrowsError(try app.vatifier.verify("202", country: .denmark).wait(), "") { error in + XCTAssertEqual(error as? VIESError, VIESError.invalidRequesterInfo) + } + } + + func testVIESReturnsServiceUnavailable() throws { + app.vatifier.use(.VIES(environment: .testing)) + + XCTAssertThrowsError(try app.vatifier.verify("300", country: .denmark).wait(), "") { error in + XCTAssertEqual(error as? VIESError, VIESError.serviceUnavailable) + } + } + + func testVIESReturnsMSUnavailable() throws { + app.vatifier.use(.VIES(environment: .testing)) + + XCTAssertThrowsError(try app.vatifier.verify("301", country: .denmark).wait(), "") { error in + XCTAssertEqual(error as? VIESError, VIESError.msUnavailable) + } + } + + func testVIESReturnsTimeout() throws { + app.vatifier.use(.VIES(environment: .testing)) + + XCTAssertThrowsError(try app.vatifier.verify("302", country: .denmark).wait(), "") { error in + XCTAssertEqual(error as? VIESError, VIESError.timeout) + } + } +} diff --git a/Tests/VatifierTests/XCTestManifests.swift b/Tests/VatifierTests/XCTestManifests.swift new file mode 100644 index 0000000..85224e2 --- /dev/null +++ b/Tests/VatifierTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(VatifierTests.allTests), + ] +} +#endif