├── .gitignore ├── .sourcery.yml ├── LICENSE ├── README.md ├── Sourcery ├── Models │ └── Demo.swift └── Templates │ ├── Client │ ├── Models+Presentable.stencil │ ├── Models+Randomizable.stencil │ ├── NetworkClients.stencil │ ├── ObjectModels.stencil │ ├── ObjectsDTOs.stencil │ ├── ServiceRegistration.stencil │ └── SyncableDTOs.stencil │ └── Vapor │ ├── Controllers.stencil │ └── Models.stencil ├── Sublimate.jpg ├── Sublimate.pdf ├── Sublimate.xml ├── SublimateClient ├── .gitignore ├── Podfile ├── Podfile.lock ├── SublimateClient.xcodeproj │ └── project.pbxproj ├── SublimateClient.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── WorkspaceSettings.xcsettings ├── SublimateClient │ ├── Info.plist │ ├── Resources │ │ ├── Assets.xcassets │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── data.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── documents.png │ │ │ │ ├── documents@2x.png │ │ │ │ └── documents@3x.png │ │ │ └── users.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── user_group_man_woman.png │ │ │ │ ├── user_group_man_woman@2x.png │ │ │ │ └── user_group_man_woman@3x.png │ │ └── Base.lproj │ │ │ ├── LaunchScreen.storyboard │ │ │ └── Main.storyboard │ └── Sources │ │ ├── AppDelegate+DI.swift │ │ ├── AppDelegate+Init.swift │ │ ├── AppDelegate.swift │ │ ├── Authentication │ │ ├── Authentication.storyboard │ │ ├── AuthenticationClient.swift │ │ └── AuthenticationViewController.swift │ │ ├── MessageView+Utility.swift │ │ └── RealmConfiguration+Utility.swift ├── SublimateClientTests │ ├── Info.plist │ └── SublimateClientTests.swift └── SublimateClientUITests │ ├── Info.plist │ └── SublimateClientUITests.swift ├── SublimateSync.podspec ├── SublimateSync └── SublimateSync │ └── Classes │ ├── AuthenticationManager.swift │ ├── AuthenticationProtocols.swift │ ├── JWT.swift │ ├── Logger.swift │ ├── NetworkManager.swift │ ├── Rx Support │ ├── ReadOnlyBehaviourRelay.swift │ └── ReadOnlyVariable.swift │ ├── Syncing │ ├── Syncable.swift │ └── Syncer.swift │ └── Type Extensions │ ├── LoremGenerator.swift │ ├── Types+Defaults.swift │ └── Types+Random.swift ├── SublimateUI.podspec ├── SublimateUI └── SublimateUI │ ├── Classes │ ├── .gitkeep │ ├── Detail Screen │ │ ├── ActionPanelViewController.swift │ │ ├── FieldCell.swift │ │ └── FieldsTableViewController.swift │ ├── Object List │ │ ├── ObjectCell.swift │ │ └── ObjectsTableViewController.swift │ ├── Schemes Overview │ │ ├── SchemeCell.swift │ │ └── SchemesTableViewController.swift │ ├── SublimateUICompatible.swift │ └── UIStoryboard+Instantiate.swift │ └── Resources │ ├── ActionPanel.storyboard │ ├── FieldCell.xib │ ├── ObjectCell.xib │ └── SchemeCell.xib └── SublimateVapor ├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── Package.resolved ├── Package.swift ├── Public └── .gitkeep ├── README.md ├── Sources ├── App │ ├── Controllers │ │ └── UserController.swift │ ├── Middlewares │ │ ├── BearerMiddleware.swift │ │ ├── PasswordMiddleware.swift │ │ └── RefreshMiddleware.swift │ ├── Models │ │ ├── RefreshToken.swift │ │ ├── SublimateJwt.swift │ │ ├── User+JWT.swift │ │ └── User.swift │ ├── app.swift │ ├── boot.swift │ ├── configure.swift │ └── routes.swift └── Run │ └── main.swift ├── Tests ├── .gitkeep ├── AppTests │ └── AppTests.swift └── LinuxMain.swift └── cloud.yml /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/.gitignore -------------------------------------------------------------------------------- /.sourcery.yml: -------------------------------------------------------------------------------- 1 | sources: 2 | - Sourcery 3 | - SublimateVapor 4 | - SublimateClient/SublimateClient 5 | templates: 6 | - Sourcery/Templates/Client 7 | - Sourcery/Templates/Vapor 8 | output: Sourcery/Generated 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Gabriele 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sublimate 2 | Sublimate: Ridiculously fast full stack Swift prototyping with Vapor and Sourcery 3 | 4 | # Quick start: 5 | 6 | - install Sourcery, Vapor and CocoaPods 7 | ```sh 8 | brew install cocoapods 9 | brew install sourcery 10 | brew install vapor/tap/vapor 11 | ``` 12 | - clone Sublimate repo 13 | ```sh 14 | $ git clone https://github.com/gabrielepalma/sublimate.git 15 | ``` 16 | - edit the file Demo.swift in Sourcery/Models: 17 | - add new frost models as you see fit. 18 | - if implements FrozenModel, it will be public and won't require any authentication 19 | - if implements OwnedFrozenModel, it will be private, matched to the user and require authentication 20 | - the primary keys will be added automatically. 21 | - only Int, Double and String field types are currently supported 22 | - run Sourcery: from the repository root run 23 | ```sh 24 | $ sourcery 25 | ``` 26 | - create the Vapor project: from SublimateVapor folder run 27 | ```sh 28 | $ vapor xcode 29 | ``` 30 | - open the Vapor project and run it 31 | - download CocoaPods dependencies: from SublimateClient folder run 32 | ```sh 33 | $ pod install 34 | ``` 35 | - open the Client workspace and run it on simulator 36 | 37 | # Features 38 | The project provides: 39 | 40 | On server: 41 | - fluent models for Vapor generated from the frost models provided 42 | - appropriate GET, POST and DELETE routes for CRUD operation on models 43 | - middlewares for the routes requiring authentication 44 | - authentication logic based on Refresh/Access dichotomy and JWT tokens. 45 | 46 | On client: 47 | - network clients and related DTOs based on PromiseKit 48 | - an offline-first synchronization framework (SublimateSync) based on RxSwift and Realm. 49 | - a mock UI (SublimateUI) to be used as a demo/test application 50 | - an authentication client, manager and view controller, including automatic refresh of the access token 51 | 52 | The resulting mock UI is ready to run, demonstrating authentication, synchronization and (randomized) CRUD operations. 53 | 54 | 55 | 56 | # Coming next 57 | 58 | - customizable User Profile 59 | - support for image upload (via multipart POST requests) and download (with cache and arbitrary thumbnail resizing) 60 | 61 | 62 | -------------------------------------------------------------------------------- /Sourcery/Models/Demo.swift: -------------------------------------------------------------------------------- 1 | protocol FrozenModel {} 2 | protocol OwnedFrozenModel : FrozenModel {} 3 | 4 | class DemoPublic : FrozenModel { 5 | var name : String 6 | var surname : String 7 | var email : String 8 | var age : Int 9 | var weight : Double 10 | } 11 | 12 | class DemoPrivate : OwnedFrozenModel { 13 | var title : String 14 | var content : String 15 | var duration : Int 16 | var grade : Double 17 | } 18 | -------------------------------------------------------------------------------- /Sourcery/Templates/Client/Models+Presentable.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateClient/SublimateClient/Sources/Generated/Models+Presentable.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import UIKit 5 | import RxSwift 6 | import SublimateUI 7 | 8 | {% for type in types.implementing.FrozenModel|class %} 9 | extension {{ type.name }}Object : OverviewPresentable { 10 | var presentationId: String? { 11 | return remoteId 12 | } 13 | 14 | var presentationTitle: String? { 15 | {% for variable in type.storedVariables %} 16 | {% if forloop.first %} 17 | return {{ variable.name }} 18 | {% endif %} 19 | {% endfor %} 20 | } 21 | 22 | var presentationThumbnail: Observable { 23 | return Observable.just(nil) 24 | } 25 | } 26 | 27 | extension {{ type.name }}Object : DetailPresentable { 28 | var presentationKeyPaths : [(String, PartialKeyPath<{{ type.name }}Object>)] { 29 | return [ 30 | {% for variable in type.storedVariables %} 31 | ("{{ variable.name }}", \{{ type.name }}Object.{{ variable.name }}){% if not forloop.last %}, {% endif %} 32 | {% endfor %} 33 | ] 34 | } 35 | 36 | var metadataKeyPaths : [(String, PartialKeyPath<{{ type.name }}Object>)] { 37 | return [ 38 | ("localId", \{{ type.name }}Object.localId), 39 | ("remoteId", \{{ type.name }}Object.remoteId), 40 | ("remoteCreated", \{{ type.name }}Object.remoteCreatedDate), 41 | ("clientCreated", \{{ type.name }}Object.clientCreatedDate), 42 | ("clientLastUpdated", \{{ type.name }}Object.clientLastUpdatedDate), 43 | ("syncOperation", \{{ type.name }}Object.syncOperation), 44 | ("inInErrorState", \{{ type.name }}Object.isInErrorState) 45 | ] 46 | } 47 | } 48 | 49 | {% endfor %} 50 | 51 | func sublimateTableSources() -> [TableSource] { 52 | var sources = [TableSource]() 53 | var temp : TableSource 54 | 55 | {% for type in types.implementing.FrozenModel|class %} 56 | temp = TableSource() 57 | {% if type.implements.OwnedFrozenModel %} 58 | temp.availability = .onlyAuthenthicated 59 | {% else %} 60 | temp.availability = .openAccess 61 | {% endif %} 62 | temp.description = "{{ type.name }}" 63 | temp.viewController = { 64 | let vc = ObjectsTableViewController<{{ type.name }}Object>() 65 | vc.syncer = DI.{{ type.name|lowerFirstLetter }}Syncer 66 | vc.realmConfiguration = DI.realmConfiguration 67 | return vc 68 | } 69 | sources.append(temp) 70 | {% endfor %} 71 | 72 | return sources 73 | } 74 | 75 | // sourcery:end 76 | -------------------------------------------------------------------------------- /Sourcery/Templates/Client/Models+Randomizable.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateClient/SublimateClient/Sources/Generated/Models+Randomizable.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import UIKit 5 | import RealmSwift 6 | import SublimateUI 7 | 8 | {% for type in types.implementing.FrozenModel|class %} 9 | extension {{ type.name }}Object: Randomizable { 10 | func randomize() { 11 | {% for variable in type.storedVariables %} 12 | {{ variable.name }} = {{ variable.typeName }}.random() 13 | {% endfor %} 14 | } 15 | } 16 | 17 | {% endfor %} 18 | // sourcery:end 19 | -------------------------------------------------------------------------------- /Sourcery/Templates/Client/NetworkClients.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateClient/SublimateClient/Sources/Generated/NetworkClients.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import UIKit 5 | import PromiseKit 6 | import RxSwift 7 | import SublimateSync 8 | 9 | {% for type in types.implementing.FrozenModel|class %} 10 | // MARK: - {{ type.name }} network client 11 | final class {{ type.name }}NetworkClient: NetworkClient<{{ type.name }}Object> { 12 | var networkManager : NetworkManagerProtocol 13 | 14 | init(networkManager : NetworkManagerProtocol) { 15 | self.networkManager = networkManager 16 | } 17 | 18 | override func fetchAll(withSyncingOptions options: NSDictionary?) -> Promise<[SyncableDTO<{{ type.name }}Object>]> { 19 | let request = Request(method: .GET, contentType: .JSON, path: "{{ type.name|lowerFirstLetter }}") 20 | 21 | return Promise<[SyncableDTO<{{ type.name }}Object>]>(resolver: { (resolver) in 22 | networkManager.makeRequest(request: request, responseType: [{{ type.name }}SyncableDTO.self]).done({ (response) in 23 | resolver.fulfill(response) 24 | }).catch({ (error) in 25 | resolver.reject(error) 26 | }) 27 | }) 28 | } 29 | 30 | override func syncOne(item: {{ type.name }}Object) -> Promise> { 31 | let body = try? JSONEncoder().encode({{ type.name }}SyncableDTO(from: item)) 32 | let request = Request(method: .POST, contentType: .JSON, path: "{{ type.name|lowerFirstLetter }}", body: body) 33 | 34 | return Promise>(resolver: { (resolver) in 35 | networkManager.makeRequest(request: request, responseType: {{ type.name }}SyncableDTO.self).done({ (response) in 36 | resolver.fulfill(response) 37 | }).catch({ (error) in 38 | resolver.reject(error) 39 | }) 40 | }) 41 | } 42 | 43 | override func delete(item: {{ type.name }}Object) -> Promise { 44 | guard let remoteId = item.remoteId else { 45 | return Promise() 46 | } 47 | let request = Request(method: .DELETE, contentType: .JSON, path: "{{ type.name|lowerFirstLetter }}/\(remoteId)") 48 | return networkManager.makeRequest(request: request) 49 | } 50 | } 51 | 52 | {% endfor %} 53 | // sourcery:end 54 | -------------------------------------------------------------------------------- /Sourcery/Templates/Client/ObjectModels.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateClient/SublimateClient/Sources/Generated/ObjectModels.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import UIKit 5 | import RealmSwift 6 | import SublimateSync 7 | 8 | {% for type in types.implementing.FrozenModel|class %} 9 | // MARK: - {{ type.name }} Realm object 10 | final class {{ type.name }}Object: Object, Syncable { 11 | override public class func ignoredProperties() -> [String] { 12 | return ["syncIdentifier", "syncOperation"] 13 | } 14 | 15 | override public static func primaryKey() -> String? { 16 | return "localId" 17 | } 18 | 19 | override static func indexedProperties() -> [String] { 20 | return ["remoteId"] 21 | } 22 | 23 | var syncIdentifier: String? { 24 | return remoteId 25 | } 26 | 27 | var syncOperation: SyncOperation { 28 | get { 29 | return SyncOperation(rawValue: realmSyncOperation) ?? .none 30 | } 31 | set { 32 | realmSyncOperation = newValue.rawValue 33 | } 34 | } 35 | 36 | static func pendingObjectsPredicate() -> NSPredicate { 37 | let p1 = NSPredicate(format: "realmSyncOperation in %@", [SyncOperation.create.rawValue, SyncOperation.update.rawValue, SyncOperation.delete.rawValue]) 38 | let p2 = NSPredicate(format: "isInErrorState == false") 39 | return NSCompoundPredicate(andPredicateWithSubpredicates: [p1, p2]) 40 | } 41 | 42 | static func syncableObjectsPredicate(withSyncingOptions options: NSDictionary?) -> NSPredicate { 43 | return NSPredicate(value: true) 44 | } 45 | 46 | @objc dynamic var clientCreated: TimeInterval = Date().timeIntervalSince1970 47 | @objc dynamic var clientLastUpdated: TimeInterval = Date().timeIntervalSince1970 48 | @objc dynamic var remoteId: String? = nil 49 | @objc dynamic var isInErrorState: Bool = false 50 | @objc dynamic var remoteCreated: Double = Date().timeIntervalSince1970 51 | @objc dynamic var localId: String = UUID().uuidString 52 | @objc private dynamic var realmSyncOperation : String = SyncOperation.none.rawValue 53 | 54 | {% for variable in type.storedVariables %} 55 | @objc dynamic var {{ variable.name }}: {{ variable.typeName }} = {{ variable.typeName }}.sublimateDefault() 56 | {% endfor %} 57 | 58 | convenience init(dto: SyncableDTO<{{ type.name }}Object>) { 59 | self.init() 60 | remoteId = dto.syncIdentifier() 61 | dto.update(object: self) 62 | } 63 | 64 | var clientCreatedDate : Date { 65 | return Date(timeIntervalSince1970: clientCreated) 66 | } 67 | 68 | var remoteCreatedDate : Date { 69 | return Date(timeIntervalSince1970: remoteCreated) 70 | } 71 | 72 | var clientLastUpdatedDate : Date { 73 | return Date(timeIntervalSince1970: clientLastUpdated) 74 | } 75 | } 76 | 77 | {% endfor %} 78 | // sourcery:end 79 | -------------------------------------------------------------------------------- /Sourcery/Templates/Client/ObjectsDTOs.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateClient/SublimateClient/Sources/Generated/ObjectDTOs.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import UIKit 5 | import SublimateSync 6 | 7 | {% for type in types.implementing.FrozenModel|class %} 8 | // MARK: - {{ type.name }} network dto 9 | final class {{ type.name }}DTO: Codable { 10 | var id : String? 11 | var remoteCreated : Double? 12 | var clientCreated : Double? 13 | {% for variable in type.storedVariables %} 14 | var {{ variable.name }}: {{ variable.typeName }} = {{ variable.typeName }}.sublimateDefault() 15 | {% endfor %} 16 | } 17 | {% endfor %} 18 | // sourcery:end 19 | 20 | -------------------------------------------------------------------------------- /Sourcery/Templates/Client/ServiceRegistration.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateClient/SublimateClient/Sources/Generated/ServiceRegistration.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import UIKit 5 | import RealmSwift 6 | import Swinject 7 | import PromiseKit 8 | import Reachability 9 | import SublimateSync 10 | 11 | func registerObjectSyncers(container : Container) { 12 | 13 | {% for type in types.implementing.FrozenModel|class %} 14 | container.register(NetworkClient<{{ type.name }}Object>.self, factory: { (r) -> NetworkClient<{{ type.name }}Object> in 15 | return {{ type.name }}NetworkClient(networkManager: r.resolve(NetworkManagerProtocol.self)!) 16 | }).inObjectScope(.weak) 17 | 18 | container.register(Syncer<{{ type.name }}Object>.self) { (r) -> Syncer<{{ type.name }}Object> in 19 | return Syncer<{{ type.name }}Object>(networkClient: r.resolve(NetworkClient<{{ type.name }}Object>.self)!, realmConfiguration: r.resolve(Realm.Configuration.self)!, reachability: r.resolve(Reachability.self)!) 20 | }.inObjectScope(.weak) 21 | 22 | {% endfor %} 23 | } 24 | 25 | func sublimateObjectTypes() -> [Object.Type]? { 26 | return [ 27 | {% for type in types.implementing.FrozenModel|class %} 28 | {{ type.name }}Object.self{% if not forloop.last %}, {% endif %}{% endfor %} 29 | ] 30 | } 31 | 32 | func sublimatePrivateObjectTypes() -> [Object.Type]? { 33 | return [ 34 | {% for type in types.implementing.OwnedFrozenModel|class %} 35 | {{ type.name }}Object.self{% if not forloop.last %}, {% endif %}{% endfor %} 36 | ] 37 | } 38 | 39 | extension DI { 40 | {% for type in types.implementing.FrozenModel|class %} 41 | static var {{ type.name|lowerFirstLetter }}NetworkClient : NetworkClient<{{ type.name }}Object>? { 42 | get { 43 | return box.resolve(NetworkClient<{{ type.name }}Object>.self) 44 | } 45 | } 46 | static var {{ type.name|lowerFirstLetter }}Syncer : Syncer<{{ type.name }}Object>? { 47 | get { 48 | return box.resolve(Syncer<{{ type.name }}Object>.self) 49 | } 50 | } 51 | {% endfor %} 52 | } 53 | // sourcery:end 54 | -------------------------------------------------------------------------------- /Sourcery/Templates/Client/SyncableDTOs.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateClient/SublimateClient/Sources/Generated/SyncableDTOs.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import SublimateSync 5 | 6 | {% for type in types.implementing.FrozenModel|class %} 7 | // MARK: - {{ type.name }} network dto 8 | final class {{ type.name }}SyncableDTO: SyncableDTO<{{ type.name }}Object>, Codable { 9 | private var dto : {{ type.name }}DTO 10 | 11 | override init(from object: {{ type.name }}Object) { 12 | dto = {{ type.name }}DTO() 13 | {% for variable in type.storedVariables %} 14 | self.dto.{{ variable.name }} = object.{{ variable.name }} 15 | {% endfor %} 16 | self.dto.id = object.remoteId 17 | self.dto.remoteCreated = nil 18 | self.dto.clientCreated = object.clientCreated 19 | super.init(from: object) 20 | } 21 | override func syncIdentifier() -> String? { 22 | return dto.id 23 | } 24 | 25 | override func update(object: {{ type.name }}Object) { 26 | assert(object.remoteId == nil || self.dto.id == object.remoteId, "Updating an Object from a non-matching DTO") 27 | object.remoteId = self.dto.id 28 | if let remoteCreated = self.dto.remoteCreated { 29 | object.remoteCreated = remoteCreated 30 | } 31 | if let clientCreated = self.dto.clientCreated { 32 | object.clientCreated = clientCreated 33 | } 34 | {% for variable in type.storedVariables %} 35 | object.{{ variable.name }} = self.dto.{{ variable.name }} 36 | {% endfor %} 37 | } 38 | 39 | public init(from decoder: Decoder) throws { 40 | self.dto = try {{ type.name }}DTO(from : decoder) 41 | super.init() 42 | } 43 | 44 | public func encode(to encoder: Encoder) throws { 45 | try dto.encode(to: encoder) 46 | } 47 | } 48 | 49 | {% endfor %} 50 | // sourcery:end 51 | 52 | -------------------------------------------------------------------------------- /Sourcery/Templates/Vapor/Controllers.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateVapor/Sources/App/Generated/Controllers.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import Foundation 5 | import Vapor 6 | import Authentication 7 | 8 | public func configureSublimateRoutes(plain : Router, resourceGroup: Router) { 9 | 10 | {% for type in types.implementing.FrozenModel|class %} 11 | let {{ type.name|lowerFirstLetter }}Controller = {{ type.name }}Controller() 12 | {% if type.implements.OwnedFrozenModel %}resourceGroup{% else %}plain{% endif %}.get("{{ type.name|lowerFirstLetter }}", use: {{ type.name|lowerFirstLetter }}Controller.list) 13 | {% if type.implements.OwnedFrozenModel %}resourceGroup{% else %}plain{% endif %}.post("{{ type.name|lowerFirstLetter }}", use: {{ type.name|lowerFirstLetter }}Controller.create) 14 | {% if type.implements.OwnedFrozenModel %}resourceGroup{% else %}plain{% endif %}.delete("{{ type.name|lowerFirstLetter }}", {{ type.name }}.parameter, use: {{ type.name|lowerFirstLetter }}Controller.delete) 15 | 16 | {% endfor %} 17 | } 18 | 19 | {% for type in types.implementing.FrozenModel|class %} 20 | class {{ type.name }}Controller { 21 | 22 | /// Returns the list 23 | func list(_ req: Request) throws -> Future<[{{ type.name }}]> { 24 | {% if type.implements.OwnedFrozenModel %} 25 | let user = try req.requireAuthenticated(PublicUser.self) 26 | {% endif %} 27 | 28 | return {{ type.name }}.query(on: req){% if type.implements.OwnedFrozenModel %}.filter(\{{ type.name }}.owner == user.userId){% endif %}.all() 29 | } 30 | 31 | /// Creation API 32 | func create(_ req: Request) throws -> Future<{{ type.name }}> { 33 | {% if type.implements.OwnedFrozenModel %} 34 | let user = try req.requireAuthenticated(PublicUser.self) 35 | {% endif %} 36 | return try req.content.decode({{ type.name }}.self) 37 | .map ({ item -> {{ type.name }} in 38 | {% if type.implements.OwnedFrozenModel %} 39 | item.owner = user.userId 40 | {% endif %} 41 | item.remoteCreated = Date().timeIntervalSince1970 42 | return item 43 | }) 44 | .flatMap({ decoded -> Future<{{ type.name }}> in 45 | guard let id = decoded.id else { 46 | return req.future(decoded) 47 | } 48 | return {{ type.name }}.find(id, on: req) 49 | .flatMap({ found -> Future<{{ type.name }}> in 50 | guard let found = found else { 51 | return req.future(error: Abort(HTTPResponseStatus.notFound)) 52 | } 53 | {% if type.implements.OwnedFrozenModel %} 54 | guard found.owner == user.userId else { 55 | return req.future(error: Abort(HTTPResponseStatus.unauthorized)) 56 | } 57 | {% endif %} 58 | decoded.remoteCreated = found.remoteCreated 59 | decoded.clientCreated = found.clientCreated 60 | return req.future(decoded) 61 | }) 62 | }) 63 | .then({ toBeSaved -> Future<{{ type.name }}> in 64 | return toBeSaved.save(on: req) 65 | }) 66 | } 67 | 68 | /// Deletion API 69 | func delete(_ req: Request) throws -> Future { 70 | {% if type.implements.OwnedFrozenModel %} 71 | // GPTODO: Rewrite this to be more functional 72 | let user = try req.requireAuthenticated(PublicUser.self) 73 | 74 | let promise = req.eventLoop.newPromise(of: HTTPStatus.self) 75 | DispatchQueue.global().async { 76 | guard let param = try? req.parameters.next({{ type.name }}.self).wait() else { 77 | promise.fail(error:Abort(HTTPResponseStatus.internalServerError)) 78 | return 79 | } 80 | guard let objId = param.id?.uuidString else { 81 | promise.fail(error:Abort(HTTPResponseStatus.internalServerError)) 82 | return 83 | } 84 | let uuid = UUID(objId) 85 | if let fetch = try? {{ type.name }}.query(on: req).filter(\{{ type.name }}.id == uuid).first().wait(), let object = fetch, object.owner == user.userId { 86 | try? object.delete(on: req).wait() 87 | } 88 | promise.succeed(result: .ok) 89 | } 90 | return promise.futureResult 91 | {% else %} 92 | return try req.parameters.next({{ type.name }}.self).flatMap { {{ type.name|lowerFirstLetter }} in 93 | return {{ type.name|lowerFirstLetter }}.delete(on: req) 94 | }.transform(to: .ok) 95 | {% endif %} 96 | } 97 | } 98 | {% endfor %} 99 | // sourcery:end 100 | -------------------------------------------------------------------------------- /Sourcery/Templates/Vapor/Models.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:../../SublimateVapor/Sources/App/Generated/Models.swift 2 | // swiftlint:disable vertical_whitespace 3 | 4 | import FluentSQLite 5 | import Vapor 6 | 7 | public func configureSublimateMigration(migrations : inout MigrationConfig) { 8 | {% for type in types.implementing.FrozenModel|class %} 9 | migrations.add(model: {{ type.name }}.self, database: .sqlite) 10 | {% endfor %} 11 | } 12 | 13 | {% for type in types.implementing.FrozenModel|class %} 14 | // MARK: - {{ type.name }} definition 15 | final class {{ type.name }} : SQLiteUUIDModel { 16 | var id: UUID? 17 | var remoteCreated: Double? 18 | var clientCreated: Double 19 | {% if type.implements.OwnedFrozenModel %} 20 | var owner: String? 21 | {% endif %} 22 | {% for variable in type.storedVariables %}var {{ variable.name }}: {{ variable.typeName }} 23 | {% endfor %} 24 | 25 | init(id: UUID? = nil, clientCreated: Double{% if type.implements.OwnedFrozenModel %}, owner : String? = nil {% endif %} {% for variable in type.storedVariables %}, {{ variable.name }} : {{ variable.typeName }} {% endfor %}) { 26 | self.id = id 27 | self.clientCreated = clientCreated 28 | {% if type.implements.OwnedFrozenModel %} 29 | self.owner = owner 30 | {% endif %} 31 | {% for variable in type.storedVariables %}self.{{ variable.name }} = {{ variable.name }} 32 | {% endfor %} 33 | } 34 | } 35 | 36 | extension {{ type.name }}: Migration { } 37 | extension {{ type.name }}: Content { } 38 | extension {{ type.name }}: Parameter { } 39 | 40 | {% endfor %} 41 | // sourcery:end 42 | -------------------------------------------------------------------------------- /Sublimate.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/Sublimate.jpg -------------------------------------------------------------------------------- /Sublimate.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/Sublimate.pdf -------------------------------------------------------------------------------- /Sublimate.xml: -------------------------------------------------------------------------------- 1 | 7Vrbdto4FP0aHqcL2dgxjwGSzHQuyQpp0zwKWzFqZMtLiAD9+jmyZWwjEdLGXNL2BaSji6W995HP0XLHHSbLK4Gz6b88IqzjdKNlxx11HKfvdeFXGVaFwfODwhALGhUmVBnG9BvRRj0untOIzBodJedM0qxpDHmaklA2bFgIvmh2e+Ss+dQMx8QwjEPMTOs9jeS0sAbltpT9T0Ljaflk1NUtCS47a8NsiiO+qJnci447FJzLopQsh4Qp7EpcinGXW1rXCxMkla8ZcDO7X14/L3r30ccL+vfV+O7q+fMfaoCa5hmzud6xXq1clRAIPk8jombpdtzBYkolGWc4VK0L4BxsU5kwqCEoPvJUahaRo+qUsSFnXORzuZFHgqgH9pkU/InUWgJn4vo+tOgFESHJcutW0RpAEB7hCZFiBV30AM/VmGvRBbq6qBhEvrZNa+ytO2Ktmng9dQUsFDS234MzOjDOmASPoQ1nPwzI5LEdnHs7YXa7B4X5zACVRODOusqFnPKYp5hdVNZBE/YaxGRJ5RdtVuUHVf7geLo6WtbaRquyksI+vpQzqEoxzCur1bC8Vo77SqRcaULxXHIwVcv9h/NMT1lsUO3qZc4ABD4XIXkBrPI4xSIm8iXt2jUgCMOSPjfX0TqjwW6/eQPFJ4y6e0zU+/v3o7Nfzo/8YzKK3H1Qin7oaOz+NJSiLe/HV3OaDz0XAq9qHTJOUzmrzXyjDLXwxmm+dx3kNSO/Xf273Q09FSuo1LXeyhsE19u/4F57hvxEgnvry7gdwfW+U3D+IQRnRArjHHW1b58BqIOJgFKsSiGkqSq5JCkRgBtPzS5vC89bCK7dwO61jSzGsYTX/t6SGAPh/4hccPE0ZJTkAtoE8XryFZLy0d31CeKLNvBFPQu+vUPi65oKXqUhnjACCG5FN79ysbSeHMBWAR8U4J4B8ADPaAgmLWSaxgZsAIBsYtPMqVOeko0EXJswo3EKVVFsbaDQpCFm59qe0CjKX3s2Lpps5em+XpPTAjfehvZ9CzXIlrrvixrPqv2p4Cn9tuWEHs8njCYYkIOeav4kYzY32OkYvwLDjntshn2D4fLkGtwIMoNtqZPOpGvd6xanEU9ADtZup3fc2XzqoMddSW8D8vAJLCOScPg7z7IX3OrTX7+daodT9Y/tVMiMei+hlAfjpeMc2S+8zXfN2bHjWGQGssPbT/DXveVzScTpYWY9vQ+L2ZmBmYHSO7uI1SHH7pzbTtWB7u3MwOgBdmYewJeCz07I6zezV3T07BUFbQp2fan9obyDeujUrqCs91HHELn/LkRuxobrKMQSoEiShlRNekcgOoE+pyd39/hy75svufyapoj7GERURVrVetwWwkOIaC9wQy3w47i7jyNbbL63wM0xY/PPOAM43w079Y8PWmbL6x+MLahWn+AUV9HVd0zuxf8= -------------------------------------------------------------------------------- /SublimateClient/.gitignore: -------------------------------------------------------------------------------- 1 | #### CocoaPods 2 | 3 | Pods 4 | 5 | 6 | 7 | #### Build generated 8 | build 9 | DerivedData 10 | 11 | 12 | 13 | #### Various settings 14 | 15 | !default.pbxuser 16 | !default.mode1v3 17 | !default.mode2v3 18 | !default.perspectivev3 19 | 20 | *.pbxuser 21 | *.mode1v3 22 | *.mode2v3 23 | *.perspectivev3 24 | *.xcuserstate 25 | project.xcworkspace/ 26 | xcuserdata/ 27 | 28 | 29 | 30 | #### Other 31 | *.moved-aside 32 | *.xccheckout 33 | *.xcscmblueprint 34 | 35 | 36 | 37 | #### Obj-C/Swift specific 38 | *.hmap 39 | *.ipa 40 | *.dSYM.zip 41 | *.dSYM 42 | 43 | 44 | -------------------------------------------------------------------------------- /SublimateClient/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment the next line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | target 'SublimateClient' do 5 | use_frameworks! 6 | 7 | # Pods for SublimateClient 8 | pod 'SublimateSync', :path => '..' 9 | pod 'SublimateUI', :path => '..' 10 | 11 | pod 'RealmSwift' 12 | pod 'RxRealmDataSources' 13 | pod 'RxSwift' 14 | pod 'PromiseKit' 15 | pod 'Swinject' 16 | pod 'RxRealm' 17 | pod 'RxCocoa' 18 | pod 'RxReachability' 19 | pod 'KeychainAccess' 20 | pod 'NVActivityIndicatorView' 21 | pod 'SwiftMessages' 22 | 23 | target 'SublimateClientTests' do 24 | inherit! :search_paths 25 | # Pods for testing 26 | end 27 | 28 | target 'SublimateClientUITests' do 29 | inherit! :search_paths 30 | # Pods for testing 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /SublimateClient/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - KeychainAccess (3.1.2) 3 | - NVActivityIndicatorView (4.4.1): 4 | - NVActivityIndicatorView/Presenter (= 4.4.1) 5 | - NVActivityIndicatorView/Presenter (4.4.1) 6 | - PromiseKit (6.5.2): 7 | - PromiseKit/CorePromise (= 6.5.2) 8 | - PromiseKit/Foundation (= 6.5.2) 9 | - PromiseKit/UIKit (= 6.5.2) 10 | - PromiseKit/CorePromise (6.5.2) 11 | - PromiseKit/Foundation (6.5.2): 12 | - PromiseKit/CorePromise 13 | - PromiseKit/UIKit (6.5.2): 14 | - PromiseKit/CorePromise 15 | - ReachabilitySwift (4.3.0) 16 | - Realm (3.11.1): 17 | - Realm/Headers (= 3.11.1) 18 | - Realm/Headers (3.11.1) 19 | - RealmSwift (3.11.1): 20 | - Realm (= 3.11.1) 21 | - RxAtomic (4.4.0) 22 | - RxCocoa (4.4.0): 23 | - RxSwift (~> 4.0) 24 | - RxReachability (0.1.7): 25 | - ReachabilitySwift (~> 4.3.0) 26 | - RxCocoa (~> 4) 27 | - RxSwift (~> 4) 28 | - RxRealm (0.7.6): 29 | - RealmSwift (~> 3.0) 30 | - RxSwift (~> 4.0) 31 | - RxRealmDataSources (0.2.10): 32 | - RealmSwift (~> 3.10) 33 | - RxCocoa (~> 4.0) 34 | - RxRealm (~> 0.7.6) 35 | - RxSwift (~> 4.0) 36 | - RxSwift (4.4.0): 37 | - RxAtomic (~> 4.4) 38 | - SublimateSync (0.1.0): 39 | - KeychainAccess 40 | - PromiseKit 41 | - ReachabilitySwift 42 | - RealmSwift 43 | - RxCocoa 44 | - RxReachability 45 | - RxRealm 46 | - RxSwift 47 | - Swinject 48 | - SublimateUI (0.1.0): 49 | - KeychainAccess 50 | - PromiseKit 51 | - ReachabilitySwift 52 | - RealmSwift 53 | - RxCocoa 54 | - RxRealm 55 | - RxRealmDataSources 56 | - RxSwift 57 | - SublimateSync 58 | - SwiftMessages 59 | - SwiftMessages (6.0.2): 60 | - SwiftMessages/App (= 6.0.2) 61 | - SwiftMessages/App (6.0.2) 62 | - Swinject (2.5.0) 63 | 64 | DEPENDENCIES: 65 | - KeychainAccess 66 | - NVActivityIndicatorView 67 | - PromiseKit 68 | - RealmSwift 69 | - RxCocoa 70 | - RxReachability 71 | - RxRealm 72 | - RxRealmDataSources 73 | - RxSwift 74 | - SublimateSync (from `..`) 75 | - SublimateUI (from `..`) 76 | - SwiftMessages 77 | - Swinject 78 | 79 | SPEC REPOS: 80 | https://github.com/cocoapods/specs.git: 81 | - KeychainAccess 82 | - NVActivityIndicatorView 83 | - PromiseKit 84 | - ReachabilitySwift 85 | - Realm 86 | - RealmSwift 87 | - RxAtomic 88 | - RxCocoa 89 | - RxReachability 90 | - RxRealm 91 | - RxRealmDataSources 92 | - RxSwift 93 | - SwiftMessages 94 | - Swinject 95 | 96 | EXTERNAL SOURCES: 97 | SublimateSync: 98 | :path: ".." 99 | SublimateUI: 100 | :path: ".." 101 | 102 | SPEC CHECKSUMS: 103 | KeychainAccess: b3816fddcf28aa29d94b10ec305cd52be14c472b 104 | NVActivityIndicatorView: f0a6b0ed2973d9544da268f4eb76696f0a9577b0 105 | PromiseKit: 27c1601bfb73405871b805bcb8cf7e55c4dad3db 106 | ReachabilitySwift: 408477d1b6ed9779dba301953171e017c31241f3 107 | Realm: 037c5919b9ceb59d6beed5d3b031096856b119b3 108 | RealmSwift: c9580133e73ef40ed340401af2dbc9a5790dfea7 109 | RxAtomic: eacf60db868c96bfd63320e28619fe29c179656f 110 | RxCocoa: df63ebf7b9a70d6b4eeea407ed5dd4efc8979749 111 | RxReachability: 899637fce53e076465126e58d71ad835fce17924 112 | RxRealm: 5379eddd74f8d617ca7681d1f8d144af25b432b0 113 | RxRealmDataSources: 89b61c0d3edf7769da2a6bf4dcf317bc713394db 114 | RxSwift: 5976ecd04fc2fefd648827c23de5e11157faa973 115 | SublimateSync: 3f553e4a9bf0c27f3bc7e3d1ffeec13a58fb30ea 116 | SublimateUI: 8e456d9e332fe92494fe1b9e7cfb13498ed76e1c 117 | SwiftMessages: 8b3cc68dba2f1c91aac1a74c90b10287aac35371 118 | Swinject: 82cdb851f63f91bba974e3eca1d69780f2f7677e 119 | 120 | PODFILE CHECKSUM: b5a9ec38e7c98b525c50f4c8263fb84f847de999 121 | 122 | COCOAPODS: 1.5.3 123 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Original 7 | 8 | 9 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | NSAppTransportSecurity 24 | 25 | NSAllowsArbitraryLoads 26 | 27 | NSExceptionDomains 28 | 29 | localhost 30 | 31 | NSExceptionAllowsInsecureHTTPLoads 32 | 33 | NSIncludesSubdomains 34 | 35 | 36 | 37 | 38 | UILaunchStoryboardName 39 | LaunchScreen 40 | UIRequiredDeviceCapabilities 41 | 42 | armv7 43 | 44 | UISupportedInterfaceOrientations 45 | 46 | UIInterfaceOrientationPortrait 47 | UIInterfaceOrientationLandscapeLeft 48 | UIInterfaceOrientationLandscapeRight 49 | 50 | UISupportedInterfaceOrientations~ipad 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationPortraitUpsideDown 54 | UIInterfaceOrientationLandscapeLeft 55 | UIInterfaceOrientationLandscapeRight 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/data.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "documents.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "documents@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "documents@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/data.imageset/documents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateClient/SublimateClient/Resources/Assets.xcassets/data.imageset/documents.png -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/data.imageset/documents@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateClient/SublimateClient/Resources/Assets.xcassets/data.imageset/documents@2x.png -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/data.imageset/documents@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateClient/SublimateClient/Resources/Assets.xcassets/data.imageset/documents@3x.png -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/users.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "user_group_man_woman.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "user_group_man_woman@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "user_group_man_woman@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/users.imageset/user_group_man_woman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateClient/SublimateClient/Resources/Assets.xcassets/users.imageset/user_group_man_woman.png -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/users.imageset/user_group_man_woman@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateClient/SublimateClient/Resources/Assets.xcassets/users.imageset/user_group_man_woman@2x.png -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Assets.xcassets/users.imageset/user_group_man_woman@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateClient/SublimateClient/Resources/Assets.xcassets/users.imageset/user_group_man_woman@3x.png -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Resources/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Sources/AppDelegate+DI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate+DI.swift 3 | // SublimateClient 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import Foundation 12 | import RealmSwift 13 | import Swinject 14 | import Reachability 15 | import SublimateSync 16 | 17 | class DI { 18 | public static let box : Container = Container() 19 | } 20 | 21 | extension AppDelegate { 22 | func initSublimateDependencies() { 23 | 24 | DI.box.register(Reachability.self) { r -> Reachability in 25 | let reachability = Reachability()! 26 | try! reachability.startNotifier() 27 | return reachability 28 | }.inObjectScope(.container) 29 | 30 | DI.box.register(Realm.Configuration.self) { (r) -> Realm.Configuration in 31 | return Realm.Configuration.sublimate(objects: sublimateObjectTypes()) 32 | }.inObjectScope(.container) 33 | 34 | DI.box.register(Realm.Configuration.self, name: DI.RealmConfigurationPrivateOnly) { (r) -> Realm.Configuration in 35 | return Realm.Configuration.sublimate(objects: sublimatePrivateObjectTypes()) 36 | }.inObjectScope(.container) 37 | 38 | DI.box.register(NetworkConfigurationProtocol.self) { (r) -> NetworkConfigurationProtocol in 39 | return NetworkConfiguration() 40 | }.inObjectScope(.container) 41 | 42 | DI.box.register(AuthenticationClientProtocol.self) { (r) -> AuthenticationClientProtocol in 43 | return AuthenticationClient( 44 | networkConfiguration: r.resolve(NetworkConfigurationProtocol.self)!) 45 | }.inObjectScope(.graph) 46 | 47 | DI.box.register(AuthenticationManagerProtocol.self) { (r) -> AuthenticationManagerProtocol in 48 | return AuthenticationManager( 49 | client: r.resolve(AuthenticationClientProtocol.self)!) 50 | }.inObjectScope(.container) 51 | 52 | DI.box.register(NetworkManagerProtocol.self) { (r) -> NetworkManagerProtocol in 53 | return NetworkManager( 54 | networkConfiguration: r.resolve(NetworkConfigurationProtocol.self)!, 55 | authManager: r.resolve(AuthenticationManagerProtocol.self)!) 56 | }.inObjectScope(.weak) 57 | } 58 | } 59 | 60 | extension DI { 61 | static var authenticationManager : AuthenticationManagerProtocol? { 62 | get { 63 | return DI.box.resolve(AuthenticationManagerProtocol.self) 64 | } 65 | } 66 | 67 | static public let RealmConfigurationPrivateOnly = "OwnedOnly" 68 | static var realmConfiguration : Realm.Configuration? { 69 | get { 70 | return DI.box.resolve(Realm.Configuration.self) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Sources/AppDelegate+Init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate+Init.swift 3 | // SublimateClient 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import RxReachability 13 | import Reachability 14 | import RxSwift 15 | import SwiftMessages 16 | import RealmSwift 17 | import SublimateSync 18 | import SublimateUI 19 | 20 | extension AppDelegate { 21 | func sublimateSetup() { 22 | initSublimateDependencies() 23 | registerObjectSyncers(container: DI.box) 24 | registerForLogoutCleanUp() 25 | registerForReachabilityMessages(reachability: DI.box.resolve(Reachability.self)!) 26 | registerForAuthenticationMessages(authManager: DI.box.resolve(AuthenticationManagerProtocol.self)!) 27 | displayInitialViewController() 28 | } 29 | 30 | func displayInitialViewController() { 31 | let list = SchemesTableViewController(datasource: sublimateTableSources(), authManager: DI.authenticationManager!) 32 | let navigation = UINavigationController(rootViewController: list) 33 | let initialViewController : UIViewController 34 | if sublimateTableSources().filter({ source -> Bool in 35 | source.availability == TableSource.Availability.onlyAuthenthicated 36 | }).count > 0 { 37 | let tabBar = UITabBarController() 38 | 39 | let auth = AuthenticationViewController.instantiate(authManager: DI.authenticationManager!) 40 | 41 | let hasAuth = sublimateTableSources().filter { $0.availability == .onlyAuthenthicated }.count == 1 42 | if hasAuth { 43 | tabBar.viewControllers = [auth, navigation] 44 | tabBar.tabBar.items?[0].title = "Authentication" 45 | tabBar.tabBar.items?[0].image = UIImage(named: "users") 46 | tabBar.tabBar.items?[1].title = "Data" 47 | tabBar.tabBar.items?[1].image = UIImage(named: "data") 48 | } 49 | else { 50 | tabBar.viewControllers = [navigation] 51 | tabBar.tabBar.items?[0].title = "Data" 52 | tabBar.tabBar.items?[0].image = UIImage(named: "data") 53 | } 54 | 55 | 56 | initialViewController = tabBar 57 | } 58 | else { 59 | initialViewController = navigation 60 | } 61 | self.window = UIWindow(frame: UIScreen.main.bounds) 62 | self.window?.rootViewController = initialViewController 63 | self.window?.makeKeyAndVisible() 64 | } 65 | 66 | func registerForReachabilityMessages(reachability : Reachability) { 67 | reachability.rx.isReachable.skip(1).subscribe(onNext: { reachable in 68 | if reachable { 69 | MessageView.showOnline() 70 | } 71 | else { 72 | MessageView.showOffline() 73 | } 74 | }).disposed(by: disposeBag) 75 | } 76 | 77 | func registerForAuthenticationMessages(authManager : AuthenticationManagerProtocol) { 78 | var userWasLoggedIn = authManager.isLoggedIn 79 | authManager.authState.skip(1).subscribe(onNext: { state in 80 | switch state { 81 | case .loggedOut: 82 | if userWasLoggedIn { 83 | MessageView.showLoggedOut() 84 | } 85 | userWasLoggedIn = false 86 | break 87 | case .loggedIn: 88 | if userWasLoggedIn { 89 | MessageView.showRefreshedToken() 90 | } 91 | else { 92 | MessageView.showLoggedIn() 93 | } 94 | userWasLoggedIn = true 95 | break 96 | } 97 | }).disposed(by: disposeBag) 98 | } 99 | 100 | func registerForLogoutCleanUp() { 101 | DI.authenticationManager?.authState.distinctUntilChanged().subscribe(onNext: { state in 102 | if state == AuthState.loggedOut { 103 | do { 104 | if let realm = try? Realm(configuration: DI.box.resolve(Realm.Configuration.self, name: DI.RealmConfigurationPrivateOnly)!) { 105 | try? realm.write { 106 | realm.deleteAll() 107 | } 108 | } 109 | } 110 | } 111 | }).disposed(by: disposeBag) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SublimateClient 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import RealmSwift 13 | import Swinject 14 | import RxSwift 15 | import Reachability 16 | import RxReachability 17 | import SwiftMessages 18 | 19 | @UIApplicationMain 20 | class AppDelegate: UIResponder, UIApplicationDelegate { 21 | 22 | var window: UIWindow? 23 | var disposeBag = DisposeBag() 24 | 25 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 26 | // Override point for customization after application launch. 27 | // Initializing Sublimate 28 | sublimateSetup() 29 | return true 30 | } 31 | 32 | func applicationWillResignActive(_ application: UIApplication) { 33 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 34 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 35 | } 36 | 37 | func applicationDidEnterBackground(_ application: UIApplication) { 38 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 39 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 40 | } 41 | 42 | func applicationWillEnterForeground(_ application: UIApplication) { 43 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 44 | } 45 | 46 | func applicationDidBecomeActive(_ application: UIApplication) { 47 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 48 | } 49 | 50 | func applicationWillTerminate(_ application: UIApplication) { 51 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Sources/Authentication/AuthenticationClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationClient.swift 3 | // SublimateClient 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import PromiseKit 13 | import SublimateSync 14 | 15 | class AuthenticationClient : AuthenticationClientProtocol { 16 | 17 | var networkConfiguration : NetworkConfigurationProtocol 18 | 19 | init(networkConfiguration : NetworkConfigurationProtocol) { 20 | self.networkConfiguration = networkConfiguration 21 | } 22 | 23 | struct AuthenticatedResponse: Codable { 24 | var userId: String 25 | var username: String 26 | var refreshToken: String 27 | var accessToken: String 28 | } 29 | 30 | // Login with Username 31 | 32 | struct LoginRequest : Codable { 33 | let grantType : String = "password" 34 | var username : String 35 | var password : String 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case grantType = "grant_type" 39 | case username 40 | case password 41 | } 42 | } 43 | 44 | func loginWithUserCredentials(username: String, password: String) -> Promise { 45 | guard let body = try? JSONEncoder().encode(LoginRequest(username: username, password: password)) else { 46 | return Promise(error: NSError(domain: "sublimate.authentication.refresh", code: 900, userInfo: ["reason" : "Unable to encode body"])) 47 | } 48 | guard let base = URL(string: networkConfiguration.baseUrl) else { 49 | return Promise(error: NSError(domain: "sublimate.authentication.refresh", code: 900, userInfo: ["reason" : "URL was invalid "] )) 50 | } 51 | 52 | var urlRequest = URLRequest(url: base.appendingPathComponent("token")) 53 | urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 54 | urlRequest.httpBody = body 55 | urlRequest.httpMethod = "POST" 56 | 57 | return URLSession.shared.dataTask(.promise, with: urlRequest).validate() 58 | .map { (response : (data: Data, response: URLResponse)) -> AuthenticatedResponse in 59 | return try JSONDecoder().decode(AuthenticatedResponse.self, from: response.data) 60 | } 61 | .map({ response -> Authorizations in 62 | let user = UserInfo(username: response.username, userId: response.userId) 63 | let tokens = TokenInfo(accessToken: response.accessToken, refreshToken: response.refreshToken) 64 | return (user, tokens) 65 | }) 66 | } 67 | 68 | // Login with Refresh Token 69 | 70 | struct RefreshRequest : Codable { 71 | let grantType : String = "refresh_token" 72 | var refreshToken : String 73 | 74 | enum CodingKeys: String, CodingKey { 75 | case grantType = "grant_type" 76 | case refreshToken = "refresh_token" 77 | } 78 | } 79 | 80 | func loginWithRefreshToken(refreshToken: String) -> Promise { 81 | guard let body = try? JSONEncoder().encode(RefreshRequest(refreshToken: refreshToken)) else { 82 | return Promise(error: NSError(domain: "sublimate.authentication.refresh", code: 900, userInfo: ["reason" : "Unable to encode body"])) 83 | } 84 | guard let base = URL(string: networkConfiguration.baseUrl) else { 85 | return Promise(error: NSError(domain: "sublimate.authentication.refresh", code: 900, userInfo: ["reason" : "URL was invalid "] )) 86 | } 87 | 88 | var urlRequest = URLRequest(url: base.appendingPathComponent("token")) 89 | urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 90 | urlRequest.httpBody = body 91 | urlRequest.httpMethod = "POST" 92 | 93 | return URLSession.shared.dataTask(.promise, with: urlRequest).validate() 94 | .map { (response : (data: Data, response: URLResponse)) -> AuthenticatedResponse in 95 | return try JSONDecoder().decode(AuthenticatedResponse.self, from: response.data) 96 | } 97 | .map({ response -> Authorizations in 98 | let user = UserInfo(username: response.username, userId: response.userId) 99 | let tokens = TokenInfo(accessToken: response.accessToken, refreshToken: response.refreshToken) 100 | return (user, tokens) 101 | }) 102 | } 103 | 104 | // MARK: Logout 105 | 106 | struct LogoutRequest : Codable { 107 | let grantType : String = "refresh_token" 108 | var refreshToken : String 109 | enum CodingKeys: String, CodingKey { 110 | case grantType = "grant_type" 111 | case refreshToken = "refresh_token" 112 | } 113 | } 114 | 115 | struct LogoutResponse : Codable { 116 | var userId : String 117 | } 118 | 119 | func logout(refreshToken: String) -> Promise { 120 | guard let body = try? JSONEncoder().encode(LogoutRequest(refreshToken: refreshToken)) else { 121 | return Promise(error: NSError(domain: "sublimate.authentication.refresh", code: 900, userInfo: ["reason" : "Unable to encode body"])) 122 | } 123 | guard let base = URL(string: networkConfiguration.baseUrl) else { 124 | return Promise(error: NSError(domain: "sublimate.authentication.refresh", code: 900, userInfo: ["reason" : "URL was invalid "] )) 125 | } 126 | 127 | var urlRequest = URLRequest(url: base.appendingPathComponent("logout")) 128 | urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 129 | urlRequest.httpBody = body 130 | urlRequest.httpMethod = "POST" 131 | 132 | return URLSession.shared.dataTask(.promise, with: urlRequest).validate() 133 | .map { (response : (data: Data, response: URLResponse)) -> LogoutResponse in 134 | return try JSONDecoder().decode(LogoutResponse.self, from: response.data) 135 | } 136 | .map({ _ in }) 137 | } 138 | 139 | // MARK: Create User 140 | 141 | struct CreateUserResponse : Codable { 142 | var userId : String 143 | } 144 | 145 | struct CreateUserRequest : Codable { 146 | var username : String 147 | var password : String 148 | } 149 | 150 | func createUser(username: String, password: String) -> Promise { 151 | guard let body = try? JSONEncoder().encode(CreateUserRequest(username: username, password: password)) else { 152 | return Promise(error: NSError(domain: "sublimate.authentication.refresh", code: 900, userInfo: ["reason" : "Unable to encode body"])) 153 | } 154 | guard let base = URL(string: networkConfiguration.baseUrl) else { 155 | return Promise(error: NSError(domain: "sublimate.authentication.refresh", code: 900, userInfo: ["reason" : "URL was invalid "] )) 156 | } 157 | 158 | var urlRequest = URLRequest(url: base.appendingPathComponent("createUser")) 159 | urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 160 | urlRequest.httpBody = body 161 | urlRequest.httpMethod = "POST" 162 | 163 | return URLSession.shared.dataTask(.promise, with: urlRequest).validate() 164 | .map { (response : (data: Data, response: URLResponse)) -> CreateUserResponse in 165 | return try JSONDecoder().decode(CreateUserResponse.self, from: response.data) 166 | } 167 | .map({ response -> UserInfo in 168 | return UserInfo(username: username, userId: response.userId) 169 | }) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Sources/Authentication/AuthenticationViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationViewController.swift 3 | // SublimateClient 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import NVActivityIndicatorView 13 | import RxSwift 14 | import Reachability 15 | import RxReachability 16 | import PromiseKit 17 | import SwiftMessages 18 | import SublimateSync 19 | 20 | class AuthenticationViewController: UIViewController, NVActivityIndicatorViewable { 21 | 22 | let disposeBag = DisposeBag() 23 | 24 | var authManager : AuthenticationManagerProtocol? 25 | 26 | @IBOutlet weak var messageLabel: UILabel! 27 | @IBOutlet weak var usernameTextView: UITextField! 28 | @IBOutlet weak var passwordTextView: UITextField! 29 | @IBOutlet weak var loginButton: UIButton! 30 | @IBOutlet weak var createButton: UIButton! 31 | @IBOutlet weak var loggedInIdLabel: UILabel! 32 | @IBOutlet weak var loggedInUsernameLabel: UILabel! 33 | @IBOutlet weak var logoutButton: UIButton! 34 | @IBOutlet weak var logoutView: UIView! 35 | @IBOutlet weak var loginView: UIView! 36 | @IBOutlet weak var messagePaddingConstraint: NSLayoutConstraint! 37 | 38 | override func viewDidLoad() { 39 | super.viewDidLoad() 40 | setupVisuals() 41 | if let auth = authManager { 42 | auth.authState.asDriver(onErrorJustReturn: AuthState.loggedOut).drive(onNext: { [weak self] state in 43 | self?.updateLoggedIn() 44 | }).disposed(by: disposeBag) 45 | } 46 | } 47 | 48 | func updateLoggedIn() { 49 | guard let auth = authManager else { 50 | return 51 | } 52 | if auth.isLoggedIn { 53 | self.logoutView.isHidden = false 54 | self.loginView.isHidden = true 55 | self.loggedInIdLabel.text = auth.userId ?? "" 56 | self.loggedInUsernameLabel.text = auth.username ?? "" 57 | } 58 | else { 59 | self.logoutView.isHidden = true 60 | self.loginView.isHidden = false 61 | self.passwordTextView.text = "" 62 | self.usernameTextView.text = "" 63 | 64 | } 65 | } 66 | 67 | override func viewDidAppear(_ animated: Bool) { 68 | super.viewDidAppear(animated) 69 | } 70 | 71 | override func viewWillAppear(_ animated: Bool) { 72 | super.viewWillAppear(animated) 73 | } 74 | 75 | let minimumDurationAnimation = 0.75 76 | @IBAction func loginButtonTapped(_ sender: Any) { 77 | 78 | startAnimating(self.view.frame.size, message: "Logging in", type: .ballZigZagDeflect, color: .white, minimumDisplayTime: 1, backgroundColor: .black, textColor: .white) 79 | 80 | 81 | let waitAtLeast = after(seconds: minimumDurationAnimation) 82 | authManager?.loginWithUserCredentials(username: usernameTextView.text ?? "", password: passwordTextView.text ?? "") 83 | .ensureThen({ () -> Guarantee in 84 | waitAtLeast 85 | }) 86 | .catch({ error in 87 | MessageView.showMessage(message: "Login failed", type: .failed) 88 | }) 89 | .finally { 90 | self.stopAnimating() 91 | } 92 | } 93 | 94 | @IBAction func createButtonTapped(_ sender: Any) { 95 | startAnimating(self.view.frame.size, message: "Creating user", type: .ballZigZagDeflect, color: .white, minimumDisplayTime: 1, backgroundColor: .black, textColor: .white) 96 | 97 | let waitAtLeast = after(seconds: minimumDurationAnimation) 98 | authManager?.createUser(username: usernameTextView.text ?? "", password: passwordTextView.text ?? "") 99 | .ensureThen({ () -> Guarantee in 100 | waitAtLeast 101 | }) 102 | .done { 103 | MessageView.showMessage(message: "User was created", type: .success) 104 | } 105 | .catch({ error in 106 | MessageView.showMessage(message: "Operation failed", type: .failed) 107 | }) 108 | .finally { 109 | self.stopAnimating() 110 | } 111 | } 112 | 113 | @IBAction func logoutButtonTapped(_ sender: Any) { 114 | startAnimating(self.view.frame.size, message: "Logging out", type: .ballZigZagDeflect, color: .white, minimumDisplayTime: 1, backgroundColor: .black, textColor: .white) 115 | 116 | let waitAtLeast = after(seconds: minimumDurationAnimation) 117 | authManager?.logout() 118 | .ensureThen({ () -> Guarantee in 119 | waitAtLeast 120 | }) 121 | .catch({ error in 122 | MessageView.showMessage(message: "An unexpected error occurred", type: .failed) 123 | }) 124 | .finally { 125 | self.stopAnimating() 126 | } 127 | } 128 | 129 | func setupVisuals() { 130 | self.view.backgroundColor = UIColor.white 131 | 132 | self.passwordTextView.isSecureTextEntry = true 133 | self.passwordTextView.backgroundColor = UIColor.lightGray 134 | self.usernameTextView.backgroundColor = UIColor.lightGray 135 | 136 | loginButton.backgroundColor = UIColor.black 137 | loginButton.setTitleColor(UIColor.white, for: .normal) 138 | loginButton.layer.cornerRadius = 8 139 | loginButton.clipsToBounds = true 140 | 141 | createButton.backgroundColor = UIColor.black 142 | createButton.setTitleColor(UIColor.white, for: .normal) 143 | createButton.layer.cornerRadius = 8 144 | createButton.clipsToBounds = true 145 | 146 | logoutButton.backgroundColor = UIColor.black 147 | logoutButton.setTitleColor(UIColor.white, for: .normal) 148 | logoutButton.layer.cornerRadius = 8 149 | logoutButton.clipsToBounds = true 150 | } 151 | } 152 | 153 | extension AuthenticationViewController { 154 | static func instantiate(authManager : AuthenticationManagerProtocol) -> AuthenticationViewController { 155 | let vc : AuthenticationViewController = UIStoryboard.instantiate(storyboard: "Authentication", identifier: "AuthenticationViewController") 156 | vc.authManager = authManager 157 | return vc 158 | } 159 | } 160 | 161 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Sources/MessageView+Utility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageView+Utility.swift 3 | // SublimateClient 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import SwiftMessages 13 | import RxSwift 14 | import RxReachability 15 | import Reachability 16 | 17 | extension MessageView { 18 | 19 | enum SublimateMessageIcon: String { 20 | case offline = "😢" 21 | case online = "🙂" 22 | case success = "👍" 23 | case failed = "👎" 24 | case loggedOut = "🔐" 25 | case loggedIn = "🔑" 26 | } 27 | 28 | static func showMessage(message : String, type: SublimateMessageIcon) { 29 | 30 | let view = MessageView.viewFromNib(layout: .cardView) 31 | 32 | view.configureTheme(.info) 33 | view.configureDropShadow() 34 | view.button?.isHidden = true 35 | 36 | view.configureContent(title: "", body: message, iconText: type.rawValue) 37 | view.layoutMarginAdditions = UIEdgeInsets(top: 40, left: 40, bottom: 40, right: 40) 38 | (view.backgroundView as? CornerRoundingView)?.cornerRadius = 10 39 | SwiftMessages.show(view: view) 40 | } 41 | 42 | static func showOnline() { 43 | showMessage(message: "Heya! You are online again!", type: .online) 44 | } 45 | 46 | static func showOffline() { 47 | showMessage(message: "Oh no! You went offline!", type: .offline) 48 | } 49 | 50 | static func showLoggedIn() { 51 | showMessage(message: "You successfully logged in", type: .loggedIn) 52 | } 53 | 54 | static func showRefreshedToken() { 55 | showMessage(message: "You successfully refreshed your access token", type: .loggedIn) 56 | } 57 | 58 | static func showLoggedOut() { 59 | showMessage(message: "You have been logged out", type: .loggedOut) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClient/Sources/RealmConfiguration+Utility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmConfiguration+Utility.swift 3 | // SublimateClient 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import RealmSwift 13 | 14 | extension Realm.Configuration { 15 | static var sublimateSchemaVersion : UInt64 = 10000 // 1.(0)0.(0)0 16 | static func sublimate(objects : [Object.Type]? = nil) -> Realm.Configuration { 17 | 18 | let fileURL = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appendingPathComponent("Sublimate.realm") 19 | var configuration = Realm.Configuration(fileURL: fileURL, objectTypes: objects) 20 | configuration.schemaVersion = sublimateSchemaVersion 21 | configuration.migrationBlock = { migration, oldSchemaVersion in 22 | } 23 | return configuration 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClientTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClientTests/SublimateClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SublimateClientTests.swift 3 | // SublimateClientTests 4 | // 5 | // Created by Gabriele on 24/09/2018. 6 | // Copyright © 2018 Gabriele. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SublimateClient 11 | 12 | class SublimateClientTests: XCTestCase { 13 | 14 | override func setUp() { 15 | super.setUp() 16 | // Put setup code here. This method is called before the invocation of each test method in the class. 17 | } 18 | 19 | override func tearDown() { 20 | // Put teardown code here. This method is called after the invocation of each test method in the class. 21 | super.tearDown() 22 | } 23 | 24 | func testExample() { 25 | // This is an example of a functional test case. 26 | // Use XCTAssert and related functions to verify your tests produce the correct results. 27 | } 28 | 29 | func testPerformanceExample() { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClientUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /SublimateClient/SublimateClientUITests/SublimateClientUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SublimateClientUITests.swift 3 | // SublimateClientUITests 4 | // 5 | // Created by Gabriele on 24/09/2018. 6 | // Copyright © 2018 Gabriele. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class SublimateClientUITests: XCTestCase { 12 | 13 | override func setUp() { 14 | super.setUp() 15 | 16 | // Put setup code here. This method is called before the invocation of each test method in the class. 17 | 18 | // In UI tests it is usually best to stop immediately when a failure occurs. 19 | continueAfterFailure = false 20 | // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. 21 | XCUIApplication().launch() 22 | 23 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 24 | } 25 | 26 | override func tearDown() { 27 | // Put teardown code here. This method is called after the invocation of each test method in the class. 28 | super.tearDown() 29 | } 30 | 31 | func testExample() { 32 | // Use recording to get started writing UI tests. 33 | // Use XCTAssert and related functions to verify your tests produce the correct results. 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /SublimateSync.podspec: -------------------------------------------------------------------------------- 1 | # 2 | # Be sure to run `pod lib lint SublimateSync.podspec' to ensure this is a 3 | # valid spec before submitting. 4 | # 5 | # Any lines starting with a # are optional, but their use is encouraged 6 | # To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html 7 | # 8 | 9 | Pod::Spec.new do |s| 10 | s.name = 'SublimateSync' 11 | s.version = '0.1.0' 12 | s.summary = 'SublimateSync: Data synchronization library' 13 | 14 | # This description is used to generate tags and improve search results. 15 | # * Think: What does it do? Why did you write it? What is the focus? 16 | # * Try to keep it short, snappy and to the point. 17 | # * Write the description between the DESC delimiters below. 18 | # * Finally, don't worry about the indent, CocoaPods strips it! 19 | 20 | s.description = <<-DESC 21 | Library to manage network data synchronization with a Offline first approach, based on Realm and PromiseKit 22 | DESC 23 | 24 | s.homepage = 'https://github.com/gabrielepalma/sublimate' 25 | s.license = { :type => 'MIT', :file => 'LICENSE' } 26 | s.author = { 'Gabriele Palma' => 'gabrielepalma82@gmail.com' } 27 | s.source = { :git => 'https://github.com/gabrielepalma/sublimate.git', :tag => s.version.to_s } 28 | 29 | s.ios.deployment_target = '10.0' 30 | s.swift_version = '4.2' 31 | 32 | s.source_files = 'SublimateSync/SublimateSync/Classes/**/*' 33 | 34 | s.frameworks = 'Foundation' 35 | 36 | s.dependency 'KeychainAccess' 37 | s.dependency 'RxCocoa' 38 | s.dependency 'PromiseKit' 39 | s.dependency 'RxSwift' 40 | s.dependency 'RealmSwift' 41 | s.dependency 'RxRealm' 42 | s.dependency 'RxReachability' 43 | s.dependency 'ReachabilitySwift' 44 | s.dependency 'Swinject' 45 | 46 | end 47 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/AuthenticationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationManager.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import KeychainAccess 13 | import RxCocoa 14 | import PromiseKit 15 | import RxSwift 16 | 17 | public class AuthenticationManager: AuthenticationManagerProtocol { 18 | 19 | public let client : AuthenticationClientProtocol 20 | private let keychain = Keychain(service: "sublimate") 21 | 22 | public init(client: AuthenticationClientProtocol) { 23 | self.client = client 24 | let refreshToken = keychain["refreshToken"] 25 | if refreshToken?.isEmpty ?? true { 26 | state = BehaviorRelay(value: .loggedIn) 27 | } 28 | else { 29 | state = BehaviorRelay(value: .loggedOut) 30 | } 31 | } 32 | 33 | private var state: BehaviorRelay 34 | public var authState: Observable { 35 | return state.asObservable() 36 | } 37 | 38 | private var refreshToken : String? { 39 | return keychain["refreshToken"] 40 | } 41 | 42 | private var accessToken : String? { 43 | return keychain["accessToken"] 44 | } 45 | 46 | public var username : String? { 47 | return keychain["username"] 48 | } 49 | 50 | public var userId : String? { 51 | return keychain["userId"] 52 | } 53 | 54 | public var isLoggedIn: Bool { 55 | return !(refreshToken?.isEmpty ?? true) 56 | } 57 | 58 | public func handleError(error: PMKHTTPError, duringTokenRefresh: Bool) { 59 | if case let PMKHTTPError.badStatusCode(code, _, _) = error, code == 401 { 60 | // Access token validates but is not authorized, rare case (probably only on server side encryption key changes) 61 | keychain["accessToken"] = nil 62 | } 63 | } 64 | 65 | public func refreshAccessTokenIfNeeded() -> Promise { 66 | if !isLoggedIn { 67 | // We have nothing to refresh: skip this step but make sure user state is logged out 68 | state.accept(.loggedOut) 69 | return Promise() 70 | } 71 | if let accessToken = accessToken, JwtUtilities.isJwtTokenValid(jwtToken: accessToken) { 72 | // We have a valid token: no need to refresh 73 | return Promise() 74 | } 75 | return loginWithRefreshToken() 76 | } 77 | 78 | public func authorizationHeaders() -> [String : String] { 79 | guard let accessToken = accessToken else { 80 | return [:] 81 | } 82 | return ["Authorization" : "Bearer \(accessToken)"] 83 | } 84 | 85 | public func createUser(username: String, password: String) -> Promise { 86 | return client.createUser(username: username, password: password).map({ _ in () }) 87 | } 88 | 89 | public func loginWithUserCredentials(username: String, password: String) -> Promise { 90 | return client.loginWithUserCredentials(username: username, password: password).map({ result -> Void in 91 | self.saveLogin(auth: result) 92 | }) 93 | } 94 | 95 | public func loginWithRefreshToken() -> Promise { 96 | guard let refreshToken = refreshToken else { 97 | // We are not logged in! 98 | saveLogout() 99 | return Promise(error: NSError(domain: "sublimate.authentication", code: 900, userInfo: ["reason" : "Failed while refreshing token: user is logged out"])) 100 | } 101 | return client.loginWithRefreshToken(refreshToken: refreshToken) 102 | .tap({ [weak self] result in 103 | if case let Result.rejected(error) = result, case let PromiseKit.PMKHTTPError.badStatusCode(code, _, _) = error, code == 401 { 104 | // Refresh token stored on device has not been authorized, we clear it out 105 | self?.saveLogout() 106 | } 107 | }) 108 | .map({ [weak self] result -> Void in 109 | self?.saveLogin(auth: result) 110 | }) 111 | } 112 | 113 | public func logout() -> Promise { 114 | return client.logout(refreshToken: refreshToken ?? "").ensure { 115 | self.saveLogout() 116 | } 117 | } 118 | 119 | private func saveLogout() { 120 | keychain["username"] = nil 121 | keychain["userId"] = nil 122 | keychain["accessToken"] = nil 123 | keychain["refreshToken"] = nil 124 | state.accept(.loggedOut) 125 | } 126 | 127 | private func saveLogin(auth: Authorizations) { 128 | keychain["username"] = auth.0.username 129 | keychain["userId"] = auth.0.userId 130 | keychain["accessToken"] = auth.1.accessToken 131 | keychain["refreshToken"] = auth.1.refreshToken 132 | state.accept(.loggedIn) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/AuthenticationProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationProtocols.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import KeychainAccess 13 | import RxCocoa 14 | import PromiseKit 15 | import RxSwift 16 | 17 | public enum AuthState { 18 | case loggedIn 19 | case loggedOut 20 | } 21 | 22 | public typealias Authorizations = (UserInfo, TokenInfo) 23 | 24 | public struct UserInfo { 25 | var username: String 26 | var userId: String 27 | public init(username: String, userId: String) { 28 | self.username = username 29 | self.userId = userId 30 | } 31 | } 32 | 33 | public struct TokenInfo { 34 | var accessToken: String 35 | var refreshToken: String 36 | public init(accessToken: String, refreshToken: String) { 37 | self.accessToken = accessToken 38 | self.refreshToken = refreshToken 39 | } 40 | } 41 | 42 | public protocol AuthenticationClientProtocol { 43 | func createUser(username : String, password : String) -> Promise 44 | func loginWithUserCredentials(username : String, password : String) -> Promise 45 | func loginWithRefreshToken(refreshToken : String) -> Promise 46 | func logout(refreshToken : String) -> Promise 47 | } 48 | 49 | public protocol AuthenticationManagerProtocol { 50 | func createUser(username : String, password : String) -> Promise 51 | func loginWithUserCredentials(username : String, password : String) -> Promise 52 | func loginWithRefreshToken() -> Promise 53 | func logout() -> Promise 54 | 55 | var username : String? { get } 56 | var userId : String? { get } 57 | var isLoggedIn: Bool { get } 58 | var authState : Observable { get } 59 | 60 | func refreshAccessTokenIfNeeded() -> Promise 61 | func authorizationHeaders() -> [String : String] 62 | func handleError(error : PMKHTTPError, duringTokenRefresh: Bool) 63 | } 64 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/JWT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JWT.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | 13 | class JwtUtilities { 14 | 15 | public static func isJwtTokenValid(jwtToken jwt: String) -> Bool { 16 | let payload = decode(jwtToken: jwt) 17 | guard let exp = payload["exp"] as? Double else { 18 | return false 19 | } 20 | return exp > Date().timeIntervalSince1970 21 | } 22 | 23 | public static func decode(jwtToken jwt: String) -> [String: Any] { 24 | let segments = jwt.components(separatedBy: ".") 25 | return decodeJWTPart(segments[1]) ?? [:] 26 | } 27 | 28 | private static func base64UrlDecode(_ value: String) -> Data? { 29 | var base64 = value 30 | .replacingOccurrences(of: "-", with: "+") 31 | .replacingOccurrences(of: "_", with: "/") 32 | 33 | let length = Double(base64.lengthOfBytes(using: String.Encoding.utf8)) 34 | let requiredLength = 4 * ceil(length / 4.0) 35 | let paddingLength = requiredLength - length 36 | if paddingLength > 0 { 37 | let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0) 38 | base64 = base64 + padding 39 | } 40 | return Data(base64Encoded: base64, options: .ignoreUnknownCharacters) 41 | } 42 | 43 | private static func decodeJWTPart(_ value: String) -> [String: Any]? { 44 | guard let bodyData = base64UrlDecode(value), 45 | let json = try? JSONSerialization.jsonObject(with: bodyData, options: []), let payload = json as? [String: Any] else { 46 | return nil 47 | } 48 | 49 | return payload 50 | } 51 | } 52 | 53 | 54 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import Foundation 12 | import Swinject 13 | 14 | struct Logger { 15 | public static var muted = false 16 | 17 | public static func log(_ string: String) { 18 | if !muted { 19 | print("\(string)") 20 | } 21 | } 22 | 23 | public static func error(_ string: String) { 24 | if !muted { 25 | print("🛑 \(string) 🛑") 26 | } 27 | } 28 | 29 | public static func warning(_ string: String) { 30 | if !muted { 31 | print("⚠️ \(string) ⚠️") 32 | } 33 | } 34 | } 35 | 36 | 37 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/NetworkManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkManager.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import PromiseKit 13 | import RxSwift 14 | import Foundation 15 | 16 | public class NetworkResponse { 17 | public private(set) var meta: URLResponse 18 | public private(set) var body: T 19 | 20 | public init(meta: URLResponse, body: T) { 21 | self.meta = meta 22 | self.body = body 23 | } 24 | } 25 | 26 | public protocol NetworkConfigurationProtocol { 27 | var baseUrl : String { get } 28 | } 29 | 30 | public struct Request { 31 | 32 | public enum Method : String { 33 | case POST = "POST" 34 | case GET = "GET" 35 | case DELETE = "DELETE" 36 | case PUT = "PUT" 37 | } 38 | 39 | public enum ContentType { 40 | case JSON 41 | case MULTIPART(boundary : String) 42 | } 43 | 44 | public var id : String = UUID().uuidString 45 | public var method : Method 46 | public var contentType : ContentType 47 | public var additionalHeaders : [String : String] 48 | public var path : String 49 | public var body : Data? 50 | 51 | public init(method : Method, contentType : ContentType, path : String, body : Data? = nil, additionalHeaders : [String : String] = [:]) { 52 | self.method = method 53 | self.contentType = contentType 54 | self.path = path 55 | self.body = body 56 | self.additionalHeaders = additionalHeaders 57 | } 58 | } 59 | 60 | public protocol NetworkManagerProtocol { 61 | func makeRequest(request : Request) -> Promise 62 | func makeRequest(request : Request, responseType: T.Type) -> Promise 63 | func makeRequest(request : Request, responseType: [T.Type]) -> Promise<[T]> 64 | func makeRequest(request : Request, responseType: Data.Type) -> Promise 65 | } 66 | 67 | public class NetworkManager : NetworkManagerProtocol { 68 | public var networkConfiguration : NetworkConfigurationProtocol 69 | public var authManager : AuthenticationManagerProtocol 70 | 71 | public init(networkConfiguration : NetworkConfigurationProtocol, authManager : AuthenticationManagerProtocol) { 72 | self.networkConfiguration = networkConfiguration 73 | self.authManager = authManager 74 | } 75 | 76 | public func makeRequest(request: Request) -> Promise { 77 | return firstly { 78 | makeRequestInternal(request: request) 79 | }.map({ r in }) 80 | } 81 | 82 | public func makeRequest(request: Request, responseType: T.Type) -> Promise where T : Decodable, T : Encodable { 83 | return firstly { 84 | makeRequestInternal(request: request) 85 | }.map({ (response) -> T in 86 | return try JSONDecoder().decode(T.self, from: response.body) 87 | }) 88 | } 89 | 90 | public func makeRequest(request: Request, responseType: [T.Type]) -> Promise<[T]> where T : Decodable, T : Encodable { 91 | return firstly { 92 | makeRequestInternal(request: request) 93 | }.map({ (response) -> [T] in 94 | return try JSONDecoder().decode([T].self, from: response.body) 95 | }) 96 | } 97 | 98 | public func makeRequest(request: Request, responseType: Data.Type) -> Promise { 99 | return firstly { 100 | makeRequestInternal(request: request) 101 | }.map({ (response) -> Data in 102 | return response.body 103 | }) 104 | } 105 | 106 | private func makeRequestInternal(request : Request) -> Promise> { 107 | guard let base = URL(string: networkConfiguration.baseUrl) else { 108 | return Promise>(error: NSError(domain: "NetworkManager", code: 599, userInfo: ["reason" : "URL was invalid "] )) 109 | } 110 | 111 | let url = base.appendingPathComponent(request.path) 112 | var urlRequest = URLRequest(url: url) 113 | switch request.contentType { 114 | case .JSON: 115 | urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 116 | case .MULTIPART(let boundary): 117 | urlRequest.setValue("multipart/mixed;boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 118 | } 119 | urlRequest.httpBody = request.body 120 | urlRequest.httpMethod = request.method.rawValue 121 | 122 | for header in request.additionalHeaders { 123 | urlRequest.setValue(header.value, forHTTPHeaderField: header.key) 124 | } 125 | 126 | for header in authManager.authorizationHeaders() { 127 | urlRequest.setValue(header.value, forHTTPHeaderField: header.key) 128 | } 129 | 130 | return DispatchQueue.global().async(.promise) { [authManager] () -> Promise in 131 | return authManager.refreshAccessTokenIfNeeded() 132 | } 133 | .tap({ [authManager] r in 134 | if case let Result.rejected(error) = r, let pmerror = error as? PMKHTTPError { 135 | authManager.handleError(error: pmerror, duringTokenRefresh: true) 136 | } 137 | }) 138 | .then { (_) -> Promise<(data: Data, response: URLResponse)> in 139 | URLSession.shared.dataTask(.promise, with: urlRequest).validate() 140 | } 141 | .tap({ [authManager] r in 142 | if case let Result.rejected(error) = r, let pmerror = error as? PMKHTTPError { 143 | authManager.handleError(error: pmerror, duringTokenRefresh: false) 144 | } 145 | }) 146 | .map { (response : (data: Data, response: URLResponse)) -> NetworkResponse in 147 | NetworkResponse(meta: response.response, body: response.data) 148 | } 149 | } 150 | } 151 | 152 | public class NetworkConfiguration : NetworkConfigurationProtocol { 153 | 154 | public init() { 155 | } 156 | 157 | public enum Environment : String { 158 | case local = "http://localhost:8080" 159 | case rqa = "rqa" 160 | case production = "production" 161 | } 162 | 163 | public var baseUrl: String { 164 | get { 165 | return environment.rawValue 166 | } 167 | } 168 | 169 | public var environment : Environment = .local 170 | } 171 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/Rx Support/ReadOnlyBehaviourRelay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReadOnlyBehaviourRelay.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import Foundation 12 | import RxSwift 13 | import RxCocoa 14 | 15 | public final class ReadOnlyBehaviorRelay { 16 | private var relay: BehaviorRelay 17 | public var value: T { 18 | return relay.value 19 | } 20 | 21 | public init(of relay: BehaviorRelay) { 22 | self.relay = relay 23 | } 24 | 25 | public func asObservable() -> Observable { 26 | return relay.asObservable() 27 | } 28 | } 29 | 30 | public extension BehaviorRelay { 31 | public func asReadOnly() -> ReadOnlyBehaviorRelay { 32 | return ReadOnlyBehaviorRelay(of: self) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/Rx Support/ReadOnlyVariable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReadOnlyVariable.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | // GPTODO: We need to get rid of Variable, they are deprecated 12 | 13 | import Foundation 14 | import RxSwift 15 | import RxCocoa 16 | 17 | public final class ReadOnlyVariable { 18 | private var relay: Variable 19 | public var value: T { 20 | return relay.value 21 | } 22 | 23 | public init(of relay: Variable) { 24 | self.relay = relay 25 | } 26 | 27 | public func asObservable() -> Observable { 28 | return relay.asObservable() 29 | } 30 | } 31 | 32 | public extension Variable { 33 | public func asReadOnly() -> ReadOnlyVariable { 34 | return ReadOnlyVariable(of: self) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/Syncing/Syncable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Syncable.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import Foundation 12 | import RealmSwift 13 | import PromiseKit 14 | 15 | public enum SyncOperation : String { 16 | case none = "NONE" 17 | case create = "CREATE" 18 | case update = "UPDATE" 19 | case delete = "DELETE" 20 | } 21 | 22 | open class SyncableDTO where T : Syncable{ 23 | public init() { 24 | } 25 | 26 | public init(from object: T) { 27 | } 28 | 29 | open func update(object : T) { 30 | } 31 | 32 | open func syncIdentifier() -> String? { 33 | return nil 34 | } 35 | } 36 | 37 | open class NetworkClient where T : Syncable { 38 | public init() { 39 | } 40 | 41 | open func fetchAll(withSyncingOptions options : NSDictionary? = nil) -> Promise<[SyncableDTO]> { 42 | return Promise(error: NSError(domain: "sublimate.networkclient", code: 900, userInfo: ["reason" : "Unsupported operation"])) 43 | } 44 | 45 | open func syncOne(item : T) -> Promise> { 46 | return Promise(error: NSError(domain: "sublimate.networkclient", code: 900, userInfo: ["reason" : "Unsupported operation"])) 47 | } 48 | 49 | open func delete(item : T) -> Promise { 50 | return Promise(error: NSError(domain: "sublimate.networkclient", code: 900, userInfo: ["reason" : "Unsupported operation"])) 51 | } 52 | } 53 | 54 | extension NetworkClient { 55 | func syncOrDelete(item: T) -> Promise?> { 56 | if item.syncOperation == .delete { 57 | return self.delete(item: item).map({ void -> SyncableDTO? in 58 | return nil 59 | }) 60 | } 61 | else if item.syncOperation == .update || item.syncOperation == .create { 62 | return self.syncOne(item: item).map({ res -> SyncableDTO? in 63 | return res 64 | }) 65 | } 66 | else { 67 | return Promise?>(resolver: { r in 68 | r.fulfill(nil) 69 | }) 70 | } 71 | } 72 | } 73 | 74 | public protocol Syncable where Self : Object { 75 | var syncIdentifier : String? { get } 76 | var syncOperation : SyncOperation { get set } 77 | var isInErrorState : Bool { get set } 78 | var clientLastUpdated : TimeInterval { get set } 79 | 80 | static func pendingObjectsPredicate() -> NSPredicate 81 | static func syncableObjectsPredicate(withSyncingOptions options : NSDictionary?) -> NSPredicate 82 | } 83 | 84 | public protocol SublimatePresentable where Self : Object { 85 | var title : String? { get } 86 | var thumbnail : Promise { get } 87 | } 88 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/Syncing/Syncer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Syncer.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import Foundation 12 | import RxSwift 13 | import RealmSwift 14 | import PromiseKit 15 | import RxRealm 16 | import Reachability 17 | import RxReachability 18 | import RxCocoa 19 | 20 | public protocol SyncerProtocol { 21 | var isSyncing: ReadOnlyVariable { get } 22 | var lastError: ReadOnlyVariable { get } 23 | 24 | func activate() 25 | func scheduleSyncronization() 26 | func startRefreshTimer(period : RxTimeInterval) 27 | func stopRefreshTimer() 28 | } 29 | 30 | public class Syncer : SyncerProtocol { 31 | let realmConfiguration : Realm.Configuration 32 | 33 | var isReachable: Observable { 34 | return reachability.rx.isReachable.startWith(reachability.connection != .none) 35 | } 36 | 37 | private let networkClient : NetworkClient 38 | private let reachability : Reachability 39 | 40 | private let syncQueue : DispatchQueue 41 | private let syncScheduler : SerialDispatchQueueScheduler 42 | 43 | private var disposeBag = DisposeBag() 44 | 45 | private let isSyncingInternal = Variable(false) 46 | private let hasPendingChanges = Variable(false) 47 | private let isSyncScheduled = Variable(false) 48 | private let lastSyncError: Variable = Variable(nil) 49 | 50 | private func subscribePendingChanges() { 51 | if let realm = try? Realm(configuration: realmConfiguration) { 52 | let pending = realm.objects(T.self).filter(T.pendingObjectsPredicate()) 53 | Observable.changeset(from: pending, synchronousStart: true).asObservable().map { (result, changes) -> Bool in 54 | return result.count > 0 55 | }.subscribe(onNext: { [weak self] hasItems in 56 | self?.hasPendingChanges.value = hasItems 57 | }).disposed(by: disposeBag) 58 | } 59 | } 60 | 61 | public init(networkClient : NetworkClient, realmConfiguration : Realm.Configuration, reachability : Reachability) { 62 | self.networkClient = networkClient 63 | self.realmConfiguration = realmConfiguration 64 | self.reachability = reachability 65 | self.syncQueue = DispatchQueue.global(qos: .userInitiated) 66 | self.syncScheduler = SerialDispatchQueueScheduler(queue: syncQueue, internalSerialQueueName: "sublimate.syncer") 67 | } 68 | 69 | private func runSynchronizationFlow() { 70 | isSyncingInternal.value = true 71 | Logger.log("Syncer for \(T.self) started") 72 | firstly { () -> Promise in 73 | upstreamChanges() 74 | }.then { _ -> Promise in 75 | self.downstreamChanges() 76 | }.done { _ in 77 | Logger.log("Syncer for \(T.self) completed successfully") 78 | self.lastSyncError.value = nil 79 | }.catch { (error) in 80 | Logger.error("Syncer for \(T.self) completed with error \(error)") 81 | self.lastSyncError.value = error 82 | }.finally { 83 | self.isSyncingInternal.value = false 84 | self.isSyncScheduled.value = false 85 | } 86 | } 87 | 88 | private func upstreamChanges() -> Promise { 89 | guard let realm = try? Realm(configuration: realmConfiguration) else { 90 | return Promise(error: NSError(domain: "sublimate.syncer", code: 900, userInfo: ["reason" : "Unable to instantiate realm"])) 91 | } 92 | debug("upstreamChanges for \(T.self) started") 93 | let pending = realm.objects(T.self).filter(T.pendingObjectsPredicate()) 94 | let promises : [Promise] = pending.map { (item) -> Promise in 95 | self.sync(item: item) 96 | } 97 | return when(resolved: promises).asVoid() 98 | } 99 | 100 | private func sync(item : T) -> Promise { 101 | debug("Syncing upstream object id \(item.syncIdentifier ?? "*local*") for operation \(item.syncOperation)") 102 | let clientLastUpdated = item.clientLastUpdated 103 | guard let keyField = T.primaryKey(), let itemKey = item[keyField] else { 104 | Logger.error("Unable to sync object without a key") 105 | return Promise(error: NSError(domain: "sublimate.syncer", code: 900, userInfo: ["reason" : "Unable to sync object without a key"])) 106 | } 107 | 108 | return networkClient.syncOrDelete(item: item) 109 | .tap({ result in 110 | if case Result.rejected(_) = result, 111 | let realm = try? Realm(configuration: self.realmConfiguration), 112 | var object = realm.object(ofType: T.self, forPrimaryKey: itemKey) 113 | { 114 | try? realm.write 115 | { 116 | object.isInErrorState = true 117 | } 118 | } 119 | }) 120 | .then(on: syncQueue) { (dto) -> Promise in 121 | do { 122 | let realm = try Realm(configuration: self.realmConfiguration) 123 | guard var object = realm.object(ofType: T.self, forPrimaryKey: itemKey) else { 124 | Logger.error("An object has been deleted from Realm while being synchronized") 125 | return Promise(error: NSError(domain: "sublimate.syncer", code: 900, userInfo: ["reason" : "Object not found in Realm"])) 126 | } 127 | let syncOp = object.syncOperation 128 | let syncIdentifier = object.syncIdentifier 129 | 130 | switch syncOp { 131 | case .create, .update: 132 | if let dto = dto, clientLastUpdated == object.clientLastUpdated { 133 | try realm.write { 134 | dto.update(object: object) 135 | object.syncOperation = .none 136 | realm.add(object, update: true) 137 | } 138 | self.debug("Object id \(syncIdentifier ?? "*local*") was successfully synced with operation \(syncOp)") 139 | } 140 | else { 141 | self.debug("Object id \(syncIdentifier ?? "*local*") was mutated during synchronization and is not clean") 142 | } 143 | return Promise.value(()) 144 | case .delete: 145 | let realm = try Realm(configuration: self.realmConfiguration) 146 | try realm.write { 147 | realm.delete(object) 148 | } 149 | self.debug("Object id \(syncIdentifier ?? "*local*") was successfully deleted") 150 | return Promise.value(()) 151 | default: 152 | Logger.error("An object with inappropriate syncOperation has been fetched from Realm for synchronization") 153 | return Promise(error: NSError(domain: "sublimate.syncer", code: 900, userInfo: ["reason" : "Object has inappropriate syncOperation"])) 154 | } 155 | } 156 | catch let error { 157 | Logger.error("Error while syncing: \(error.localizedDescription)") 158 | return Promise(error: error) 159 | } 160 | } 161 | } 162 | 163 | private func downstreamChanges() -> Promise { 164 | debug("downstreamChanges for \(T.self) started") 165 | let configuration = self.realmConfiguration 166 | return networkClient.fetchAll().then(on: syncQueue) { (dtos) -> Promise in 167 | self.debug("downstreamChanges for \(T.self) received \(dtos.count) dtos") 168 | do { 169 | let realm = try Realm(configuration: configuration) 170 | var toBeRemoved = Set(realm.objects(T.self).filter(T.syncableObjectsPredicate(withSyncingOptions: nil))) 171 | try realm.write { 172 | for dto in dtos { 173 | guard let syncIdentifier = dto.syncIdentifier() else { 174 | return 175 | } 176 | if let item = realm.object(ofType: T.self, forSynchronizationId:syncIdentifier) { 177 | toBeRemoved.remove(item) 178 | if item.syncOperation == .none { 179 | dto.update(object: item) 180 | realm.add(item, update: true) 181 | } 182 | } else { 183 | Logger.log("adding new \(T.self)") 184 | let item = T() 185 | dto.update(object: item) 186 | realm.add(item) 187 | } 188 | } 189 | toBeRemoved = toBeRemoved.filter( { $0.syncOperation == .none || $0.syncOperation == .delete || $0.syncOperation == .update } ) 190 | realm.delete(toBeRemoved) 191 | } 192 | return Promise.value(()) 193 | } catch let error { 194 | Logger.error("Error while syncing: \(error.localizedDescription)") 195 | return Promise(error: error) 196 | } 197 | } 198 | } 199 | 200 | var verboseOutput : Bool = false 201 | private func debug(_ string : String) { 202 | if verboseOutput { 203 | Logger.log(string) 204 | } 205 | } 206 | 207 | private func subscribeSynchronization() { 208 | // GPTODO: We need an exponential backoff when the synchronization is failing 209 | Observable 210 | .combineLatest( 211 | hasPendingChanges.asObservable(), 212 | isSyncScheduled.asObservable(), 213 | isReachable, 214 | isSyncingInternal.asObservable()) 215 | .map { (hasPendingChanges, isSyncScheduled, isReachable, isSyncingInternal) -> Bool in 216 | return !isSyncingInternal && isReachable && (hasPendingChanges || isSyncScheduled) 217 | } 218 | .filter { (shouldSync) -> Bool in 219 | return shouldSync 220 | } 221 | .throttle(3, scheduler: syncScheduler).observeOn(syncScheduler).subscribe(onNext: { [weak self] (_) in 222 | self?.runSynchronizationFlow() 223 | }) 224 | .disposed(by: disposeBag) 225 | } 226 | 227 | // MARK: - Public 228 | public var isSyncing: ReadOnlyVariable { 229 | get { 230 | return isSyncingInternal.asReadOnly() 231 | } 232 | } 233 | 234 | public var lastError: ReadOnlyVariable { 235 | get { 236 | return lastSyncError.asReadOnly() 237 | } 238 | } 239 | 240 | public func activate() { 241 | disposeBag = DisposeBag() 242 | if T.pendingObjectsPredicate() != NSPredicate(value: false) { 243 | subscribePendingChanges() 244 | } 245 | subscribeSynchronization() 246 | } 247 | 248 | public func scheduleSyncronization() { 249 | isSyncScheduled.value = true 250 | } 251 | 252 | private var timerDisposeBag : DisposeBag? 253 | public func startRefreshTimer(period : RxTimeInterval = 30) { 254 | Logger.log("Refresh timer for \(self) has been started") 255 | timerDisposeBag = DisposeBag() 256 | if let timerDisposeBag = timerDisposeBag { 257 | Observable 258 | .timer(period, period: period, scheduler: MainScheduler.instance).subscribe { [weak self] _ in 259 | self?.scheduleSyncronization() 260 | } 261 | .disposed(by: timerDisposeBag) 262 | } 263 | } 264 | 265 | public func stopRefreshTimer() { 266 | Logger.log("Refresh timer for \(self) has been stopped") 267 | timerDisposeBag = nil 268 | } 269 | } 270 | 271 | extension Realm { 272 | public func object(ofType type: T.Type, forSynchronizationId syncId: String) -> T? { 273 | return self.objects(T.self).filter( { $0.syncIdentifier == syncId } ).first 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/Type Extensions/LoremGenerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A lightweight lorem ipsum generator. 4 | public final class Lorem { 5 | 6 | // ======================================================= // 7 | // MARK: - Text 8 | // ======================================================= // 9 | 10 | /// Generates a single word. 11 | public static var word: String { 12 | return allWords.randomElement()! 13 | } 14 | 15 | /// Generates multiple words whose count is defined by the given value. 16 | /// 17 | /// - Parameter count: The number of words to generate. 18 | /// - Returns: The generated words joined by a space character. 19 | public static func words(_ count: Int) -> String { 20 | return _compose( 21 | word, 22 | count: count, 23 | joinBy: .space 24 | ) 25 | } 26 | 27 | /// Generates multiple words whose count is randomly selected from within the given range. 28 | /// 29 | /// - Parameter range: The range of number of words to generate. 30 | /// - Returns: The generated words joined by a space character. 31 | public static func words(_ range: Range) -> String { 32 | return _compose(word, count: Int.random(in: range), joinBy: .space) 33 | } 34 | 35 | /// Generates multiple words whose count is randomly selected from within the given closed range. 36 | /// 37 | /// - Parameter range: The range of number of words to generate. 38 | /// - Returns: The generated words joined by a space character. 39 | public static func words(_ range: ClosedRange) -> String { 40 | return _compose(word, count: Int.random(in: range), joinBy: .space) 41 | } 42 | 43 | /// Generates a single sentence. 44 | public static var sentence: String { 45 | let numberOfWords = Int.random( 46 | in: minWordsCountInSentence...maxWordsCountInSentence 47 | ) 48 | 49 | return _compose( 50 | word, 51 | count: numberOfWords, 52 | joinBy: .space, 53 | endWith: .dot, 54 | decorate: { $0.firstLetterCapitalized } 55 | ) 56 | } 57 | 58 | /// Generates multiple sentences whose count is defined by the given value. 59 | /// 60 | /// - Parameter count: The number of sentences to generate. 61 | /// - Returns: The generated sentences joined by a space character. 62 | public static func sentences(_ count: Int) -> String { 63 | return _compose( 64 | sentence, 65 | count: count, 66 | joinBy: .space 67 | ) 68 | } 69 | 70 | /// Generates multiple sentences whose count is selected from within the given range. 71 | /// 72 | /// - Parameter count: The number of sentences to generate. 73 | /// - Returns: The generated sentences joined by a space character. 74 | public static func sentences(_ range: Range) -> String { 75 | return _compose(sentence, count: Int.random(in: range), joinBy: .space) 76 | } 77 | 78 | /// Generates multiple sentences whose count is selected from within the given closed range. 79 | /// 80 | /// - Parameter count: The number of sentences to generate. 81 | /// - Returns: The generated sentences joined by a space character. 82 | public static func sentences(_ range: ClosedRange) -> String { 83 | return _compose(sentence, count: Int.random(in: range), joinBy: .space) 84 | } 85 | 86 | /// Generates a single paragraph. 87 | public static var paragraph: String { 88 | let numberOfSentences = Int.random( 89 | in: minSentencesCountInParagraph...maxSentencesCountInParagraph 90 | ) 91 | 92 | return _compose( 93 | sentence, 94 | count: numberOfSentences, 95 | joinBy: .space 96 | ) 97 | } 98 | 99 | /// Generates multiple paragraphs whose count is defined by the given value. 100 | /// 101 | /// - Parameter count: The number of paragraphs to generate. 102 | /// - Returns: The generated paragraphs joined by a new line character. 103 | public static func paragraphs(_ count: Int) -> String { 104 | return _compose( 105 | paragraph, 106 | count: count, 107 | joinBy: .newLine 108 | ) 109 | } 110 | 111 | /// Generates multiple paragraphs whose count is selected from within the given range. 112 | /// 113 | /// - Parameter count: The number of paragraphs to generate. 114 | /// - Returns: The generated paragraphs joined by a new line character. 115 | public static func paragraphs(_ range: Range) -> String { 116 | return _compose( 117 | paragraph, 118 | count: Int.random(in: range), 119 | joinBy: .newLine 120 | ) 121 | } 122 | 123 | /// Generates multiple paragraphs whose count is selected from within the given closed range. 124 | /// 125 | /// - Parameter count: The number of paragraphs to generate. 126 | /// - Returns: The generated paragraphs joined by a new line character. 127 | public static func paragraphs(_ range: ClosedRange) -> String { 128 | return _compose( 129 | paragraph, 130 | count: Int.random(in: range), 131 | joinBy: .newLine 132 | ) 133 | } 134 | 135 | /// Generates a capitalized title. 136 | public static var title: String { 137 | let numberOfWords = Int.random( 138 | in: minWordsCountInTitle...maxWordsCountInTitle 139 | ) 140 | 141 | return _compose( 142 | word, 143 | count: numberOfWords, 144 | joinBy: .space, 145 | decorate: { $0.capitalized } 146 | ) 147 | } 148 | 149 | // ======================================================= // 150 | // MARK: - Names 151 | // ======================================================= // 152 | 153 | /// Generates a first name. 154 | public static var firstName: String { 155 | return firstNames.randomElement()! 156 | } 157 | 158 | /// Generates a last name. 159 | public static var lastName: String { 160 | return lastNames.randomElement()! 161 | } 162 | 163 | /// Generates a full name. 164 | public static var fullName: String { 165 | return "\(firstName) \(lastName)" 166 | } 167 | 168 | // ======================================================= // 169 | // MARK: - Email Addresses & URLs 170 | // ======================================================= // 171 | 172 | /// Generates an email address. 173 | public static var emailAddress: String { 174 | let emailDelimiter = emailDelimiters.randomElement()! 175 | let emailDomain = emailDomains.randomElement()! 176 | 177 | return "\(firstName)\(emailDelimiter)\(lastName)@\(emailDomain)".lowercased() 178 | } 179 | 180 | /// Generates a URL. 181 | public static var url: String { 182 | let urlScheme = urlSchemes.randomElement()! 183 | let urlDomain = urlDomains.randomElement()! 184 | return "\(urlScheme)://\(urlDomain)" 185 | } 186 | 187 | // ======================================================= // 188 | // MARK: - Tweets 189 | // ======================================================= // 190 | 191 | /// Generates a random tweet which is shorter than 140 characters. 192 | public static var shortTweet: String { 193 | return _composeTweet(shortTweetMaxLength) 194 | } 195 | 196 | /// Generates a random tweet which is shorter than 280 characters. 197 | public static var tweet: String { 198 | return _composeTweet(tweetMaxLength) 199 | } 200 | 201 | } 202 | 203 | fileprivate extension Lorem { 204 | 205 | fileprivate enum Separator: String { 206 | case none = "" 207 | case space = " " 208 | case dot = "." 209 | case newLine = "\n" 210 | } 211 | 212 | fileprivate static func _compose( 213 | _ provider: @autoclosure () -> String, 214 | count: Int, 215 | joinBy middleSeparator: Separator, 216 | endWith endSeparator: Separator = .none, 217 | decorate decorator: ((String) -> String)? = nil 218 | ) -> String { 219 | var string = "" 220 | 221 | for index in 0.. String { 239 | for numberOfSentences in [4, 3, 2, 1] { 240 | let tweet = sentences(numberOfSentences) 241 | if tweet.count < maxLength { 242 | return tweet 243 | } 244 | } 245 | 246 | return "" 247 | } 248 | 249 | fileprivate static let minWordsCountInSentence = 4 250 | fileprivate static let maxWordsCountInSentence = 16 251 | fileprivate static let minSentencesCountInParagraph = 3 252 | fileprivate static let maxSentencesCountInParagraph = 9 253 | fileprivate static let minWordsCountInTitle = 2 254 | fileprivate static let maxWordsCountInTitle = 7 255 | fileprivate static let shortTweetMaxLength = 140 256 | fileprivate static let tweetMaxLength = 280 257 | 258 | fileprivate static let allWords = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] 259 | 260 | fileprivate static let firstNames = ["Judith", "Angelo", "Margarita", "Kerry", "Elaine", "Lorenzo", "Justice", "Doris", "Raul", "Liliana", "Kerry", "Elise", "Ciaran", "Johnny", "Moses", "Davion", "Penny", "Mohammed", "Harvey", "Sheryl", "Hudson", "Brendan", "Brooklynn", "Denis", "Sadie", "Trisha", "Jacquelyn", "Virgil", "Cindy", "Alexa", "Marianne", "Giselle", "Casey", "Alondra", "Angela", "Katherine", "Skyler", "Kyleigh", "Carly", "Abel", "Adrianna", "Luis", "Dominick", "Eoin", "Noel", "Ciara", "Roberto", "Skylar", "Brock", "Earl", "Dwayne", "Jackie", "Hamish", "Sienna", "Nolan", "Daren", "Jean", "Shirley", "Connor", "Geraldine", "Niall", "Kristi", "Monty", "Yvonne", "Tammie", "Zachariah", "Fatima", "Ruby", "Nadia", "Anahi", "Calum", "Peggy", "Alfredo", "Marybeth", "Bonnie", "Gordon", "Cara", "John", "Staci", "Samuel", "Carmen", "Rylee", "Yehudi", "Colm", "Beth", "Dulce", "Darius", "inley", "Javon", "Jason", "Perla", "Wayne", "Laila", "Kaleigh", "Maggie", "Don", "Quinn", "Collin", "Aniya", "Zoe", "Isabel", "Clint", "Leland", "Esmeralda", "Emma", "Madeline", "Byron", "Courtney", "Vanessa", "Terry", "Antoinette", "George", "Constance", "Preston", "Rolando", "Caleb", "Kenneth", "Lynette", "Carley", "Francesca", "Johnnie", "Jordyn", "Arturo", "Camila", "Skye", "Guy", "Ana", "Kaylin", "Nia", "Colton", "Bart", "Brendon", "Alvin", "Daryl", "Dirk", "Mya", "Pete", "Joann", "Uriel", "Alonzo", "Agnes", "Chris", "Alyson", "Paola", "Dora", "Elias", "Allen", "Jackie", "Eric", "Bonita", "Kelvin", "Emiliano", "Ashton", "Kyra", "Kailey", "Sonja", "Alberto", "Ty", "Summer", "Brayden", "Lori", "Kelly", "Tomas", "Joey", "Billie", "Katie", "Stephanie", "Danielle", "Alexis", "Jamal", "Kieran", "Lucinda", "Eliza", "Allyson", "Melinda", "Alma", "Piper", "Deana", "Harriet", "Bryce", "Eli", "Jadyn", "Rogelio", "Orlaith", "Janet", "Randal", "Toby", "Carla", "Lorie", "Caitlyn", "Annika", "Isabelle", "inn", "Ewan", "Maisie", "Michelle", "Grady", "Ida", "Reid", "Emely", "Tricia", "Beau", "Reese", "Vance", "Dalton", "Lexi", "Rafael", "Makenzie", "Mitzi", "Clinton", "Xena", "Angelina", "Kendrick", "Leslie", "Teddy", "Jerald", "Noelle", "Neil", "Marsha", "Gayle", "Omar", "Abigail", "Alexandra", "Phil", "Andre", "Billy", "Brenden", "Bianca", "Jared", "Gretchen", "Patrick", "Antonio", "Josephine", "Kyla", "Manuel", "Freya", "Kellie", "Tonia", "Jamie", "Sydney", "Andres", "Ruben", "Harrison", "Hector", "Clyde", "Wendell", "Kaden", "Ian", "Tracy", "Cathleen", "Shawn"] 261 | 262 | fileprivate static let lastNames = ["Chung", "Chen", "Melton", "Hill", "Puckett", "Song", "Hamilton", "Bender", "Wagner", "McLaughlin", "McNamara", "Raynor", "Moon", "Woodard", "Desai", "Wallace", "Lawrence", "Griffin", "Dougherty", "Powers", "May", "Steele", "Teague", "Vick", "Gallagher", "Solomon", "Walsh", "Monroe", "Connolly", "Hawkins", "Middleton", "Goldstein", "Watts", "Johnston", "Weeks", "Wilkerson", "Barton", "Walton", "Hall", "Ross", "Chung", "Bender", "Woods", "Mangum", "Joseph", "Rosenthal", "Bowden", "Barton", "Underwood", "Jones", "Baker", "Merritt", "Cross", "Cooper", "Holmes", "Sharpe", "Morgan", "Hoyle", "Allen", "Rich", "Rich", "Grant", "Proctor", "Diaz", "Graham", "Watkins", "Hinton", "Marsh", "Hewitt", "Branch", "Walton", "O'Brien", "Case", "Watts", "Christensen", "Parks", "Hardin", "Lucas", "Eason", "Davidson", "Whitehead", "Rose", "Sparks", "Moore", "Pearson", "Rodgers", "Graves", "Scarborough", "Sutton", "Sinclair", "Bowman", "Olsen", "Love", "McLean", "Christian", "Lamb", "James", "Chandler", "Stout", "Cowan", "Golden", "Bowling", "Beasley", "Clapp", "Abrams", "Tilley", "Morse", "Boykin", "Sumner", "Cassidy", "Davidson", "Heath", "Blanchard", "McAllister", "McKenzie", "Byrne", "Schroeder", "Griffin", "Gross", "Perkins", "Robertson", "Palmer", "Brady", "Rowe", "Zhang", "Hodge", "Li", "Bowling", "Justice", "Glass", "Willis", "Hester", "Floyd", "Graves", "Fischer", "Norman", "Chan", "Hunt", "Byrd", "Lane", "Kaplan", "Heller", "May", "Jennings", "Hanna", "Locklear", "Holloway", "Jones", "Glover", "Vick", "O'Donnell", "Goldman", "McKenna", "Starr", "Stone", "McClure", "Watson", "Monroe", "Abbott", "Singer", "Hall", "Farrell", "Lucas", "Norman", "Atkins", "Monroe", "Robertson", "Sykes", "Reid", "Chandler", "Finch", "Hobbs", "Adkins", "Kinney", "Whitaker", "Alexander", "Conner", "Waters", "Becker", "Rollins", "Love", "Adkins", "Black", "Fox", "Hatcher", "Wu", "Lloyd", "Joyce", "Welch", "Matthews", "Chappell", "MacDonald", "Kane", "Butler", "Pickett", "Bowman", "Barton", "Kennedy", "Branch", "Thornton", "McNeill", "Weinstein", "Middleton", "Moss", "Lucas", "Rich", "Carlton", "Brady", "Schultz", "Nichols", "Harvey", "Stevenson", "Houston", "Dunn", "West", "O'Brien", "Barr", "Snyder", "Cain", "Heath", "Boswell", "Olsen", "Pittman", "Weiner", "Petersen", "Davis", "Coleman", "Terrell", "Norman", "Burch", "Weiner", "Parrott", "Henry", "Gray", "Chang", "McLean", "Eason", "Weeks", "Siegel", "Puckett", "Heath", "Hoyle", "Garrett", "Neal", "Baker", "Goldman", "Shaffer", "Choi", "Carver"] 263 | 264 | fileprivate static let emailDomains = ["gmail.com", "yahoo.com", "hotmail.com", "email.com", "live.com", "me.com", "mac.com", "aol.com", "fastmail.com", "mail.com"] 265 | 266 | fileprivate static let emailDelimiters = ["", ".", "-", "_"] 267 | 268 | fileprivate static let urlSchemes = ["http", "https"] 269 | 270 | fileprivate static let urlDomains = ["twitter.com", "google.com", "youtube.com", "wordpress.org", "adobe.com", "blogspot.com", "godaddy.com", "wikipedia.org", "wordpress.com", "yahoo.com", "linkedin.com", "amazon.com", "flickr.com", "w3.org", "apple.com", "myspace.com", "tumblr.com", "digg.com", "microsoft.com", "vimeo.com", "pinterest.com", "stumbleupon.com", "youtu.be", "miibeian.gov.cn", "baidu.com", "feedburner.com", "bit.ly"] 271 | 272 | } 273 | 274 | fileprivate extension String { 275 | 276 | fileprivate var firstLetterCapitalized: String { 277 | guard !isEmpty else { return self } 278 | return prefix(1).capitalized + dropFirst() 279 | } 280 | 281 | } 282 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/Type Extensions/Types+Defaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Types+Defaults.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | 13 | extension Double { 14 | @inline(__always) static public func sublimateDefault() -> Double { 15 | return 0.0 16 | } 17 | } 18 | 19 | extension String { 20 | @inline(__always) static public func sublimateDefault() -> String { 21 | return "" 22 | } 23 | } 24 | 25 | extension Int { 26 | @inline(__always) static public func sublimateDefault() -> Int { 27 | return 0 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /SublimateSync/SublimateSync/Classes/Type Extensions/Types+Random.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Types+Random.swift 3 | // SublimateSync 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | 13 | public extension Int { 14 | 15 | public static func random() -> Int { 16 | return Int.random(n: Int(UInt32.max)) 17 | } 18 | 19 | public static func random(n: Int) -> Int { 20 | return Int(arc4random_uniform(UInt32(n))) 21 | } 22 | 23 | public static func random(min: Int, max: Int) -> Int { 24 | return Int.random(n: max - min + 1) + min 25 | 26 | } 27 | } 28 | 29 | public extension Double { 30 | 31 | public static func random() -> Double { 32 | return Double(arc4random()) / 0xFFFFFFFF 33 | } 34 | 35 | public static func random(min: Double, max: Double) -> Double { 36 | return Double.random() * (max - min) + min 37 | } 38 | } 39 | 40 | extension String { 41 | public static func random() -> String { 42 | let type = Int.random(n: 4) 43 | switch type { 44 | case 0: 45 | return Lorem.words(3) 46 | case 1: 47 | return Lorem.fullName 48 | case 2: 49 | return Lorem.emailAddress 50 | case 3: 51 | return Lorem.url 52 | default: 53 | return "" 54 | } 55 | } 56 | } 57 | 58 | -------------------------------------------------------------------------------- /SublimateUI.podspec: -------------------------------------------------------------------------------- 1 | # 2 | # Be sure to run `pod lib lint SublimateUI.podspec' to ensure this is a 3 | # valid spec before submitting. 4 | # 5 | # Any lines starting with a # are optional, but their use is encouraged 6 | # To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html 7 | # 8 | 9 | Pod::Spec.new do |s| 10 | s.name = 'SublimateUI' 11 | s.version = '0.1.0' 12 | s.summary = 'A short description of SublimateUI.' 13 | 14 | # This description is used to generate tags and improve search results. 15 | # * Think: What does it do? Why did you write it? What is the focus? 16 | # * Try to keep it short, snappy and to the point. 17 | # * Write the description between the DESC delimiters below. 18 | # * Finally, don't worry about the indent, CocoaPods strips it! 19 | 20 | s.description = <<-DESC 21 | Mock UI to enable automatic generation of tables in Sublimate. 22 | DESC 23 | 24 | s.homepage = 'https://github.com/gabrielepalma/sublimate' 25 | s.license = { :type => 'MIT', :file => 'LICENSE' } 26 | s.author = { 'Gabriele' => 'gabrielepalma82@gmail.com' } 27 | s.source = { :git => 'https://github.com/gabrielepalma/sublimate.git', :tag => s.version.to_s } 28 | 29 | s.ios.deployment_target = '10.0' 30 | s.swift_version = '4.2' 31 | 32 | s.source_files = 'SublimateUI/SublimateUI/Classes/**/*.swift' 33 | s.resources = 'SublimateUI/SublimateUI/Resources/*.{xib,storyboard}' 34 | 35 | s.frameworks = 'UIKit' 36 | 37 | s.dependency 'KeychainAccess' 38 | s.dependency 'RxCocoa' 39 | s.dependency 'PromiseKit' 40 | s.dependency 'RxSwift' 41 | s.dependency 'RealmSwift' 42 | s.dependency 'RxRealm' 43 | s.dependency 'ReachabilitySwift' 44 | s.dependency 'SwiftMessages' 45 | s.dependency 'SublimateSync' 46 | s.dependency 'RxRealmDataSources' 47 | 48 | end 49 | 50 | 51 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateUI/SublimateUI/Classes/.gitkeep -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/Detail Screen/ActionPanelViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionPanelViewController.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import SwiftMessages 13 | 14 | class ActionPanelSegue: SwiftMessagesSegue { 15 | override public init(identifier: String?, source: UIViewController, destination: UIViewController) { 16 | super.init(identifier: identifier, source: source, destination: destination) 17 | configure(layout: .bottomCard) 18 | dimMode = .blur(style: .dark, alpha: 0.9, interactive: true) 19 | messageView.configureNoDropShadow() 20 | } 21 | } 22 | 23 | public class ActionPanelViewController: UIViewController { 24 | 25 | @IBOutlet private weak var cancelButton: UIButton! 26 | @IBOutlet private weak var deleteButton: UIButton! 27 | @IBOutlet private weak var randomButton: UIButton! 28 | 29 | public var deleteButtonTitle = "Delete" 30 | public var randomButtonTitle = "Randomize" 31 | public var cancelButtonTitle = "Cancel" 32 | 33 | public var cancelButtonCallBack: (() -> ())? 34 | public var deleteButtonCallBack: (() -> ())? 35 | public var randomButtonCallBack: (() -> ())? 36 | 37 | override public func viewDidLoad() { 38 | super.viewDidLoad() 39 | setupVisuals() 40 | } 41 | 42 | func setupVisuals() { 43 | cancelButton.backgroundColor = UIColor.black 44 | cancelButton.setTitleColor(UIColor.white, for: .normal) 45 | cancelButton.setTitle(cancelButtonTitle, for: .normal) 46 | cancelButton.layer.cornerRadius = 10.0 47 | deleteButton.backgroundColor = UIColor.red 48 | deleteButton.setTitleColor(UIColor.white, for: .normal) 49 | deleteButton.setTitle(deleteButtonTitle, for: .normal) 50 | deleteButton.layer.cornerRadius = 10.0 51 | randomButton.backgroundColor = UIColor.darkGray 52 | randomButton.setTitleColor(UIColor.white, for: .normal) 53 | randomButton.setTitle(randomButtonTitle, for: .normal) 54 | randomButton.layer.cornerRadius = 10.0 55 | } 56 | 57 | @IBAction func cancelButtonTapped() { 58 | if let cancelButtonCallBack = cancelButtonCallBack { 59 | cancelButtonCallBack() 60 | } 61 | } 62 | 63 | @IBAction func deleteButtonTapped() { 64 | if let deleteButtonCallBack = deleteButtonCallBack { 65 | deleteButtonCallBack() 66 | } 67 | } 68 | 69 | @IBAction func randomButtonTapped() { 70 | if let randomButtonCallBack = randomButtonCallBack { 71 | randomButtonCallBack() 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/Detail Screen/FieldCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FieldCell.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | 13 | class FieldCell: UITableViewCell { 14 | 15 | @IBOutlet var titleLabel : UILabel? 16 | @IBOutlet var valueLabel : UILabel? 17 | 18 | override func awakeFromNib() { 19 | super.awakeFromNib() 20 | // Initialization code 21 | } 22 | 23 | override func setSelected(_ selected: Bool, animated: Bool) { 24 | super.setSelected(selected, animated: animated) 25 | 26 | // Configure the view for the selected state 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/Detail Screen/FieldsTableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FieldsTableViewController.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import RealmSwift 13 | import RxRealm 14 | import RxSwift 15 | import SublimateSync 16 | 17 | class FieldsTableViewController: UITableViewController { 18 | let reuseIdentifier = "FieldCell" 19 | private var disposeBag = DisposeBag() 20 | var realmConfiguration : Realm.Configuration? 21 | var model : T? 22 | 23 | func modelWasInvalidated() { 24 | self.presentedViewController?.dismiss(animated: true, completion: nil) 25 | self.navigationController?.popViewController(animated: true) 26 | } 27 | 28 | override func viewDidLoad() { 29 | super.viewDidLoad() 30 | tableView.separatorStyle = .none 31 | tableView.register(UINib(nibName: "FieldCell", bundle: Bundle(for: FieldCell.self)), forCellReuseIdentifier: reuseIdentifier) 32 | self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(FieldsTableViewController.editWasTapped)) 33 | 34 | guard let model = model, !model.isInvalidated else { 35 | modelWasInvalidated() 36 | return 37 | } 38 | Observable.from(object: model).observeOn(MainScheduler.instance).subscribe(onNext: { [weak self] obj in 39 | guard !obj.isInvalidated else { 40 | self?.modelWasInvalidated() 41 | return 42 | } 43 | self?.tableView.reloadData() 44 | }, onError: { [weak self] error in 45 | if let rxError = error as? RxRealmError, rxError == .objectDeleted { 46 | self?.modelWasInvalidated() 47 | } 48 | }).disposed(by: disposeBag) 49 | } 50 | 51 | func editErrorObject() { 52 | let panel : ActionPanelViewController = UIStoryboard.instantiate(storyboard: "ActionPanel") 53 | panel.cancelButtonCallBack = { [weak panel] in 54 | panel?.dismiss(animated: true, completion: nil) 55 | } 56 | panel.randomButtonTitle = "Retry" 57 | panel.randomButtonCallBack = { [weak self, weak panel] in 58 | guard let self = self else { 59 | panel?.dismiss(animated: true, completion: nil) 60 | return 61 | } 62 | guard var model = self.model, !model.isInvalidated else { 63 | self.modelWasInvalidated() 64 | return 65 | } 66 | if let realmConfiguration = self.realmConfiguration, let realm = try? Realm(configuration: realmConfiguration){ 67 | try? realm.write { 68 | model.isInErrorState = false 69 | } 70 | } 71 | panel?.dismiss(animated: true, completion: nil) 72 | } 73 | panel.deleteButtonTitle = "Remove" 74 | panel.deleteButtonCallBack = { [weak self, weak panel] in 75 | guard let self = self else { 76 | panel?.dismiss(animated: true, completion: nil) 77 | return 78 | } 79 | guard let model = self.model, !model.isInvalidated else { 80 | self.modelWasInvalidated() 81 | return 82 | } 83 | if let realmConfiguration = self.realmConfiguration, let realm = try? Realm(configuration: realmConfiguration){ 84 | try? realm.write { 85 | realm.delete(model) 86 | } 87 | } 88 | panel?.dismiss(animated: true, completion: nil) 89 | } 90 | let segue = ActionPanelSegue(identifier: "actionPanelSegue", source: self, destination: panel) 91 | segue.perform() 92 | } 93 | 94 | func editRegularObject() { 95 | let panel : ActionPanelViewController = UIStoryboard.instantiate(storyboard: "ActionPanel") 96 | panel.cancelButtonCallBack = { [weak panel] in 97 | panel?.dismiss(animated: true, completion: nil) 98 | } 99 | panel.randomButtonCallBack = { [weak self, weak panel] in 100 | guard let self = self else { 101 | panel?.dismiss(animated: true, completion: nil) 102 | return 103 | } 104 | guard var model = self.model, !model.isInvalidated else { 105 | self.modelWasInvalidated() 106 | return 107 | } 108 | if let realmConfiguration = self.realmConfiguration, let realm = try? Realm(configuration: realmConfiguration){ 109 | try? realm.write { 110 | model.randomize() 111 | model.clientLastUpdated = Date().timeIntervalSince1970 112 | if model.syncOperation != .create { 113 | model.syncOperation = .update 114 | } 115 | } 116 | } 117 | panel?.dismiss(animated: true, completion: nil) 118 | } 119 | panel.deleteButtonCallBack = { [weak self, weak panel] in 120 | guard let self = self else { 121 | panel?.dismiss(animated: true, completion: nil) 122 | return 123 | } 124 | guard var model = self.model, !model.isInvalidated else { 125 | self.modelWasInvalidated() 126 | return 127 | } 128 | if let realmConfiguration = self.realmConfiguration, let realm = try? Realm(configuration: realmConfiguration){ 129 | try? realm.write { 130 | if model.syncOperation != .create { 131 | model.syncOperation = .delete 132 | } 133 | else { 134 | realm.delete(model) 135 | } 136 | } 137 | } 138 | panel?.dismiss(animated: true, completion: nil) 139 | } 140 | let segue = ActionPanelSegue(identifier: "actionPanelSegue", source: self, destination: panel) 141 | segue.perform() 142 | } 143 | 144 | @objc func editWasTapped() { 145 | guard var model = self.model, !model.isInvalidated else { 146 | self.modelWasInvalidated() 147 | return 148 | } 149 | if model.isInErrorState { 150 | editErrorObject() 151 | } 152 | else { 153 | editRegularObject() 154 | } 155 | } 156 | 157 | // MARK: - Table view data source 158 | 159 | override func numberOfSections(in tableView: UITableView) -> Int { 160 | return 2 161 | } 162 | 163 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 164 | guard let model = self.model, !model.isInvalidated else { 165 | self.modelWasInvalidated() 166 | return 0 167 | } 168 | if section == 0 { 169 | return model.presentationKeyPaths.count 170 | } 171 | else { 172 | return model.metadataKeyPaths.count 173 | } 174 | } 175 | 176 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 177 | let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) 178 | guard let model = self.model, !model.isInvalidated else { 179 | self.modelWasInvalidated() 180 | return cell 181 | } 182 | if let cell = cell as? FieldCell { 183 | if indexPath.section == 0 { 184 | cell.titleLabel?.text = model.presentationKeyPaths[indexPath.row].0 185 | cell.valueLabel?.text = String(describing: model[keyPath: model.presentationKeyPaths[indexPath.row].1]) 186 | } 187 | else { 188 | cell.titleLabel?.text = model.metadataKeyPaths[indexPath.row].0 189 | cell.valueLabel?.text = String(describing: model[keyPath: model.metadataKeyPaths[indexPath.row].1]) 190 | } 191 | } 192 | cell.selectionStyle = .none 193 | return cell 194 | } 195 | 196 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 197 | if section == 0 { 198 | return "Object Information" 199 | } 200 | if section == 1 { 201 | return "Object metadata" 202 | } 203 | return "" 204 | } 205 | 206 | /* 207 | // MARK: - Navigation 208 | 209 | // In a storyboard-based application, you will often want to do a little preparation before navigation 210 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 211 | // Get the new view controller using segue.destination. 212 | // Pass the selected object to the new view controller. 213 | } 214 | */ 215 | 216 | } 217 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/Object List/ObjectCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectCell.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | 13 | class ObjectCell: UITableViewCell { 14 | 15 | @IBOutlet var identifierLabel : UILabel? 16 | @IBOutlet var titleLabel : UILabel? 17 | @IBOutlet var syncStatusLabel : UILabel? 18 | 19 | override func awakeFromNib() { 20 | super.awakeFromNib() 21 | // Initialization code 22 | } 23 | 24 | override func setSelected(_ selected: Bool, animated: Bool) { 25 | super.setSelected(selected, animated: animated) 26 | 27 | // Configure the view for the selected state 28 | } 29 | 30 | } 31 | 32 | extension UITableViewCell { 33 | func loadCellFromNib(nibName : String) -> UITableViewCell { 34 | let bundle = Bundle(for: type(of: self)) 35 | let nib = UINib(nibName: nibName, bundle: bundle) 36 | return nib.instantiate(withOwner: self, options: nil).first as! UITableViewCell 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/Object List/ObjectsTableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectsTableViewController.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import RealmSwift 13 | import RxRealm 14 | import RxSwift 15 | import RxRealmDataSources 16 | import SublimateSync 17 | 18 | public class ObjectsTableViewController: UITableViewController { 19 | 20 | let reuseIdentifier = "ObjectCell" 21 | private var disposeBag = DisposeBag() 22 | public var syncer : Syncer? 23 | public var dataSource : RxTableViewRealmDataSource? 24 | public var realmConfiguration : Realm.Configuration? 25 | 26 | override public func viewDidLoad() { 27 | super.viewDidLoad() 28 | tableView.register(UINib(nibName: "ObjectCell", bundle: Bundle(for: ObjectCell.self)), forCellReuseIdentifier: reuseIdentifier) 29 | tableView.separatorStyle = .none 30 | let dataSource = RxTableViewRealmDataSource( 31 | cellIdentifier: reuseIdentifier, cellType: ObjectCell.self) {cell, indexPath, object in 32 | cell.identifierLabel?.text = object.presentationId 33 | cell.titleLabel?.text = object.presentationTitle 34 | switch object.syncOperation { 35 | case .none: 36 | cell.syncStatusLabel?.text = "" 37 | default: 38 | cell.syncStatusLabel?.text = object.syncOperation.rawValue 39 | } 40 | if object.isInErrorState { 41 | cell.syncStatusLabel?.text = "ERROR" 42 | } 43 | cell.selectionStyle = .none 44 | } 45 | 46 | refreshControl = UIRefreshControl() 47 | if let refreshControl = refreshControl { 48 | let refresh = refreshControl.rx.controlEvent(.valueChanged) 49 | 50 | refresh.bind(onNext: { 51 | self.syncer?.scheduleSyncronization() 52 | }).disposed(by: disposeBag) 53 | 54 | syncer?.isSyncing.asObservable().asDriver(onErrorJustReturn: false).filter { $0 == false }.drive(onNext: { (value) in 55 | refreshControl.endRefreshing() 56 | }).disposed(by: disposeBag) 57 | } 58 | 59 | let realm = try! Realm(configuration: realmConfiguration!) 60 | let list = realm.objects(T.self).sorted(byKeyPath: "clientCreated", ascending: false) 61 | Observable.changeset(from: list).bind(to: tableView.rx.realmChanges(dataSource)).disposed(by: disposeBag) 62 | self.dataSource = dataSource 63 | 64 | navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(ObjectsTableViewController.randomlyCreateTapped)) 65 | } 66 | 67 | @objc func randomlyCreateTapped() { 68 | var obj = T() 69 | obj.randomize() 70 | obj.syncOperation = SyncOperation.create 71 | if let realmConfiguration = realmConfiguration, let realm = try? Realm(configuration: realmConfiguration) { 72 | try? realm.write { 73 | realm.add(obj, update: false) 74 | } 75 | } 76 | } 77 | 78 | override public func viewDidAppear(_ animated: Bool) { 79 | super.viewDidAppear(animated) 80 | syncer?.activate() 81 | syncer?.startRefreshTimer(period: 30) 82 | syncer?.scheduleSyncronization() 83 | } 84 | 85 | public override func viewDidDisappear(_ animated: Bool) { 86 | super.viewDidDisappear(animated) 87 | syncer?.stopRefreshTimer() 88 | } 89 | 90 | // MARK: - Table view data source 91 | 92 | override public func numberOfSections(in tableView: UITableView) -> Int { 93 | return 0 94 | } 95 | 96 | override public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 97 | return 0 98 | } 99 | 100 | override public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 101 | if let dataSource = dataSource { 102 | let obj = dataSource.model(at: indexPath) 103 | let vc = FieldsTableViewController() 104 | vc.model = obj 105 | vc.realmConfiguration = realmConfiguration 106 | self.navigationController?.pushViewController(vc, animated: true) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/Schemes Overview/SchemeCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SchemeCell.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | 13 | class SchemeCell: UITableViewCell { 14 | 15 | @IBOutlet var descriptionLabel : UILabel? 16 | @IBOutlet var accessLabel : UILabel? 17 | 18 | override func awakeFromNib() { 19 | super.awakeFromNib() 20 | // Initialization code 21 | } 22 | 23 | override func setSelected(_ selected: Bool, animated: Bool) { 24 | super.setSelected(selected, animated: animated) 25 | 26 | // Configure the view for the selected state 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/Schemes Overview/SchemesTableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SchemesTableViewController.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import RxSwift 13 | import SublimateSync 14 | 15 | public class SchemesTableViewController: UITableViewController { 16 | let reuseIdentifier = "SchemeCell" 17 | 18 | private var disposeBag = DisposeBag() 19 | 20 | var datasource : [TableSource] 21 | var authManager : AuthenticationManagerProtocol 22 | 23 | public init(datasource : [TableSource], authManager : AuthenticationManagerProtocol) { 24 | self.datasource = datasource 25 | self.authManager = authManager 26 | super.init(style: .plain) 27 | } 28 | 29 | required init?(coder aDecoder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | private var splitDataSource : [[TableSource]]? 34 | 35 | func isLoggedIn() -> Bool { 36 | return authManager.isLoggedIn 37 | } 38 | 39 | override public func viewDidLoad() { 40 | super.viewDidLoad() 41 | self.tableView.separatorStyle = .none 42 | tableView.register(UINib(nibName: "SchemeCell", bundle: Bundle(for: SchemeCell.self)), forCellReuseIdentifier: reuseIdentifier) 43 | 44 | let openDatasource = datasource.filter { $0.availability == .openAccess } 45 | let authDatasource = datasource.filter { $0.availability == .onlyAuthenthicated } 46 | splitDataSource = [openDatasource, authDatasource] 47 | 48 | authManager.authState.distinctUntilChanged().asDriver(onErrorJustReturn: AuthState.loggedOut).drive(onNext: { [weak self] state in 49 | self?.tableView.reloadData() 50 | }).disposed(by: disposeBag) 51 | } 52 | 53 | override public func numberOfSections(in tableView: UITableView) -> Int { 54 | return splitDataSource?.count ?? 0 55 | } 56 | 57 | override public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 58 | guard isLoggedIn() || section == 0 else { 59 | return 1 60 | } 61 | 62 | return max(splitDataSource?[section].count ?? 0, 1) 63 | } 64 | 65 | override public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 66 | let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) 67 | 68 | if let splitDataSource = splitDataSource, let cell = cell as? SchemeCell { 69 | if splitDataSource[indexPath.section].count == 0 { 70 | cell.descriptionLabel?.text = "No tables in this category" 71 | cell.accessLabel?.text = "" 72 | } 73 | else if !isLoggedIn() && indexPath.section == 1 { 74 | cell.descriptionLabel?.text = "Log in to access these tables" 75 | cell.accessLabel?.text = "" 76 | } 77 | else { 78 | cell.descriptionLabel?.text = splitDataSource[indexPath.section][indexPath.row].description 79 | cell.accessLabel?.text = "" 80 | } 81 | } 82 | cell.selectionStyle = .none 83 | return cell 84 | } 85 | 86 | override public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 87 | if let splitDataSource = splitDataSource, 88 | (indexPath.section == 0 || isLoggedIn()), 89 | splitDataSource[indexPath.section].count > indexPath.row { 90 | let source = splitDataSource[indexPath.section][indexPath.row] 91 | self.navigationController?.pushViewController(source.viewController(), animated: true) 92 | } 93 | } 94 | 95 | override public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 96 | if section == 0 { 97 | return "Open Access" 98 | } 99 | if section == 1 { 100 | return "Private Tables" 101 | } 102 | return "" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/SublimateUICompatible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SublimateUICompatible.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | import RxSwift 13 | import SublimateSync 14 | 15 | public typealias SublimateUICompatible = DetailPresentable & OverviewPresentable & Randomizable & Syncable 16 | 17 | public protocol OverviewPresentable { 18 | var presentationId : String? { get } 19 | var presentationTitle : String? { get } 20 | var presentationThumbnail : Observable { get } 21 | } 22 | 23 | public protocol DetailPresentable { 24 | var presentationKeyPaths : [(String, PartialKeyPath)] { get } 25 | var metadataKeyPaths : [(String, PartialKeyPath)] { get } 26 | } 27 | 28 | public protocol Randomizable { 29 | func randomize() 30 | } 31 | 32 | public struct TableSource { 33 | public init() { 34 | } 35 | 36 | public enum Availability { 37 | case onlyAuthenthicated 38 | case openAccess 39 | } 40 | 41 | public var description : String = "" 42 | public var availability : Availability = .openAccess 43 | public var viewController : () -> (UIViewController) = { 44 | return UIViewController() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Classes/UIStoryboard+Instantiate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIStoryboard+Instantiate.swift 3 | // SublimateUI 4 | // 5 | // ___ ____ 6 | // / __)( _ \ 7 | // ( (_ \ ) __/ 8 | // \___/(__) gabrielepalma.name 9 | // 10 | 11 | import UIKit 12 | 13 | extension UIStoryboard { 14 | static public func instantiate(storyboard: String, identifier : String = String(describing: T.self)) -> T { 15 | guard let result = UIStoryboard(name: storyboard, bundle: Bundle(for: T.self)).instantiateViewController(withIdentifier: identifier) as? T else { 16 | fatalError("Fatal error when instantiating \(identifier) from storyboard \(storyboard).") 17 | } 18 | return result 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Resources/ActionPanel.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 31 | 38 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Resources/FieldCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 28 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Resources/ObjectCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 28 | 37 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /SublimateUI/SublimateUI/Resources/SchemeCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /SublimateVapor/.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | linux: 5 | docker: 6 | - image: swift:4.1 7 | steps: 8 | - checkout 9 | - run: 10 | name: Compile code 11 | command: swift build 12 | - run: 13 | name: Run unit tests 14 | command: swift test 15 | 16 | linux-release: 17 | docker: 18 | - image: swift:4.1 19 | steps: 20 | - checkout 21 | - run: 22 | name: Compile code with optimizations 23 | command: swift build -c release 24 | 25 | workflows: 26 | version: 2 27 | tests: 28 | jobs: 29 | - linux 30 | - linux-release 31 | 32 | nightly: 33 | triggers: 34 | - schedule: 35 | cron: "0 0 * * *" 36 | filters: 37 | branches: 38 | only: 39 | - master 40 | jobs: 41 | - linux 42 | - linux-release 43 | 44 | -------------------------------------------------------------------------------- /SublimateVapor/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .build 3 | DerivedData 4 | Package.resolved 5 | *.xcodeproj 6 | 7 | -------------------------------------------------------------------------------- /SublimateVapor/.gitignore: -------------------------------------------------------------------------------- 1 | #### Build generated 2 | build 3 | DerivedData 4 | 5 | 6 | 7 | #### Various settings 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata/ 17 | 18 | 19 | 20 | #### Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | 26 | 27 | #### Obj-C/Swift specific 28 | *.hmap 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | 34 | 35 | #### Playgrounds 36 | 37 | timeline.xctimeline 38 | playground.xcworkspace 39 | 40 | 41 | 42 | #### SublimateVapor, Package Manager 43 | 44 | Packages 45 | Package.pins 46 | .build 47 | xcuserdata 48 | *.xcodeproj 49 | DerivedData 50 | .DS_Store 51 | 52 | # SublimateVapor/Package.resolved 53 | -------------------------------------------------------------------------------- /SublimateVapor/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Auth", 6 | "repositoryURL": "https://github.com/vapor/auth.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "90868627c7587ea207c0b6d4054265e68f6a33ef", 10 | "version": "2.0.1" 11 | } 12 | }, 13 | { 14 | "package": "Console", 15 | "repositoryURL": "https://github.com/vapor/console.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "d6cf07af59ae63cd95c4b5f98cf1f25627750fd1", 19 | "version": "3.1.0" 20 | } 21 | }, 22 | { 23 | "package": "Core", 24 | "repositoryURL": "https://github.com/vapor/core.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "96ce86ebf9198328795c4b9cb711489460be083c", 28 | "version": "3.4.4" 29 | } 30 | }, 31 | { 32 | "package": "Crypto", 33 | "repositoryURL": "https://github.com/vapor/crypto.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "5605334590affd4785a5839806b4504407e054ac", 37 | "version": "3.3.0" 38 | } 39 | }, 40 | { 41 | "package": "DatabaseKit", 42 | "repositoryURL": "https://github.com/vapor/database-kit.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "3a17dbbe9be5f8c37703e4b7982c1332ad6b00c4", 46 | "version": "1.3.1" 47 | } 48 | }, 49 | { 50 | "package": "Fluent", 51 | "repositoryURL": "https://github.com/vapor/fluent.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "00b81a9362549facb8e2ac93b17d2a78599fce3b", 55 | "version": "3.1.2" 56 | } 57 | }, 58 | { 59 | "package": "FluentSQLite", 60 | "repositoryURL": "https://github.com/vapor/fluent-sqlite.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "c32f5bda84bf4ea691d19afe183d40044f579e11", 64 | "version": "3.0.0" 65 | } 66 | }, 67 | { 68 | "package": "HTTP", 69 | "repositoryURL": "https://github.com/vapor/http.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "6973bf50dab8dd00e4daf8cb82ca72b33f5db016", 73 | "version": "3.1.6" 74 | } 75 | }, 76 | { 77 | "package": "JWT", 78 | "repositoryURL": "https://github.com/vapor/jwt.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "2e225c722bf26407c1c4bd11d341e48759f46095", 82 | "version": "3.0.0" 83 | } 84 | }, 85 | { 86 | "package": "Multipart", 87 | "repositoryURL": "https://github.com/vapor/multipart.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "e57007c23a52b68e44ebdfc70cbe882a7c4f1ec3", 91 | "version": "3.0.2" 92 | } 93 | }, 94 | { 95 | "package": "Routing", 96 | "repositoryURL": "https://github.com/vapor/routing.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "3219e328491b0853b8554c5a694add344d2c6cfb", 100 | "version": "3.0.1" 101 | } 102 | }, 103 | { 104 | "package": "Service", 105 | "repositoryURL": "https://github.com/vapor/service.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "281a70b69783891900be31a9e70051b6fe19e146", 109 | "version": "1.0.0" 110 | } 111 | }, 112 | { 113 | "package": "SQL", 114 | "repositoryURL": "https://github.com/vapor/sql.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "839cf96eba296d26151ff1d7a746e9fe35053584", 118 | "version": "2.2.0" 119 | } 120 | }, 121 | { 122 | "package": "SQLite", 123 | "repositoryURL": "https://github.com/vapor/sqlite.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "ad2e9bc9f0ed00ef2c6a05f89c1cec605467c90f", 127 | "version": "3.1.0" 128 | } 129 | }, 130 | { 131 | "package": "swift-nio", 132 | "repositoryURL": "https://github.com/apple/swift-nio.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "a20e129c22ad00a51c902dca54a5456f90664780", 136 | "version": "1.12.0" 137 | } 138 | }, 139 | { 140 | "package": "swift-nio-ssl", 141 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "db16c3a90b101bb53b26a58867a344ad428072e0", 145 | "version": "1.3.2" 146 | } 147 | }, 148 | { 149 | "package": "swift-nio-ssl-support", 150 | "repositoryURL": "https://github.com/apple/swift-nio-ssl-support.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "c02eec4e0e6d351cd092938cf44195a8e669f555", 154 | "version": "1.0.0" 155 | } 156 | }, 157 | { 158 | "package": "swift-nio-zlib-support", 159 | "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", 160 | "state": { 161 | "branch": null, 162 | "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", 163 | "version": "1.0.0" 164 | } 165 | }, 166 | { 167 | "package": "TemplateKit", 168 | "repositoryURL": "https://github.com/vapor/template-kit.git", 169 | "state": { 170 | "branch": null, 171 | "revision": "aff2d6fc65bfd04579b0201b31a8d6720239c1cf", 172 | "version": "1.1.1" 173 | } 174 | }, 175 | { 176 | "package": "URLEncodedForm", 177 | "repositoryURL": "https://github.com/vapor/url-encoded-form.git", 178 | "state": { 179 | "branch": null, 180 | "revision": "932024f363ee5ff59059cf7d67194a1c271d3d0c", 181 | "version": "1.0.5" 182 | } 183 | }, 184 | { 185 | "package": "Validation", 186 | "repositoryURL": "https://github.com/vapor/validation.git", 187 | "state": { 188 | "branch": null, 189 | "revision": "4de213cf319b694e4ce19e5339592601d4dd3ff6", 190 | "version": "2.1.1" 191 | } 192 | }, 193 | { 194 | "package": "Vapor", 195 | "repositoryURL": "https://github.com/vapor/vapor.git", 196 | "state": { 197 | "branch": null, 198 | "revision": "157d3b15336caa882662cc75024dd04b2e225246", 199 | "version": "3.1.0" 200 | } 201 | }, 202 | { 203 | "package": "WebSocket", 204 | "repositoryURL": "https://github.com/vapor/websocket.git", 205 | "state": { 206 | "branch": null, 207 | "revision": "eb4277f75f1d96a3d15c852cdd89af1799093dcd", 208 | "version": "1.1.0" 209 | } 210 | } 211 | ] 212 | }, 213 | "version": 1 214 | } 215 | -------------------------------------------------------------------------------- /SublimateVapor/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "SublimateVapor", 6 | dependencies: [ 7 | .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"), 8 | .package(url: "https://github.com/vapor/fluent-sqlite.git", from: "3.0.0"), 9 | .package(url: "https://github.com/vapor/jwt.git", from: "3.0.0"), 10 | .package(url: "https://github.com/vapor/multipart.git", from: "3.0.0"), 11 | .package(url: "https://github.com/vapor/auth.git", from: "2.0.0") 12 | ], 13 | targets: [ 14 | .target(name: "App", dependencies: ["FluentSQLite", "Vapor", "JWT", "Authentication", "Multipart"]), 15 | .target(name: "Run", dependencies: ["App"]), 16 | .testTarget(name: "AppTests", dependencies: ["App"]) 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /SublimateVapor/Public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateVapor/Public/.gitkeep -------------------------------------------------------------------------------- /SublimateVapor/README.md: -------------------------------------------------------------------------------- 1 |

