diff --git a/Package.swift b/Package.swift index 6b7b76a..9566fb6 100644 --- a/Package.swift +++ b/Package.swift @@ -9,19 +9,22 @@ let package = Package( .executable(name: "memcache-swift-example", targets: ["memcache-swift-example"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.40.0") + .package(url: "https://github.com/apple/swift-nio.git", from: "2.40.0"), + .package(url: "https://github.com/swift-extras/swift-extras-base64.git", from: "0.7.0") ], targets: [ .target( name: "Memcache", dependencies: [ - .product(name: "NIO", package: "swift-nio") + .product(name: "NIO", package: "swift-nio"), + .product(name: "ExtrasBase64", package: "swift-extras-base64") ] ), .testTarget( name: "MemcacheTests", dependencies: [ - .target(name: "Memcache") + .target(name: "Memcache"), + .product(name: "NIOTestUtils", package: "swift-nio") ] ), diff --git a/Sources/Memcache/Extensions/ByteBuffer+Memcache.swift b/Sources/Memcache/Extensions/ByteBuffer+Memcache.swift new file mode 100644 index 0000000..88086ac --- /dev/null +++ b/Sources/Memcache/Extensions/ByteBuffer+Memcache.swift @@ -0,0 +1,54 @@ +import NIOCore + +extension ByteBuffer { + func _getCarriageReturnNewlineTerminatedSliceLength(at index: Int) -> Int? { + guard readerIndex <= index && index < writerIndex - 1 else { + return nil + } + + var subview = readableBytesView[index...] + repeat { + guard let carriageReturnIndex = subview.firstIndex(of: UInt8(ascii: "\r")) else { + return nil + } + let expectedNewlineIndex = carriageReturnIndex + 1 + + if subview.endIndex > expectedNewlineIndex, + subview[expectedNewlineIndex] == UInt8(ascii: "\n") { + return (expectedNewlineIndex - 1) &- index + } else { + subview = readableBytesView[expectedNewlineIndex...] + } + } while subview.count >= 2 + + return nil + } + + /// Read a slice off this `ByteBuffer` that is terminated with `\r\n`. Move the reader index forward by the slices length and + /// it's two terminator characters. + /// + /// - Returns: A `ByteBuffer` slice of this `ByteBuffer` or `nil` if there isn't a complete `\r\n`-terminated slice, including + /// terminators, in the readable bytes of the buffer. The returned slice does not include the terminators. + mutating func readCarriageReturnNewlineTerminatedSlice() -> ByteBuffer? { + guard let sliceLength = _getCarriageReturnNewlineTerminatedSliceLength(at: readerIndex) else { + return nil + } + let result = readSlice(length: sliceLength) + moveReaderIndex(forwardBy: 2) // move forward by \r\n + return result + } + + /// Get a slice at `index` from this `ByteBuffer` that is terminated with `\r\n`. Does not move the reader index. + /// The selected bytes must be readable or else `nil` will be returned. + /// + /// - Parameters: + /// - index: The starting index into `ByteBuffer` containing the `\r\n`-terminated slice of interest. + /// - Returns: A `ByteBuffer` slice of this `ByteBuffer` or `nil` if there isn't a complete `\r\n`-terminated slice, including + /// terminators, in the readable bytes after `index` in the buffer. The returned slice does not include the terminators. + func getCarriageReturnNewlineTerminatedSlice(at index: Int) -> ByteBuffer? { + guard let sliceLength = _getCarriageReturnNewlineTerminatedSliceLength(at: index) else { + return nil + } + return getSlice(at: index, length: sliceLength) + } +} diff --git a/Sources/Memcache/Extensions/UInt8+characters.swift b/Sources/Memcache/Extensions/UInt8+characters.swift new file mode 100644 index 0000000..a0a056a --- /dev/null +++ b/Sources/Memcache/Extensions/UInt8+characters.swift @@ -0,0 +1,5 @@ +extension UInt8 { + static let newline = UInt8(ascii: "\n") + static let carriageReturn = UInt8(ascii: "\r") + static let space = UInt8(ascii: " ") +} diff --git a/Sources/Memcache/Flags/MemcacheFlag.swift b/Sources/Memcache/Flags/MemcacheFlag.swift new file mode 100644 index 0000000..06ad48f --- /dev/null +++ b/Sources/Memcache/Flags/MemcacheFlag.swift @@ -0,0 +1,300 @@ +/// Flags for Meta Get, Set, Delete, and Arithmetic commands. Meta Debug and Meta No-Op don't support flags. +enum MemcacheFlag { + // MARK: Common flags + + /// Interpret key as base64 encoded binary value. + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Set** + /// - **Meta Delete** + /// - **Meta Arithmetic** + case b + + /// Return item CAS token. + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Set**: Return item's CAS token if successfully stored. + /// - **Meta Arithmetic**: Return item's CAS token if successfully stored. + case c + + /// Return key as a token. + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Set** + /// - **Meta Delete** + case k + + /// Opaque value, consumes a token and copies back with response. + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Set** + /// - **Meta Delete** + case O(OpaqueToken) + + /// Use noreply semantics for return codes. + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Set** + /// - **Meta Delete** + /// - **Meta Arithmetic** + case q + + /// Return item value in \. + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Arithmetic**: Return new value. + case v + + /// Return item TTL remaining in seconds (-1 for unlimited). + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Arithmetic** + case t + + /// Update remaining TTL. + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Set** + /// - **Meta Delete**: Updates TTL, only when paired with the `.I` flag. + /// - **Meta Arithmetic**: Update TTL on success. + case T(TTLToken) + + /// Compare CAS value. + /// + /// Available for the following commands: + /// - **Meta Set**: Compare CAS value when storing item. + /// - **Meta Delete** + /// - **Meta Arithmetic** + case C(NumericToken) + + /// Invalidate: set to invalid if supplied CAS is older than item's CAS. + /// + /// Available for the following commands: + /// - **Meta Set** + /// - **Meta Delete** + case I + + /// Vivify on miss, takes TTL as a argument. + /// + /// Available for the following commands: + /// - **Meta Get** + /// - **Meta Arithmetic**: Auto-create item on miss with supplied TTL. + case N(TTLToken) + + /// Mode switch. + /// + /// Available for the following commands: + /// - **Meta Set**: Mode switch to change behavior to add, replace, append, prepend. + /// - **Meta Arithmetic**: Mode switch to change between incr and decr modes. + case M(ModeToken) + + // MARK: 'Get'-only flags + + /// Return client flags token. + /// + /// Available for the following commands: + /// - **Meta Get** + case f + + /// Return whether item has been hit before as a 0 or 1. + /// + /// Available for the following commands: + /// - **Meta Get** + case h + + /// Return time since item was last accessed in seconds. + /// + /// Available for the following commands: + /// - **Meta Get** + case l + + /// Return item size token. + /// + /// Available for the following commands: + /// - **Meta Get** + case s + + /// Don't bump the item in the LRU. + /// + /// Available for the following commands: + /// - **Meta Get** + case u + + /// If token is less than remaining TTL win for recache. + /// + /// Available for the following commands: + /// - **Meta Get** + case R(TTLToken) + + /// Client has "won" the recache flag. + /// + /// Available for the following commands: + /// - **Meta Get** + case W + + /// Item is stale. + /// + /// Available for the following commands: + /// - **Meta Get** + case X + + /// Item has already sent a winning flag. + /// + /// Available for the following commands: + /// - **Meta Get** + case Z + + // MARK: 'Set'-only flags + + /// Set client flags to token. + /// + /// Available for the following commands: + /// - **Meta Set** + case F(NumericToken) // TODO: Docs say '32 bit unsigned numeric' + + // MARK: 'Delete'-only flags + + // No 'Delete'-only flags + + // MARK: 'Arithmetic'-only flags + + /// Initial value to use if auto created after miss (default 0). + /// + /// Available for the following commands: + /// - **Meta Arithmetic** + case J(NumericToken) + + /// Delta to apply (default 1). + /// + /// Available for the following commands: + /// - **Meta Arithmetic** + case D(NumericToken) // TODO: Docs say 'unsigned decimal number' + + // MARK: Ignored tokens + + /// This flag is completely ignored by the memcached daemon. It can be used as a hint or path specification to a proxy or + /// router inbetween a client and the memcached daemon. + case P(StringToken) + + /// This flag is completely ignored by the memcached daemon. It can be used as a hint or path specification to a proxy or + /// router inbetween a client and the memcached daemon. + case L(StringToken) +} + +extension MemcacheFlag: CustomDebugStringConvertible { + var debugDescription: String { + switch self { + case .b: + return ".b" + case .c: + return ".c" + case .k: + return ".k" + case let .O(token): + return ".O(\(token))" + case .q: + return ".q" + case .v: + return ".v" + case .t: + return ".t" + case let .T(token): + return ".T(\(token))" + case let .C(token): + return ".C(\(token))" + case .I: + return ".I" + case let .N(token): + return ".N(\(token))" + case let .M(token): + return ".M(\(token))" + case .f: + return ".f" + case .h: + return ".h" + case .l: + return ".l" + case .s: + return ".s" + case .u: + return ".u" + case let .R(token): + return ".R(\(token))" + case .W: + return ".W" + case .X: + return ".X" + case .Z: + return ".Z" + case let .F(token): + return ".F(\(token))" + case let .J(token): + return ".J(\(token))" + case let .D(token): + return ".D(\(token))" + case let .P(token): + return ".P(\(token))" + case let .L(token): + return ".L(\(token))" + } + } +} + +// MARK: - + +extension MemcacheFlag { + enum Code: Character { + // MARK: Common flags + + case b = "b" + case c = "c" + case k = "k" + case O = "O" + case q = "q" + case v = "v" + case t = "t" + case T = "T" + case C = "C" + case I = "I" + case N = "N" + case M = "M" + + // MARK: 'Get'-only flags + + case f = "f" + case h = "h" + case l = "l" + case s = "s" + case u = "u" + case R = "R" + case W = "W" + case X = "X" + case Z = "Z" + + // MARK: 'Set'-only flags + + case F = "F" + + // MARK: 'Delete'-only flags + + // No 'Delete'-only flags + + // MARK: 'Arithmetic'-only flags + + case J = "J" + case D = "D" + + // MARK: Ignored flags + + case P = "P" + case L = "L" + } +} diff --git a/Sources/Memcache/Flags/MemcacheFlags+Tokens.swift b/Sources/Memcache/Flags/MemcacheFlags+Tokens.swift new file mode 100644 index 0000000..34a0883 --- /dev/null +++ b/Sources/Memcache/Flags/MemcacheFlags+Tokens.swift @@ -0,0 +1,126 @@ +extension MemcacheFlag { + /// A string token without any constraints. + struct StringToken: ExpressibleByStringLiteral, CustomDebugStringConvertible { + var value: String + + init(stringLiteral value: String) { + self.value = value + } + + var debugDescription: String { + "string: \(value)" + } + } + + /// A numeric token. + struct NumericToken: CustomDebugStringConvertible { + var value: Value + + var debugDescription: String { + "numeric: \(value)" + } + } + + /// Opaque tokens may be up to 32 bytes in length. + struct OpaqueToken: ExpressibleByStringLiteral, CustomDebugStringConvertible { + var value: String + + init(stringLiteral value: StringLiteralType) { + self.value = value + } + + var debugDescription: String { + "opaque: \(value)" + } + } + + /// A numeric token representing a TTL value. + struct TTLToken: ExpressibleByIntegerLiteral, CustomDebugStringConvertible { + let value: UInt32 + + init(integerLiteral value: UInt32) { + self.value = value + } + + var debugDescription: String { + "ttl: \(value)" + } + } + + /// Mode switch token used in Set and Arithmetic commands. + enum ModeToken: RawRepresentable, CustomDebugStringConvertible { + typealias RawValue = Character + + // MARK: 'Set'-modes + + case add + case append + case prepend + case replace + case set + + // MARK: 'Arithmetic'-modes + + case increment + case decrement + + init?(rawValue: Character) { + switch rawValue { + case "E": + self = .add + case "A": + self = .append + case "P": + self = .prepend + case "R": + self = .replace + case "S": + self = .set + case "I", "+": + self = .increment + case "D", "-": + self = .decrement + default: + return nil + } + } + + var rawValue: Character { + switch self { + case .add: + return "E" + case .append: + return "A" + case .prepend: + return "P" + case .replace: + return "R" + case .set: + return "S" + case .increment: + return "I" + case .decrement: + return "D" + } + } + + var debugDescription: String { + switch self { + case .add: + return ".add" + case .append: + return ".append" + case .prepend: + return ".prepend" + case .replace: + return ".replace" + case .set: + return ".set" + case .increment: + return ".increment" + case .decrement: + return ".decrement" + } + } + } +} diff --git a/Sources/Memcache/Memcache.swift b/Sources/Memcache/Memcache.swift deleted file mode 100644 index e69de29..0000000 diff --git a/Sources/Memcache/MemcacheBackendMessageDecoder.swift b/Sources/Memcache/MemcacheBackendMessageDecoder.swift new file mode 100644 index 0000000..993387b --- /dev/null +++ b/Sources/Memcache/MemcacheBackendMessageDecoder.swift @@ -0,0 +1,59 @@ +import Foundation +import NIOCore + +struct MemcacheBackendMessageDecoder: NIOSingleStepByteToMessageDecoder { + typealias InboundOut = MemcacheBackendMessage + + func decode(buffer: inout ByteBuffer) throws -> MemcacheBackendMessage? { + // Keep a copy in case we later notice that we need more data + var peekableBuffer = buffer + + // Peek at the message to read the verb. It is before the first \r\n and before the first if the message + // contains one. + guard let textLine = peekableBuffer.getCarriageReturnNewlineTerminatedSlice(at: peekableBuffer.readerIndex) else { + return nil // wait for more bytes + } + + // The verb is either the entire text line or the part before the first + let verbLength = (textLine.readableBytesView.firstIndex(of: .space) ?? textLine.writerIndex) - textLine.readerIndex + + guard let verbString = textLine.getString(at: textLine.readerIndex, length: verbLength) else { + // If we can't read a string, the text line must be empty (i.e. no characters before the first occurence of \r\n) + throw MemcacheDecodingError.emptyMessageReceived(bytes: peekableBuffer) + } + + guard let verb = MemcacheBackendMessage.Verb(rawValue: verbString) else { + throw MemcacheDecodingError.unknownVerbReceived(messageVerb: verbString, messageBytes: peekableBuffer) + } + + // Move the peekable buffer's readerIndex to after the verb so we can continue reading flags and/or data. + peekableBuffer.moveReaderIndex(forwardBy: verbLength) + + if peekableBuffer.readableBytesView.first == .space { + // Move the reader index to after the that is following the verb + peekableBuffer.moveReaderIndex(forwardBy: 1) + } + + do { + // Pass the entire buffer instead of the text line because .value messages continue after the first \r\n + let result = try MemcacheBackendMessage.decode(from: &peekableBuffer, for: verb) + + // Message was read successfully, write new reader index back + buffer = peekableBuffer + + // TODO: Can we make sure the message was read entirely? Difficult because we don't know the length of VA messages here. + return result + } catch _ as MemcacheNeedMoreDataError { + // A message decoder expects more data. Try again. + return nil + } catch let error as MemcachePartialDecodingError { + throw MemcacheDecodingError.withPartialError(error, messageVerb: verb.rawValue, messageBytes: peekableBuffer) + } catch { + preconditionFailure("Expected to only see `MemcachePartialDecodingError` here.") + } + } + + func decodeLast(buffer: inout ByteBuffer, seenEOF: Bool) throws -> MemcacheBackendMessage? { + try self.decode(buffer: &buffer) + } +} diff --git a/Sources/Memcache/MemcacheDecodingError.swift b/Sources/Memcache/MemcacheDecodingError.swift new file mode 100644 index 0000000..0376582 --- /dev/null +++ b/Sources/Memcache/MemcacheDecodingError.swift @@ -0,0 +1,53 @@ +import ExtrasBase64 +import NIOCore + +struct MemcacheDecodingError: Error { + let messageVerb: String + let payload: String + let description: String + let file: String + let line: UInt + + static func withPartialError( + _ partialError: MemcachePartialDecodingError, + messageVerb: String, + messageBytes: ByteBuffer + ) -> Self { + MemcacheDecodingError( + messageVerb: messageVerb, + payload: String(base64Encoding: messageBytes.readableBytesView), + description: partialError.description, + file: partialError.file, + line: partialError.line + ) + } + + static func emptyMessageReceived( + bytes: ByteBuffer, + file: String = #fileID, + line: UInt = #line + ) -> Self { + MemcacheDecodingError( + messageVerb: "", + payload: String(base64Encoding: bytes.readableBytesView), + description: "Received an empty message (i.e. no characters before the first occurence of \r\n). A valid message has to contain a messageVerb at least.", + file: file, + line: line + ) + } + + static func unknownVerbReceived( + messageVerb: String, + messageBytes: ByteBuffer, + file: String = #fileID, + line: UInt = #line + ) -> Self { + MemcacheDecodingError( + messageVerb: messageVerb, + payload: String(base64Encoding: messageBytes.readableBytesView), + description: "Received a message with messageVerb '\(messageVerb)'. There is no message type associated with this message identifier.", + file: file, + line: line + ) + } +} diff --git a/Sources/Memcache/MemcacheNeedMoreDataError.swift b/Sources/Memcache/MemcacheNeedMoreDataError.swift new file mode 100644 index 0000000..ff8a554 --- /dev/null +++ b/Sources/Memcache/MemcacheNeedMoreDataError.swift @@ -0,0 +1 @@ +struct MemcacheNeedMoreDataError: Error {} diff --git a/Sources/Memcache/MemcachePartialDecodingError.swift b/Sources/Memcache/MemcachePartialDecodingError.swift new file mode 100644 index 0000000..6088196 --- /dev/null +++ b/Sources/Memcache/MemcachePartialDecodingError.swift @@ -0,0 +1,59 @@ +import NIOCore + +struct MemcachePartialDecodingError: Error { + let description: String + let file: String + let line: UInt + + static func expectedExactlyNRemainingCharacters( + _ expected: Int, + actual: Int, + file: String = #fileID, + line: UInt = #line + ) -> Self { + MemcachePartialDecodingError( + description: "Expected exactly '\(expected)' but found '\(actual)' remaining characters", + file: file, + line: line + ) + } + + static func expectedAtLeastNRemainingCharacters( + _ expected: Int, + actual: Int, + file: String = #fileID, + line: UInt = #line + ) -> Self { + MemcachePartialDecodingError( + description: "Expected at least '\(expected)' but found '\(actual)' remaining characters", + file: file, + line: line + ) + } + + static func expectedAtMostNRemainingCharacters( + _ expected: Int, + actual: Int, + file: String = #fileID, + line: UInt = #line + ) -> Self { + MemcachePartialDecodingError( + description: "Expected at most '\(expected)' but found '\(actual)' remaining characters", + file: file, + line: line + ) + } + + static func fieldNotDecodable( + as type: Any.Type, + from string: String, + file: String = #fileID, + line: UInt = #line + ) -> Self { + MemcachePartialDecodingError( + description: "Could not decode '\(type)' from '\(string)'", + file: file, + line: line + ) + } +} diff --git a/Sources/Memcache/Messages/Backend+ErrorMessage.swift b/Sources/Memcache/Messages/Backend+ErrorMessage.swift new file mode 100644 index 0000000..ca8667e --- /dev/null +++ b/Sources/Memcache/Messages/Backend+ErrorMessage.swift @@ -0,0 +1,32 @@ +import NIOCore + +extension MemcacheBackendMessage { + struct ErrorMessage: MemcacheMessagePayloadDecodable, ExpressibleByStringLiteral { + let message: String + + init(stringLiteral value: String) { + self.message = value + } + + static func decode(from buffer: inout ByteBuffer) throws -> Self { + // An error message is always the last part of a text line, which is terminated by \r\n. + guard var messageSlice = buffer.readCarriageReturnNewlineTerminatedSlice() else { + preconditionFailure("Expected to only see messages that contain \r\n here.") + } + + guard messageSlice.readableBytes > 0 else { + throw MemcachePartialDecodingError.expectedAtLeastNRemainingCharacters(1, actual: messageSlice.readableBytes) + } + + let messageString = messageSlice.readString(length: messageSlice.readableBytes)! + + return ErrorMessage(stringLiteral: messageString) + } + } +} + +extension MemcacheBackendMessage.ErrorMessage: CustomDebugStringConvertible { + var debugDescription: String { + "message: \"\(message)\"" + } +} diff --git a/Sources/Memcache/Messages/Backend+Flags.swift b/Sources/Memcache/Messages/Backend+Flags.swift new file mode 100644 index 0000000..1c78770 --- /dev/null +++ b/Sources/Memcache/Messages/Backend+Flags.swift @@ -0,0 +1,168 @@ +import NIOCore + +extension MemcacheBackendMessage { + struct Flags: MemcacheMessagePayloadDecodable, ExpressibleByArrayLiteral { + let flags: [MemcacheFlag] + + init(_ flags: [MemcacheFlag]) { + self.flags = flags + } + + init(arrayLiteral elements: MemcacheFlag...) { + flags = elements + } + + /// Decode flags from any backend message. + /// + /// The following formats can be decoded from the `buffer`: + /// - `\r\n`. Flags are space-separated strings. + /// - `\r\n`. No flags. + static func decode(from buffer: inout ByteBuffer) throws -> Self { + // Flags are always the last part of a message, which is terminated by \r\n. + // Because we get passed a potentially longer buffer here, we parse until \r\n. + guard var flagsSlice = buffer.readCarriageReturnNewlineTerminatedSlice() else { + // No \r\n? Something went terribly wrong... + preconditionFailure("Expected to only see messages that contain \r\n here.") + } + + // Flags can always be empty + guard flagsSlice.readableBytes > 0 else { return [] } + + // The slice now only contains the flags separated by + let flagsString = flagsSlice.readString(length: flagsSlice.readableBytes)! + + return try Flags( + flagsString + .split(separator: " ") + .map { flag in + guard let codeCharacter = flag.first, + let code = MemcacheFlag.Code(rawValue: codeCharacter) + else { + throw MemcachePartialDecodingError.fieldNotDecodable(as: MemcacheFlag.Code.self, from: String(flag)) + } + return try .decode(from: flag.dropFirst(), for: code) + } + ) + } + } +} + +extension MemcacheBackendMessage.Flags: CustomDebugStringConvertible { + var debugDescription: String { + "flags: [" + + flags + .map(String.init(describing:)) + .joined(separator: ", ") + + "]" + } +} + +// MARK: - Flag decoding + +extension MemcacheFlag { + static func decode(from substring: Substring, for code: Code) throws -> MemcacheFlag { + switch code { + case .b: + return .b + case .c: + return .c + case .k: + return .k + case .O: + return try .O(.decode(from: substring)) + case .q: + return .q + case .v: + return .v + case .t: + return .t + case .T: + return try .T(.decode(from: substring)) + case .C: + return try .C(.decode(from: substring)) + case .I: + return .I + case .N: + return try .N(.decode(from: substring)) + case .M: + return try .M(.decode(from: substring)) + case .f: + return .f + case .h: + return .h + case .l: + return .l + case .s: + return .s + case .u: + return .u + case .R: + return try .R(.decode(from: substring)) + case .W: + return .W + case .X: + return .X + case .Z: + return .Z + case .F: + return try .F(.decode(from: substring)) + case .J: + return try .J(.decode(from: substring)) + case .D: + return try .D(.decode(from: substring)) + case .P: + return try .P(.decode(from: substring)) + case .L: + return try .L(.decode(from: substring)) + } + } +} + +// MARK: - Token types + +protocol MemcacheMessageFlagTokenDecodable { + static func decode(from substring: Substring) throws -> Self +} + +extension MemcacheFlag.StringToken: MemcacheMessageFlagTokenDecodable { + static func decode(from substring: Substring) throws -> Self { + return Self(stringLiteral: String(substring)) + } +} + +extension MemcacheFlag.NumericToken: MemcacheMessageFlagTokenDecodable where Value: LosslessStringConvertible { + static func decode(from substring: Substring) throws -> Self { + guard let numericValue = Value(String(substring)) else { + throw MemcachePartialDecodingError.fieldNotDecodable(as: Value.self, from: String(substring)) + } + return Self(value: numericValue) + } +} + +extension MemcacheFlag.OpaqueToken: MemcacheMessageFlagTokenDecodable { + static func decode(from substring: Substring) throws -> Self { + guard substring.count <= 32 else { + throw MemcachePartialDecodingError.expectedAtMostNRemainingCharacters(32, actual: substring.count) + } + return Self(stringLiteral: String(substring)) + } +} + +extension MemcacheFlag.TTLToken: MemcacheMessageFlagTokenDecodable { + static func decode(from substring: Substring) throws -> Self { + let numeric = try MemcacheFlag.NumericToken.decode(from: substring) + return Self(integerLiteral: numeric.value) + } +} + +extension MemcacheFlag.ModeToken: MemcacheMessageFlagTokenDecodable { + static func decode(from substring: Substring) throws -> Self { + guard substring.count == 1 else { + throw MemcachePartialDecodingError.expectedExactlyNRemainingCharacters(1, actual: substring.count) + } + guard let token = Self(rawValue: substring.first!) else { + throw MemcachePartialDecodingError.fieldNotDecodable(as: Self.self, from: String(substring)) + } + return token + } +} diff --git a/Sources/Memcache/Messages/Backend+Value.swift b/Sources/Memcache/Messages/Backend+Value.swift new file mode 100644 index 0000000..50e1fa3 --- /dev/null +++ b/Sources/Memcache/Messages/Backend+Value.swift @@ -0,0 +1,58 @@ +import ExtrasBase64 +import NIOCore + +extension MemcacheBackendMessage { + struct Value: MemcacheMessagePayloadDecodable { + var flags: Flags + var data: ByteBuffer + + /// Decode a `VA` backend message. + /// + /// The message can have the following formats: + /// - ` \r\n\r\n`. Flags are space-separated strings. + /// - `\r\n\r\n` + static func decode(from buffer: inout ByteBuffer) throws -> Self { + // Decode the size of the data block, optional flags, and the data block itself + guard let valueMetaSlice = buffer.getCarriageReturnNewlineTerminatedSlice(at: buffer.readerIndex) else { + // No \r\n? Something went terribly wrong... + preconditionFailure("Expected to only see messages that contain \r\n here.") + } + + // The size value in valueMetaSlice is either the entire slice or the part before the first + let endSizeIndex = valueMetaSlice.readableBytesView.firstIndex(of: .space) ?? valueMetaSlice.writerIndex + let sizeString = valueMetaSlice.getString(at: valueMetaSlice.readerIndex, length: endSizeIndex)! + + guard let size = Int(sizeString) else { + throw MemcachePartialDecodingError.fieldNotDecodable(as: Int.self, from: sizeString) + } + + // Move the buffer's readerIndex to after the size so we can continue reading flags and/or data. + buffer.moveReaderIndex(forwardBy: endSizeIndex) + + if buffer.readableBytesView.first == .space { + // Move the reader index to after the that is following the size + buffer.moveReaderIndex(forwardBy: 1) + } + + let flags = try Flags.decode(from: &buffer) + + guard let dataBlock = buffer.readSlice(length: size) else { + // Tell the decoder that we expect more data + throw MemcacheNeedMoreDataError() + } + + // Make sure we received the final terminating \r\n. + guard let _ = buffer.readCarriageReturnNewlineTerminatedSlice() else { + throw MemcacheNeedMoreDataError() + } + + return Value(flags: flags, data: dataBlock) + } + } +} + +extension MemcacheBackendMessage.Value: CustomDebugStringConvertible { + var debugDescription: String { + return "\(flags), data: \(String(base64Encoding: data.readableBytesView))" + } +} diff --git a/Sources/Memcache/Messages/MemcacheBackendMessage.swift b/Sources/Memcache/Messages/MemcacheBackendMessage.swift new file mode 100644 index 0000000..3975d7c --- /dev/null +++ b/Sources/Memcache/Messages/MemcacheBackendMessage.swift @@ -0,0 +1,151 @@ +import NIOCore + +protocol MemcacheMessagePayloadDecodable { + static func decode(from buffer: inout ByteBuffer) throws -> Self +} + +enum MemcacheBackendMessage { + /// Header (`HD *\r\n`) + case header(Flags) + + /// Not found (`NF *\r\n`) + case notFound(Flags) + + /// Not stored (`NS *\r\n`) + case notStored(Flags) + + /// Exists (`EX *\r\n`) + case exists(Flags) + + /// Value (`VA *\r\n\r\n`) + case value(Value) + + /// End (`EN\r\n`) + case end + + /// Nonexistent command name (`ERROR\r\n`) + case nonExistentCommandError + + /// Client error (`CLIENT_ERROR \r\n`) + case clientError(ErrorMessage) + + /// Server error (`SERVER_ERROR \r\n`) + case serverError(ErrorMessage) +} + +extension MemcacheBackendMessage { + enum Verb: RawRepresentable { + typealias RawValue = String + + case header + case notFound + case notStored + case exists + case value + case end + + case nonExistentCommandError + case clientError + case serverError + + init?(rawValue: String) { + switch rawValue { + case "HD": + self = .header + case "NF": + self = .notFound + case "NS": + self = .notStored + case "EX": + self = .exists + case "VA": + self = .value + case "EN": + self = .end + case "ERROR": + self = .nonExistentCommandError + case "CLIENT_ERROR": + self = .clientError + case "SERVER_ERROR": + self = .serverError + default: + return nil + } + } + + var rawValue: String { + switch self { + case .header: + return "HD" + case .notFound: + return "NF" + case .notStored: + return "NS" + case .exists: + return "EX" + case .value: + return "VA" + case .end: + return "EN" + case .nonExistentCommandError: + return "ERROR" + case .clientError: + return "CLIENT_ERROR" + case .serverError: + return "SERVER_ERROR" + } + } + } +} + +extension MemcacheBackendMessage { + static func decode(from buffer: inout ByteBuffer, for verb: Verb) throws -> MemcacheBackendMessage { + switch verb { + case .header: + return try .header(.decode(from: &buffer)) + case .notFound: + return try .notFound(.decode(from: &buffer)) + case .notStored: + return try .notStored(.decode(from: &buffer)) + case .exists: + return try .exists(.decode(from: &buffer)) + case .value: + return try .value(.decode(from: &buffer)) + case .end: + buffer.moveReaderIndex(forwardBy: 2) + return .end + case .nonExistentCommandError: + buffer.moveReaderIndex(forwardBy: 2) + return .nonExistentCommandError + case .clientError: + return try .clientError(.decode(from: &buffer)) + case .serverError: + return try .serverError(.decode(from: &buffer)) + } + } +} + +extension MemcacheBackendMessage: CustomDebugStringConvertible { + var debugDescription: String { + switch self { + case let .header(flags): + return ".header(\(String(reflecting: flags)))" + case let .notFound(flags): + return ".notFound(\(String(reflecting: flags)))" + case let .notStored(flags): + return ".notStored(\(String(reflecting: flags)))" + case let .exists(flags): + return ".exists(\(String(reflecting: flags)))" + case let .value(value): + return ".value(\(String(reflecting: value)))" + case .end: + return ".end" + case .nonExistentCommandError: + return ".nonExistentCommandError" + case let .clientError(message): + return ".clientError(\(String(reflecting: message)))" + case let .serverError(message): + return ".serverError(\(String(reflecting: message)))" + } + } +} diff --git a/Tests/MemcacheTests/Extensions/MemcacheBackendMessage+Equatable.swift b/Tests/MemcacheTests/Extensions/MemcacheBackendMessage+Equatable.swift new file mode 100644 index 0000000..d1223c8 --- /dev/null +++ b/Tests/MemcacheTests/Extensions/MemcacheBackendMessage+Equatable.swift @@ -0,0 +1,93 @@ +@testable import Memcache + +extension MemcacheBackendMessage: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.header(lhs), .header(rhs)): + return lhs == rhs + case let (.notFound(lhs), .notFound(rhs)): + return lhs == rhs + case let (.notStored(lhs), .notStored(rhs)): + return lhs == rhs + case let (.exists(lhs), .exists(rhs)): + return lhs == rhs + case let (.value(lhs), .value(rhs)): + return lhs == rhs + case (.end, .end): + return true + case (.nonExistentCommandError, .nonExistentCommandError): + return true + case let (.clientError(lhs), .clientError(rhs)): + return lhs == rhs + case let (.serverError(lhs), .serverError(rhs)): + return lhs == rhs + default: + return false + } + } +} + +extension MemcacheBackendMessage.Value: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.flags == rhs.flags && lhs.data == rhs.data + } +} + +extension MemcacheBackendMessage.Flags: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.flags == rhs.flags + } +} + +extension MemcacheFlag: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.b, .b), (.c, .c), (.k, .k), (.q, .q), (.v, .v), (.t, .t), (.I, .I), (.f, .f), (.h, .h), (.l, .l), (.s, .s), (.u, .u), (.W, .W), (.X, .X), (.Z, .Z): + return true + case let (.P(lhs), .P(rhs)), let (.L(lhs), .L(rhs)): + return lhs == rhs + case let (.F(lhs), .F(rhs)): + return lhs == rhs + case let (.C(lhs), .C(rhs)), let (.J(lhs), .J(rhs)), let (.D(lhs), .D(rhs)): + return lhs == rhs + case let (.O(lhs), .O(rhs)): + return lhs == rhs + case let (.T(lhs), .T(rhs)), let (.N(lhs), .N(rhs)), let (.R(lhs), .R(rhs)): + return lhs == rhs + case let (.M(lhs), .M(rhs)): + return lhs == rhs + default: + return false + } + } +} + +extension MemcacheFlag.StringToken: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } +} + +extension MemcacheFlag.NumericToken: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } +} + +extension MemcacheFlag.OpaqueToken: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } +} + +extension MemcacheFlag.TTLToken: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } +} + +extension MemcacheBackendMessage.ErrorMessage: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.message == rhs.message + } +} diff --git a/Tests/MemcacheTests/MemcacheBackendMessageDecoderTests.swift b/Tests/MemcacheTests/MemcacheBackendMessageDecoderTests.swift new file mode 100644 index 0000000..28b77b2 --- /dev/null +++ b/Tests/MemcacheTests/MemcacheBackendMessageDecoderTests.swift @@ -0,0 +1,69 @@ +import NIOCore +import NIOTestUtils +import XCTest + +@testable import Memcache + +final class MemcacheBackendMessageDecoderTests: XCTestCase { + func testDecodeMessageWithAndWithoutFlags() { + // TODO: Test decoding these messages also without flags. Test EN\r\n should fail when we receive it with flags. + let flags: MemcacheBackendMessage.Flags = [.b, .R(0), .f] + let expected: [MemcacheBackendMessage] = [ + .header(flags), + .notFound(flags), + .notStored(flags), + .exists(flags), + .end, + .nonExistentCommandError, + .clientError("Test Error"), + .serverError("Test Error") + ] + + let flagsString = "b R0 f" + let messageString = [MemcacheBackendMessage.Verb]([.header, .notFound, .notStored, .exists]) + .map { "\($0.rawValue) \(flagsString)\r\n" } + .joined() + + "EN\r\n" + + "ERROR\r\nCLIENT_ERROR Test Error\r\nSERVER_ERROR Test Error\r\n" + + XCTAssertNoThrow(try ByteToMessageDecoderVerifier.verifyDecoder( + stringInputOutputPairs: [(messageString, expected)], + decoderFactory: { MemcacheBackendMessageDecoder() } + )) + } + + func testDecodeValueMessageWithFlags() { + var buffer = ByteBuffer() + buffer.writeString("foo") + let expected: [MemcacheBackendMessage] = [ + .value(.init(flags: [.b, .R(0), .f], data: buffer)) + ] + + XCTAssertNoThrow(try ByteToMessageDecoderVerifier.verifyDecoder( + stringInputOutputPairs: [("VA 3 b R0 f\r\nfoo\r\n", expected)], + decoderFactory: { MemcacheBackendMessageDecoder() } + )) + } + + func testDecodeValueMessageWithoutFlags() { + var buffer = ByteBuffer() + buffer.writeString("foo") + let expected: [MemcacheBackendMessage] = [ + .value(.init(flags: [], data: buffer)) + ] + + XCTAssertNoThrow(try ByteToMessageDecoderVerifier.verifyDecoder( + stringInputOutputPairs: [("VA 3\r\nfoo\r\n", expected)], + decoderFactory: { MemcacheBackendMessageDecoder() } + )) + } + + func testDecodeMessageWithUnknownVerb() { + XCTAssertThrowsError(try ByteToMessageDecoderVerifier.verifyDecoder( + stringInputOutputPairs: [("XX T1 foo\r\n", [])], + decoderFactory: { MemcacheBackendMessageDecoder() } + )) { + XCTAssert($0 is MemcacheDecodingError) + } + } +} diff --git a/Tests/MemcacheTests/MemcacheBackendMessageTests.swift b/Tests/MemcacheTests/MemcacheBackendMessageTests.swift new file mode 100644 index 0000000..8618ce9 --- /dev/null +++ b/Tests/MemcacheTests/MemcacheBackendMessageTests.swift @@ -0,0 +1,45 @@ +import NIOCore +import NIOTestUtils +import XCTest + +@testable import Memcache + +final class MemcacheBackendMessageTests: XCTestCase { + func testInitVerbWithString() { + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "HD"), .header) + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "NF"), .notFound) + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "NS"), .notStored) + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "EX"), .exists) + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "VA"), .value) + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "EN"), .end) + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "ERROR"), .nonExistentCommandError) + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "CLIENT_ERROR"), .clientError) + XCTAssertEqual(MemcacheBackendMessage.Verb(rawValue: "SERVER_ERROR"), .serverError) + + XCTAssertNil(MemcacheBackendMessage.Verb(rawValue: "")) + } + + func testVerbHasCorrectRawValue() { + XCTAssertEqual(MemcacheBackendMessage.Verb.header.rawValue, "HD") + XCTAssertEqual(MemcacheBackendMessage.Verb.notFound.rawValue, "NF") + XCTAssertEqual(MemcacheBackendMessage.Verb.notStored.rawValue, "NS") + XCTAssertEqual(MemcacheBackendMessage.Verb.exists.rawValue, "EX") + XCTAssertEqual(MemcacheBackendMessage.Verb.value.rawValue, "VA") + XCTAssertEqual(MemcacheBackendMessage.Verb.end.rawValue, "EN") + XCTAssertEqual(MemcacheBackendMessage.Verb.nonExistentCommandError.rawValue, "ERROR") + XCTAssertEqual(MemcacheBackendMessage.Verb.clientError.rawValue, "CLIENT_ERROR") + XCTAssertEqual(MemcacheBackendMessage.Verb.serverError.rawValue, "SERVER_ERROR") + } + + func testDebugDescription() { + XCTAssertEqual("\(MemcacheBackendMessage.header([.T(1), .v]))", ".header(flags: [.T(ttl: 1), .v])") + XCTAssertEqual("\(MemcacheBackendMessage.notFound([.T(1), .v]))", ".notFound(flags: [.T(ttl: 1), .v])") + XCTAssertEqual("\(MemcacheBackendMessage.notStored([.T(1), .v]))", ".notStored(flags: [.T(ttl: 1), .v])") + XCTAssertEqual("\(MemcacheBackendMessage.exists([.T(1), .v]))", ".exists(flags: [.T(ttl: 1), .v])") + XCTAssertEqual("\(MemcacheBackendMessage.value(.init(flags: [.T(1), .v], data: ByteBuffer())))", ".value(flags: [.T(ttl: 1), .v], data: )") + XCTAssertEqual("\(MemcacheBackendMessage.end)", ".end") + XCTAssertEqual("\(MemcacheBackendMessage.nonExistentCommandError)", ".nonExistentCommandError") + XCTAssertEqual("\(MemcacheBackendMessage.clientError("Test Error"))", ".clientError(message: \"Test Error\")") + XCTAssertEqual("\(MemcacheBackendMessage.serverError("Test Error"))", ".serverError(message: \"Test Error\")") + } +} diff --git a/Tests/MemcacheTests/MemcacheTests.swift b/Tests/MemcacheTests/MemcacheTests.swift deleted file mode 100644 index e69de29..0000000