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