diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index a6ef3b9..e7535d2 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,3 +1,4 @@ +branches: [master] name-template: '$NEXT_PATCH_VERSION 🌈' tag-template: '$NEXT_PATCH_VERSION' categories: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d71ab3..f124167 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,4 @@ jobs: - name: Build run: swift build - name: Test - run: swift test + run: swift test --enable-test-discovery --sanitize=thread diff --git a/.jazzy.yml b/.jazzy.yml index 4041787..2b486dd 100644 --- a/.jazzy.yml +++ b/.jazzy.yml @@ -1,6 +1,5 @@ module: Corvus author: Berzan Yildiz -min_acl: internal theme: fullwidth output: ./docs documentation: ./*.md diff --git a/.swiftlint.yml b/.swiftlint.yml index cc82acc..311df3f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,18 +1,11 @@ -# SwiftLint file LS1 TUM -# Created by Paul Schmiedmayer on 08/16/18. -# Copyright © 2018 Paul Schmiedmayer. All rights reserved. -# The opt_in_rules also include all rules that are enabled by default to provide a good overview of all rules. - -disabled_rules: - - discouraged_optional_collection # TODO: Enable as soon as https://github.com/realm/SwiftLint/issues/2298 is closed. - # Prefer empty collection over optional collection. - - leading_whitespace - # Files should not contain leading whitespace. - - opening_brace - - first_where - # Prefer using ``.first(where:)`` over ``.filter { }.first` in collections. - -opt_in_rules: +# Apodini SwiftLint file Apodini + +# The whitelist_rules configuration also includes rules that are enabled by default to provide a good overview of all rules. +whitelist_rules: + - anyobject_protocol: + # Prefer using AnyObject over class for class-only protocols. + - array_init + # Prefer using Array(seq) over seq.map { $0 } to convert a sequence into an Array. - block_based_kvo # Prefer the new block based KVO API with keypaths when using Swift 3.2 or later. - class_delegate_protocol @@ -21,13 +14,14 @@ opt_in_rules: # Closing brace with closing parenthesis should not have any whitespaces in the middle. - closure_body_length # Closure bodies should not span too many lines. - # See closure_body_length below for the exact configuration. - closure_end_indentation # Closure end should have the same indentation as the line that started it. - closure_parameter_position # Closure parameters should be on the same line as opening brace. - closure_spacing # Closure expressions should have a single space inside each brace. + - collection_alignment + # All elements in a collection literal should be vertically aligned - colon # Colons should be next to the identifier when specifying a type and next to the key in dictionary literals. - comma @@ -36,8 +30,14 @@ opt_in_rules: # The initializers declared in compiler protocols such as ExpressibleByArrayLiteral shouldn't be called directly. - conditional_returns_on_newline # Conditional statements should always return on the next line + - contains_over_filter_count + # Prefer contains over comparing filter(where:).count to 0. + - contains_over_filter_is_empty + # Prefer contains over using filter(where:).isEmpty - contains_over_first_not_nil # Prefer `contains` over `first(where:) != nil` + - contains_over_range_nil_comparison + # Prefer contains over range(of:) != nil and range(of:) == nil - control_statement # if, for, guard, switch, while, and catch statements shouldn't unnecessarily wrap their conditionals or arguments in parentheses. - convenience_type @@ -50,8 +50,14 @@ opt_in_rules: # Discouraged direct initialization of types that can be harmful. e.g. UIDevice(), Bundle() - discouraged_optional_boolean # Prefer non-optional booleans over optional booleans. + # - discouraged_optional_collection # Enable as soon as https://github.com/realm/SwiftLint/issues/2298 is fixed + # Prefer empty collection over optional collection. + - duplicate_imports + # Duplicate Imports - dynamic_inline # Avoid using 'dynamic' and '@inline(__always)' together. + - empty_collection_literal + # Prefer checking isEmpty over comparing collection to an empty array or dictionary literal. - empty_count # Prefer checking `isEmpty` over comparing `count` to zero. - empty_enum_arguments @@ -62,11 +68,21 @@ opt_in_rules: # When using trailing closures, empty parentheses should be avoided after the method call. - empty_string # Prefer checking `isEmpty` over comparing string to an empty string literal. + - empty_xctest_method + # Empty XCTest method should be avoided. + - enum_case_associated_values_count + # Number of associated values in an enum case should be low + - explicit_init + # Explicitly calling .init() should be avoided. - fatal_error_message # A fatalError call should have a message. - file_length # Files should not span too many lines. # See file_length below for the exact configuration. + - file_types_order + # Specifies how the types within a file should be ordered. + - flatmap_over_map_reduce + # Prefer flatMap over map followed by reduce([], +). - for_where # where clauses are preferred over a single if inside a for. - force_cast @@ -83,6 +99,8 @@ opt_in_rules: # See function_parameter_count below for the exact configuration. - generic_type_name # Generic type name should only contain alphanumeric characters, start with an uppercase character and span between 1 and 20 characters in length. + - identical_operands + # Comparing two identical operands is likely a mistake. - identifier_name # Identifier names should only contain alphanumeric characters and start with a lowercase character or should only contain capital letters. # In an exception to the above, variable names may start with a capital letter when they are declared static and immutable. @@ -93,6 +111,8 @@ opt_in_rules: # Prefer implicit returns in closures. - implicitly_unwrapped_optional # Implicitly unwrapped optionals should be avoided when possible. + - indentation_width + # Indent code using either one tab or the configured amount of spaces, unindent to match previous indentations. Don’t indent the first line. - inert_defer # If defer is at the end of its parent scope, it will be executed right where it is anyway. - is_disjoint @@ -102,29 +122,45 @@ opt_in_rules: - large_tuple # Tuples shouldn't have too many members. Create a custom type instead. # See large_tuple below for the exact configuration. + - last_where + # Prefer using .last(where:) over .filter { }.last in collections. + - leading_whitespace + # Files should not contain leading whitespace. - legacy_cggeometry_functions # CGGeometry: Struct extension properties and methods are preferred over legacy functions - legacy_constant # Struct-scoped constants are preferred over legacy global constants (CGSize, CGRect, NSPoint, ...). - legacy_constructor # Swift constructors are preferred over legacy convenience functions (CGPointMake, CGSizeMake, UIOffsetMake, ...). + - legacy_hashing + # Prefer using the hash(into:) function instead of overriding hashValue + - legacy_multiple + # Prefer using the isMultiple(of:) function instead of using the remainder operator (%). + - legacy_nsgeometry_functions + # Struct extension properties and methods are preferred over legacy functions + - legacy_random + # Prefer using type.random(in:) over legacy functions. - line_length # Lines should not span too many characters. # See line_length below for the exact configuration. - literal_expression_end_indentation # Array and dictionary literal end should have the same indentation as the line that started it. + - lower_acl_than_parent + # Ensure definitions have a lower access control level than their enclosing parent - mark # MARK comment should be in valid format. e.g. '// MARK: ...' or '// MARK: - ...' + - missing_docs + # Declarations should be documented. - modifier_order # Modifier order should be consistent. - multiline_arguments # Arguments should be either on the same line, or one per line. - multiline_function_chains # Chained function calls should be either on the same line, or one per line. + - multiline_literal_brackets + # Multiline literals should have their surrounding brackets in a new line. - multiline_parameters # Functions and methods parameters should be either on the same line, or one per line. - - multiple_closures_with_trailing_closure - # Trailing closure syntax should not be used when passing more than one closure argument. - nesting # Types and statements should only be nested to a certain level deep. # See nesting below for the exact configuration. @@ -132,18 +168,30 @@ opt_in_rules: # Prefer Nimble operator overloads over free matcher functions. - no_fallthrough_only # Fallthroughs can only be used if the case contains at least one other statement. + - no_space_in_method_call + # Don’t add a space between the method name and the parentheses. - notification_center_detachment # An object should only remove itself as an observer in deinit. + - nslocalizedstring_key + # Static strings should be used as key in NSLocalizedString in order to genstrings work. + - nsobject_prefer_isequal + # NSObject subclasses should implement isEqual instead of ==. - object_literal # Prefer object literals over image and color inits. - operator_usage_whitespace # Operators should be surrounded by a single whitespace when they are being used. - operator_whitespace # Operators should be surrounded by a single whitespace when defining them. + - optional_enum_case_matching + # Matching an enum case against an optional enum without ‘?’ is supported on Swift 5.1 and above. + - orphaned_doc_comment + # A doc comment should be attached to a declaration. - overridden_super_call # Some overridden methods should always call super - pattern_matching_keywords # Combine multiple pattern matching bindings by moving keywords out of tuples. + - prefer_self_type_over_type_of_self + # Prefer Self over type(of: self) when accessing properties or calling methods. - private_action # IBActions should be private. - private_outlet @@ -160,10 +208,16 @@ opt_in_rules: # UIViewController loadView()) - protocol_property_accessors_order # When declaring properties in protocols, the order of accessors should be get set. + - reduce_boolean + # Prefer using .allSatisfy() or .contains() over reduce(true) or reduce(false) + - reduce_into + # Prefer reduce(into:_:) over reduce(_:_:) for copy-on-write types - redundant_discardable_let # Prefer _ = foo() over let _ = foo() when discarding a result from a function. - redundant_nil_coalescing # nil coalescing operator is only evaluated if the lhs is nil, coalescing operator with nil as rhs is redundant + - redundant_objc_attribute + # Objective-C attribute (@objc) is redundant in declaration. - redundant_optional_initialization # Initializing an optional variable with nil is redundant. - redundant_set_access_control @@ -178,16 +232,26 @@ opt_in_rules: # Return arrow and return type should be separated by a single space or on a separate line. - shorthand_operator # Prefer shorthand operators (+=, -=, *=, /=) over doing the operation and assigning. + - single_test_class + # Test files should contain a single QuickSpec or XCTestCase class. - sorted_first_last # Prefer using `min()`` or `max()`` over `sorted().first` or `sorted().last` - statement_position # Else and catch should be on the same line, one space after the previous declaration. + - static_operator + # Operators should be declared as static functions, not free functions. + - superfluous_disable_command + # SwiftLint ‘disable’ commands are superfluous when the disabled rule would not have triggered a violation in the disabled region. Use “ - ” if you wish to document a command. - switch_case_alignment # Case statements should vertically align with their enclosing switch statement, or indented if configured otherwise. - syntactic_sugar # Shorthand syntactic sugar should be used, i.e. [Int] instead of Array. - todo # TODOs and FIXMEs should be resolved. + - toggle_bool + # Prefer someBool.toggle() over someBool = !someBool. + - trailing_closure + # Trailing closure syntax should be used whenever possible. - trailing_comma # Trailing commas in arrays and dictionaries should be avoided/enforced. - trailing_newline @@ -209,12 +273,24 @@ opt_in_rules: # Avoid using unneeded break statements. - unneeded_parentheses_in_closure_argument # Parentheses are not needed when declaring closure arguments. + - untyped_error_in_catch + # Catch statements should not declare error variables without type casting. + - unused_capture_list + # Unused reference in a capture list should be removed. - unused_closure_parameter # Unused parameter in a closure should be replaced with _. + - unused_control_flow_label + # Unused control flow label should be removed. + - unused_declaration + # Declarations should be referenced at least once within all files linted. - unused_enumerated # When the index or the item is not used, .enumerated() can be removed. + - unused_import + # All imported modules should be required to make the file compile. - unused_optional_binding # Prefer != nil over let _ = + - unused_setter_value + # Setter value is not used. - valid_ibinspectable # @IBInspectable should be applied to variables only, have its type explicit and be of a supported type - vertical_parameter_alignment @@ -224,6 +300,10 @@ opt_in_rules: - vertical_whitespace # Limit vertical whitespace to a single empty line. # See vertical_whitespace below for the exact configuration. + - vertical_whitespace_closing_braces + # Don’t include vertical whitespace (empty line) before closing braces. + - vertical_whitespace_opening_braces + # Don’t include vertical whitespace (empty line) after opening braces. - void_return # Prefer -> Void over -> (). - weak_delegate @@ -233,22 +313,27 @@ opt_in_rules: - yoda_condition # The variable should be placed on the left, the constant on the right of a comparison operator. +# These rules still get triggered even though they are not in the whitelist_rules +disabled_rules: + - multiple_closures_with_trailing_closure + # Trailing closure syntax should not be used when passing more than one closure argument. + - opening_brace + excluded: # paths to ignore during linting. Takes precedence over `included`. - Carthage - Pods - - Tests - - "*/Pods" - - "*/*/Pods" - - "*/*/*/Pods" - - "*/*/*/*/Pods" - - "*/*/*/*/*/Pods" - .build + - .swiftpm - R.generated.swift closure_body_length: # Closure bodies should not span too many lines. - 25 # warning - default: 20 - 25 # error - default: 100 +enum_case_associated_values_count: # Number of associated values in an enum case should be low + - 5 # warning - default: 5 + - 5 # error - default: 6 + file_length: # Files should not span too many lines. - 500 # warning - default: 400 - 500 # error - default: 1000 @@ -274,10 +359,10 @@ large_tuple: # Tuples shouldn't have too many members. Create a custom type inst line_length: # Lines should not span too many characters. warning: 80 # default: 120 error: 80 # default: 200 - ignores_comments: true # default: false - ignores_urls: true # default: false + ignores_comments: false # default: false + ignores_urls: false # default: false ignores_function_declarations: false # default: false - ignores_interpolated_strings: true # default: false + ignores_interpolated_strings: false # default: false nesting: # Types should be nested at most 2 level deep, and statements should be nested at most 5 levels deep. type_level: diff --git a/Sources/Corvus/Authentication/CorvusModelUser.swift b/Sources/Corvus/Authentication/CorvusModelUser.swift new file mode 100644 index 0000000..6da2463 --- /dev/null +++ b/Sources/Corvus/Authentication/CorvusModelUser.swift @@ -0,0 +1,5 @@ +import Fluent +import Vapor + +/// A protocol that wraps `Vapor`'s `ModelUser` for consistency in naming. +public protocol CorvusModelUser: ModelUser {} diff --git a/Sources/Corvus/Authentication/CorvusModelUserToken.swift b/Sources/Corvus/Authentication/CorvusModelUserToken.swift new file mode 100644 index 0000000..e3ae606 --- /dev/null +++ b/Sources/Corvus/Authentication/CorvusModelUserToken.swift @@ -0,0 +1,109 @@ +import Fluent +import Vapor + +// swiftlint:disable identifier_name + +/// A protocol that defines bearer authentication tokens, similar to +/// `ModelUserToken`. +public protocol CorvusModelUserToken: CorvusModel { + + /// The `User` type the token belongs to. + associatedtype User: CorvusModel & Authenticatable + + /// The `String` value of the token. + var value: String { get set } + + /// The `User` associated with the token. + var user: User { get set } + + /// A boolean that deletes tokens if they're not in use. + var isValid: Bool { get } +} + +/// An extension to provide a default initializer to tokens so that they can +/// be initialized manually in module code. +extension CorvusModelUserToken { + + /// Initializes a token. + /// - Parameters: + /// - id: The unique identifier of the token. + /// - value: The `String` value of the token. + /// - userId: The id of the `User` the token belongs to. + init(id: Self.IDValue? = nil, value: String, userId: User.IDValue) { + self.init() + self.value = value + _$user.id = userId + } +} + +/// An extension to provide an authenticator for the token that can be +/// registered to the `Vapor` application, and field accessors for `value` and +/// `user`. +extension CorvusModelUserToken { + + /// Provides a `Vapor` authenticator defined below. + public static func authenticator( + database: DatabaseID? = nil + ) -> CorvusModelUserTokenAuthenticator { + CorvusModelUserTokenAuthenticator(database: database) + } + + /// Provides access to the `value` attribute. + var _$value: Field { + guard let mirror = Mirror(reflecting: self).descendant("_value"), + let token = mirror as? Field else { + fatalError("value property must be declared using @Field") + } + + return token + } + + /// Provides access to the `user` attribute. + var _$user: Parent { + guard let mirror = Mirror(reflecting: self).descendant("_user"), + let user = mirror as? Parent else { + fatalError("user property must be declared using @Parent") + } + + return user + } +} + +/// Provides a `BearerAuthenticator` struct that defines how tokens are +/// authenticated. +public struct CorvusModelUserTokenAuthenticator: +BearerAuthenticator +{ + + /// The token's user. + public typealias User = T.User + + /// The database the token is saved in. + public let database: DatabaseID? + + /// Authenticates a token. + /// - Parameters: + /// - bearer: The bearer token passed in the request. + /// - request: The `Request` to be authenticated. + /// - Returns: The `User` the token belongs to. + public func authenticate( + bearer: BearerAuthorization, + for request: Request + ) -> EventLoopFuture { + let db = request.db(self.database) + return T.query(on: db) + .filter(\._$value == bearer.token) + .first() + .flatMap + { token -> EventLoopFuture in + guard let token = token else { + return request.eventLoop.makeSucceededFuture(nil) + } + guard token.isValid else { + return token.delete(on: db).map { nil } + } + return token._$user.get(on: db) + .map { $0 } + } + } +} diff --git a/Sources/Corvus/Authentication/CorvusToken.swift b/Sources/Corvus/Authentication/CorvusToken.swift index 589a004..9f6e846 100644 --- a/Sources/Corvus/Authentication/CorvusToken.swift +++ b/Sources/Corvus/Authentication/CorvusToken.swift @@ -5,7 +5,7 @@ import Fluent public final class CorvusToken: CorvusModel { /// The corresponding database schema. - public static let schema = "tokens" + public static let schema = "corvus_tokens" /// The unique identifier of the model in the database. @ID @@ -67,15 +67,9 @@ public struct CreateCorvusToken: Migration { } } -/// An extension to conform to the `ModelUserToken` protocol, which provides -/// functionality to authenticate a token. -extension CorvusToken: ModelUserToken { - - /// Makes the path to a token's value publicly accessible. - public static let valueKey = \CorvusToken.$value - - /// Makes the path to a token's user publicly accessible. - public static let userKey = \CorvusToken.$user +/// An extension to conform to the `CorvusModelUserToken` protocol, which +/// provides functionality to authenticate a token. +extension CorvusToken: CorvusModelUserToken { /// Prevents tokens from being deleted after authentication. public var isValid: Bool { diff --git a/Sources/Corvus/Authentication/CorvusUser.swift b/Sources/Corvus/Authentication/CorvusUser.swift index aa269a9..1f84192 100644 --- a/Sources/Corvus/Authentication/CorvusUser.swift +++ b/Sources/Corvus/Authentication/CorvusUser.swift @@ -5,24 +5,19 @@ import Fluent public final class CorvusUser: CorvusModel { /// The corresponding database schema. - public static let schema = "users" + public static let schema = "corvus_users" /// The unique identifier of the model in the database. @ID public var id: UUID? /// The name of the user. - @Field(key: "name") - public var name: String + @Field(key: "username") + public var username: String - /// The email of the user, which is used as the username during - /// authentication. - @Field(key: "email") - public var email: String - - /// The password of the user, used during authentication. - @Field(key: "password") - public var password: String + /// The hashed password of the user, used during authentication. + @Field(key: "password_hash") + public var passwordHash: String /// Timestamp for soft deletion. @Timestamp(key: "deleted_at", on: .delete) @@ -35,19 +30,16 @@ public final class CorvusUser: CorvusModel { /// /// - Parameters: /// - id: The identifier of the user, auto generated if not provided. - /// - name: The name of the user. - /// - email: The email (or username) of the user. - /// - password: The password of the user. + /// - username: The username of the user. + /// - passwordHash: The hashed password of the user. public init( id: UUID? = nil, - name: String, - email: String, - password: String + username: String, + passwordHash: String ) { self.id = id - self.name = name - self.email = email - self.password = password + self.username = username + self.passwordHash = passwordHash } } @@ -61,9 +53,8 @@ public struct CreateCorvusUser: Migration { public func prepare(on database: Database) -> EventLoopFuture { database.schema(CorvusUser.schema) .id() - .field("name", .string, .required) - .field("email", .string, .required) - .field("password", .string, .required) + .field("username", .string, .required) + .field("password_hash", .string, .required) .field("deleted_at", .date) .create() } @@ -74,15 +65,15 @@ public struct CreateCorvusUser: Migration { } } -/// An extension to conform to the `ModelUser` protocol, which provides +/// An extension to conform to the `CorvusModelUser` protocol, which provides /// functionality to authenticate a user with username and password. -extension CorvusUser: ModelUser { - - /// Provides a path to the user's username (or in Corvus, the email). - public static let usernameKey = \CorvusUser.$email +extension CorvusUser: CorvusModelUser { + + /// Provides a path to the user's username. + public static let usernameKey = \CorvusUser.$username - /// Provides a path to the user's password. - public static let passwordHashKey = \CorvusUser.$password + /// Provides a path to the user's hashed password. + public static let passwordHashKey = \CorvusUser.$passwordHash /// Verifies a given string by checking if it matches a user's password. /// @@ -90,35 +81,6 @@ extension CorvusUser: ModelUser { /// - Returns: True if the provided password matches the user's, false if /// not. public func verify(password: String) throws -> Bool { - password == self.password - } -} - -/// An extension to generate a `CorvusToken` for a given user. -extension CorvusUser { - - /// A method that generates a unique token for a given user. - /// - /// - Returns: The generated token. - public func generateToken() throws -> CorvusToken { - try .init( - value: [UInt8].random(count: 16).base64, - userID: self.requireID() - ) - } -} - -/// An extension to validate if a given `CorvusUser` is equal to the current -/// `CorvusUser`, used for `AuthEndpoint.auth()`. -extension CorvusUser { - - /// Validates if a given user is equal to the current user. - /// - /// - Parameter requestUser: The user from the request that is to be - /// validated. - /// - Returns: True if the request's user matches the current user, false if - /// not. - public func validate(_ requestUser: CorvusUser) -> Bool { - requestUser.id == self.id + try Bcrypt.verify(password, created: self.passwordHash) } } diff --git a/Sources/Corvus/Endpoints/Create/Create.swift b/Sources/Corvus/Endpoints/Create/Create.swift index 6e18719..4ba28a4 100644 --- a/Sources/Corvus/Endpoints/Create/Create.swift +++ b/Sources/Corvus/Endpoints/Create/Create.swift @@ -17,6 +17,7 @@ public final class Create: QueryEndpoint { /// /// - Parameter req: An incoming `Request`. /// - Returns: An `EventLoopFuture` containing the saved object. + /// - Throws: An `Abort` error if something goes wrong. public func handler(_ req: Request) throws -> EventLoopFuture { diff --git a/Sources/Corvus/Endpoints/Custom.swift b/Sources/Corvus/Endpoints/Custom.swift index dfe8df0..434f6d5 100644 --- a/Sources/Corvus/Endpoints/Custom.swift +++ b/Sources/Corvus/Endpoints/Custom.swift @@ -27,11 +27,11 @@ public final class Custom: RestEndpoint { /// - customHandler: A closure that implements the functionality for the /// `Custom` component. public init( - path: PathComponent..., + pathComponents: PathComponent..., type operationType: OperationType, _ customHandler: @escaping (Request) throws -> EventLoopFuture ) { - self.pathComponents = path + self.pathComponents = pathComponents self.operationType = operationType self.customHandler = customHandler } diff --git a/Sources/Corvus/Endpoints/Delete/Delete.swift b/Sources/Corvus/Endpoints/Delete/Delete.swift index 933c29f..03fed28 100644 --- a/Sources/Corvus/Endpoints/Delete/Delete.swift +++ b/Sources/Corvus/Endpoints/Delete/Delete.swift @@ -30,6 +30,7 @@ public final class Delete: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A `QueryBuilder`, which represents a `Fluent` query after /// having found the object with the supplied ID. + /// - Throws: An `Abort` error if the item is not found. public func query(_ req: Request) throws -> QueryBuilder { let parameter = String(id.description.dropFirst()) guard let itemId = req.parameters.get( @@ -46,6 +47,7 @@ public final class Delete: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A HTTPStatus of either `.ok`, when the object was /// successfully deleted, or `.notFound`, when the object was not found. + /// - Throws: An `Abort` error if the item is not found. public func handler(_ req: Request) throws -> EventLoopFuture { try query(req) .first() diff --git a/Sources/Corvus/Endpoints/Delete/SoftDelete.swift b/Sources/Corvus/Endpoints/Delete/SoftDelete.swift index 1daf707..9d13378 100644 --- a/Sources/Corvus/Endpoints/Delete/SoftDelete.swift +++ b/Sources/Corvus/Endpoints/Delete/SoftDelete.swift @@ -29,6 +29,7 @@ public final class SoftDelete: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A `QueryBuilder`, which represents a `Fluent` query after /// having found the object with the supplied ID. + /// - Throws: An `Abort` error if the item is not found. public func query(_ req: Request) throws -> QueryBuilder { let parameter = String(id.description.dropFirst()) guard let itemId = req.parameters.get( @@ -45,6 +46,7 @@ public final class SoftDelete: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A HTTPStatus of either `.ok`, when the object was /// successfully deleted, or `.notFound`, when the object was not found. + /// - Throws: An `Abort` error if the item is not found. public func handler(_ req: Request) throws -> EventLoopFuture { try query(req) .first() diff --git a/Sources/Corvus/Endpoints/Groups/BasicAuthGroup.swift b/Sources/Corvus/Endpoints/Groups/BasicAuthGroup.swift index f04f34b..e550087 100644 --- a/Sources/Corvus/Endpoints/Groups/BasicAuthGroup.swift +++ b/Sources/Corvus/Endpoints/Groups/BasicAuthGroup.swift @@ -2,8 +2,8 @@ import Vapor import Fluent /// A special type of `Group` that protects its `content` with basic -/// authentication for a generic `ModelUser`. -public struct BasicAuthGroup: Endpoint { +/// authentication for a generic `CorvusModelUser`. +public struct BasicAuthGroup: Endpoint { /// An array of `PathComponent` describing the path that the /// `BasicAuthGroup` extends. @@ -32,7 +32,7 @@ public struct BasicAuthGroup: Endpoint { /// A method that registers the `content` of the `BasicAuthGroup` to the /// supplied `RoutesBuilder`. It also registers basic authentication - /// middleware using `T` conforming to `ModelUser`. + /// middleware using `T` conforming to `CorvusModelUser`. /// /// - Parameter routes: A `RoutesBuilder` containing all the information /// about the HTTP route leading to the current component. diff --git a/Sources/Corvus/Endpoints/Groups/BearerAuthGroup.swift b/Sources/Corvus/Endpoints/Groups/BearerAuthGroup.swift index 9976e9a..781ceb6 100644 --- a/Sources/Corvus/Endpoints/Groups/BearerAuthGroup.swift +++ b/Sources/Corvus/Endpoints/Groups/BearerAuthGroup.swift @@ -2,8 +2,8 @@ import Vapor import Fluent /// A special type of `Group` that protects its `content` with bearer token -/// authentication for a generic `ModelUserToken`. -public struct BearerAuthGroup: Endpoint { +/// authentication for a generic `CorvusModelUserToken`. +public struct BearerAuthGroup: Endpoint { /// An array of `PathComponent` describing the path that the /// `BearerAuthGroup` extends. @@ -32,7 +32,7 @@ public struct BearerAuthGroup: Endpoint { /// A method that registers the `content` of the `BearerAuthGroup` to the /// supplied `RoutesBuilder`. It also registers basic authentication - /// middleware using `T`conforming to `ModelUserToken`. + /// middleware using `T`conforming to `CorvusModelUserToken`. /// /// - Parameter routes: A `RoutesBuilder` containing all the information /// about the HTTP route leading to the current component. diff --git a/Sources/Corvus/Endpoints/Login.swift b/Sources/Corvus/Endpoints/Login.swift index fc7f5dd..a03cc71 100644 --- a/Sources/Corvus/Endpoints/Login.swift +++ b/Sources/Corvus/Endpoints/Login.swift @@ -1,9 +1,12 @@ import Vapor +import Fluent /// A class that provides functionality to log in a user with username and /// password credentials sent in a HTTP POST `Request` and save a token for -/// that user. -public final class Login: Endpoint { +/// that user. Needs an object of type `T` which represents the token to be +/// created upon login. +public final class Login: Endpoint +where T.User: CorvusModelUser { /// The route for the login functionality let path: PathComponent @@ -19,21 +22,27 @@ public final class Login: Endpoint { /// and saving it in the database. /// /// - Parameter req: An incoming HTTP `Request`. - /// - Returns: An `EventLoopFuture` containing the created `CorvusToken`. - public func handler(_ req: Request) throws -> EventLoopFuture { - let user = try req.auth.require(CorvusUser.self) - let token = try user.generateToken() + /// - Returns: An `EventLoopFuture` containing the created token. + /// - Throws: An `Abort` error if something goes wrong. + public func handler(_ req: Request) throws -> EventLoopFuture { + let user = try req.auth.require(T.User.self) + let token = T.init( + value: [UInt8].random(count: 16).base64, + userId: try user.requireID() + ) + return token.save(on: req.db).map { token } } /// A method that registers the `handler()` to the supplied `RoutesBuilder`. - /// It also registers basic authentication middleware using `CorvusUser`. + /// It also registers basic authentication middleware using the user + /// belonging to the token `T`. /// /// - Parameter routes: A `RoutesBuilder` containing all the information /// about the HTTP route leading to the current component. public func register(to routes: RoutesBuilder) { let guardedRoutesBuilder = routes.grouped( - CorvusUser.authenticator().middleware() + T.User.authenticator().middleware() ) guardedRoutesBuilder.post(path, use: handler) diff --git a/Sources/Corvus/Endpoints/Modifiers/AuthModifier.swift b/Sources/Corvus/Endpoints/Modifiers/AuthModifier.swift index 38d78cf..3586b00 100644 --- a/Sources/Corvus/Endpoints/Modifiers/AuthModifier.swift +++ b/Sources/Corvus/Endpoints/Modifiers/AuthModifier.swift @@ -3,13 +3,15 @@ import Fluent /// A class that wraps a component which utilizes an `.auth()` modifier. That /// allows Corvus to chain modifiers, as it gets treated as any other struct -/// conforming to `AuthEndpoint`. -public final class AuthModifier: AuthEndpoint { +/// conforming to `AuthEndpoint`. Requires an object `T` that represents the +/// user to authorize. +public final class AuthModifier: +AuthEndpoint { /// The return type for the `.handler()` modifier. public typealias Element = Q.Element - /// The return value of the `.handler()`, so the type being operated on in + /// The return value of the `.query()`, so the type being operated on in /// the current component. public typealias QuerySubject = Q.QuerySubject @@ -17,7 +19,7 @@ public final class AuthModifier: AuthEndpoint { /// authenticated. public typealias UserKeyPath = KeyPath< Q.QuerySubject, - Q.QuerySubject.Parent + Q.QuerySubject.Parent > /// The `ReadEndpoint` the `.auth()` modifier is attached to. @@ -49,17 +51,19 @@ public final class AuthModifier: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A `QueryBuilder`, which represents a `Fluent` query defined /// by the `queryEndpoint`. + /// - Throws: An `Abort` error if the item is not found. public func query(_ req: Request) throws -> QueryBuilder { try queryEndpoint.query(req) } - /// A method which checks if the `CorvusUser` supplied in the `Request` is + /// A method which checks if the user `T` supplied in the `Request` is /// equal to the user belonging to the particular `QuerySubject`. /// /// - Parameter req: An incoming `Request`. /// - Returns: An `EventLoopFuture` containing an eagerloaded value as /// defined by `Element`. If authentication fails or a user is not found, /// HTTP `.unauthorized` and `.notFound` are thrown respectively. + /// - Throws: An `Abort` error if an item is not found. public func handler(_ req: Request) throws -> EventLoopFuture { let users = try query(req) .with(userKeyPath) @@ -74,11 +78,11 @@ public final class AuthModifier: AuthEndpoint { throw Abort(.notFound) } - guard let authorized = req.auth.get(CorvusUser.self) else { + guard let authorized = req.auth.get(T.self) else { throw Abort(.unauthorized) } - return authorized.validate(user) + return authorized.id == user.id } return authorized.flatMap { authorized in @@ -100,14 +104,14 @@ public final class AuthModifier: AuthEndpoint { extension AuthEndpoint { /// A modifier used to make sure components only authorize requests where - /// the supplied `CorvusUser` is actually related to the `QuerySubject`. + /// the supplied user `T` is actually related to the `QuerySubject`. /// /// - Parameter user: A `KeyPath` to the related user property. /// - Returns: An instance of a `AuthModifier` with the supplied `KeyPath` /// to the user. - public func auth( - _ user: AuthModifier.UserKeyPath - ) -> AuthModifier { + public func auth( + _ user: AuthModifier.UserKeyPath + ) -> AuthModifier { AuthModifier(self, user: user) } } diff --git a/Sources/Corvus/Endpoints/Modifiers/Read/ChildrenModifier.swift b/Sources/Corvus/Endpoints/Modifiers/Read/ChildrenModifier.swift index 3ca0490..d779c24 100644 --- a/Sources/Corvus/Endpoints/Modifiers/Read/ChildrenModifier.swift +++ b/Sources/Corvus/Endpoints/Modifiers/Read/ChildrenModifier.swift @@ -44,6 +44,7 @@ ReadEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A `QueryBuilder`, which represents a `Fluent` query after /// having attached a with modifier to the `queryEndpoint`'s query. + /// - Throws: An `Abort` error if the item is not found. public func query(_ req: Request) throws -> QueryBuilder { try queryEndpoint.query(req).with(path) } @@ -54,6 +55,7 @@ ReadEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: An `EventLoopFuture` containing an eagerloaded value as /// defined by `Element`. + /// - Throws: An `Abort` error if the item is not found. public func handler(_ req: Request) throws -> EventLoopFuture { try query(req).first().flatMapThrowing { optionalItem in guard let item = optionalItem else { diff --git a/Sources/Corvus/Endpoints/Modifiers/Read/FilterModifier.swift b/Sources/Corvus/Endpoints/Modifiers/Read/FilterModifier.swift index 6081e02..f0d4704 100644 --- a/Sources/Corvus/Endpoints/Modifiers/Read/FilterModifier.swift +++ b/Sources/Corvus/Endpoints/Modifiers/Read/FilterModifier.swift @@ -38,6 +38,7 @@ public final class FilterModifier: ReadEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A `QueryBuilder`, which represents a `Fluent` query after /// having attached a filter to the `queryEndpoint`'s query. + /// - Throws: An `Abort` error if the item is not found. public func query(_ req: Request) throws -> QueryBuilder { try queryEndpoint.query(req).filter(filter) } @@ -47,6 +48,7 @@ public final class FilterModifier: ReadEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: An `EventLoopFuture` containing an array of the /// `FilterModifier`'s `QuerySubject`. + /// - Throws: An `Abort` error if the item is not found. public func handler(_ req: Request) throws -> EventLoopFuture<[QuerySubject]> { try query(req).all() diff --git a/Sources/Corvus/Endpoints/Modifiers/ResponseModifier.swift b/Sources/Corvus/Endpoints/Modifiers/ResponseModifier.swift index d066678..746e3e0 100644 --- a/Sources/Corvus/Endpoints/Modifiers/ResponseModifier.swift +++ b/Sources/Corvus/Endpoints/Modifiers/ResponseModifier.swift @@ -1,9 +1,9 @@ import Vapor import Fluent -/// A class that wraps a component which utilizes a `.respond(with:)` modifier. That -/// allows Corvus to chain modifiers, as it gets treated as any other struct -/// conforming to `RestEndpoint`. +/// A class that wraps a component which utilizes a `.respond(with:)` modifier. +/// That allows Corvus to chain modifiers, as it gets treated as any other +/// struct conforming to `RestEndpoint`. public final class ResponseModifier< Q: RestEndpoint, R: CorvusResponse>: @@ -34,8 +34,10 @@ RestEndpoint where Q.Element == R.Item { /// - Parameter req: An incoming `Request`. /// - Returns: An `EventLoopFuture` containing the /// `ResponseModifier`'s `Response`. - public func handler(_ req: Request) - throws -> EventLoopFuture { + /// - Throws: An `Abort` error if something goes wrong. + public func handler(_ req: Request) throws -> + EventLoopFuture + { try restEndpoint.handler(req).map(Response.init) } @@ -48,7 +50,8 @@ extension RestEndpoint { /// `CorvusResponse`. /// /// - Parameter as: A type conforming to `CorvusResponse`. - /// - Returns: An instance of a `ResponseModifier` with the supplied `CorvusResponse`. + /// - Returns: An instance of a `ResponseModifier` with the supplied + /// `CorvusResponse`. public func respond( with: R.Type ) -> ResponseModifier { diff --git a/Sources/Corvus/Endpoints/Modifiers/UserAuthModifier.swift b/Sources/Corvus/Endpoints/Modifiers/UserAuthModifier.swift new file mode 100644 index 0000000..67699b7 --- /dev/null +++ b/Sources/Corvus/Endpoints/Modifiers/UserAuthModifier.swift @@ -0,0 +1,92 @@ +import Vapor +import Fluent + +/// A class that wraps a component which utilizes an `.userAuth()` modifier. +/// That allows Corvus to chain modifiers, as it gets treated as any other +/// struct conforming to `AuthEndpoint`. +public final class UserAuthModifier: AuthEndpoint +where Q.QuerySubject: CorvusModelUser { + + /// The return type for the `.handler()` modifier. + public typealias Element = Q.Element + + /// The return value of the `.query()`, so the type being operated on in + /// the current component. + public typealias QuerySubject = Q.QuerySubject + + /// The `AuthEndpoint` the `.userAuth()` modifier is attached to. + public let queryEndpoint: Q + + /// The HTTP method of the wrapped method. + public let operationType: OperationType + + /// Initializes the modifier with its underlying `QueryEndpoint`. + /// + /// - Parameters: + /// - queryEndpoint: The `QueryEndpoint` which the modifer is attached + /// to. + /// - operationType: The HTTP method of the wrapped component. + public init(_ queryEndpoint: Q) { + self.queryEndpoint = queryEndpoint + self.operationType = queryEndpoint.operationType + } + + /// Returns the `queryEndpoint`'s query. + /// + /// - Parameter req: An incoming `Request`. + /// - Returns: A `QueryBuilder`, which represents a `Fluent` query defined + /// by the `queryEndpoint`. + /// - Throws: An `Abort` error if an item is not found. + public func query(_ req: Request) throws -> QueryBuilder { + try queryEndpoint.query(req) + } + + /// A method which checks if the user supplied in the `Request` is + /// equal to the user belonging to the particular `QuerySubject`. + /// + /// - Parameter req: An incoming `Request`. + /// - Returns: An `EventLoopFuture` containing an eagerloaded value as + /// defined by `Element`. If authentication fails or a user is not found, + /// HTTP `.unauthorized` and `.notFound` are thrown respectively. + /// - Throws: An `Abort` error if an item is not found. + public func handler(_ req: Request) throws -> EventLoopFuture { + let users = try query(req).all() + + let authorized: EventLoopFuture<[Bool]> = users + .mapEachThrowing { user throws -> Bool in + + guard let authorized = req.auth.get(QuerySubject.self) else { + throw Abort(.unauthorized) + } + + return authorized.id == user.id + } + + return authorized.flatMap { authorized in + guard authorized.allSatisfy({ $0 }) else { + return req.eventLoop.makeFailedFuture(Abort(.unauthorized)) + } + + do { + return try self.queryEndpoint.handler(req) + } catch { + return req.eventLoop.makeFailedFuture(error) + } + } + } +} + +/// An extension that adds the `.userAuth()` modifier to components conforming +/// to `AuthEndpoint`. +extension AuthEndpoint where Self.QuerySubject: CorvusModelUser { + + /// A modifier used to make sure components only authorize requests where + /// the supplied `CorvusUser` is actually related to the `QuerySubject`. + /// + /// - Parameter user: A `KeyPath` to the related user property. + /// - Returns: An instance of a `AuthModifier` with the supplied `KeyPath` + /// to the user. + func userAuth() -> UserAuthModifier { + UserAuthModifier(self) + } +} diff --git a/Sources/Corvus/Endpoints/Read/ReadAll.swift b/Sources/Corvus/Endpoints/Read/ReadAll.swift index 108f28c..9d4ee15 100644 --- a/Sources/Corvus/Endpoints/Read/ReadAll.swift +++ b/Sources/Corvus/Endpoints/Read/ReadAll.swift @@ -25,6 +25,7 @@ public final class ReadAll: ReadEndpoint { /// /// - Parameter req: An incoming `Request`. /// - Returns: An array of `QuerySubjects`. + /// - Throws: An `Abort` error if something goes wrong. public func handler(_ req: Request) throws -> EventLoopFuture<[QuerySubject]> { diff --git a/Sources/Corvus/Endpoints/Read/ReadOne.swift b/Sources/Corvus/Endpoints/Read/ReadOne.swift index e3c06f6..d8d960a 100644 --- a/Sources/Corvus/Endpoints/Read/ReadOne.swift +++ b/Sources/Corvus/Endpoints/Read/ReadOne.swift @@ -31,6 +31,7 @@ public final class ReadOne: ReadEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A `QueryBuilder`, which represents a `Fluent` query after /// having found the object with the supplied ID. + /// - Throws: An `Abort` error if the item is not found. public func query(_ req: Request) throws -> QueryBuilder { let parameter = String(id.description.dropFirst()) guard let itemId = req.parameters.get( @@ -48,6 +49,7 @@ public final class ReadOne: ReadEndpoint { /// /// - Parameter req: An incoming `Request`. /// - Returns: The found object. + /// - Throws: An `Abort` error if the item is not found. public func handler(_ req: Request) throws -> EventLoopFuture { diff --git a/Sources/Corvus/Endpoints/Restore/Restore.swift b/Sources/Corvus/Endpoints/Restore/Restore.swift index 4200182..0725e0b 100644 --- a/Sources/Corvus/Endpoints/Restore/Restore.swift +++ b/Sources/Corvus/Endpoints/Restore/Restore.swift @@ -41,6 +41,7 @@ public final class Restore: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A `QueryBuilder`, which represents a `Fluent` query after /// having found the object with the supplied ID. + /// - Throws: An `Abort` error if the item is not found. public func query(_ req: Request) throws -> QueryBuilder { let parameter = String(id.description.dropFirst()) guard let itemId = req.parameters.get( @@ -63,6 +64,7 @@ public final class Restore: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A HTTPStatus of either `.ok`, when the object was /// successfully deleted, or `.notFound`, when the object was not found. + /// - Throws: An `Abort` error if something goes wrong. public func handler(_ req: Request) throws -> EventLoopFuture { try query(req) .first() @@ -71,7 +73,8 @@ public final class Restore: AuthEndpoint { .map { .ok } } - /// A method that registers the `.handler()` to the supplied `RoutesBuilder`. + /// A method that registers the `.handler()` to the supplied + /// `RoutesBuilder`. /// /// - Parameter routes: A `RoutesBuilder` containing all the information /// about the HTTP route leading to the current component. diff --git a/Sources/Corvus/Endpoints/Update/Update.swift b/Sources/Corvus/Endpoints/Update/Update.swift index 3855ede..8ed356d 100644 --- a/Sources/Corvus/Endpoints/Update/Update.swift +++ b/Sources/Corvus/Endpoints/Update/Update.swift @@ -27,6 +27,7 @@ public final class Update: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: A `QueryBuilder`, which represents a `Fluent` query after /// having found the object with the supplied ID. + /// - Throws: An `Abort` error if the item is not found. public func query(_ req: Request) throws -> QueryBuilder { let parameter = String(id.description.dropFirst()) guard let itemId = req.parameters.get( @@ -43,6 +44,7 @@ public final class Update: AuthEndpoint { /// - Parameter req: An incoming `Request`. /// - Returns: An `EventLoopFuture` containing the updated value of the /// object of type `QuerySubject`. + /// - Throws: An `Abort` error if the item is not found. public func handler(_ req: Request) throws -> EventLoopFuture { diff --git a/Sources/Corvus/Endpoints/User.swift b/Sources/Corvus/Endpoints/User.swift new file mode 100644 index 0000000..eca7d2f --- /dev/null +++ b/Sources/Corvus/Endpoints/User.swift @@ -0,0 +1,83 @@ +import Vapor +import Fluent + +/// A class that contains Create, Read, Update and Delete functionality for a +/// generic type `T` representing a user object. +public final class User: Endpoint { + + /// The route path to the parameters. + let pathComponents: [PathComponent] + + /// A property to generate route parameter placeholders. + let parameter = Parameter() + + /// Indicates wether soft delete should be included or not. + let useSoftDelete: Bool + + /// Initializes the component with one or more route path components. + /// + /// - Parameter pathComponents: One or more `PathComponents` identifying the + /// path to the operations defined by the `CRUD` component. + /// - Parameter softDelete: Enable/Disable soft deletion of Models. + public init(_ pathComponents: PathComponent..., softDelete: Bool = true) { + self.pathComponents = pathComponents + self.useSoftDelete = softDelete + } + + /// The `content` of the `User`, containing Create, Read, Update and Delete + /// functionality grouped under one. + public var content: Endpoint { + if useSoftDelete { + return contentWithSoftDelete + } + + return Group(pathComponents) { + Custom(type: .post) { req in + let requestContent = try req.content.decode(T.self) + return requestContent.save(on: req.db).map { .ok } + } + + BasicAuthGroup { + ReadAll().userAuth() + Group(parameter.id) { + ReadOne(parameter.id).userAuth() + Update(parameter.id).userAuth() + Delete(parameter.id).userAuth() + } + } + } + } + + /// The `content` of the `User`, containing Create, Read, Update, Delete and + /// SoftDelete functionality grouped under one. + public var contentWithSoftDelete: Endpoint { + Group(pathComponents) { + Custom(type: .post) { req in + let requestContent = try req.content.decode(T.self) + return requestContent.save(on: req.db).map { .ok } + } + + BasicAuthGroup { + ReadAll().userAuth() + + Group(parameter.id) { + ReadOne(parameter.id).userAuth() + Update(parameter.id).userAuth() + SoftDelete(parameter.id).userAuth() + } + + Group("trash") { + ReadAll(.trashed).userAuth() + Group(parameter.id) { + ReadOne(parameter.id, .trashed).userAuth() + Delete(parameter.id).userAuth() + + Group("restore") { + Restore(parameter.id).userAuth() + } + } + } + } + } + } +} diff --git a/Sources/Corvus/Endpoints/Utilities/EmptyEndpoint.swift b/Sources/Corvus/Endpoints/Utilities/EmptyEndpoint.swift index 19410d0..397c75c 100644 --- a/Sources/Corvus/Endpoints/Utilities/EmptyEndpoint.swift +++ b/Sources/Corvus/Endpoints/Utilities/EmptyEndpoint.swift @@ -2,6 +2,10 @@ import Vapor /// An empty default value when no `Endpoint` value is needed. public struct EmptyEndpoint: Endpoint { - /// An empty default implementation of `.register()` to avoid endless loops when registering `EmptyEndpoint`s + /// An empty default implementation of `.register()` to avoid endless loops + /// when registering `EmptyEndpoint`s. + /// + /// - Parameter routes: The `RoutesBuilder` that contains HTTP route + /// information up to this point. public func register(to routes: RoutesBuilder) { } } diff --git a/Sources/Corvus/Protocols/Endpoints/Endpoint.swift b/Sources/Corvus/Protocols/Endpoints/Endpoint.swift index 9519ff6..c50d7ba 100644 --- a/Sources/Corvus/Protocols/Endpoints/Endpoint.swift +++ b/Sources/Corvus/Protocols/Endpoints/Endpoint.swift @@ -11,6 +11,9 @@ public protocol Endpoint { /// A method needed to implement registration of an endpoint to the /// `Router` provided by Vapor, this handles the logic of making certain /// operations accessible on certain route paths. + /// + /// - Parameter routes: The `RoutesBuilder` containing HTTP route + /// information up to this point. func register(to routes: RoutesBuilder) } @@ -27,7 +30,11 @@ extension Endpoint { /// do not need to be registered. extension Endpoint { - /// A default implementation of `.register()` for components that do not need special behaviour. + /// A default implementation of `.register()` for components that do not + /// need special behaviour. + /// + /// - Parameter routes: The `RoutesBuilder` containing HTTP route + /// information up to this point. public func register(to routes: RoutesBuilder) { content.register(to: routes) } @@ -39,6 +46,9 @@ extension Array: Endpoint where Element == Endpoint { /// An `Array` of `Endpoint` is registered by registering all of the /// `Array`'s elements. + /// + /// - Parameter routes: The `RoutesBuilder` containing HTTP route + /// information up to this point. public func register(to routes: RoutesBuilder) { forEach({ $0.register(to: routes) }) } diff --git a/Sources/Corvus/Protocols/Endpoints/QueryEndpoint.swift b/Sources/Corvus/Protocols/Endpoints/QueryEndpoint.swift index bc7a286..56a1b65 100644 --- a/Sources/Corvus/Protocols/Endpoints/QueryEndpoint.swift +++ b/Sources/Corvus/Protocols/Endpoints/QueryEndpoint.swift @@ -10,6 +10,8 @@ public protocol QueryEndpoint: RestEndpoint { associatedtype QuerySubject: CorvusModel /// A method to run database queries on a component's `QuerySubject`. + /// + /// - Parameter req: The incoming `Request`. func query(_ req: Request) throws -> QueryBuilder } @@ -19,6 +21,10 @@ extension QueryEndpoint { /// A default implementation of `.query()` for components that do not /// require customized database queries. + /// + /// - Parameter req: The incoming `Request`. + /// - Throws: An error if something goes wrong. + /// - Returns: A `QueryBuilder` object for further querying. public func query(_ req: Request) throws -> QueryBuilder { QuerySubject.query(on: req.db) } diff --git a/Sources/Corvus/Protocols/Endpoints/RestEndpoint.swift b/Sources/Corvus/Protocols/Endpoints/RestEndpoint.swift index 2c1855b..7c96a86 100644 --- a/Sources/Corvus/Protocols/Endpoints/RestEndpoint.swift +++ b/Sources/Corvus/Protocols/Endpoints/RestEndpoint.swift @@ -15,6 +15,8 @@ public protocol RestEndpoint: Endpoint { /// A method that runs logic on the results of the `.query()` and returns /// those results asynchronously in an `EventLoopFuture`. + /// + /// - Parameter req: The incoming `Request`. func handler(_ req: Request) throws -> EventLoopFuture } @@ -26,6 +28,7 @@ public extension RestEndpoint { /// Registers the component to the `Vapor` router depending on its /// `operationType`. + /// /// - Parameter routes: The `RoutesBuilder` to extend. func register(to routes: RoutesBuilder) { switch operationType { diff --git a/Sources/Corvus/Protocols/RestApi.swift b/Sources/Corvus/Protocols/RestApi.swift index e2a22b0..9536f00 100644 --- a/Sources/Corvus/Protocols/RestApi.swift +++ b/Sources/Corvus/Protocols/RestApi.swift @@ -12,6 +12,9 @@ extension RestApi { /// A default implementation for `boot` that recurses down the API's /// hierarchy. + /// + /// - Parameter routes: The `RoutesBuilder` containing HTTP route + /// information up to this point. public func boot(routes: RoutesBuilder) throws { content.register(to: routes) } diff --git a/Sources/Corvus/Protocols/http/CorvusResponse.swift b/Sources/Corvus/Protocols/http/CorvusResponse.swift index c9c7c46..d7fab75 100644 --- a/Sources/Corvus/Protocols/http/CorvusResponse.swift +++ b/Sources/Corvus/Protocols/http/CorvusResponse.swift @@ -10,5 +10,7 @@ public protocol CorvusResponse: Content { /// Initialises a `CorvusResponse` with a given item. /// Normally this is the result of the `QueryEndpoints`'s handler function. + /// + /// - Parameter item: The item to initialize the response with. init(item: Item) } diff --git a/Tests/CorvusTests/ApplicationTests.swift b/Tests/CorvusTests/ApplicationTests.swift index 3af1b49..c41bd5e 100644 --- a/Tests/CorvusTests/ApplicationTests.swift +++ b/Tests/CorvusTests/ApplicationTests.swift @@ -4,6 +4,7 @@ import FluentSQLiteDriver import XCTVapor import Foundation +// swiftlint:disable file_length type_body_length function_body_length final class ApplicationTests: XCTestCase { func testCreate() throws { @@ -293,7 +294,10 @@ final class ApplicationTests: XCTestCase { var content: Endpoint { Group("api", "accounts") { - Custom(path: "userId", type: .post) { req in + Custom( + pathComponents: "userId", + type: .post + ) { req in let requestContent = try req.content.decode( Account.self ) @@ -403,7 +407,7 @@ final class ApplicationTests: XCTestCase { try app.register(collection: readOneTest) let account = Account(id: nil, name: "Berzan") - var AccountRes: Account! + var accountRes: Account! try app.testable() .test( @@ -412,27 +416,27 @@ final class ApplicationTests: XCTestCase { headers: ["content-type": "application/json"], body: account.encode() ) { res in - AccountRes = try res.content.decode(Account.self) + accountRes = try res.content.decode(Account.self) } - .test(.GET, "/api/accounts/\(AccountRes.id!)") { res in + .test(.GET, "/api/accounts/\(accountRes.id!)") { res in let response = try res.content.decode(Account.self) XCTAssertEqual(res.status, .ok) XCTAssertEqual(response, account) } - .test(.DELETE, "/api/accounts/\(AccountRes.id!)") { res in + .test(.DELETE, "/api/accounts/\(accountRes.id!)") { res in print(res.body.string) XCTAssertEqual(res.status, .ok) } - .test(.GET, "/api/accounts/\(AccountRes.id!)") { res in + .test(.GET, "/api/accounts/\(accountRes.id!)") { res in XCTAssertEqual(res.status, .notFound) } .test( .PATCH, - "/api/accounts/trash/\(AccountRes.id!)/restore" + "/api/accounts/trash/\(accountRes.id!)/restore" ) { res in XCTAssertEqual(res.status, .ok) } - .test(.GET, "/api/accounts/\(AccountRes.id!)") { res in + .test(.GET, "/api/accounts/\(accountRes.id!)") { res in let response = try res.content.decode(Account.self) XCTAssertEqual(res.status, .ok) XCTAssertEqual(response, account) @@ -690,7 +694,9 @@ final class ApplicationTests: XCTestCase { // This response is a more complex example using generics. // This allows for responses which work with any number of models. - struct ReadResponse: CorvusResponse, Equatable { + struct ReadResponse: + CorvusResponse, + Equatable { let success = true let payload: [Model] diff --git a/Tests/CorvusTests/AuthenticationTests.swift b/Tests/CorvusTests/AuthenticationTests.swift index 9940609..265c663 100644 --- a/Tests/CorvusTests/AuthenticationTests.swift +++ b/Tests/CorvusTests/AuthenticationTests.swift @@ -1,9 +1,11 @@ import Corvus import Fluent import FluentSQLiteDriver +import Vapor import XCTVapor import Foundation +// swiftlint:disable file_length type_body_length function_body_length final class AuthenticationTests: XCTestCase { func testBasicAuthenticatorSuccess() throws { @@ -11,7 +13,7 @@ final class AuthenticationTests: XCTestCase { var content: Endpoint { Group("api") { - CRUD("users", softDelete: false) + User("users", softDelete: false) BasicAuthGroup("accounts") { Create() @@ -32,42 +34,35 @@ final class AuthenticationTests: XCTestCase { try app.autoMigrate().wait() try app.register(collection: basicAuthenticatorTest) - let basic = "berzan@corvus.com:pass" + let basic = "berzan:pass" .data(using: .utf8)! .base64EncodedString() let user = CorvusUser( - name: "berzan", - email: "berzan@corvus.com", - password: "pass" + username: "berzan", + passwordHash: try Bcrypt.hash("pass") ) let account = Account(name: "Berzan") - var response: Account! try app.testable() .test( .POST, "/api/users", headers: ["content-type": "application/json"], - body: user.encode(), - afterResponse: { - response = try $0.content.decode(Account.self) - account.id = response.id - } + body: user.encode() ) .test( .POST, "/api/accounts", - headers: ["Authorization": "Basic \(basic)", "content-type": "application/json"], + headers: [ + "Authorization": "Basic \(basic)", + "content-type": "application/json" + ], body: account.encode() ) { res in print(res.body.string) XCTAssertEqual(res.status, .ok) - XCTAssertEqualJSON( - res.body.string, - account - ) } } @@ -76,7 +71,7 @@ final class AuthenticationTests: XCTestCase { var content: Endpoint { Group("api") { - CRUD("users", softDelete: false) + User("users", softDelete: false) BasicAuthGroup("accounts") { Create() @@ -99,13 +94,12 @@ final class AuthenticationTests: XCTestCase { try app.register(collection: basicAuthenticatorTest) let user = CorvusUser( - name: "berzan", - email: "berzan@corvus.com", - password: "pass" + username: "berzan", + passwordHash: try Bcrypt.hash("pass") ) let account = Account(name: "berzan") - let basic = "berzan@corvus.com:wrong" + let basic = "berzan:wrong" .data(using: .utf8)! .base64EncodedString() @@ -131,9 +125,9 @@ final class AuthenticationTests: XCTestCase { var content: Endpoint { Group("api") { - CRUD("users", softDelete: false) + User("users", softDelete: false) - Login("login") + Login("login") BearerAuthGroup("accounts") { Create() @@ -148,6 +142,7 @@ final class AuthenticationTests: XCTestCase { app.databases.use(.sqlite(.memory), as: .test, isDefault: true) app.middleware.use(CorvusToken.authenticator().middleware()) + app.middleware.use(CorvusUser.authenticator().middleware()) app.migrations.add(CreateAccount()) app.migrations.add(CreateCorvusUser()) app.migrations.add(CreateCorvusToken()) @@ -157,13 +152,12 @@ final class AuthenticationTests: XCTestCase { try app.register(collection: bearerAuthenticatorTest) let user = CorvusUser( - name: "berzan", - email: "berzan@corvus.com", - password: "pass" + username: "berzan", + passwordHash: try Bcrypt.hash("pass") ) let account = Account(name: "berzan") - let basic = "berzan@corvus.com:pass" + let basic = "berzan:pass" .data(using: .utf8)! .base64EncodedString() @@ -183,19 +177,22 @@ final class AuthenticationTests: XCTestCase { ) { res in token = try res.content.decode(CorvusToken.self) XCTAssertTrue(true) - } + } .test( - .POST, - "/api/accounts", - headers: ["content-type": "application/json", "Authorization": "Bearer \(token.value)"], - body: account.encode() + .POST, + "/api/accounts", + headers: [ + "content-type": "application/json", + "Authorization": "Bearer \(token.value)" + ], + body: account.encode() ) { res in XCTAssertEqual(res.status, .ok) XCTAssertEqualJSON( res.body.string, account ) - } + } } func testBearerAuthenticatorFailure() throws { @@ -203,9 +200,9 @@ final class AuthenticationTests: XCTestCase { var content: Endpoint { Group("api") { - CRUD("users", softDelete: false) + User("users", softDelete: false) - Login("login") + Login("login") BearerAuthGroup("accounts") { Create() @@ -247,7 +244,7 @@ final class AuthenticationTests: XCTestCase { Group("api") { CRUD("users", softDelete: false) - Login("login") + Login("login") BearerAuthGroup("accounts") { Create() @@ -266,6 +263,7 @@ final class AuthenticationTests: XCTestCase { app.databases.use(.sqlite(.memory), as: .test, isDefault: true) app.middleware.use(CorvusToken.authenticator().middleware()) + app.middleware.use(CorvusUser.authenticator().middleware()) app.migrations.add(CreateSecureAccount()) app.migrations.add(CreateCorvusUser()) app.migrations.add(CreateCorvusToken()) @@ -275,31 +273,29 @@ final class AuthenticationTests: XCTestCase { try app.register(collection: authModifierTest) let user1 = CorvusUser( - name: "berzan", - email: "berzan@corvus.com", - password: "pass" + username: "berzan", + passwordHash: try Bcrypt.hash("pass") ) let user2 = CorvusUser( - name: "paul", - email: "paul@corvus.com", - password: "pass" + username: "paul", + passwordHash: try Bcrypt.hash("pass") ) var account: SecureAccount! - let basic1 = "berzan@corvus.com:pass" + let basic1 = "berzan:pass" .data(using: .utf8)! .base64EncodedString() - let basic2 = "paul@corvus.com:pass" + let basic2 = "paul:pass" .data(using: .utf8)! .base64EncodedString() var token1: CorvusToken! var token2: CorvusToken! var accountRes: SecureAccount! - + try app.testable() .test( .POST, @@ -368,4 +364,222 @@ final class AuthenticationTests: XCTestCase { XCTAssertEqualJSON(res.body.string, account) } } + + func testAuthModifierCustom() throws { + final class AuthModifierTest: RestApi { + + let testParameter = Parameter() + + var content: Endpoint { + Group("api") { + CRUD("users", softDelete: false) + + Login("login") + + BearerAuthGroup("accounts") { + Create() + Group(testParameter.id) { + ReadOne(testParameter.id) + .auth(\.$user) + } + } + } + } + } + + let app = Application(.testing) + defer { app.shutdown() } + let authModifierTest = AuthModifierTest() + + app.databases.use(.sqlite(.memory), as: .test, isDefault: true) + app.middleware.use(CustomToken.authenticator().middleware()) + app.middleware.use(CustomUser.authenticator().middleware()) + app.migrations.add(CreateCustomAccount()) + app.migrations.add(CreateCustomUser()) + app.migrations.add(CreateCustomToken()) + + try app.autoMigrate().wait() + + try app.register(collection: authModifierTest) + + let user1 = CustomUser( + username: "berzan", + surname: "yildiz", + email: "berzan@corvus.com", + passwordHash: try Bcrypt.hash("pass") + ) + + let user2 = CustomUser( + username: "paul", + surname: "schmiedmayer", + email: "paul@corvus.com", + passwordHash: try Bcrypt.hash("pass") + ) + + var account: CustomAccount! + + let basic1 = "berzan:pass" + .data(using: .utf8)! + .base64EncodedString() + + let basic2 = "paul:pass" + .data(using: .utf8)! + .base64EncodedString() + + var token1: CustomToken! + var token2: CustomToken! + var accountRes: CustomAccount! + + try app.testable() + .test( + .POST, + "/api/users", + headers: ["content-type": "application/json"], + body: user1.encode(), + afterResponse: { res in + let userRes = try res.content.decode(CustomUser.self) + account = CustomAccount( + name: "berzan", + userID: userRes.id! + ) + } + ) + .test( + .POST, + "/api/users", + headers: ["content-type": "application/json"], + body: user2.encode() + ) + .test( + .POST, + "/api/login", + headers: ["Authorization": "Basic \(basic1)"] + ) { res in + token1 = try res.content.decode(CustomToken.self) + XCTAssertTrue(true) + } + .test( + .POST, + "/api/login", + headers: ["Authorization": "Basic \(basic2)"] + ) { res in + token2 = try res.content.decode(CustomToken.self) + XCTAssertTrue(true) + } + .test( + .POST, + "/api/accounts", + headers: [ + "content-type": "application/json", + "Authorization": "Bearer \(token1.value)" + ], + body: account.encode() + ) { res in + accountRes = try res.content.decode(CustomAccount.self) + XCTAssertTrue(true) + } + .test( + .GET, + "/api/accounts/\(accountRes.id!)", + headers: [ + "Authorization": "Bearer \(token2.value)" + ] + ) { res in + XCTAssertEqual(res.status, .unauthorized) + } + .test( + .GET, + "/api/accounts/\(accountRes.id!)", + headers: [ + "Authorization": "Bearer \(token1.value)" + ] + ) { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqualJSON(res.body.string, account) + } + } + + func testUserAuthModifier() throws { + final class UserAuthModifierTest: RestApi { + + var content: Endpoint { + Group("api") { + Create() + User("users", softDelete: false) + Login("login") + } + } + } + + let app = Application(.testing) + defer { app.shutdown() } + let userAuthModifierTest = UserAuthModifierTest() + + app.databases.use(.sqlite(.memory), as: .test, isDefault: true) + app.middleware.use(CorvusToken.authenticator().middleware()) + app.middleware.use(CorvusUser.authenticator().middleware()) + app.migrations.add(CreateSecureAccount()) + app.migrations.add(CreateCorvusUser()) + app.migrations.add(CreateCorvusToken()) + + try app.autoMigrate().wait() + + try app.register(collection: userAuthModifierTest) + + let user1 = CorvusUser( + username: "berzan", + passwordHash: try Bcrypt.hash("pass") + ) + + let user2 = CorvusUser( + username: "paul", + passwordHash: try Bcrypt.hash("pass") + ) + + let basic1 = "berzan:pass" + .data(using: .utf8)! + .base64EncodedString() + + let basic2 = "paul:pass" + .data(using: .utf8)! + .base64EncodedString() + + var userRes: CorvusUser! + + try app.testable() + .test( + .POST, + "/api", + headers: ["content-type": "application/json"], + body: user1.encode(), + afterResponse: { res in + userRes = try res.content.decode(CorvusUser.self) + } + ) + .test( + .POST, + "/api/users", + headers: ["content-type": "application/json"], + body: user2.encode() + ) + .test( + .GET, + "/api/users/\(userRes.id!)", + headers: [ + "Authorization": "Basic \(basic2)" + ] + ) { res in + XCTAssertEqual(res.status, .unauthorized) + } + .test( + .GET, + "/api/users/\(userRes.id!)", + headers: [ + "Authorization": "Basic \(basic1)" + ] + ) { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqualJSON(res.body.string, user1) + } + } } diff --git a/Tests/CorvusTests/Models/CustomAccount.swift b/Tests/CorvusTests/Models/CustomAccount.swift new file mode 100644 index 0000000..a60888a --- /dev/null +++ b/Tests/CorvusTests/Models/CustomAccount.swift @@ -0,0 +1,63 @@ +import Corvus +import Fluent +import Foundation + +final class CustomAccount: CorvusModel { + + static let schema = "custom_accounts" + + @ID + var id: UUID? { + didSet { + $id.exists = true + } + } + + @Field(key: "name") + var name: String + + @Parent(key: "user_id") + var user: CustomUser + + @Children(for: \.$account) + var transactions: [CustomTransaction] + + init(id: UUID? = nil, name: String, userID: CustomUser.IDValue) { + self.id = id + self.name = name + self.$user.id = userID + } + + init() {} +} + +struct CreateCustomAccount: Migration { + + func prepare(on database: Database) -> EventLoopFuture { + return database.schema(CustomAccount.schema) + .id() + .field("name", .string, .required) + .field( + "user_id", + .uuid, + .references(CustomUser.schema, "id") + ) + .create() + } + + func revert(on database: Database) -> EventLoopFuture { + return database.schema(CustomAccount.schema).delete() + } +} + +extension CustomAccount: Equatable { + static func == (lhs: CustomAccount, rhs: CustomAccount) -> Bool { + var result = lhs.name == rhs.name + + if let lhsId = lhs.id, let rhsId = rhs.id { + result = result && lhsId == rhsId + } + + return result + } +} diff --git a/Tests/CorvusTests/Models/CustomToken.swift b/Tests/CorvusTests/Models/CustomToken.swift new file mode 100644 index 0000000..074b3dd --- /dev/null +++ b/Tests/CorvusTests/Models/CustomToken.swift @@ -0,0 +1,60 @@ +import Corvus +import Foundation +import Fluent + +public final class CustomToken: CorvusModelUserToken { + + public static let schema = "custom_tokens" + + @ID + public var id: UUID? + + @Field(key: "value") + public var value: String + + @Parent(key: "user_id") + public var user: CustomUser + + @Timestamp(key: "deleted_at", on: .delete) + var deletedAt: Date? + + public init() { } + + + public init(id: UUID? = nil, value: String, userID: CustomUser.IDValue) { + self.id = id + self.value = value + self.$user.id = userID + } +} + +public struct CreateCustomToken: Migration { + + public init() {} + + public func prepare(on database: Database) -> EventLoopFuture { + database.schema(CustomToken.schema) + .id() + .field("value", .string, .required) + .field( + "user_id", + .uuid, + .required, + .references(CustomUser.schema, .id) + ) + .field("deleted_at", .date) + .unique(on: "value") + .create() + } + + public func revert(on database: Database) -> EventLoopFuture { + database.schema(CustomToken.schema).delete() + } +} + +extension CustomToken { + + public var isValid: Bool { + true + } +} diff --git a/Tests/CorvusTests/Models/CustomTransaction.swift b/Tests/CorvusTests/Models/CustomTransaction.swift new file mode 100644 index 0000000..ce3a843 --- /dev/null +++ b/Tests/CorvusTests/Models/CustomTransaction.swift @@ -0,0 +1,70 @@ +import Corvus +import Fluent +import Foundation + +final class CustomTransaction: CorvusModel { + + static let schema = "custom_transactions" + + @ID(key: .id) + var id: UUID? + + @Field(key: "amount") + var amount: Double + + @Field(key: "currency") + var currency: String + + @Field(key: "date") + var date: Date + + @Parent(key: "account_id") + var account: CustomAccount + + init( + id: UUID? = nil, + amount: Double, + currency: String, + date: Date, + accountID: CustomAccount.IDValue + ) { + self.id = id + self.amount = amount + self.currency = currency + self.date = date + self.$account.id = accountID + } + + init() {} +} + +struct CreateCustomTransaction: Migration { + + func prepare(on database: Database) -> EventLoopFuture { + return database.schema(CustomTransaction.schema) + .id() + .field("amount", .double, .required) + .field("currency", .string, .required) + .field("date", .datetime, .required) + .field("account_id", .uuid, .references(CustomAccount.schema, .id)) + .create() + } + + func revert(on database: Database) -> EventLoopFuture { + return database.schema(CustomAccount.schema).delete() + } +} + +extension CustomTransaction: Equatable { + static func == (lhs: CustomTransaction, rhs: CustomTransaction) -> Bool { + var result = lhs.amount == rhs.amount + && lhs.currency == rhs.currency + && lhs.$account.id == rhs.$account.id + + if let lhsID = lhs.id, let rhsID = rhs.id { + result = result && lhsID == rhsID + } + + return result + } +} diff --git a/Tests/CorvusTests/Models/CustomUser.swift b/Tests/CorvusTests/Models/CustomUser.swift new file mode 100644 index 0000000..3b2ad87 --- /dev/null +++ b/Tests/CorvusTests/Models/CustomUser.swift @@ -0,0 +1,84 @@ +import Corvus +import Fluent +import Vapor +import Foundation + +public final class CustomUser: CorvusModel { + + public static let schema = "custom_users" + + @ID + public var id: UUID? + + @Field(key: "username") + public var username: String + + @Field(key: "surname") + public var surname: String + + @Field(key: "email") + public var email: String + + @Field(key: "password_hash") + public var passwordHash: String + + @Timestamp(key: "deleted_at", on: .delete) + var deletedAt: Date? + + public init() { } + + public init( + id: UUID? = nil, + username: String, + surname: String, + email: String, + passwordHash: String + ) { + self.id = id + self.username = username + self.surname = surname + self.email = email + self.passwordHash = passwordHash + } +} + +public struct CreateCustomUser: Migration { + + public init() {} + + public func prepare(on database: Database) -> EventLoopFuture { + database.schema(CustomUser.schema) + .id() + .field("username", .string, .required) + .field("surname", .string, .required) + .field("email", .string, .required) + .field("password_hash", .string, .required) + .field("deleted_at", .date) + .create() + } + + public func revert(on database: Database) -> EventLoopFuture { + database.schema(CustomUser.schema).delete() + } +} + +extension CustomUser: CorvusModelUser { + + public static let usernameKey = \CustomUser.$username + + public static let passwordHashKey = \CustomUser.$passwordHash + + public func verify(password: String) throws -> Bool { + try Bcrypt.verify(password, created: self.passwordHash) + } +} + +extension CustomUser { + + public func generateToken() throws -> CorvusToken { + try .init( + value: [UInt8].random(count: 16).base64, + userID: self.requireID() + ) + } +} diff --git a/Tests/CorvusTests/Utilities/CorvusUser+Equatable.swift b/Tests/CorvusTests/Utilities/CorvusUser+Equatable.swift new file mode 100644 index 0000000..27602a6 --- /dev/null +++ b/Tests/CorvusTests/Utilities/CorvusUser+Equatable.swift @@ -0,0 +1,14 @@ +import Corvus + +extension CorvusUser: Equatable { + + public static func == (lhs: CorvusUser, rhs: CorvusUser) -> Bool { + var result = lhs.username == rhs.username + + if let lhsId = lhs.id, let rhsId = rhs.id { + result = result && lhsId == rhsId + } + + return result + } +}