From b8ffb2d2aa9552a5bf6e6dc13bdbe1f45cd2ab07 Mon Sep 17 00:00:00 2001 From: Paul Toffoloni Date: Thu, 19 Jun 2025 12:20:10 +0200 Subject: [PATCH 1/2] Update for Swift 6 --- .github/workflows/swift.yml | 4 +- .swift-format | 70 ++ Package.swift | 34 +- .../ElasticsearchClient+Requests.swift | 262 +++++++ .../ElasticsearchClient+ValidationError.swift | 10 +- .../Elasticsearch/ElasticsearchClient.swift | 191 +++++ .../ElasticsearchRequester.swift | 11 + .../HTTPClientElasticsearchRequester.swift | 40 + .../Models/BulkOperations/BulkCreate.swift | 0 .../Models/BulkOperations/BulkDelete.swift | 0 .../Models/BulkOperations/BulkIndex.swift | 0 .../BulkOperations/BulkOperationBody.swift | 0 .../Models/BulkOperations/BulkUpdate.swift | 0 .../BulkOperations/BulkUpdateScript.swift | 0 .../Models/ESAcknowledgedResponse.swift | 0 .../Models/ESBulkOperation.swift | 0 .../Models/ESBulkResponse.swift | 0 .../Models/ESCountResponse.swift | 0 .../Models/ESCreateDocumentResponse.swift | 2 +- .../Models/ESDeleteDocumentResponse.swift | 0 .../Models/ESEmptyResponse.swift | 0 .../Models/ESMultipleDocumentResponse.swift | 0 .../Models/ESSearchRequest.swift | 0 .../Models/ESShardsResponse.swift | 0 .../Models/ESSingleDocumentResponse.swift | 2 +- .../Models/ElasticsearchClientError.swift | 11 + .../ElasticsearchClient+Requests.swift | 290 -------- .../ElasticsearchClient.swift | 154 ---- .../ElasticsearchRequester.swift | 7 - .../HTTPClientElasticsearchRequester.swift | 45 -- .../Models/ElasticsearchClientError.swift | 11 - .../ElasticsearchNIOClientTests.swift | 675 ----------------- .../ElasticsearchTests.swift | 704 ++++++++++++++++++ .../SomeItem.swift | 0 scripts/startLocalDockerESTest.swift | 18 +- 35 files changed, 1323 insertions(+), 1218 deletions(-) create mode 100644 .swift-format create mode 100644 Sources/Elasticsearch/ElasticsearchClient+Requests.swift rename Sources/{ElasticsearchNIOClient => Elasticsearch}/ElasticsearchClient+ValidationError.swift (92%) create mode 100644 Sources/Elasticsearch/ElasticsearchClient.swift create mode 100644 Sources/Elasticsearch/ElasticsearchRequester.swift create mode 100644 Sources/Elasticsearch/HTTPClientElasticsearchRequester.swift rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/BulkOperations/BulkCreate.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/BulkOperations/BulkDelete.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/BulkOperations/BulkIndex.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/BulkOperations/BulkOperationBody.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/BulkOperations/BulkUpdate.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/BulkOperations/BulkUpdateScript.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESAcknowledgedResponse.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESBulkOperation.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESBulkResponse.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESCountResponse.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESCreateDocumentResponse.swift (98%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESDeleteDocumentResponse.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESEmptyResponse.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESMultipleDocumentResponse.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESSearchRequest.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESShardsResponse.swift (100%) rename Sources/{ElasticsearchNIOClient => Elasticsearch}/Models/ESSingleDocumentResponse.swift (98%) create mode 100644 Sources/Elasticsearch/Models/ElasticsearchClientError.swift delete mode 100644 Sources/ElasticsearchNIOClient/ElasticsearchClient+Requests.swift delete mode 100644 Sources/ElasticsearchNIOClient/ElasticsearchClient.swift delete mode 100644 Sources/ElasticsearchNIOClient/ElasticsearchRequester.swift delete mode 100644 Sources/ElasticsearchNIOClient/HTTPClientElasticsearchRequester.swift delete mode 100644 Sources/ElasticsearchNIOClient/Models/ElasticsearchClientError.swift delete mode 100644 Tests/ElasticsearchNIOClientTests/ElasticsearchNIOClientTests.swift create mode 100644 Tests/ElasticsearchTests/ElasticsearchTests.swift rename Tests/{ElasticsearchNIOClientTests => ElasticsearchTests}/SomeItem.swift (100%) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 61e5931..2de8c0f 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Test - run: swift test --sanitize=thread + run: swift test test-v8_4: name: Run Tests for Elasticsearch 8.4 @@ -37,4 +37,4 @@ jobs: steps: - uses: actions/checkout@v4 - name: Test - run: swift test --sanitize=thread + run: swift test diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..d24dda1 --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentation": { + "spaces": 4 + }, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachGenericRequirement": false, + "lineLength": 140, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "noAssignmentInExpressions": { + "allowedFunctions": [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether": false, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + }, + "spacesAroundRangeFormationOperators": false, + "tabWidth": 4, + "version": 1 +} diff --git a/Package.swift b/Package.swift index e4351a1..73e14a7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,37 +1,33 @@ -// swift-tools-version:5.3 -// The swift-tools-version declares the minimum version of Swift required to build this package. - +// swift-tools-version:6.0 import PackageDescription let package = Package( - name: "elasticsearch-nio-client", + name: "elasticsearch-swift", platforms: [ - .macOS(.v10_15), - .iOS(.v13) + .macOS(.v13), + .iOS(.v15), ], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. .library( - name: "ElasticsearchNIOClient", - targets: ["ElasticsearchNIOClient"]), + name: "Elasticsearch", + targets: ["Elasticsearch"] + ) ], dependencies: [ .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.33.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.4.0"), ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( - name: "ElasticsearchNIOClient", + name: "Elasticsearch", dependencies: [ .product(name: "AsyncHTTPClient", package: "async-http-client"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOHTTP1", package: "swift-nio"), - .product(name: "NIOFoundationCompat", package: "swift-nio"), - ]), + .product(name: "HTTPTypes", package: "swift-http-types"), + ] + ), .testTarget( - name: "ElasticsearchNIOClientTests", - dependencies: ["ElasticsearchNIOClient"]), + name: "ElasticsearchTests", + dependencies: ["Elasticsearch"] + ), ] ) diff --git a/Sources/Elasticsearch/ElasticsearchClient+Requests.swift b/Sources/Elasticsearch/ElasticsearchClient+Requests.swift new file mode 100644 index 0000000..bd61573 --- /dev/null +++ b/Sources/Elasticsearch/ElasticsearchClient+Requests.swift @@ -0,0 +1,262 @@ +import AsyncHTTPClient +import Foundation +import HTTPTypes + +extension ElasticsearchClient { + public func get( + id: ID, + from indexName: String + ) async throws -> ESGetSingleDocumentResponse { + let url = try buildURL(path: "/\(indexName)/_doc/\(id)") + return try await sendRequest(url: url, method: .get, headers: .init(), body: nil) + } + + public func bulk(_ operations: [ESBulkOperation]) async throws -> ESBulkResponse { + guard operations.count > 0 else { + throw ElasticsearchClientError(message: "No operations to perform for the bulk API", status: nil) + } + let url = try buildURL(path: "/_bulk") + var bodyString = "" + for operation in operations { + let bulkOperationBody = BulkOperationBody(index: operation.index, id: "\(operation.id)") + switch operation.operationType { + case .create: + guard let document = operation.document else { + throw ElasticsearchClientError(message: "No document provided for create bulk operation", status: nil) + } + let createInfo = BulkCreate(create: bulkOperationBody) + let createLine = try self.jsonEncoder.encode(createInfo) + let dataLine = try self.jsonEncoder.encode(document) + guard let createLineString = String(data: createLine, encoding: .utf8), + let dataLineString = String(data: dataLine, encoding: .utf8) + else { + throw ElasticsearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) + } + bodyString.append("\(createLineString)\n\(dataLineString)\n") + case .delete: + let deleteInfo = BulkDelete(delete: bulkOperationBody) + let deleteLine = try self.jsonEncoder.encode(deleteInfo) + guard let deleteLineString = String(data: deleteLine, encoding: .utf8) else { + throw ElasticsearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) + } + bodyString.append("\(deleteLineString)\n") + case .index: + guard let document = operation.document else { + throw ElasticsearchClientError(message: "No document provided for index bulk operation", status: nil) + } + let indexInfo = BulkIndex(index: bulkOperationBody) + let indexLine = try self.jsonEncoder.encode(indexInfo) + let dataLine = try self.jsonEncoder.encode(document) + guard let indexLineString = String(data: indexLine, encoding: .utf8), + let dataLineString = String(data: dataLine, encoding: .utf8) + else { + throw ElasticsearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) + } + bodyString.append("\(indexLineString)\n\(dataLineString)\n") + case .update: + guard let document = operation.document else { + throw ElasticsearchClientError(message: "No document provided for update bulk operation", status: nil) + } + let updateInfo = BulkUpdate(update: bulkOperationBody) + let updateLine = try self.jsonEncoder.encode(updateInfo) + let dataLine = try self.jsonEncoder.encode(BulkUpdateDocument(doc: document)) + guard let updateLineString = String(data: updateLine, encoding: .utf8), + let dataLineString = String(data: dataLine, encoding: .utf8) + else { + throw ElasticsearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) + } + bodyString.append("\(updateLineString)\n\(dataLineString)\n") + case .updateScript: + guard let document = operation.document else { + throw ElasticsearchClientError(message: "No script provided for update script bulk operation", status: nil) + } + let updateInfo = BulkUpdateScript(update: bulkOperationBody) + let updateLine = try self.jsonEncoder.encode(updateInfo) + let dataLine = try self.jsonEncoder.encode(BulkUpdateScriptDocument(script: document)) + guard let updateLineString = String(data: updateLine, encoding: .utf8), + let dataLineString = String(data: dataLine, encoding: .utf8) + else { + throw ElasticsearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) + } + bodyString.append("\(updateLineString)\n\(dataLineString)\n") + } + } + var headers = HTTPFields() + headers[.contentType] = "application/x-ndjson" + return try await sendRequest(url: url, method: .post, headers: headers, body: .bytes(.init(string: bodyString))) + } + + public func createDocument( + _ document: Document, + in indexName: String + ) async throws -> ESCreateDocumentResponse { + let url = try buildURL(path: "/\(indexName)/_doc") + let body = try self.jsonEncoder.encode(document) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .post, headers: headers, body: .bytes(body)) + } + + public func createDocumentWithID( + _ document: Document, + in indexName: String + ) async throws -> ESCreateDocumentResponse { + let url = try buildURL(path: "/\(indexName)/_doc/\(document.id)") + let body = try self.jsonEncoder.encode(document) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .post, headers: headers, body: .bytes(body)) + } + + public func updateDocument( + _ document: Document, id: ID, in indexName: String + ) async throws -> ESUpdateDocumentResponse { + let url = try buildURL(path: "/\(indexName)/_doc/\(id)") + let body = try self.jsonEncoder.encode(document) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .put, headers: headers, body: .bytes(body)) + } + + public func updateDocument( + _ document: Document, in indexName: String + ) async throws -> ESUpdateDocumentResponse { + let url = try buildURL(path: "/\(indexName)/_doc/\(document.id)") + let body = try self.jsonEncoder.encode(document) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .put, headers: headers, body: .bytes(body)) + } + + public func updateDocumentWithScript( + _ script: Script, id: ID, in indexName: String + ) async throws -> ESUpdateDocumentResponse { + let url = try buildURL(path: "/\(indexName)/_update/\(id)") + let body = try self.jsonEncoder.encode(script) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .post, headers: headers, body: .bytes(body)) + } + + public func deleteDocument( + id: ID, from indexName: String + ) async throws -> ESDeleteDocumentResponse { + let url = try buildURL(path: "/\(indexName)/_doc/\(id)") + return try await sendRequest(url: url, method: .delete, headers: .init(), body: nil) + } + + public func searchDocuments( + from indexName: String, searchTerm: String, type: Document.Type = Document.self + ) async throws -> ESGetMultipleDocumentsResponse { + let url = try buildURL(path: "/\(indexName)/_search", queryItems: [URLQueryItem(name: "q", value: searchTerm)]) + return try await sendRequest(url: url, method: .get, headers: .init(), body: nil) + } + + public func searchDocumentsCount( + from indexName: String, searchTerm: String? + ) async throws -> ESCountResponse { + var queryItems = [URLQueryItem]() + if let searchTermToUse = searchTerm { + queryItems.append(URLQueryItem(name: "q", value: searchTermToUse)) + } + let url = try buildURL(path: "/\(indexName)/_count", queryItems: queryItems) + return try await sendRequest(url: url, method: .get, headers: .init(), body: nil) + } + + public func searchDocumentsPaginated( + from indexName: String, searchTerm: String, size: Int = 10, offset: Int = 0, type: Document.Type = Document.self + ) async throws -> ESGetMultipleDocumentsResponse { + let url = try buildURL(path: "/\(indexName)/_search") + let query = ESSearchRequest(searchQuery: searchTerm, size: size, from: offset) + let body = try self.jsonEncoder.encode(query) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .get, headers: headers, body: .bytes(body)) + } + + public func searchDocumentsCount( + from indexName: String, query: Query + ) async throws -> ESCountResponse { + let url = try buildURL(path: "/\(indexName)/_count") + let body = try self.jsonEncoder.encode(query) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .get, headers: headers, body: .bytes(body)) + } + + public func searchDocumentsPaginated( + from indexName: String, queryBody: QueryBody, size: Int = 10, offset: Int = 0, type: Document.Type = Document.self + ) async throws -> ESGetMultipleDocumentsResponse { + let url = try buildURL(path: "/\(indexName)/_search") + let queryBody = ESComplexSearchRequest(from: offset, size: size, query: queryBody) + let body = try self.jsonEncoder.encode(queryBody) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .get, headers: headers, body: .bytes(body)) + } + + public func customSearch( + from indexName: String, query: Query, type: Document.Type = Document.self + ) async throws -> ESGetMultipleDocumentsResponse { + let body = try self.jsonEncoder.encode(query) + return try await sendCustomRequest(from: indexName, body: body) + } + + public func customSearch( + from indexName: String, query: Data, type: Document.Type = Document.self + ) async throws -> ESGetMultipleDocumentsResponse { + return try await sendCustomRequest(from: indexName, body: query) + } + + private func sendCustomRequest( + from indexName: String, body: Data + ) async throws -> ESGetMultipleDocumentsResponse { + let url = try buildURL(path: "/\(indexName)/_search") + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .get, headers: headers, body: .bytes(body)) + } + + public func createIndex( + _ indexName: String, mappings: [String: Any], settings: [String: Any] + ) async throws -> ESAcknowledgedResponse { + let url = try buildURL(path: "/\(indexName)") + let jsonBase: [String: Any] = [ + "mappings": mappings, + "settings": settings, + ] + let body = try JSONSerialization.data(withJSONObject: jsonBase) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return try await sendRequest(url: url, method: .put, headers: headers, body: .bytes(body)) + } + + public func deleteIndex( + _ name: String + ) async throws -> ESAcknowledgedResponse { + let url = try buildURL(path: "/\(name)") + return try await sendRequest(url: url, method: .delete, headers: .init(), body: nil) + } + + public func checkIndexExists( + _ name: String + ) async throws -> Bool { + let url = try buildURL(path: "/\(name)") + let response = try await requester.executeRequest(url: url, method: .head, headers: .init(), body: nil) + guard response.status == .ok || response.status == .notFound else { + throw ElasticsearchClientError( + message: "Invalid response from index exists API - \(response)", status: .init(code: Int(response.status.code))) + } + return response.status == .ok + } + + public func custom( + _ path: String, queryItems: [URLQueryItem] = [], method: HTTPRequest.Method, body: Data + ) async throws -> Data { + let url = try buildURL(path: path, queryItems: queryItems) + var headers = HTTPFields() + headers[.contentType] = "application/json" + let responseBody = try await sendRequest(url: url, method: method, headers: headers, body: .bytes(body)).collect(upTo: 1024 * 1024) + return Data(buffer: responseBody) + } +} diff --git a/Sources/ElasticsearchNIOClient/ElasticsearchClient+ValidationError.swift b/Sources/Elasticsearch/ElasticsearchClient+ValidationError.swift similarity index 92% rename from Sources/ElasticsearchNIOClient/ElasticsearchClient+ValidationError.swift rename to Sources/Elasticsearch/ElasticsearchClient+ValidationError.swift index 2150b75..d7d1f20 100644 --- a/Sources/ElasticsearchNIOClient/ElasticsearchClient+ValidationError.swift +++ b/Sources/Elasticsearch/ElasticsearchClient+ValidationError.swift @@ -10,19 +10,19 @@ extension ElasticsearchClient { var localizedDescription: String { self.kind.localizedDescription } private let kind: Kind - + private init(_ kind: Kind) { self.kind = kind } - - public static func ==(lhs: ValidationError, rhs: ValidationError) -> Bool { + + public static func == (lhs: ValidationError, rhs: ValidationError) -> Bool { return lhs.kind == rhs.kind } - + private enum Kind: LocalizedError { case invalidURLString case missingURLScheme case invalidURLScheme case missingURLHost - + var localizedDescription: String { let message: String = { switch self { diff --git a/Sources/Elasticsearch/ElasticsearchClient.swift b/Sources/Elasticsearch/ElasticsearchClient.swift new file mode 100644 index 0000000..b52f32e --- /dev/null +++ b/Sources/Elasticsearch/ElasticsearchClient.swift @@ -0,0 +1,191 @@ +import AsyncHTTPClient +import Foundation +import HTTPTypes +import Logging + +public struct ElasticsearchClient { + public static let defaultPort = 9200 + public static let allowedUrlSchemes = ["http", "https"] + + let requester: ElasticsearchRequester + let logger: Logger + let scheme: String + let host: String + let port: Int? + let username: String? + let password: String? + let jsonEncoder: JSONEncoder + let jsonDecoder: JSONDecoder + + public init( + httpClient: HTTPClient, + logger: Logger, + url string: String, + username: String? = nil, + password: String? = nil, + jsonEncoder: JSONEncoder = JSONEncoder(), + jsonDecoder: JSONDecoder = JSONDecoder() + ) throws { + guard let url = URL(string: string) else { throw ValidationError.invalidURLString } + + try self.init( + httpClient: httpClient, + logger: logger, + url: url, + username: username, + password: password, + jsonEncoder: jsonEncoder, + jsonDecoder: jsonDecoder + ) + } + + public init( + httpClient: HTTPClient, + logger: Logger, + url: URL, + username: String? = nil, + password: String? = nil, + jsonEncoder: JSONEncoder = JSONEncoder(), + jsonDecoder: JSONDecoder = JSONDecoder() + ) throws { + guard + let scheme = url.scheme, + !scheme.isEmpty + else { + throw ValidationError.missingURLScheme + } + + guard Self.allowedUrlSchemes.contains(scheme) else { + throw ValidationError.invalidURLScheme + } + + guard let host = url.host, !host.isEmpty else { throw ValidationError.missingURLHost } + + try self.init( + requester: HTTPClientElasticsearchRequester(logger: logger, username: username, password: password, client: httpClient), + logger: logger, + scheme: scheme, + host: host, + port: url.port, + username: username, + password: password, + jsonEncoder: jsonEncoder, + jsonDecoder: jsonDecoder + ) + } + + public init( + httpClient: HTTPClient, + logger: Logger, + scheme: String? = nil, + host: String, + port: Int? = defaultPort, + username: String? = nil, + password: String? = nil, + jsonEncoder: JSONEncoder = JSONEncoder(), + jsonDecoder: JSONDecoder = JSONDecoder() + ) throws { + try self.init( + requester: HTTPClientElasticsearchRequester(logger: logger, username: username, password: password, client: httpClient), + logger: logger, + scheme: scheme, + host: host, + port: port, + username: username, + password: password, + jsonEncoder: jsonEncoder, + jsonDecoder: jsonDecoder + ) + } + + public init( + requester: ElasticsearchRequester, + logger: Logger, + scheme: String? = nil, + host: String, + port: Int? = defaultPort, + username: String? = nil, + password: String? = nil, + jsonEncoder: JSONEncoder = JSONEncoder(), + jsonDecoder: JSONDecoder = JSONDecoder() + ) throws { + self.requester = requester + self.logger = logger + if let scheme = scheme { + guard Self.allowedUrlSchemes.contains(scheme) else { + throw ValidationError.invalidURLScheme + } + self.scheme = scheme + } else { + self.scheme = Self.allowedUrlSchemes.first! + } + self.host = host + self.port = port + self.username = username + self.password = password + self.jsonEncoder = jsonEncoder + self.jsonDecoder = jsonDecoder + } + + func sendRequest( + url: String, + method: HTTPRequest.Method, + headers: HTTPFields, + body: HTTPClientRequest.Body? + ) async throws -> HTTPClientResponse.Body { + let clientResponse = try await requester.executeRequest(url: url, method: method, headers: headers, body: body) + self.logger.trace("Response: \(clientResponse)") + + switch clientResponse.status.code { + case 200...299: + return clientResponse.body + default: + let requestBody = try await body?.collect(upTo: 1024 * 1024) ?? .init() + let responseBody = try await clientResponse.body.collect(upTo: 1024 * 1024) + self.logger.trace( + "Got response status \(clientResponse.status) from Elasticsearch with response \(clientResponse) when trying \(method) request to \(url). Request body was \(requestBody) and response body was \(responseBody)" + ) + throw ElasticsearchClientError( + message: "Bad status code from Elasticsearch", status: .init(code: Int(clientResponse.status.code))) + } + } + + func sendRequest( + url: String, + method: HTTPRequest.Method, + headers: HTTPFields, + body: HTTPClientRequest.Body? + ) async throws -> D { + let body = try await sendRequest(url: url, method: method, headers: headers, body: body) + let bodyBytes = try await body.collect(upTo: 1024 * 1024) + + let response: D + do { + response = try jsonDecoder.decode(D.self, from: bodyBytes) + } catch { + let string = String(buffer: bodyBytes) + self.logger.debug("Failed to convert \(D.self). Bytes: \(string)") + throw ElasticsearchClientError(message: "Failed to convert \(D.self)", status: nil) + } + return response + } +} + +//// MARK: - Helper +extension ElasticsearchClient { + func buildURL(path: String, queryItems: [URLQueryItem] = []) throws -> String { + var urlComponents = URLComponents() + urlComponents.scheme = scheme + urlComponents.host = host + if let port = self.port { + urlComponents.port = port + } + urlComponents.path = path + urlComponents.queryItems = queryItems + guard let url = urlComponents.url else { + self.logger.debug("malformed url: \(urlComponents)") + throw ElasticsearchClientError(message: "malformed url: \(urlComponents)", status: nil) + } + return url.absoluteString + } +} diff --git a/Sources/Elasticsearch/ElasticsearchRequester.swift b/Sources/Elasticsearch/ElasticsearchRequester.swift new file mode 100644 index 0000000..34462f1 --- /dev/null +++ b/Sources/Elasticsearch/ElasticsearchRequester.swift @@ -0,0 +1,11 @@ +import AsyncHTTPClient +import HTTPTypes + +public protocol ElasticsearchRequester: Sendable { + func executeRequest( + url urlString: String, + method: HTTPRequest.Method, + headers: HTTPFields, + body: HTTPClientRequest.Body? + ) async throws -> HTTPClientResponse +} diff --git a/Sources/Elasticsearch/HTTPClientElasticsearchRequester.swift b/Sources/Elasticsearch/HTTPClientElasticsearchRequester.swift new file mode 100644 index 0000000..ead8106 --- /dev/null +++ b/Sources/Elasticsearch/HTTPClientElasticsearchRequester.swift @@ -0,0 +1,40 @@ +import AsyncHTTPClient +import HTTPTypes +import Logging + +public struct HTTPClientElasticsearchRequester: ElasticsearchRequester { + let logger: Logger + let username: String? + let password: String? + let client: HTTPClient + + public func executeRequest( + url urlString: String, + method: HTTPRequest.Method, + headers: HTTPFields, + body: HTTPClientRequest.Body? + ) async throws -> HTTPClientResponse { + var clientRequest = HTTPClientRequest(url: urlString) + clientRequest.method = .init(rawValue: method.rawValue) + + var headers = headers + if let username = self.username, let password = self.password { + let pair = "\(username):\(password)" + if let data = pair.data(using: .utf8) { + let basic = data.base64EncodedString() + headers[.authorization] = "Basic \(basic)" + } + } + + for header in headers { + clientRequest.headers.add(name: header.name.canonicalName, value: header.value) + } + + if let body { + self.logger.trace("Request body: \(body)") + clientRequest.body = body + } + + return try await client.execute(clientRequest, timeout: .seconds(30)) + } +} diff --git a/Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkCreate.swift b/Sources/Elasticsearch/Models/BulkOperations/BulkCreate.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkCreate.swift rename to Sources/Elasticsearch/Models/BulkOperations/BulkCreate.swift diff --git a/Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkDelete.swift b/Sources/Elasticsearch/Models/BulkOperations/BulkDelete.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkDelete.swift rename to Sources/Elasticsearch/Models/BulkOperations/BulkDelete.swift diff --git a/Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkIndex.swift b/Sources/Elasticsearch/Models/BulkOperations/BulkIndex.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkIndex.swift rename to Sources/Elasticsearch/Models/BulkOperations/BulkIndex.swift diff --git a/Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkOperationBody.swift b/Sources/Elasticsearch/Models/BulkOperations/BulkOperationBody.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkOperationBody.swift rename to Sources/Elasticsearch/Models/BulkOperations/BulkOperationBody.swift diff --git a/Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkUpdate.swift b/Sources/Elasticsearch/Models/BulkOperations/BulkUpdate.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkUpdate.swift rename to Sources/Elasticsearch/Models/BulkOperations/BulkUpdate.swift diff --git a/Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkUpdateScript.swift b/Sources/Elasticsearch/Models/BulkOperations/BulkUpdateScript.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/BulkOperations/BulkUpdateScript.swift rename to Sources/Elasticsearch/Models/BulkOperations/BulkUpdateScript.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESAcknowledgedResponse.swift b/Sources/Elasticsearch/Models/ESAcknowledgedResponse.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESAcknowledgedResponse.swift rename to Sources/Elasticsearch/Models/ESAcknowledgedResponse.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESBulkOperation.swift b/Sources/Elasticsearch/Models/ESBulkOperation.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESBulkOperation.swift rename to Sources/Elasticsearch/Models/ESBulkOperation.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESBulkResponse.swift b/Sources/Elasticsearch/Models/ESBulkResponse.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESBulkResponse.swift rename to Sources/Elasticsearch/Models/ESBulkResponse.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESCountResponse.swift b/Sources/Elasticsearch/Models/ESCountResponse.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESCountResponse.swift rename to Sources/Elasticsearch/Models/ESCountResponse.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESCreateDocumentResponse.swift b/Sources/Elasticsearch/Models/ESCreateDocumentResponse.swift similarity index 98% rename from Sources/ElasticsearchNIOClient/Models/ESCreateDocumentResponse.swift rename to Sources/Elasticsearch/Models/ESCreateDocumentResponse.swift index d11a7b6..c808df5 100644 --- a/Sources/ElasticsearchNIOClient/Models/ESCreateDocumentResponse.swift +++ b/Sources/Elasticsearch/Models/ESCreateDocumentResponse.swift @@ -5,7 +5,7 @@ public struct ESCreateDocumentResponse: Codable where ID: Hashable & Codable public let index: String public let version: Int? public let result: String - + enum CodingKeys: String, CodingKey { case id = "_id" case index = "_index" diff --git a/Sources/ElasticsearchNIOClient/Models/ESDeleteDocumentResponse.swift b/Sources/Elasticsearch/Models/ESDeleteDocumentResponse.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESDeleteDocumentResponse.swift rename to Sources/Elasticsearch/Models/ESDeleteDocumentResponse.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESEmptyResponse.swift b/Sources/Elasticsearch/Models/ESEmptyResponse.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESEmptyResponse.swift rename to Sources/Elasticsearch/Models/ESEmptyResponse.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESMultipleDocumentResponse.swift b/Sources/Elasticsearch/Models/ESMultipleDocumentResponse.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESMultipleDocumentResponse.swift rename to Sources/Elasticsearch/Models/ESMultipleDocumentResponse.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESSearchRequest.swift b/Sources/Elasticsearch/Models/ESSearchRequest.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESSearchRequest.swift rename to Sources/Elasticsearch/Models/ESSearchRequest.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESShardsResponse.swift b/Sources/Elasticsearch/Models/ESShardsResponse.swift similarity index 100% rename from Sources/ElasticsearchNIOClient/Models/ESShardsResponse.swift rename to Sources/Elasticsearch/Models/ESShardsResponse.swift diff --git a/Sources/ElasticsearchNIOClient/Models/ESSingleDocumentResponse.swift b/Sources/Elasticsearch/Models/ESSingleDocumentResponse.swift similarity index 98% rename from Sources/ElasticsearchNIOClient/Models/ESSingleDocumentResponse.swift rename to Sources/Elasticsearch/Models/ESSingleDocumentResponse.swift index cdceb6a..4101304 100644 --- a/Sources/ElasticsearchNIOClient/Models/ESSingleDocumentResponse.swift +++ b/Sources/Elasticsearch/Models/ESSingleDocumentResponse.swift @@ -5,7 +5,7 @@ public struct ESGetSingleDocumentResponse: Decodable { public let index: String public let version: Int? public let source: Document - + enum CodingKeys: String, CodingKey { case id = "_id" case index = "_index" diff --git a/Sources/Elasticsearch/Models/ElasticsearchClientError.swift b/Sources/Elasticsearch/Models/ElasticsearchClientError.swift new file mode 100644 index 0000000..32f713e --- /dev/null +++ b/Sources/Elasticsearch/Models/ElasticsearchClientError.swift @@ -0,0 +1,11 @@ +import HTTPTypes + +public struct ElasticsearchClientError: Error { + public let message: String + public let status: HTTPResponse.Status? + + public init(message: String, status: HTTPResponse.Status?) { + self.message = message + self.status = status + } +} diff --git a/Sources/ElasticsearchNIOClient/ElasticsearchClient+Requests.swift b/Sources/ElasticsearchNIOClient/ElasticsearchClient+Requests.swift deleted file mode 100644 index acbfb96..0000000 --- a/Sources/ElasticsearchNIOClient/ElasticsearchClient+Requests.swift +++ /dev/null @@ -1,290 +0,0 @@ -import Foundation -import NIO -import NIOHTTP1 -import NIOFoundationCompat - -extension ElasticsearchClient { - public func get(id: ID, from indexName: String) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_doc/\(id)") - return sendRequest(url: url, method: .GET, headers: .init(), body: nil) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func bulk(_ operations: [ESBulkOperation]) -> EventLoopFuture { - guard operations.count > 0 else { - return self.eventLoop.makeFailedFuture(ElasticSearchClientError(message: "No operations to perform for the bulk API", status: nil)) - } - do { - let url = try buildURL(path: "/_bulk") - var bodyString = "" - for operation in operations { - let bulkOperationBody = BulkOperationBody(index: operation.index, id: "\(operation.id)") - switch operation.operationType { - case .create: - guard let document = operation.document else { - return self.eventLoop.makeFailedFuture(ElasticSearchClientError(message: "No document provided for create bulk operation", status: nil)) - } - let createInfo = BulkCreate(create: bulkOperationBody) - let createLine = try self.jsonEncoder.encode(createInfo) - let dataLine = try self.jsonEncoder.encode(document) - guard let createLineString = String(data: createLine, encoding: .utf8), let dataLineString = String(data: dataLine, encoding: .utf8) else { - throw ElasticSearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) - } - bodyString.append("\(createLineString)\n\(dataLineString)\n") - case .delete: - let deleteInfo = BulkDelete(delete: bulkOperationBody) - let deleteLine = try self.jsonEncoder.encode(deleteInfo) - guard let deleteLineString = String(data: deleteLine, encoding: .utf8) else { - throw ElasticSearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) - } - bodyString.append("\(deleteLineString)\n") - case .index: - guard let document = operation.document else { - return self.eventLoop.makeFailedFuture(ElasticSearchClientError(message: "No document provided for index bulk operation", status: nil)) - } - let indexInfo = BulkIndex(index: bulkOperationBody) - let indexLine = try self.jsonEncoder.encode(indexInfo) - let dataLine = try self.jsonEncoder.encode(document) - guard let indexLineString = String(data: indexLine, encoding: .utf8), let dataLineString = String(data: dataLine, encoding: .utf8) else { - throw ElasticSearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) - } - bodyString.append("\(indexLineString)\n\(dataLineString)\n") - case .update: - guard let document = operation.document else { - return self.eventLoop.makeFailedFuture(ElasticSearchClientError(message: "No document provided for update bulk operation", status: nil)) - } - let updateInfo = BulkUpdate(update: bulkOperationBody) - let updateLine = try self.jsonEncoder.encode(updateInfo) - let dataLine = try self.jsonEncoder.encode(BulkUpdateDocument(doc: document)) - guard let updateLineString = String(data: updateLine, encoding: .utf8), let dataLineString = String(data: dataLine, encoding: .utf8) else { - throw ElasticSearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) - } - bodyString.append("\(updateLineString)\n\(dataLineString)\n") - case .updateScript: - guard let document = operation.document else { - return self.eventLoop.makeFailedFuture(ElasticSearchClientError(message: "No script provided for update script bulk operation", status: nil)) - } - let updateInfo = BulkUpdateScript(update: bulkOperationBody) - let updateLine = try self.jsonEncoder.encode(updateInfo) - let dataLine = try self.jsonEncoder.encode(BulkUpdateScriptDocument(script: document)) - guard let updateLineString = String(data: updateLine, encoding: .utf8), let dataLineString = String(data: dataLine, encoding: .utf8) else { - throw ElasticSearchClientError(message: "Failed to convert bulk data from Data to String", status: nil) - } - bodyString.append("\(updateLineString)\n\(dataLineString)\n") - } - } - let body = ByteBuffer(string: bodyString) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/x-ndjson") - return sendRequest(url: url, method: .POST, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func createDocument(_ document: Document, in indexName: String) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_doc") - let body = try ByteBuffer(data: self.jsonEncoder.encode(document)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .POST, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func createDocumentWithID(_ document: Document, in indexName: String) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_doc/\(document.id)") - let body = try ByteBuffer(data: self.jsonEncoder.encode(document)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .POST, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func updateDocument(_ document: Document, id: ID, in indexName: String) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_doc/\(id)") - let body = try ByteBuffer(data: self.jsonEncoder.encode(document)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .PUT, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func updateDocument(_ document: Document, in indexName: String) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_doc/\(document.id)") - let body = try ByteBuffer(data: self.jsonEncoder.encode(document)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .PUT, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func updateDocumentWithScript(_ script: Script, id: ID, in indexName: String) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_update/\(id)") - let body = try ByteBuffer(data: self.jsonEncoder.encode(script)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .POST, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func deleteDocument(id: ID, from indexName: String) -> EventLoopFuture { - do { - let url = try buildURL(path: "/\(indexName)/_doc/\(id)") - return sendRequest(url: url, method: .DELETE, headers: .init(), body: nil) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func searchDocuments(from indexName: String, searchTerm: String, type: Document.Type = Document.self) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_search", queryItems: [URLQueryItem(name: "q", value: searchTerm)]) - return sendRequest(url: url, method: .GET, headers: .init(), body: nil) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func searchDocumentsCount(from indexName: String, searchTerm: String?) -> EventLoopFuture { - do { - var queryItems = [URLQueryItem]() - if let searchTermToUse = searchTerm { - queryItems.append(URLQueryItem(name: "q", value: searchTermToUse)) - } - let url = try buildURL(path: "/\(indexName)/_count", queryItems: queryItems) - return sendRequest(url: url, method: .GET, headers: .init(), body: nil) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func searchDocumentsPaginated(from indexName: String, searchTerm: String, size: Int = 10, offset: Int = 0, type: Document.Type = Document.self) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_search") - let query = ESSearchRequest(searchQuery: searchTerm, size: size, from: offset) - let body = try ByteBuffer(data: self.jsonEncoder.encode(query)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .GET, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func searchDocumentsCount(from indexName: String, query: Query) -> EventLoopFuture { - do { - let url = try buildURL(path: "/\(indexName)/_count") - let body = try ByteBuffer(data: self.jsonEncoder.encode(query)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .GET, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func searchDocumentsPaginated(from indexName: String, queryBody: QueryBody, size: Int = 10, offset: Int = 0, type: Document.Type = Document.self) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_search") - let queryBody = ESComplexSearchRequest(from: offset, size: size, query: queryBody) - let body = try ByteBuffer(data: self.jsonEncoder.encode(queryBody)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .GET, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func customSearch(from indexName: String, query: Query, type: Document.Type = Document.self) -> EventLoopFuture> { - do { - let body = try ByteBuffer(data: self.jsonEncoder.encode(query)) - return sendCustomRequest(from: indexName, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - public func customSearch(from indexName: String, query: Data, type: Document.Type = Document.self) -> EventLoopFuture> { - let body = ByteBuffer(data: query) - return sendCustomRequest(from: indexName, body: body) - } - private func sendCustomRequest(from indexName: String, body: ByteBuffer) -> EventLoopFuture> { - do { - let url = try buildURL(path: "/\(indexName)/_search") - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .GET, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func createIndex(_ indexName: String, mappings: [String: Any], settings: [String: Any]) -> EventLoopFuture { - do { - let url = try buildURL(path: "/\(indexName)") - let jsonBase: [String: Any] = [ - "mappings": mappings, - "settings": settings - ] - let body = try ByteBuffer(data: JSONSerialization.data(withJSONObject: jsonBase)) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: .PUT, headers: headers, body: body) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func deleteIndex(_ name: String) -> EventLoopFuture { - do { - let url = try buildURL(path: "/\(name)") - return sendRequest(url: url, method: .DELETE, headers: .init(), body: nil) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func checkIndexExists(_ name: String) -> EventLoopFuture { - do { - let url = try buildURL(path: "/\(name)") - return requester.executeRequest(url: url, method: .HEAD, headers: .init(), body: nil).flatMapThrowing { response in - guard response.status == .ok || response.status == .notFound else { - throw ElasticSearchClientError(message: "Invalid response from index exists API - \(response)", status: response.status) - } - return response.status == .ok - } - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } - - public func custom(_ path: String, queryItems: [URLQueryItem] = [], method: HTTPMethod, body: Data) -> EventLoopFuture { - do { - let url = try buildURL(path: path, queryItems: queryItems) - let body = ByteBuffer(data: body) - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - return sendRequest(url: url, method: method, headers: headers, body: body).flatMapThrowing { return Data(buffer: $0) } - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } -} diff --git a/Sources/ElasticsearchNIOClient/ElasticsearchClient.swift b/Sources/ElasticsearchNIOClient/ElasticsearchClient.swift deleted file mode 100644 index 36c56d6..0000000 --- a/Sources/ElasticsearchNIOClient/ElasticsearchClient.swift +++ /dev/null @@ -1,154 +0,0 @@ -import NIO -import NIOFoundationCompat -import AsyncHTTPClient -import Foundation -import Logging -import NIOHTTP1 - -public struct ElasticsearchClient { - - public static let defaultPort = 9200 - public static let allowedUrlSchemes = ["http", "https"] - - let requester: ElasticsearchRequester - let eventLoop: EventLoop - let logger: Logger - let scheme: String - let host: String - let port: Int? - let username: String? - let password: String? - let jsonEncoder: JSONEncoder - let jsonDecoder: JSONDecoder - - public init(httpClient: HTTPClient, eventLoop: EventLoop, logger: Logger, url string: String, username: String? = nil, password: String? = nil, jsonEncoder: JSONEncoder = JSONEncoder(), jsonDecoder: JSONDecoder = JSONDecoder()) throws { - guard let url = URL(string: string) else { throw ValidationError.invalidURLString } - try self.init( - httpClient: httpClient, - eventLoop: eventLoop, - logger: logger, - url: url, - username: username, - password: password, - jsonEncoder: jsonEncoder, - jsonDecoder: jsonDecoder - ) - } - - public init(httpClient: HTTPClient, eventLoop: EventLoop, logger: Logger, url: URL, username: String? = nil, password: String? = nil, jsonEncoder: JSONEncoder = JSONEncoder(), jsonDecoder: JSONDecoder = JSONDecoder()) throws { - guard - let scheme = url.scheme, - !scheme.isEmpty - else { throw ValidationError.missingURLScheme } - guard Self.allowedUrlSchemes.contains(scheme) else { throw ValidationError.invalidURLScheme } - guard let host = url.host, !host.isEmpty else { throw ValidationError.missingURLHost } - - try self.init( - requester: HTTPClientElasticsearchRequester(eventLoop: eventLoop, logger: logger, username: username, password: password, client: httpClient), - eventLoop: eventLoop, - logger: logger, - scheme: scheme, - host: host, - port: url.port, - username: username, - password: password, - jsonEncoder: jsonEncoder, - jsonDecoder: jsonDecoder - ) - } - - public init(httpClient: HTTPClient, eventLoop: EventLoop, logger: Logger, scheme: String? = nil, host: String, port: Int? = defaultPort, username: String? = nil, password: String? = nil, jsonEncoder: JSONEncoder = JSONEncoder(), jsonDecoder: JSONDecoder = JSONDecoder()) throws { - try self.init( - requester: HTTPClientElasticsearchRequester(eventLoop: eventLoop, logger: logger, username: username, password: password, client: httpClient), - eventLoop: eventLoop, - logger: logger, - scheme: scheme, - host: host, - port: port, - username: username, - password: password, - jsonEncoder: jsonEncoder, - jsonDecoder: jsonDecoder - ) - } - - public init(requester: ElasticsearchRequester, eventLoop: EventLoop, logger: Logger, scheme: String? = nil, host: String, port: Int? = defaultPort, username: String? = nil, password: String? = nil, jsonEncoder: JSONEncoder = JSONEncoder(), jsonDecoder: JSONDecoder = JSONDecoder()) throws { - self.requester = requester - self.eventLoop = eventLoop - self.logger = logger - if let scheme = scheme { - guard Self.allowedUrlSchemes.contains(scheme) else { throw ValidationError.invalidURLScheme } - self.scheme = scheme - } else { - self.scheme = Self.allowedUrlSchemes.first! - } - self.host = host - self.port = port - self.username = username - self.password = password - self.jsonEncoder = jsonEncoder - self.jsonDecoder = jsonDecoder - } - - func sendRequest(url: String, method: HTTPMethod, headers: HTTPHeaders, body: ByteBuffer?) -> EventLoopFuture { - requester.executeRequest(url: url, method: method, headers: headers, body: body).flatMapThrowing { clientResponse in - self.logger.trace("Response: \(clientResponse)") - if let responseBody = clientResponse.body { - self.logger.trace("Response body: \(String(decoding: responseBody.readableBytesView, as: UTF8.self))") - } - switch clientResponse.status.code { - case 200...299: - guard let body = clientResponse.body else { - self.logger.debug("No body from ElasticSearch response") - throw ElasticSearchClientError(message: "No body from ElasticSearch response", status: clientResponse.status) - } - return body - default: - let requestBody: String - if let body = body { - requestBody = String(buffer: body) - } else { - requestBody = "" - } - let responseBody: String - if let body = clientResponse.body { - responseBody = String(decoding: body.readableBytesView, as: UTF8.self) - } else { - responseBody = "Empty" - } - self.logger.trace("Got response status \(clientResponse.status) from ElasticSearch with response \(clientResponse) when trying \(method) request to \(url). Request body was \(requestBody) and response body was \(responseBody)") - throw ElasticSearchClientError(message: "Bad status code from ElasticSearch", status: clientResponse.status) - } - } - } - - func sendRequest(url: String, method: HTTPMethod, headers: HTTPHeaders, body: ByteBuffer?) -> EventLoopFuture { - sendRequest(url: url, method: method, headers: headers, body: body).flatMapThrowing { body in - var body = body - guard let response = try body.readJSONDecodable(D.self, decoder: jsonDecoder, length: body.readableBytes) else { - self.logger.debug("Failed to convert \(D.self)") - throw ElasticSearchClientError(message: "Failed to convert \(D.self)", status: nil) - } - return response - } - } -} - -//// MARK: - Helper -extension ElasticsearchClient { - func buildURL(path: String, queryItems: [URLQueryItem] = []) throws -> String { - var urlComponents = URLComponents() - urlComponents.scheme = scheme - urlComponents.host = host - if let port = self.port { - urlComponents.port = port - } - urlComponents.path = path - urlComponents.queryItems = queryItems - guard let url = urlComponents.url else { - self.logger.debug("malformed url: \(urlComponents)") - throw ElasticSearchClientError(message: "malformed url: \(urlComponents)", status: nil) - } - return url.absoluteString - } -} diff --git a/Sources/ElasticsearchNIOClient/ElasticsearchRequester.swift b/Sources/ElasticsearchNIOClient/ElasticsearchRequester.swift deleted file mode 100644 index 6aa928c..0000000 --- a/Sources/ElasticsearchNIOClient/ElasticsearchRequester.swift +++ /dev/null @@ -1,7 +0,0 @@ -import NIOHTTP1 -import AsyncHTTPClient -import NIO - -public protocol ElasticsearchRequester { - func executeRequest(url urlString: String, method: HTTPMethod, headers: HTTPHeaders, body: ByteBuffer?) -> EventLoopFuture -} diff --git a/Sources/ElasticsearchNIOClient/HTTPClientElasticsearchRequester.swift b/Sources/ElasticsearchNIOClient/HTTPClientElasticsearchRequester.swift deleted file mode 100644 index 52a980e..0000000 --- a/Sources/ElasticsearchNIOClient/HTTPClientElasticsearchRequester.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import NIO -import Logging -import AsyncHTTPClient -import NIOHTTP1 - -public struct HTTPClientElasticsearchRequester: ElasticsearchRequester { - let eventLoop: EventLoop - let logger: Logger - let username: String? - let password: String? - let client: HTTPClient - - public func executeRequest(url urlString: String, method: HTTPMethod, headers: HTTPHeaders, body: ByteBuffer?) -> EventLoopFuture { - guard let url = URL(string: urlString) else { - return self.eventLoop.makeFailedFuture(ElasticSearchClientError(message: "Failed to convert \(urlString) to a URL", status: nil)) - } - let httpClientBody: HTTPClient.Body? - if let body = body { - httpClientBody = .byteBuffer(body) - } else { - httpClientBody = nil - } - var headers = headers - if let username = self.username, let password = self.password { - let pair = "\(username):\(password)" - if let data = pair.data(using: .utf8) { - let basic = data.base64EncodedString() - headers.add(name: "Authorization", value: "Basic \(basic)") - } - } - let request: HTTPClient.Request - do { - request = try HTTPClient.Request(url: url, method: method, headers: headers, body: httpClientBody) - } catch { - return self.eventLoop.makeFailedFuture(error) - } - self.logger.trace("Request: \(request)") - if let requestBody = body { - let bodyString = String(buffer: requestBody) - self.logger.trace("Request body: \(bodyString)") - } - return self.client.execute(request: request, eventLoop: HTTPClient.EventLoopPreference.delegateAndChannel(on: self.eventLoop), logger: self.logger) - } -} diff --git a/Sources/ElasticsearchNIOClient/Models/ElasticsearchClientError.swift b/Sources/ElasticsearchNIOClient/Models/ElasticsearchClientError.swift deleted file mode 100644 index 8d472ff..0000000 --- a/Sources/ElasticsearchNIOClient/Models/ElasticsearchClientError.swift +++ /dev/null @@ -1,11 +0,0 @@ -import NIOHTTP1 - -public struct ElasticSearchClientError: Error { - public let message: String - public let status: HTTPResponseStatus? - - public init(message: String, status: HTTPResponseStatus?) { - self.message = message - self.status = status - } -} diff --git a/Tests/ElasticsearchNIOClientTests/ElasticsearchNIOClientTests.swift b/Tests/ElasticsearchNIOClientTests/ElasticsearchNIOClientTests.swift deleted file mode 100644 index 87ed385..0000000 --- a/Tests/ElasticsearchNIOClientTests/ElasticsearchNIOClientTests.swift +++ /dev/null @@ -1,675 +0,0 @@ -import XCTest -import ElasticsearchNIOClient -import NIO -import AsyncHTTPClient -import Logging - -class ElasticSearchIntegrationTests: XCTestCase { - - // MARK: - Properties - var eventLoopGroup: MultiThreadedEventLoopGroup! - var client: ElasticsearchClient! - var httpClient: HTTPClient! - let indexName = "some-index" - - // MARK: - Overrides - override func setUpWithError() throws { - eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) - let logger = Logger(label: "io.brokenhands.swift-soto-elasticsearch.test") - httpClient = HTTPClient(eventLoopGroupProvider: .shared(eventLoopGroup)) - client = try! ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, scheme: "http", host: "localhost", port: 9200) - if try client.checkIndexExists(indexName).wait() { - _ = try client.deleteIndex(indexName).wait() - } - } - - override func tearDownWithError() throws { - try httpClient.syncShutdown() - try eventLoopGroup.syncShutdownGracefully() - } - - // MARK: - Tests - func testURLSetup() throws { - let logger = Logger(label: "io.brokenhands.swift-soto-elasticsearch.test") - - let invalidURLString = "" - XCTAssertThrowsError(try ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, url: invalidURLString)) { error in - XCTAssertEqual(error as! ElasticsearchClient.ValidationError, .invalidURLString) - } - - let urlWithoutScheme = URL(string: "://localhost:9200")! - XCTAssertThrowsError(try ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, url: urlWithoutScheme)) { error in - XCTAssertEqual(error as! ElasticsearchClient.ValidationError, .missingURLScheme) - } - - let urlWithIncorrectScheme = URL(string: "localhost:9200")! - XCTAssertThrowsError(try ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, url: urlWithIncorrectScheme)) { error in - XCTAssertEqual(error as! ElasticsearchClient.ValidationError, .invalidURLScheme) - } - - let urlWithoutHost = URL(string: "http://:9200")! - XCTAssertThrowsError(try ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, url: urlWithoutHost)) { error in - XCTAssertEqual(error as! ElasticsearchClient.ValidationError, .missingURLHost) - } - - let correctURL = URL(string: "http://localhost:9200")! - XCTAssertNoThrow(try ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, url: correctURL)) - - XCTAssertThrowsError(try ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, scheme: "incorrectScheme", host: "localhost", port: 9200)) { error in - XCTAssertEqual(error as! ElasticsearchClient.ValidationError, .invalidURLScheme) - } - - XCTAssertNoThrow(try ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, scheme: "http", host: "localhost", port: 9200)) - - XCTAssertNoThrow(try ElasticsearchClient(httpClient: httpClient, eventLoop: eventLoopGroup.next(), logger: logger, scheme: "https", host: "localhost", port: 9200)) - } - - func testSearchingItems() throws { - try setupItems() - - let results: ESGetMultipleDocumentsResponse = try client.searchDocuments(from: indexName, searchTerm: "Apples").wait() - XCTAssertEqual(results.hits.hits.count, 5) - } - - func testSearchingItemsWithTypeProvided() throws { - try setupItems() - - let results = try client.searchDocuments(from: indexName, searchTerm: "Apples", type: SomeItem.self).wait() - XCTAssertEqual(results.hits.hits.count, 5) - } - - func testSearchItemsCount() throws { - try setupItems() - - let results = try client.searchDocumentsCount(from: indexName, searchTerm: "Apples").wait() - XCTAssertEqual(results.count, 5) - } - - func testSearchDocumentsTotal() throws { - for index in 1...100 { - let name = "Some \(index) Apples" - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - - let results = try client.searchDocuments(from: indexName, searchTerm: "Apples", type: SomeItem.self).wait() - XCTAssertEqual(results.hits.total!.value, 100) - XCTAssertEqual(results.hits.total!.relation, .eq) - } - - func testCreateDocument() throws { - let item = SomeItem(id: UUID(), name: "Banana") - let response = try client.createDocument(item, in: self.indexName).wait() - XCTAssertNotEqual(item.id.uuidString, response.id) - XCTAssertEqual(response.index, self.indexName) - XCTAssertEqual(response.result, "created") - } - - func testCreateDocumentWithID() throws { - let item = SomeItem(id: UUID(), name: "Banana") - let response = try client.createDocumentWithID(item, in: self.indexName).wait() - XCTAssertEqual(item.id, response.id) - XCTAssertEqual(response.index, self.indexName) - XCTAssertEqual(response.result, "created") - } - - func testUpdateDocumentWithCustomId() throws { - let item = SomeItem(id: UUID(), name: "Banana") - _ = try client.createDocumentWithID(item, in: self.indexName).wait() - Thread.sleep(forTimeInterval: 0.5) - let updatedItem = SomeItem(id: item.id, name: "Bananas") - let response = try client.updateDocument(updatedItem, id: item.id, in: self.indexName).wait() - XCTAssertEqual(response.result, "updated") - } - - func testUpdateDocumentWithID() throws { - let item = SomeItem(id: UUID(), name: "Banana") - _ = try client.createDocumentWithID(item, in: self.indexName).wait() - Thread.sleep(forTimeInterval: 1.0) - let updatedItem = SomeItem(id: item.id, name: "Bananas") - let response = try client.updateDocument(updatedItem, in: self.indexName).wait() - XCTAssertEqual(response.result, "updated") - } - - func testDeletingDocument() throws { - try setupItems() - let item = SomeItem(id: UUID(), name: "Banana") - _ = try client.createDocumentWithID(item, in: self.indexName).wait() - Thread.sleep(forTimeInterval: 1.0) - - let results = try client.searchDocumentsCount(from: indexName, searchTerm: "Banana").wait() - XCTAssertEqual(results.count, 1) - Thread.sleep(forTimeInterval: 0.5) - - let response = try client.deleteDocument(id: item.id, from: self.indexName).wait() - XCTAssertEqual(response.result, "deleted") - Thread.sleep(forTimeInterval: 0.5) - - let updatedResults = try client.searchDocumentsCount(from: indexName, searchTerm: "Banana").wait() - XCTAssertEqual(updatedResults.count, 0) - } - - func testCreateIndex() throws { - let mappings: [String: Any] = [ - "properties": [ - "keyword_field": [ - "type": "keyword", - "fields": [ - "test": [ - "type": "text" - ] - ] - ] - ] - ] - let settings: [String: Any] = ["number_of_shards": 3] - - let response = try client.createIndex(indexName, mappings: mappings, settings: settings).wait() - XCTAssertEqual(response.acknowledged, true) - - let exists = try client.checkIndexExists(self.indexName).wait() - XCTAssertTrue(exists) - } - - func testIndexExists() throws { - let item = SomeItem(id: UUID(), name: "Banana") - let response = try client.createDocument(item, in: self.indexName).wait() - XCTAssertEqual(response.index, self.indexName) - XCTAssertEqual(response.result, "created") - Thread.sleep(forTimeInterval: 0.5) - - let exists = try client.checkIndexExists(self.indexName).wait() - XCTAssertTrue(exists) - - let notExists = try client.checkIndexExists("some-random-index").wait() - XCTAssertFalse(notExists) - } - - func testDeleteIndex() throws { - let item = SomeItem(id: UUID(), name: "Banana") - _ = try client.createDocument(item, in: self.indexName).wait() - Thread.sleep(forTimeInterval: 0.5) - - let exists = try client.checkIndexExists(self.indexName).wait() - XCTAssertTrue(exists) - - let response = try client.deleteIndex(self.indexName).wait() - XCTAssertEqual(response.acknowledged, true) - - let notExists = try client.checkIndexExists(self.indexName).wait() - XCTAssertFalse(notExists) - } - - func testBulkCreate() throws { - var items = [SomeItem]() - for index in 1...10 { - let name: String - if index % 2 == 0 { - name = "Some \(index) Apples" - } else { - name = "Some \(index) Bananas" - } - let item = SomeItem(id: UUID(), name: name) - items.append(item) - } - - let itemsWithIndex = items.map { ESBulkOperation(operationType: .create, index: self.indexName, id: $0.id, document: $0) } - let response = try client.bulk(itemsWithIndex).wait() - XCTAssertEqual(response.errors, false) - XCTAssertEqual(response.items.count, 10) - XCTAssertEqual(response.items.first?.create?.result, "created") - Thread.sleep(forTimeInterval: 1.0) - - let results = try client.searchDocumentsCount(from: indexName, searchTerm: nil).wait() - XCTAssertEqual(results.count, 10) - } - - func testBulkCreateUpdateDeleteIndex() throws { - let item1 = SomeItem(id: UUID(), name: "Item 1") - let item2 = SomeItem(id: UUID(), name: "Item 2") - let item3 = SomeItem(id: UUID(), name: "Item 3") - let item4 = SomeItem(id: UUID(), name: "Item 4") - let bulkOperation = [ - ESBulkOperation(operationType: .create, index: self.indexName, id: item1.id, document: item1), - ESBulkOperation(operationType: .index, index: self.indexName, id: item2.id, document: item2), - ESBulkOperation(operationType: .update, index: self.indexName, id: item3.id, document: item3), - ESBulkOperation(operationType: .delete, index: self.indexName, id: item4.id, document: item4), - ] - - let response = try client.bulk(bulkOperation).wait() - XCTAssertEqual(response.items.count, 4) - XCTAssertNotNil(response.items[0].create) - XCTAssertNotNil(response.items[1].index) - XCTAssertNotNil(response.items[2].update) - XCTAssertNotNil(response.items[3].delete) - } - - func testSearchingItemsPaginated() throws { - for index in 1...100 { - let name = "Some \(index) Apples" - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 2.0) - - let results: ESGetMultipleDocumentsResponse = try client.searchDocumentsPaginated(from: indexName, searchTerm: "Apples", size: 20, offset: 10).wait() - XCTAssertEqual(results.hits.hits.count, 20) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" })) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" })) - } - - func testSearchingItemsWithTypeProvidedPaginated() throws { - for index in 1...100 { - let name = "Some \(index) Apples" - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - - let results = try client.searchDocumentsPaginated(from: indexName, searchTerm: "Apples", size: 20, offset: 10, type: SomeItem.self).wait() - XCTAssertEqual(results.hits.hits.count, 20) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" })) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" })) - } - - func testGetItem() throws { - let item = SomeItem(id: UUID(), name: "Some item") - _ = try client.createDocumentWithID(item, in: self.indexName).wait() - - Thread.sleep(forTimeInterval: 1.0) - - let retrievedItem: ESGetSingleDocumentResponse = try client.get(id: item.id, from: self.indexName).wait() - XCTAssertEqual(retrievedItem.source.name, item.name) - } - - func testBulkUpdateWithScript() throws { - var items = [SomeItem]() - for index in 1...10 { - let name: String - if index % 2 == 0 { - name = "Some \(index) Apples" - } else { - name = "Some \(index) Bananas" - } - let item = SomeItem(id: UUID(), name: name, count: 0) - _ = try client.createDocumentWithID(item, in: self.indexName).wait() - items.append(item) - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - - struct ScriptBody: Codable { - let inline: String - } - - let scriptBody = ScriptBody(inline: "ctx._source.count = ctx._source.count += 1") - - let bulkOperation = [ - ESBulkOperation(operationType: .updateScript, index: self.indexName, id: items[0].id, document: scriptBody), - ] - - let response = try client.bulk(bulkOperation).wait() - XCTAssertEqual(response.items.count, 1) - XCTAssertNotNil(response.items.first?.update) - XCTAssertFalse(response.errors) - - let retrievedItem: ESGetSingleDocumentResponse = try client.get(id: items[0].id, from: self.indexName).wait() - XCTAssertEqual(retrievedItem.source.count, 1) - } - - func testUpdateWithScript() throws { - let item = SomeItem(id: UUID(), name: "Some Item", count: 0) - _ = try client.createDocumentWithID(item, in: self.indexName).wait() - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - - struct ScriptRequest: Codable { - let script: ScriptBody - } - - struct ScriptBody: Codable { - let inline: String - } - - let scriptBody = ScriptBody(inline: "ctx._source.count = ctx._source.count += 1") - let request = ScriptRequest(script: scriptBody) - - let response = try client.updateDocumentWithScript(request, id: item.id, in: self.indexName).wait() - XCTAssertEqual(response.result, "updated") - - let retrievedItem: ESGetSingleDocumentResponse = try client.get(id: item.id, from: self.indexName).wait() - XCTAssertEqual(retrievedItem.source.count, 1) - } - - func testUpdateWithNonExistentFieldScript() throws { - let item = SomeItem(id: UUID(), name: "Some Item") - _ = try client.createDocumentWithID(item, in: self.indexName).wait() - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - - struct ScriptRequest: Codable { - let script: ScriptBody - } - - struct ScriptBody: Codable { - let inline: String - } - - let scriptBody = ScriptBody(inline: "if(ctx._source.containsKey('count')) { ctx._source.count += 1 } else { ctx._source.count = 1 }") - let request = ScriptRequest(script: scriptBody) - - let response = try client.updateDocumentWithScript(request, id: item.id, in: self.indexName).wait() - XCTAssertEqual(response.result, "updated") - - let retrievedItem: ESGetSingleDocumentResponse = try client.get(id: item.id, from: self.indexName).wait() - XCTAssertEqual(retrievedItem.source.count, 1) - } - - func testBulkUpdateWithNonExistentFieldScript() throws { - var items = [SomeItem]() - for index in 1...10 { - let name: String - if index % 2 == 0 { - name = "Some \(index) Apples" - } else { - name = "Some \(index) Bananas" - } - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocumentWithID(item, in: self.indexName).wait() - items.append(item) - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - - struct ScriptBody: Codable { - let inline: String - } - - let scriptBody = ScriptBody(inline: "if(ctx._source.containsKey('count')) { ctx._source.count += 1 } else { ctx._source.count = 1 }") - - let bulkOperation = [ - ESBulkOperation(operationType: .updateScript, index: self.indexName, id: items[0].id, document: scriptBody), - ] - - let response = try client.bulk(bulkOperation).wait() - XCTAssertEqual(response.items.count, 1) - XCTAssertNotNil(response.items.first?.update) - XCTAssertFalse(response.errors) - - let retrievedItem: ESGetSingleDocumentResponse = try client.get(id: items[0].id, from: self.indexName).wait() - XCTAssertEqual(retrievedItem.source.count, 1) - } - - func testCountWithQueryBody() throws { - try setupItems() - - struct SearchQuery: Encodable { - let query: QueryBody - } - - struct QueryBody: Encodable { - let queryString: QueryString - - enum CodingKeys: String, CodingKey { - case queryString = "query_string" - } - } - - struct QueryString: Encodable { - let query: String - } - - let queryString = QueryString(query: "Apples") - let queryBody = QueryBody(queryString: queryString) - let searchQuery = SearchQuery(query: queryBody) - let results = try client.searchDocumentsCount(from: indexName, query: searchQuery).wait() - XCTAssertEqual(results.count, 5) - } - - func testPaginationQueryWithQueryBody() throws { - for index in 1...100 { - let name = "Some \(index) Apples" - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - - struct QueryBody: Encodable { - let queryString: QueryString - - enum CodingKeys: String, CodingKey { - case queryString = "query_string" - } - } - - struct QueryString: Encodable { - let query: String - } - - let queryString = QueryString(query: "Apples") - let queryBody = QueryBody(queryString: queryString) - - let results: ESGetMultipleDocumentsResponse = try client.searchDocumentsPaginated(from: indexName, queryBody: queryBody, size: 20, offset: 10).wait() - XCTAssertEqual(results.hits.hits.count, 20) - XCTAssertEqual(results.hits.total!.value, 100) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" })) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" })) - } - - func testCustomSearch() throws { - for index in 1...100 { - let name = "Some \(index) Apples" - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 2.0) - - struct Query: Encodable { - let query: QueryBody - let from: Int - let size: Int - } - - struct QueryBody: Encodable { - let queryString: QueryString - - enum CodingKeys: String, CodingKey { - case queryString = "query_string" - } - } - - struct QueryString: Encodable { - let query: String - } - - let queryString = QueryString(query: "Apples") - let queryBody = QueryBody(queryString: queryString) - let query = Query(query: queryBody, from: 10, size: 20) - - let results: ESGetMultipleDocumentsResponse = try client.customSearch(from: indexName, query: query).wait() - XCTAssertEqual(results.hits.hits.count, 20) - XCTAssertEqual(results.hits.total!.value, 100) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" })) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" })) - } - - func testCustomSearchWithTrackTotalHitsFalse() throws { - for index in 1...100 { - let name = "Some \(index) Apples" - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 2.0) - - struct Query: Encodable { - let query: QueryBody - let from: Int - let size: Int - let trackTotalHits: Bool - - enum CodingKeys: String, CodingKey { - case query - case from - case size - case trackTotalHits = "track_total_hits" - } - } - - struct QueryBody: Encodable { - let queryString: QueryString - - enum CodingKeys: String, CodingKey { - case queryString = "query_string" - } - } - - struct QueryString: Encodable { - let query: String - } - - let queryString = QueryString(query: "Apples") - let queryBody = QueryBody(queryString: queryString) - let query = Query(query: queryBody, from: 10, size: 20, trackTotalHits: false) - - let results: ESGetMultipleDocumentsResponse = try client.customSearch(from: indexName, query: query).wait() - XCTAssertNil(results.hits.total) - XCTAssertEqual(results.hits.hits.count, 20) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" })) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" })) - } - - func testCustomRequest() throws { - for index in 1...100 { - let name = "Some \(index) Apples" - let item = SomeItem(id: UUID(), name: name, count: index) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 2.0) - - let query: [String: Any] = [ - "from": 0, - "size": 10, - "collapse": [ - "field": "id.keyword" - ], - "aggs": [ - "count-objects": [ - "cardinality": [ - "field": "id.keyword" - ] - ], - "count": [ - "avg": [ - "field": "count" - ] - ] - ] - ] - let queryData = try JSONSerialization.data(withJSONObject: query) - - let resultData = try client.custom("/\(indexName)/_search", method: .GET, body: queryData).wait() - - let results = try JSONSerialization.jsonObject(with: resultData) as! [String: Any] - - let aggregations = results["aggregations"] as! [String: Any] - let countObjects = aggregations["count-objects"] as! [String: Any] - XCTAssertEqual(countObjects["value"] as! Double, 100) - let count = aggregations["count"] as! [String: Any] - XCTAssertEqual(count["value"] as! Double, 50.5) - } - - func testCustomRequestWithQueryItems() throws { - // create index - let mappings: [String: Any] = [ - "properties": [ - "keyword_field": [ - "type": "keyword", - "fields": [ - "test": [ - "type": "text" - ] - ] - ] - ] - ] - let settings: [String: Any] = ["number_of_shards": 3] - let createResponse = try client.createIndex(indexName, mappings: mappings, settings: settings).wait() - XCTAssertEqual(createResponse.acknowledged, true) - - // get indices in json format - struct ESGetSingleIndexResponse: Decodable { - let index: String - } - let resultData = try client.custom("/_cat/indices", queryItems: [URLQueryItem(name: "format", value: "json")], method: .GET, body: "".data(using: .utf8)!).wait() - let results = try JSONDecoder().decode([ESGetSingleIndexResponse].self, from: resultData) - XCTAssertNotNil(results.map { $0.index }.first { $0 == indexName }) - - // delete index - let deleteResponse = try client.deleteIndex(self.indexName).wait() - XCTAssertEqual(deleteResponse.acknowledged, true) - } - - func testCustomSearchWithDataQuery() throws { - for index in 1...100 { - let name = "Some \(index) Apples" - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - - let query = """ - { - "from": 10, - "size": 20, - "query": { - "query_string": { - "query": "Apples" - } - } - } - """.data(using: .utf8)! - - let results: ESGetMultipleDocumentsResponse = try client.customSearch(from: indexName, query: query).wait() - XCTAssertEqual(results.hits.hits.count, 20) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" })) - XCTAssertTrue(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" })) - } - - // MARK: - Private - private func setupItems() throws { - for index in 1...10 { - let name: String - if index % 2 == 0 { - name = "Some \(index) Apples" - } else { - name = "Some \(index) Bananas" - } - let item = SomeItem(id: UUID(), name: name) - _ = try client.createDocument(item, in: self.indexName).wait() - } - - // This is required for ES to settle and load the indexes to return the right results - Thread.sleep(forTimeInterval: 1.0) - } -} diff --git a/Tests/ElasticsearchTests/ElasticsearchTests.swift b/Tests/ElasticsearchTests/ElasticsearchTests.swift new file mode 100644 index 0000000..b4f718e --- /dev/null +++ b/Tests/ElasticsearchTests/ElasticsearchTests.swift @@ -0,0 +1,704 @@ +import AsyncHTTPClient +import Elasticsearch +import Foundation +import Logging +import Testing + +@Suite(.serialized) +struct ElasticsearchIntegrationTests { + var client: ElasticsearchClient! + var httpClient: HTTPClient! + let indexName = "some-index" + var logger = Logger(label: "io.brokenhands.swift-soto-elasticsearch.test") + + init() async throws { + httpClient = .shared + logger.logLevel = .debug + client = try ElasticsearchClient(httpClient: httpClient, logger: logger, scheme: "http", host: "localhost", port: 9200) + if try await client.checkIndexExists(indexName) { + _ = try await client.deleteIndex(indexName) + } + } + + @Test + func testURLSetup() async throws { + #expect(throws: ElasticsearchClient.ValidationError.invalidURLString) { + try ElasticsearchClient(httpClient: httpClient, logger: logger, url: "") + } + + #expect(throws: ElasticsearchClient.ValidationError.missingURLScheme) { + try ElasticsearchClient(httpClient: httpClient, logger: logger, url: "://localhost:9200") + } + + #expect(throws: ElasticsearchClient.ValidationError.invalidURLScheme) { + try ElasticsearchClient(httpClient: httpClient, logger: logger, url: "localhost:9200") + } + + #expect(throws: ElasticsearchClient.ValidationError.missingURLHost) { + try ElasticsearchClient(httpClient: httpClient, logger: logger, url: "http://:9200") + } + + #expect(throws: Never.self) { + try ElasticsearchClient(httpClient: httpClient, logger: logger, url: "http://localhost:9200") + } + + #expect(throws: ElasticsearchClient.ValidationError.invalidURLScheme) { + try ElasticsearchClient(httpClient: httpClient, logger: logger, scheme: "incorrectScheme", host: "localhost", port: 9200) + } + + #expect(throws: Never.self) { + try ElasticsearchClient(httpClient: httpClient, logger: logger, scheme: "http", host: "localhost", port: 9200) + } + + #expect(throws: Never.self) { + try ElasticsearchClient(httpClient: httpClient, logger: logger, scheme: "https", host: "localhost", port: 9200) + } + } + + @Test + func testSearchingItems() async throws { + try await setupItems() + + let results: ESGetMultipleDocumentsResponse = try await client.searchDocuments(from: indexName, searchTerm: "Apples") + #expect(results.hits.hits.count == 5) + } + + @Test + func testSearchingItemsWithTypeProvided() async throws { + try await setupItems() + + let results = try await client.searchDocuments(from: indexName, searchTerm: "Apples", type: SomeItem.self) + #expect(results.hits.hits.count == 5) + } + + @Test + func testSearchItemsCount() async throws { + try await setupItems() + + let results = try await client.searchDocumentsCount(from: indexName, searchTerm: "Apples") + #expect(results.count == 5) + } + + @Test + func testSearchDocumentsTotal() async throws { + for index in 1...100 { + let name = "Some \(index) Apples" + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(1)) + + let results = try await client.searchDocuments(from: indexName, searchTerm: "Apples", type: SomeItem.self) + #expect(results.hits.total!.value == 100) + #expect(results.hits.total!.relation == .eq) + } + + @Test + func testCreateDocument() async throws { + let item = SomeItem(id: UUID(), name: "Banana") + let response = try await client.createDocument(item, in: self.indexName) + #expect(item.id.uuidString != response.id) + #expect(response.index == self.indexName) + #expect(response.result == "created") + } + + @Test + func testCreateDocumentWithID() async throws { + let item = SomeItem(id: UUID(), name: "Banana") + let response = try await client.createDocumentWithID(item, in: self.indexName) + #expect(item.id == response.id) + #expect(response.index == self.indexName) + #expect(response.result == "created") + } + + @Test + func testUpdateDocumentWithCustomId() async throws { + let item = SomeItem(id: UUID(), name: "Banana") + _ = try await client.createDocumentWithID(item, in: self.indexName) + try await Task.sleep(for: .seconds(0.5)) + let updatedItem = SomeItem(id: item.id, name: "Bananas") + let response = try await client.updateDocument(updatedItem, id: item.id, in: self.indexName) + #expect(response.result == "updated") + } + + @Test + func testUpdateDocumentWithID() async throws { + let item = SomeItem(id: UUID(), name: "Banana") + _ = try await client.createDocumentWithID(item, in: self.indexName) + try await Task.sleep(for: .seconds(1)) + let updatedItem = SomeItem(id: item.id, name: "Bananas") + let response = try await client.updateDocument(updatedItem, in: self.indexName) + #expect(response.result == "updated") + } + + @Test + func testDeletingDocument() async throws { + try await setupItems() + let item = SomeItem(id: UUID(), name: "Banana") + _ = try await client.createDocumentWithID(item, in: self.indexName) + try await Task.sleep(for: .seconds(1)) + + let results = try await client.searchDocumentsCount(from: indexName, searchTerm: "Banana") + #expect(results.count == 1) + try await Task.sleep(for: .seconds(0.5)) + + let response = try await client.deleteDocument(id: item.id, from: self.indexName) + #expect(response.result == "deleted") + try await Task.sleep(for: .seconds(0.5)) + + let updatedResults = try await client.searchDocumentsCount(from: indexName, searchTerm: "Banana") + #expect(updatedResults.count == 0) + } + + @Test + func testCreateIndex() async throws { + let mappings: [String: Any] = [ + "properties": [ + "keyword_field": [ + "type": "keyword", + "fields": [ + "test": [ + "type": "text" + ] + ], + ] + ] + ] + let settings: [String: Any] = ["number_of_shards": 3] + + let response = try await client.createIndex(indexName, mappings: mappings, settings: settings) + #expect(response.acknowledged == true) + + let exists = try await client.checkIndexExists(self.indexName) + #expect(exists == true) + } + + @Test + func testIndexExists() async throws { + let item = SomeItem(id: UUID(), name: "Banana") + let response = try await client.createDocument(item, in: self.indexName) + #expect(response.index == self.indexName) + #expect(response.result == "created") + try await Task.sleep(for: .seconds(0.5)) + + let exists = try await client.checkIndexExists(self.indexName) + #expect(exists == true) + + let notExists = try await client.checkIndexExists("some-random-index") + #expect(notExists == false) + } + + @Test + func testDeleteIndex() async throws { + let item = SomeItem(id: UUID(), name: "Banana") + _ = try await client.createDocument(item, in: self.indexName) + try await Task.sleep(for: .seconds(0.5)) + + let exists = try await client.checkIndexExists(self.indexName) + #expect(exists == true) + + let response = try await client.deleteIndex(self.indexName) + #expect(response.acknowledged == true) + + let notExists = try await client.checkIndexExists(self.indexName) + #expect(notExists == false) + } + + @Test + func testBulkCreate() async throws { + var items = [SomeItem]() + for index in 1...10 { + let name: String + if index % 2 == 0 { + name = "Some \(index) Apples" + } else { + name = "Some \(index) Bananas" + } + let item = SomeItem(id: UUID(), name: name) + items.append(item) + } + + let itemsWithIndex = items.map { ESBulkOperation(operationType: .create, index: self.indexName, id: $0.id, document: $0) } + let response = try await client.bulk(itemsWithIndex) + #expect(response.errors == false) + #expect(response.items.count == 10) + #expect(response.items.first?.create?.result == "created") + try await Task.sleep(for: .seconds(1)) + + let results = try await client.searchDocumentsCount(from: indexName, searchTerm: nil) + #expect(results.count == 10) + } + + @Test + func testBulkCreateUpdateDeleteIndex() async throws { + let item1 = SomeItem(id: UUID(), name: "Item 1") + let item2 = SomeItem(id: UUID(), name: "Item 2") + let item3 = SomeItem(id: UUID(), name: "Item 3") + let item4 = SomeItem(id: UUID(), name: "Item 4") + let bulkOperation = [ + ESBulkOperation(operationType: .create, index: self.indexName, id: item1.id, document: item1), + ESBulkOperation(operationType: .index, index: self.indexName, id: item2.id, document: item2), + ESBulkOperation(operationType: .update, index: self.indexName, id: item3.id, document: item3), + ESBulkOperation(operationType: .delete, index: self.indexName, id: item4.id, document: item4), + ] + + let response = try await client.bulk(bulkOperation) + #expect(response.items.count == 4) + #expect(response.items[0].create != nil) + #expect(response.items[1].index != nil) + #expect(response.items[2].update != nil) + #expect(response.items[3].delete != nil) + } + + @Test + func testSearchingItemsPaginated() async throws { + for index in 1...100 { + let name = "Some \(index) Apples" + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(2)) + + let results: ESGetMultipleDocumentsResponse = try await client.searchDocumentsPaginated( + from: indexName, searchTerm: "Apples", size: 20, offset: 10 + ) + #expect(results.hits.hits.count == 20) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" }) == true) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" }) == true) + } + + @Test + func testSearchingItemsWithTypeProvidedPaginated() async throws { + for index in 1...100 { + let name = "Some \(index) Apples" + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(1)) + + let results = try await client.searchDocumentsPaginated( + from: indexName, searchTerm: "Apples", size: 20, offset: 10, type: SomeItem.self) + + #expect(results.hits.hits.count == 20) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" }) == true) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" }) == true) + } + + @Test + func testGetItem() async throws { + let item = SomeItem(id: UUID(), name: "Some item") + _ = try await client.createDocumentWithID(item, in: self.indexName) + + try await Task.sleep(for: .seconds(1)) + + let retrievedItem: ESGetSingleDocumentResponse = try await client.get(id: item.id, from: self.indexName) + #expect(retrievedItem.source.name == item.name) + } + + @Test + func testBulkUpdateWithScript() async throws { + var items = [SomeItem]() + for index in 1...10 { + let name: String + if index % 2 == 0 { + name = "Some \(index) Apples" + } else { + name = "Some \(index) Bananas" + } + let item = SomeItem(id: UUID(), name: name, count: 0) + _ = try await client.createDocumentWithID(item, in: self.indexName) + items.append(item) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(1)) + + struct ScriptBody: Codable { + let inline: String + } + + let scriptBody = ScriptBody(inline: "ctx._source.count = ctx._source.count += 1") + + let bulkOperation = [ + ESBulkOperation(operationType: .updateScript, index: self.indexName, id: items[0].id, document: scriptBody) + ] + + let response = try await client.bulk(bulkOperation) + #expect(response.items.count == 1) + #expect(response.items.first?.update != nil) + #expect(response.errors == false) + + let retrievedItem: ESGetSingleDocumentResponse = try await client.get(id: items[0].id, from: self.indexName) + #expect(retrievedItem.source.count == 1) + } + + @Test + func testUpdateWithScript() async throws { + let item = SomeItem(id: UUID(), name: "Some Item", count: 0) + _ = try await client.createDocumentWithID(item, in: self.indexName) + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(1)) + + struct ScriptRequest: Codable { + let script: ScriptBody + } + + struct ScriptBody: Codable { + let inline: String + } + + let scriptBody = ScriptBody(inline: "ctx._source.count = ctx._source.count += 1") + let request = ScriptRequest(script: scriptBody) + + let response = try await client.updateDocumentWithScript(request, id: item.id, in: self.indexName) + #expect(response.result == "updated") + + let retrievedItem: ESGetSingleDocumentResponse = try await client.get(id: item.id, from: self.indexName) + #expect(retrievedItem.source.count == 1) + } + + @Test + func testUpdateWithNonExistentFieldScript() async throws { + let item = SomeItem(id: UUID(), name: "Some Item") + _ = try await client.createDocumentWithID(item, in: self.indexName) + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(1)) + + struct ScriptRequest: Codable { + let script: ScriptBody + } + + struct ScriptBody: Codable { + let inline: String + } + + let scriptBody = ScriptBody( + inline: "if(ctx._source.containsKey('count')) { ctx._source.count += 1 } else { ctx._source.count = 1 }") + let request = ScriptRequest(script: scriptBody) + + let response = try await client.updateDocumentWithScript(request, id: item.id, in: self.indexName) + #expect(response.result == "updated") + + let retrievedItem: ESGetSingleDocumentResponse = try await client.get(id: item.id, from: self.indexName) + #expect(retrievedItem.source.count == 1) + } + + @Test + func testBulkUpdateWithNonExistentFieldScript() async throws { + var items = [SomeItem]() + for index in 1...10 { + let name: String + if index % 2 == 0 { + name = "Some \(index) Apples" + } else { + name = "Some \(index) Bananas" + } + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocumentWithID(item, in: self.indexName) + items.append(item) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(2)) + + struct ScriptBody: Codable { + let inline: String + } + + let scriptBody = ScriptBody( + inline: "if(ctx._source.containsKey('count')) { ctx._source.count += 1 } else { ctx._source.count = 1 }") + + let bulkOperation = [ + ESBulkOperation(operationType: .updateScript, index: self.indexName, id: items[0].id, document: scriptBody) + ] + + let response = try await client.bulk(bulkOperation) + #expect(response.items.count == 1) + #expect(response.items.first?.update != nil) + #expect(response.errors == false) + + let retrievedItem: ESGetSingleDocumentResponse = try await client.get(id: items[0].id, from: self.indexName) + #expect(retrievedItem.source.count == 1) + } + + @Test + func testCountWithQueryBody() async throws { + try await setupItems() + + struct SearchQuery: Encodable { + let query: QueryBody + } + + struct QueryBody: Encodable { + let queryString: QueryString + + enum CodingKeys: String, CodingKey { + case queryString = "query_string" + } + } + + struct QueryString: Encodable { + let query: String + } + + let queryString = QueryString(query: "Apples") + let queryBody = QueryBody(queryString: queryString) + let searchQuery = SearchQuery(query: queryBody) + let results = try await client.searchDocumentsCount(from: indexName, query: searchQuery) + #expect(results.count == 5) + } + + @Test + func testPaginationQueryWithQueryBody() async throws { + for index in 1...100 { + let name = "Some \(index) Apples" + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(2)) + + struct QueryBody: Encodable { + let queryString: QueryString + + enum CodingKeys: String, CodingKey { + case queryString = "query_string" + } + } + + struct QueryString: Encodable { + let query: String + } + + let queryString = QueryString(query: "Apples") + let queryBody = QueryBody(queryString: queryString) + + let results: ESGetMultipleDocumentsResponse = try await client.searchDocumentsPaginated( + from: indexName, queryBody: queryBody, size: 20, offset: 10 + ) + #expect(results.hits.hits.count == 20) + #expect(results.hits.total!.value == 100) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" }) == true) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" }) == true) + } + + @Test + func testCustomSearch() async throws { + for index in 1...100 { + let name = "Some \(index) Apples" + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(2)) + + struct Query: Encodable { + let query: QueryBody + let from: Int + let size: Int + } + + struct QueryBody: Encodable { + let queryString: QueryString + + enum CodingKeys: String, CodingKey { + case queryString = "query_string" + } + } + + struct QueryString: Encodable { + let query: String + } + + let queryString = QueryString(query: "Apples") + let queryBody = QueryBody(queryString: queryString) + let query = Query(query: queryBody, from: 10, size: 20) + + let results: ESGetMultipleDocumentsResponse = try await client.customSearch(from: indexName, query: query) + #expect(results.hits.hits.count == 20) + #expect(results.hits.total!.value == 100) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" }) == true) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" }) == true) + } + + @Test + func testCustomSearchWithTrackTotalHitsFalse() async throws { + for index in 1...100 { + let name = "Some \(index) Apples" + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(2)) + + struct Query: Encodable { + let query: QueryBody + let from: Int + let size: Int + let trackTotalHits: Bool + + enum CodingKeys: String, CodingKey { + case query + case from + case size + case trackTotalHits = "track_total_hits" + } + } + + struct QueryBody: Encodable { + let queryString: QueryString + + enum CodingKeys: String, CodingKey { + case queryString = "query_string" + } + } + + struct QueryString: Encodable { + let query: String + } + + let queryString = QueryString(query: "Apples") + let queryBody = QueryBody(queryString: queryString) + let query = Query(query: queryBody, from: 10, size: 20, trackTotalHits: false) + + let results: ESGetMultipleDocumentsResponse = try await client.customSearch(from: indexName, query: query) + #expect(results.hits.total == nil) + #expect(results.hits.hits.count == 20) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" }) == true) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" }) == true) + } + + @Test + func testCustomRequest() async throws { + for index in 1...100 { + let name = "Some \(index) Apples" + let item = SomeItem(id: UUID(), name: name, count: index) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(2)) + + let query: [String: Any] = [ + "from": 0, + "size": 10, + "collapse": [ + "field": "id.keyword" + ], + "aggs": [ + "count-objects": [ + "cardinality": [ + "field": "id.keyword" + ] + ], + "count": [ + "avg": [ + "field": "count" + ] + ], + ], + ] + let queryData = try JSONSerialization.data(withJSONObject: query) + + let resultData = try await client.custom("/\(indexName)/_search", method: .get, body: queryData) + + let results = try JSONSerialization.jsonObject(with: resultData) as! [String: Any] + + let aggregations = results["aggregations"] as! [String: Any] + let countObjects = aggregations["count-objects"] as! [String: Any] + #expect(countObjects["value"] as! Double == 100) + let count = aggregations["count"] as! [String: Any] + #expect(count["value"] as! Double == 50.5) + } + + @Test + func testCustomRequestWithQueryItems() async throws { + // create index + let mappings: [String: Any] = [ + "properties": [ + "keyword_field": [ + "type": "keyword", + "fields": [ + "test": [ + "type": "text" + ] + ], + ] + ] + ] + let settings: [String: Any] = ["number_of_shards": 3] + let createResponse = try await client.createIndex(indexName, mappings: mappings, settings: settings) + #expect(createResponse.acknowledged == true) + + // get indices in json format + struct ESGetSingleIndexResponse: Decodable { + let index: String + } + let resultData = try await client.custom( + "/_cat/indices", queryItems: [URLQueryItem(name: "format", value: "json")], method: .get, body: "".data(using: .utf8)! + ) + let results = try JSONDecoder().decode([ESGetSingleIndexResponse].self, from: resultData) + #expect(results.map { $0.index }.first { $0 == indexName } != nil) + + // delete index + let deleteResponse = try await client.deleteIndex(self.indexName) + #expect(deleteResponse.acknowledged == true) + } + + @Test + func testCustomSearchWithDataQuery() async throws { + for index in 1...100 { + let name = "Some \(index) Apples" + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(1)) + + let query = """ + { + "from": 10, + "size": 20, + "query": { + "query_string": { + "query": "Apples" + } + } + } + """.data(using: .utf8)! + + let results: ESGetMultipleDocumentsResponse = try await client.customSearch(from: indexName, query: query) + #expect(results.hits.hits.count == 20) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 11 Apples" }) == true) + #expect(results.hits.hits.contains(where: { $0.source.name == "Some 29 Apples" }) == true) + } + + // MARK: - Private + private func setupItems() async throws { + for index in 1...10 { + let name: String + if index % 2 == 0 { + name = "Some \(index) Apples" + } else { + name = "Some \(index) Bananas" + } + let item = SomeItem(id: UUID(), name: name) + _ = try await client.createDocument(item, in: self.indexName) + } + + // This is required for ES to settle and load the indexes to return the right results + try await Task.sleep(for: .seconds(1)) + } +} diff --git a/Tests/ElasticsearchNIOClientTests/SomeItem.swift b/Tests/ElasticsearchTests/SomeItem.swift similarity index 100% rename from Tests/ElasticsearchNIOClientTests/SomeItem.swift rename to Tests/ElasticsearchTests/SomeItem.swift diff --git a/scripts/startLocalDockerESTest.swift b/scripts/startLocalDockerESTest.swift index e209dd7..21b36af 100755 --- a/scripts/startLocalDockerESTest.swift +++ b/scripts/startLocalDockerESTest.swift @@ -11,13 +11,13 @@ func shell(_ args: String..., returnStdOut: Bool = false) -> (Int32, Pipe) { let task = Process() task.launchPath = "/usr/bin/env" task.arguments = args - let pipe = Pipe() - if returnStdOut { - task.standardOutput = pipe - } - task.launch() - task.waitUntilExit() - return (task.terminationStatus, pipe) + let pipe = Pipe() + if returnStdOut { + task.standardOutput = pipe + } + task.launch() + task.waitUntilExit() + return (task.terminationStatus, pipe) } extension Pipe { @@ -33,7 +33,9 @@ extension Pipe { } } -let (dockerResult, _) = shell("docker", "run", "--name", containerName, "-p", "\(port):9200", "-e", "discovery.type=single-node", "-e", "ES_JAVA_OPTS=-Xms256m -Xmx256m", "-d", "docker.elastic.co/elasticsearch/elasticsearch:7.6.2") +let (dockerResult, _) = shell( + "docker", "run", "--name", containerName, "-p", "\(port):9200", "-e", "discovery.type=single-node", "-e", + "ES_JAVA_OPTS=-Xms256m -Xmx256m", "-d", "docker.elastic.co/elasticsearch/elasticsearch:7.6.2") guard dockerResult == 0 else { print("❌ ERROR: Failed to create the Elasticsearch instance") From e65d02b375bed262595c4fd04f57854ec5908a46 Mon Sep 17 00:00:00 2001 From: Paul Toffoloni Date: Thu, 19 Jun 2025 10:32:26 +0000 Subject: [PATCH 2/2] Add import for Linux --- .gitignore | 1 + Sources/Elasticsearch/ElasticsearchClient.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index fe23d14..f86e1ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /.build +/.index-build /Packages /*.xcodeproj xcuserdata/ diff --git a/Sources/Elasticsearch/ElasticsearchClient.swift b/Sources/Elasticsearch/ElasticsearchClient.swift index b52f32e..250364a 100644 --- a/Sources/Elasticsearch/ElasticsearchClient.swift +++ b/Sources/Elasticsearch/ElasticsearchClient.swift @@ -2,6 +2,7 @@ import AsyncHTTPClient import Foundation import HTTPTypes import Logging +import NIOFoundationCompat public struct ElasticsearchClient { public static let defaultPort = 9200