2 | API Template 3 |
4 |
5 | 6 | Documentation 7 | 8 | 9 | Team Chat 10 | 11 | 12 | MIT License 13 | 14 | 15 | Continuous Integration 16 | 17 | 18 | Swift 4.1 19 | 20 |

21 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/Controllers/UserController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | import Authentication 4 | import JWT 5 | 6 | struct LoginBody : Codable { 7 | var refreshToken : String? 8 | enum CodingKeys: String, CodingKey { 9 | case refreshToken = "refresh_token" 10 | } 11 | } 12 | 13 | struct LogoutBody : Codable { 14 | var refreshToken : String 15 | enum CodingKeys: String, CodingKey { 16 | case refreshToken = "refresh_token" 17 | } 18 | } 19 | 20 | final class UserController { 21 | 22 | func createUser(_ request: Request) throws -> Future { 23 | return try request.content.decode(UserCreationBody.self).flatMap(to: PublicUser.self) { user in 24 | guard user.validate() else { 25 | throw Abort(HTTPResponseStatus.badRequest) 26 | } 27 | let passwordHashed = try request.make(BCryptDigest.self).hash(user.password) 28 | let newUser = User(username: user.username, password: passwordHashed) 29 | return newUser.save(on: request).map(to: PublicUser.self) { createdUser in 30 | guard let uuid = createdUser.id?.uuidString else { 31 | throw Abort(HTTPResponseStatus.internalServerError) 32 | } 33 | let publicUser = PublicUser(userId: uuid, isAdmin: newUser.isAdmin) 34 | return publicUser 35 | } 36 | } 37 | } 38 | 39 | func loginUser(_ request: Request) throws -> Future 40 | { 41 | let minimumTimeToLiveForRefreshToken : Double = 60 * 60 * 24 * 30 // One month 42 | let user = try request.requireAuthenticated(User.self) 43 | guard let userId = user.id else { 44 | throw Abort(HTTPResponseStatus.internalServerError) 45 | } 46 | let accessTokenString = try user.createJwt(usage: SublimateJwt.AccessToken.usage, expiration: SublimateJwt.AccessToken.expiration).0 47 | let promise = request.eventLoop.newPromise(of: AuthorizedUser.self) 48 | DispatchQueue.global().async { 49 | // Decode the body 50 | guard let decodedBody = try? request.content.decode(LoginBody.self).wait() else { 51 | promise.fail(error: Abort(HTTPResponseStatus.badRequest)) 52 | return 53 | } 54 | let refreshTokenString : String 55 | // If a token was given, do token cleaning and reissuing operations, otherwise just create a new one 56 | if let givenTokenString = decodedBody.refreshToken, let data = givenTokenString.data(using: .utf8), 57 | let givenToken = try? JWT< SublimateJwt.Payload>(unverifiedFrom: data) 58 | { 59 | // Delete, if existing, the refresh token that was replaced by the one used in this requet 60 | // This is now safe as we have proof the new refresh token was successfully received by the client 61 | try? RefreshToken.query(on: request).filter(\RefreshToken.issuedToReplace == givenToken.payload.tokenId.value).delete().wait() 62 | let queried = try? RefreshToken.query(on: request).filter(\RefreshToken.refreshToken == givenToken.payload.tokenId.value).first().wait() 63 | guard let fetched = queried, fetched != nil else { 64 | promise.fail(error: Abort(HTTPResponseStatus.unauthorized)) 65 | return 66 | } 67 | 68 | // Check if given token is about to expire and we want to replace it 69 | let timeToLive = givenToken.payload.exp.value.timeIntervalSince1970 - Date().timeIntervalSince1970 70 | if timeToLive < minimumTimeToLiveForRefreshToken { 71 | // Create and save the new token 72 | guard let result = newToken(for: user, on: request, toReplace: givenTokenString) else { 73 | promise.fail(error: Abort(HTTPResponseStatus.internalServerError)) 74 | return 75 | } 76 | refreshTokenString = result 77 | } 78 | else { 79 | // We don't need to reissue the refresh token, we return back the old one, no changes in database 80 | refreshTokenString = givenTokenString 81 | } 82 | } 83 | else { 84 | // Create and save the new token 85 | guard let result = newToken(for: user, on: request, toReplace: nil) else { 86 | promise.fail(error: Abort(HTTPResponseStatus.internalServerError)) 87 | return 88 | } 89 | refreshTokenString = result 90 | } 91 | promise.succeed(result: AuthorizedUser(userId: userId.uuidString, username: user.username, refreshToken: refreshTokenString, accessToken: accessTokenString)) 92 | } 93 | return promise.futureResult 94 | } 95 | 96 | func logout(_ request: Request) throws -> Future { 97 | let user = try request.requireAuthenticated(User.self) 98 | guard let userId = user.id else { 99 | throw Abort(HTTPResponseStatus.internalServerError) 100 | } 101 | let promise = request.eventLoop.newPromise(of: PublicUser.self) 102 | DispatchQueue.global().async { 103 | guard let decodedBody = try? request.content.decode(LoginBody.self).wait() else { 104 | promise.fail(error: Abort(HTTPResponseStatus.badRequest)) 105 | return 106 | } 107 | guard let givenTokenString = decodedBody.refreshToken, let data = givenTokenString.data(using: .utf8), 108 | let givenToken = try? JWT< SublimateJwt.Payload>(unverifiedFrom: data) else { 109 | promise.fail(error: Abort(HTTPResponseStatus.badRequest)) 110 | return 111 | } 112 | try? RefreshToken.query(on: request).filter(\RefreshToken.refreshToken == givenToken.payload.tokenId.value).delete().wait() 113 | promise.succeed(result: PublicUser(userId: userId.uuidString, isAdmin: user.isAdmin)) 114 | } 115 | return promise.futureResult 116 | } 117 | } 118 | 119 | func newToken(for user: User, on connection: DatabaseConnectable, toReplace: String?) -> String? { 120 | guard let userId = user.id else { 121 | return nil 122 | } 123 | // We create a new token 124 | guard let newToken = try? user.createJwt(usage: SublimateJwt.RefreshToken.usage, expiration: SublimateJwt.RefreshToken.expiration) else { 125 | return nil 126 | } 127 | // We save the new token in the database 128 | guard let _ = try? RefreshToken(refreshToken: newToken.1.tokenId.value, userId: userId, expiresAt: newToken.1.exp.value.timeIntervalSince1970, issuedToReplace: toReplace).save(on: connection).wait() else { 129 | return nil 130 | } 131 | return newToken.0 132 | } 133 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/Middlewares/BearerMiddleware.swift: -------------------------------------------------------------------------------- 1 | // This file implements Bearer access JWT token authentication middleware 2 | // I didn't use the protocols provided by Auth as Bearer authentication will not authorize a Fluent User but a different data structure, to keep Authentication layer fully separate from Resource Access layer. This hypothetically allows for a more distributed deployment. 3 | 4 | import Vapor 5 | import Authentication 6 | import JWT 7 | 8 | final class SublimateBearerAuthenticationMiddleware: Middleware where A: SublimateBearerAuthenticatable { 9 | 10 | public let signers: JWTSigners 11 | public init(authenticatable type: A.Type = A.self, signers: JWTSigners) { 12 | self.signers = signers 13 | } 14 | 15 | public func respond(to req: Request, chainingTo next: Responder) throws -> Future { 16 | if try req.isAuthenticated(A.self) { 17 | return try next.respond(to: req) 18 | } 19 | 20 | guard let token = req.http.headers.bearerAuthorization, let jwtData = token.token.data(using: .utf8) else { 21 | return try next.respond(to: req) 22 | } 23 | 24 | guard let jwt = try? JWT< SublimateJwt.Payload>(from: jwtData, verifiedUsing: signers) else { 25 | return try next.respond(to: req) 26 | } 27 | guard jwt.payload.usage == SublimateJwt.AccessToken.usage else { 28 | return try next.respond(to: req) 29 | } 30 | guard jwt.payload.iss.value == SublimateJwt.issuerClaim else { 31 | return try next.respond(to: req) 32 | } 33 | return A.authenticate(bearerJwt: jwt.payload, eventLoop: req.eventLoop).flatMap { a in 34 | if let a = a { 35 | try req.authenticate(a) 36 | } 37 | return try next.respond(to: req) 38 | } 39 | } 40 | } 41 | 42 | protocol SublimateBearerAuthenticatable : Authenticatable { 43 | static func authenticate(bearerJwt: SublimateJwt.Payload, eventLoop: EventLoop) -> Future 44 | } 45 | 46 | extension SublimateBearerAuthenticatable { 47 | public static func sublimateBearerAuthMiddleware(signers: JWTSigners) -> SublimateBearerAuthenticationMiddleware { 48 | return SublimateBearerAuthenticationMiddleware(signers: signers) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/Middlewares/PasswordMiddleware.swift: -------------------------------------------------------------------------------- 1 | // This file implements Bearer access JWT token authentication middleware 2 | // I didn't use the protocols provided by Auth as Bearer authentication will not authorize a Fluent User but a different data structure, to keep Authentication layer fully separate from Resource Access layer. This hypothetically allows for a more distributed deployment. 3 | 4 | import Vapor 5 | import Authentication 6 | 7 | final class SublimatePasswordAuthenticationMiddleware: Middleware where A: PasswordAuthenticatable { 8 | 9 | public let verifier: PasswordVerifier 10 | public init(authenticatable type: A.Type = A.self, verifier: PasswordVerifier) { 11 | self.verifier = verifier 12 | } 13 | 14 | public func respond(to req: Request, chainingTo next: Responder) throws -> Future { 15 | if try req.isAuthenticated(A.self) { 16 | return try next.respond(to: req) 17 | } 18 | 19 | return try req.content.decode(SublimateLoginBody.self).flatMap({ [verifier] (object) -> Future in 20 | guard object.grantType == "password" else { 21 | return try next.respond(to: req) 22 | } 23 | guard let username = object.username, let password = object.password else { 24 | return try next.respond(to: req) 25 | } 26 | 27 | return A.authenticate(using: BasicAuthorization(username: username, password: password), verifier: verifier, on: req).flatMap { a in 28 | if let a = a { 29 | try req.authenticate(a) 30 | } 31 | return try next.respond(to: req) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | extension PasswordAuthenticatable { 38 | static func sublimatePasswordAuthMiddleware(using verifier: PasswordVerifier) -> SublimatePasswordAuthenticationMiddleware { 39 | return SublimatePasswordAuthenticationMiddleware(verifier: verifier) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/Middlewares/RefreshMiddleware.swift: -------------------------------------------------------------------------------- 1 | // This file implements Bearer access JWT token authentication middleware 2 | // I didn't use the protocols provided by Auth as Bearer authentication will not authorize a Fluent User but a different data structure, to keep Authentication layer fully separate from Resource Access layer. This hypothetically allows for a more distributed deployment. 3 | 4 | import Vapor 5 | import Authentication 6 | import JWT 7 | 8 | struct SublimateLoginBody : Codable { 9 | var grantType : String 10 | var refreshToken : String? 11 | var username : String? 12 | var password : String? 13 | 14 | enum CodingKeys: String, CodingKey { 15 | case grantType = "grant_type" 16 | case refreshToken = "refresh_token" 17 | case username 18 | case password 19 | } 20 | } 21 | 22 | final class SublimateRefreshJwtTokenMiddleware: Middleware where A: SublimateRefreshJwtAuthenticatable { 23 | 24 | public let signers: JWTSigners 25 | public init(authenticatable type: A.Type = A.self, signers: JWTSigners) { 26 | self.signers = signers 27 | } 28 | 29 | public func respond(to req: Request, chainingTo next: Responder) throws -> Future { 30 | if try req.isAuthenticated(A.self) { 31 | return try next.respond(to: req) 32 | } 33 | 34 | return try req.content.decode(SublimateLoginBody.self).flatMap({ [signers] (object) -> Future in 35 | guard 36 | object.grantType == "refresh_token", 37 | let refreshToken = object.refreshToken, 38 | let jwtData = refreshToken.data(using: .utf8) 39 | else { 40 | return try next.respond(to: req) 41 | } 42 | 43 | guard let jwt = try? JWT< SublimateJwt.Payload>(from: jwtData, verifiedUsing: signers) else { 44 | return try next.respond(to: req) 45 | } 46 | guard jwt.payload.usage == SublimateJwt.RefreshToken.usage else { 47 | return try next.respond(to: req) 48 | } 49 | guard jwt.payload.iss.value == SublimateJwt.issuerClaim else { 50 | return try next.respond(to: req) 51 | } 52 | 53 | return A.authenticate(refreshJwt: jwt.payload, on: req).flatMap { a in 54 | if let a = a { 55 | try req.authenticate(a) 56 | } 57 | return try next.respond(to: req) 58 | } 59 | }) 60 | } 61 | } 62 | 63 | protocol SublimateRefreshJwtAuthenticatable : Authenticatable { 64 | static func authenticate(refreshJwt: SublimateJwt.Payload, on connection: DatabaseConnectable) -> Future 65 | } 66 | 67 | extension SublimateRefreshJwtAuthenticatable { 68 | static func sublimateRefreshJwtAuthMiddleware(signers: JWTSigners) -> SublimateRefreshJwtTokenMiddleware { 69 | return SublimateRefreshJwtTokenMiddleware(signers: signers) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/Models/RefreshToken.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | import FluentSQLite 4 | import Authentication 5 | 6 | final class RefreshToken: SQLiteUUIDModel { 7 | var id: UUID? // Only internal for database use 8 | var refreshToken: String // As appears in the IDClaim 9 | var expiresAt: Double 10 | var issuedToReplace: String? 11 | var userId: User.ID 12 | 13 | init(refreshToken: String, userId: User.ID, expiresAt : Double, issuedToReplace : String? = nil) { 14 | self.refreshToken = refreshToken 15 | self.expiresAt = expiresAt 16 | self.userId = userId 17 | self.issuedToReplace = issuedToReplace 18 | } 19 | } 20 | 21 | extension RefreshToken: Content { } 22 | extension RefreshToken: Parameter { } 23 | extension RefreshToken: Migration { 24 | static func prepare(on connection: SQLiteConnection) -> Future { 25 | return Database.create(self, on: connection) { builder in 26 | try addProperties(to: builder) 27 | builder.unique(on: \.refreshToken) 28 | } 29 | } 30 | } 31 | 32 | 33 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/Models/SublimateJwt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // jwt.swift 3 | // App 4 | // 5 | // Created by i335287 on 03/01/2019. 6 | // 7 | 8 | import JWT 9 | import Vapor 10 | 11 | struct SublimateJwt { 12 | static let symmetricKey = "mySymmetricKey" 13 | static let issuerClaim = "sublimate" 14 | static var signers = SublimateJwt.signers(kids: kids) 15 | static let kids = [ 16 | Kid(name: "symm", alg: "H256", signer: JWTSigner.hs256(key: Data(symmetricKey.utf8))) 17 | ] 18 | 19 | struct AccessToken { 20 | // static let expiration : Double = 60 * 20 // 20 minutes 21 | static let expiration : Double = 60 // 20 minutes 22 | static let usage = "access_token" 23 | } 24 | 25 | struct RefreshToken { 26 | static let expiration : Double = 60 * 60 * 24 * 256 // One year 27 | static let usage = "refresh_token" 28 | } 29 | 30 | static func headers(kid : Kid) -> JWTHeader { 31 | return JWTHeader(alg: kid.alg, typ: "JWT", cty: nil, crit: nil, kid: kid.name) 32 | } 33 | 34 | static func signers(kids : [Kid]) -> JWTSigners { 35 | let signers = JWTSigners() 36 | for i in kids { 37 | signers.use(i.signer, kid: i.name) 38 | } 39 | return signers 40 | } 41 | 42 | struct Payload: JWTPayload { 43 | var userId: String 44 | var isAdmin: Bool 45 | var tokenId: IDClaim 46 | var usage: String 47 | var iat: IssuedAtClaim 48 | var exp: ExpirationClaim 49 | var iss: IssuerClaim = IssuerClaim(value: SublimateJwt.issuerClaim) 50 | 51 | func verify(using signer: JWTSigner) throws { 52 | try exp.verifyNotExpired() 53 | } 54 | 55 | init(userId: String, isAdmin: Bool, usage: String, iat: Double, exp: Double, tokenId: String = UUID().uuidString) { 56 | self.userId = userId 57 | self.usage = usage 58 | self.iat = IssuedAtClaim(value: Date(timeIntervalSince1970: iat)) 59 | self.exp = ExpirationClaim(value: Date(timeIntervalSince1970: exp)) 60 | self.tokenId = IDClaim(value: tokenId) 61 | self.isAdmin = isAdmin 62 | } 63 | } 64 | 65 | struct Kid { 66 | let name : String 67 | let alg : String 68 | let signer : JWTSigner 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/Models/User+JWT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // USerViewController.swift 3 | // App 4 | // 5 | // Created by i335287 on 12/01/2019. 6 | // 7 | 8 | import JWT 9 | import Vapor 10 | 11 | extension User { 12 | func createJwt(usage : String, expiration : Double) throws -> (String, SublimateJwt.Payload) { 13 | if let id = self.id?.uuidString { 14 | let now = Date().timeIntervalSince1970 15 | let exp = Date().timeIntervalSince1970 + expiration 16 | let payLoad = SublimateJwt.Payload(userId: id, isAdmin: self.isAdmin, usage: usage, iat: now, exp: exp) 17 | let jwt = JWT(header: SublimateJwt.headers(kid: SublimateJwt.kids[0]), payload: payLoad) 18 | let data = try jwt.sign(using: SublimateJwt.signers) 19 | if let token = String(data: data, encoding: .utf8) { 20 | return (token, payLoad) 21 | } 22 | else { 23 | throw JWTError(identifier: "JWT", reason: "Error while creating JWT: Serializing data") 24 | } 25 | } 26 | else { 27 | throw JWTError(identifier: "JWT", reason: "Error while creating JWT: User ID was nil") 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAuthentication.swift 3 | // App 4 | // 5 | // Created by i335287 on 02/01/2019. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import FluentSQLite 11 | import Authentication 12 | 13 | extension User : PasswordAuthenticatable { 14 | static var usernameKey: WritableKeyPath { return \User.username } 15 | static var passwordKey: WritableKeyPath { return \User.password } 16 | } 17 | 18 | extension User : SublimateRefreshJwtAuthenticatable { 19 | static func authenticate(refreshJwt: SublimateJwt.Payload, on connection: DatabaseConnectable) -> EventLoopFuture { 20 | 21 | let promise : Promise = connection.eventLoop.newPromise() 22 | // Joins seems to have issues with Fluent + SQLite :( 23 | // https://github.com/vapor/fluent/issues/563 24 | DispatchQueue.global().async { 25 | let tokenFetched = try? RefreshToken.query(on: connection).filter(\RefreshToken.refreshToken == refreshJwt.tokenId.value).first().wait() 26 | 27 | // We successfully fetched a RefreshToken for that token id 28 | guard let tryToken = tokenFetched, let token = tryToken else { 29 | promise.succeed(result: nil) 30 | return 31 | } 32 | let userFetched = try? User.query(on: connection).filter(\User.id == token.userId).first().wait() 33 | 34 | // We successfully fetched a user for that RefreshToken token id 35 | guard let tryUser = userFetched, let user = tryUser, let userId = user.id?.uuidString else { 36 | promise.succeed(result: nil) 37 | return 38 | } 39 | 40 | // The User associated to that RefreshToken is the same specified in the payload 41 | guard userId == refreshJwt.userId else { 42 | promise.succeed(result: nil) 43 | return 44 | } 45 | promise.succeed(result: user) 46 | } 47 | return promise.futureResult 48 | } 49 | } 50 | 51 | extension PublicUser : SublimateBearerAuthenticatable { 52 | static func authenticate(bearerJwt: SublimateJwt.Payload, eventLoop: EventLoop) -> EventLoopFuture { 53 | // The Middleware already validated the JWT, nothing else to be done here 54 | return eventLoop.future(PublicUser(userId: bearerJwt.userId, isAdmin: bearerJwt.isAdmin)) 55 | } 56 | } 57 | 58 | final class User: SQLiteUUIDModel { 59 | 60 | // Primary key 61 | var id: UUID? 62 | 63 | // User is admin 64 | // GPTODO: Admin status is not respected by mutation endpoints, needs to fix 65 | var isAdmin = false 66 | var username: String 67 | 68 | // Hashed password 69 | var password: String 70 | 71 | init(username: String, password: String) { 72 | self.username = username 73 | self.password = password 74 | } 75 | } 76 | 77 | // Response after new registration or logout; authorized object for resource routes 78 | struct PublicUser: Content { 79 | var userId: String 80 | var isAdmin: Bool 81 | } 82 | 83 | // Request for user creation 84 | struct UserCreationBody : Codable { 85 | var username: String 86 | var password: String 87 | 88 | func validate() -> Bool { 89 | return username.count > 0 && password.count > 0 90 | } 91 | } 92 | 93 | // Response for login routes 94 | struct AuthorizedUser: Content { 95 | var userId: String 96 | var username: String 97 | var refreshToken: String 98 | var accessToken: String 99 | } 100 | 101 | extension User: Content { } 102 | extension User: Parameter { } 103 | extension User: Migration { 104 | static func prepare(on connection: SQLiteConnection) -> Future { 105 | return Database 106 | .create(self, on: connection) { builder in 107 | try addProperties(to: builder) 108 | builder.unique(on: \.username) 109 | } 110 | .then({ _ -> Future in 111 | let user = User(username: "admin", password: try! BCrypt.hash("admin")) 112 | user.isAdmin = true 113 | return user.save(on: connection) 114 | }) 115 | .transform(to: ()) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/app.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | /// Creates an instance of Application. This is called from main.swift in the run target. 4 | public func app(_ env: Environment) throws -> Application { 5 | var config = Config.default() 6 | var env = env 7 | var services = Services.default() 8 | try configure(&config, &env, &services) 9 | let app = try Application(config: config, environment: env, services: services) 10 | try boot(app) 11 | return app 12 | } 13 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/boot.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | /// Called after your application has initialized. 4 | public func boot(_ app: Application) throws { 5 | // your code here 6 | 7 | } 8 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import FluentSQLite 2 | import Vapor 3 | import Authentication 4 | 5 | /// Called before your application initializes. 6 | public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws { 7 | /// Register providers first 8 | try services.register(FluentSQLiteProvider()) 9 | try services.register(AuthenticationProvider()) 10 | 11 | /// Register routes to the router 12 | let router = EngineRouter.default() 13 | try routes(router) 14 | services.register(router, as: Router.self) 15 | 16 | /// Register middleware 17 | var middlewares = MiddlewareConfig() // Create _empty_ middleware config 18 | /// middlewares.use(FileMiddleware.self) // Serves files from `Public/` directory 19 | middlewares.use(ErrorMiddleware.self) // Catches errors and converts to HTTP response 20 | services.register(middlewares) 21 | 22 | // Configure a SQLite database 23 | let sqlite = try SQLiteDatabase(storage: .memory) 24 | 25 | /// Register the configured SQLite database to the database config. 26 | var databases = DatabasesConfig() 27 | databases.add(database: sqlite, as: .sqlite) 28 | services.register(databases) 29 | 30 | 31 | /// Configure migrations 32 | var migrations = MigrationConfig() 33 | migrations.add(model: User.self, database: .sqlite) 34 | migrations.add(model: RefreshToken.self, database: .sqlite) 35 | configureSublimateMigration(migrations: &migrations) 36 | services.register(migrations) 37 | } 38 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Crypto 3 | 4 | /// Register your application's routes here. 5 | public func routes(_ router: Router) throws { 6 | let password = User.sublimatePasswordAuthMiddleware(using: BCryptDigest()) 7 | let refresh = User.sublimateRefreshJwtAuthMiddleware(signers: SublimateJwt.signers) 8 | let access = PublicUser.sublimateBearerAuthMiddleware(signers: SublimateJwt.signers) 9 | 10 | let authenticationGroup = router.grouped([password, refresh]) 11 | let accessGroup = router.grouped(access) 12 | 13 | let userController = UserController() 14 | router.post("createUser", use: userController.createUser) 15 | authenticationGroup.post("token", use: userController.loginUser) 16 | authenticationGroup.post("logout", use: userController.logout) 17 | 18 | let debug = Debug() 19 | router.get("tokens", use: debug.listTokens) 20 | router.get("users", use: debug.listUsers) 21 | 22 | configureSublimateRoutes(plain: router, resourceGroup: accessGroup) 23 | } 24 | 25 | class Debug { 26 | 27 | /// Returns the list 28 | func listTokens(_ req: Request) throws -> Future<[RefreshToken]> { 29 | return RefreshToken.query(on: req).all() 30 | } 31 | 32 | /// Returns the list 33 | func listUsers(_ req: Request) throws -> Future<[User]> { 34 | return User.query(on: req).all() 35 | } 36 | 37 | 38 | } 39 | -------------------------------------------------------------------------------- /SublimateVapor/Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | 3 | try app(.detect()).run() 4 | -------------------------------------------------------------------------------- /SublimateVapor/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateVapor/Tests/.gitkeep -------------------------------------------------------------------------------- /SublimateVapor/Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import XCTest 3 | 4 | final class AppTests: XCTestCase { 5 | func testNothing() throws { 6 | // add your tests here 7 | XCTAssert(true) 8 | } 9 | 10 | static let allTests = [ 11 | ("testNothing", testNothing) 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /SublimateVapor/Tests/LinuxMain.swift: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielepalma/sublimate/99b5059f6c58f7fe2cf1c78fe84702d7117c4c87/SublimateVapor/Tests/LinuxMain.swift -------------------------------------------------------------------------------- /SublimateVapor/cloud.yml: -------------------------------------------------------------------------------- 1 | type: "vapor" 2 | swift_version: "4.1.0" 3 | run_parameters: "serve --port 8080 --hostname 0.0.0.0" 4 | --------------------------------------------------------------------------------