Added support for Buildkite's JSON errors, and wrote more tests for pagination

This commit is contained in:
Aaron Sky 2020-05-08 10:06:46 -04:00
parent 2865672821
commit 9481c26290
11 changed files with 312 additions and 115 deletions

View File

@ -49,7 +49,7 @@ public final class Buildkite {
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
}
public func send<R: Resource & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<R.Content>, Error>) -> Void) {
public func send<R: Resource & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<R.Content>, Error>) -> Void) {
let request = URLRequest(resource, configuration: configuration, pageOptions: pageOptions)
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
}
@ -65,7 +65,7 @@ public final class Buildkite {
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
}
public func send<R: Resource & HasRequestBody & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<R.Content>, Error>) -> Void) {
public func send<R: Resource & HasRequestBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<R.Content>, Error>) -> Void) {
let request: URLRequest
do {
request = try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions)
@ -92,17 +92,6 @@ public final class Buildkite {
transport.send(request: request, completion: handleEmptyResponse(completion: completion))
}
public func send<R: Resource & HasRequestBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<Void>, Error>) -> Void) {
let request: URLRequest
do {
request = try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions)
} catch {
completion(.failure(error))
return
}
transport.send(request: request, completion: handleEmptyResponse(completion: completion))
}
private func handleContentfulResponse<Content: Decodable>(completion: @escaping (Result<Response<Content>, Error>) -> Void) -> Transport.Completion {
return { [weak self] result in
guard let self = self else {
@ -113,7 +102,7 @@ public final class Buildkite {
do {
let data: Data
(data, response) = try result.get()
try self.checkResponseForIssues(response)
try self.checkResponseForIssues(response, data: data)
content = try self.decoder.decode(Content.self, from: data)
} catch {
completion(.failure(error))
@ -140,15 +129,20 @@ public final class Buildkite {
}
}
private func checkResponseForIssues(_ response: URLResponse) throws {
private func checkResponseForIssues(_ response: URLResponse, data: Data? = nil) throws {
guard let response = response as? HTTPURLResponse,
let statusCode = StatusCode(rawValue: response.statusCode) else {
throw ResponseError.missingResponse
}
if !statusCode.isSuccess {
if let data = data,
let errorIntermediary = try? decoder.decode(BuildkiteError.Intermediary.self, from: data) {
throw BuildkiteError(statusCode: statusCode, intermediary: errorIntermediary)
} else {
throw statusCode
}
}
}
}
#if canImport(Combine)
@ -159,7 +153,7 @@ extension Buildkite {
public func sendPublisher<R: Resource & HasResponseBody>(_ resource: R) -> AnyPublisher<Response<R.Content>, Error> {
transport.sendPublisher(request: URLRequest(resource, configuration: configuration))
.tryMap {
try self.checkResponseForIssues($0.response)
try self.checkResponseForIssues($0.response, data: $0.data)
let content = try self.decoder.decode(R.Content.self, from: $0.data)
return Response(content: content, response: $0.response)
}
@ -169,7 +163,7 @@ extension Buildkite {
public func sendPublisher<R: Resource & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<R.Content>, Error> {
transport.sendPublisher(request: URLRequest(resource, configuration: configuration, pageOptions: pageOptions))
.tryMap {
try self.checkResponseForIssues($0.response)
try self.checkResponseForIssues($0.response, data: $0.data)
let content = try self.decoder.decode(R.Content.self, from: $0.data)
return Response(content: content, response: $0.response)
}
@ -181,19 +175,19 @@ extension Buildkite {
.publisher
.flatMap(transport.sendPublisher)
.tryMap {
try self.checkResponseForIssues($0.response)
try self.checkResponseForIssues($0.response, data: $0.data)
let content = try self.decoder.decode(R.Content.self, from: $0.data)
return Response(content: content, response: $0.response)
}
.eraseToAnyPublisher()
}
public func sendPublisher<R: Resource & HasRequestBody & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<R.Content>, Error> {
public func sendPublisher<R: Resource & HasRequestBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<R.Content>, Error> {
Result { try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions) }
.publisher
.flatMap(transport.sendPublisher)
.tryMap {
try self.checkResponseForIssues($0.response)
try self.checkResponseForIssues($0.response, data: $0.data)
let content = try self.decoder.decode(R.Content.self, from: $0.data)
return Response(content: content, response: $0.response)
}
@ -219,16 +213,5 @@ extension Buildkite {
}
.eraseToAnyPublisher()
}
func sendPublisher<R: Resource & HasRequestBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<Void>, Error> {
Result { try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions) }
.publisher
.flatMap(transport.sendPublisher)
.tryMap {
try self.checkResponseForIssues($0.response)
return Response(content: (), response: $0.response)
}
.eraseToAnyPublisher()
}
}
#endif

View File

@ -127,7 +127,7 @@ public enum Job: Codable, Equatable {
}
}
public struct LogOutput: Codable {
public struct LogOutput: Codable, Equatable {
public var url: URL // Resource<Job.Resources.GetLogOutput>
public var content: String
public var size: Int

View File

@ -17,6 +17,23 @@ enum ResponseError: Error {
case unexpectedlyNoContent
}
public struct BuildkiteError: Error {
public var statusCode: StatusCode
public var message: String
public var errors: [String]
init(statusCode: StatusCode, intermediary: Intermediary) {
self.statusCode = statusCode
self.message = intermediary.message ?? ""
self.errors = intermediary.errors ?? []
}
struct Intermediary: Codable {
var message: String?
var errors: [String]?
}
}
public struct Response<T> {
public let content: T
public let response: URLResponse

View File

@ -12,7 +12,7 @@ import Foundation
import FoundationNetworking
#endif
public enum StatusCode: Int, Error {
public enum StatusCode: Int, Error, Codable {
/// The request was successfully processed by Buildkite.
case ok = 200
@ -22,6 +22,9 @@ public enum StatusCode: Int, Error {
/// The request has been accepted, but not yet processed.
case accepted = 202
/// The request was found
case found = 302
/// The response to the request can be found under a different URL in the Location header and can be retrieved using a GET method on that resource.
case seeOther = 303
@ -49,8 +52,6 @@ public enum StatusCode: Int, Error {
case unprocessableEntity = 422
/// The requested shop is currently locked. Shops are locked if they repeatedly exceed their API request limit, or if there is an issue with the account, such as a detected compromise or fraud risk.
///
/// Contact support if your shop is locked.
case locked = 423
/// The request was not accepted because the application has exceeded the rate limit. See the API Call Limit documentation for a breakdown of Buildkite's rate-limiting mechanism.
@ -72,5 +73,6 @@ public enum StatusCode: Int, Error {
self == .ok
|| self == .created
|| self == .accepted
|| self == .found
}
}

View File

@ -91,8 +91,8 @@ extension Job.Resources {
}
/// Get a jobs log output
public struct LogOutput: Resource {
typealias Content = Job.LogOutput
public struct LogOutput: Resource, HasResponseBody {
public typealias Content = Job.LogOutput
/// organization slug
public var organization: String
/// pipeline slug
@ -166,3 +166,36 @@ extension Job.Resources {
}
}
}
extension Job.Resources.LogOutput {
public struct Alternative: Resource, HasResponseBody {
public enum Format: String {
case html
case plainText = "txt"
}
public typealias Content = String
/// organization slug
public var organization: String
/// pipeline slug
public var pipeline: String
/// build number
public var build: Int
/// job ID
public var job: UUID
public var format: Format
public var path: String {
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/log.\(format)"
}
public init(organization: String, pipeline: String, build: Int, job: UUID, format: Format) {
self.organization = organization
self.pipeline = pipeline
self.build = build
self.job = job
self.format = format
}
}
}

View File

@ -21,7 +21,7 @@ extension Team.Resources {
public typealias Content = [Team]
/// organization slug
public var organization: String
/// Filters the results to teams that have the given user as a member.
public var userId: UUID?
public var path: String {

View File

@ -22,7 +22,10 @@ class BuildkiteTests: XCTestCase {
private struct TestData {
enum Case {
case success
case successPaginated
case successNoContent
case successHasBody
case successHasBodyPaginated
case badResponse
case unsuccessfulResponse
case noData
@ -38,8 +41,14 @@ class BuildkiteTests: XCTestCase {
switch testCase {
case .success:
responses = [try MockData.mockingSuccess(with: resources.content, url: configuration.baseURL)]
case .successPaginated:
responses = [try MockData.mockingSuccess(with: resources.paginatedContent, url: configuration.baseURL)]
case .successNoContent:
responses = [MockData.mockingSuccessNoContent(url: configuration.baseURL)]
case .successHasBody:
responses = [MockData.mockingSuccessNoContent(url: configuration.baseURL)]
case .successHasBodyPaginated:
responses = [try MockData.mockingSuccess(with: resources.bodyAndPaginatedContent, url: configuration.baseURL)]
case .badResponse:
responses = [MockData.mockingIncompatibleResponse(for: configuration.baseURL)]
case .unsuccessfulResponse:
@ -73,11 +82,55 @@ extension BuildkiteTests {
wait(for: [expectation])
}
func testClosureBasedRequestWithPagination() throws {
let testData = try TestData(testCase: .success)
let expectation = XCTestExpectation()
testData.client.send(testData.resources.paginatedContentResource, pageOptions: PageOptions(page: 1, perPage: 30)) { result in
do {
let response = try result.get()
XCTAssertEqual(testData.resources.paginatedContent, response.content)
XCTAssertNotNil(response.page)
} catch {
XCTFail(error.localizedDescription)
}
expectation.fulfill()
}
wait(for: [expectation])
}
func testClosureBasedRequestNoContent() throws {
let testData = try TestData(testCase: .successNoContent)
let expectation = XCTestExpectation()
testData.client.send(testData.resources.noContentResource) { _ in
testData.client.send(testData.resources.noContentNoBodyResource) { _ in
expectation.fulfill()
}
wait(for: [expectation])
}
func testClosureBasedRequestHasBody() throws {
let testData = try TestData(testCase: .successHasBody)
let expectation = XCTestExpectation()
testData.client.send(testData.resources.bodyResource) { _ in
expectation.fulfill()
}
wait(for: [expectation])
}
func testClosureBasedRequestHasBodyWithPagination() throws {
let testData = try TestData(testCase: .successHasBodyPaginated)
let expectation = XCTestExpectation()
testData.client.send(testData.resources.bodyAndPaginatedResource, pageOptions: PageOptions(page: 1, perPage: 30)) { result in
do {
let response = try result.get()
XCTAssertEqual(testData.resources.bodyAndPaginatedContent, response.content)
XCTAssertNotNil(response.page)
} catch {
XCTFail(error.localizedDescription)
}
expectation.fulfill()
}
wait(for: [expectation])
@ -149,11 +202,30 @@ extension BuildkiteTests {
wait(for: [expectation])
}
func testPublisherBasedRequestWithPagination() throws {
let testData = try TestData(testCase: .success)
let expectation = XCTestExpectation()
var cancellables: Set<AnyCancellable> = []
testData.client.sendPublisher(testData.resources.paginatedContentResource, pageOptions: PageOptions(page: 1, perPage: 30))
.sink(receiveCompletion: {
if case let .failure(error) = $0 {
XCTFail(error.localizedDescription)
}
expectation.fulfill()
},
receiveValue: {
XCTAssertEqual(testData.resources.paginatedContent, $0.content)
XCTAssertNotNil($0.page)
})
.store(in: &cancellables)
wait(for: [expectation])
}
func testPublisherBasedRequestNoContent() throws {
let testData = try TestData(testCase: .success)
let expectation = XCTestExpectation()
var cancellables: Set<AnyCancellable> = []
testData.client.sendPublisher(testData.resources.noContentResource)
testData.client.sendPublisher(testData.resources.noContentNoBodyResource)
.sink(receiveCompletion: {
if case let .failure(error) = $0 {
XCTFail(error.localizedDescription)
@ -164,6 +236,40 @@ extension BuildkiteTests {
wait(for: [expectation])
}
func testPublisherBasedRequestHasBody() throws {
let testData = try TestData(testCase: .successHasBody)
let expectation = XCTestExpectation()
var cancellables: Set<AnyCancellable> = []
testData.client.sendPublisher(testData.resources.bodyResource)
.sink(receiveCompletion: {
if case let .failure(error) = $0 {
XCTFail(error.localizedDescription)
}
expectation.fulfill()
}, receiveValue: { _ in })
.store(in: &cancellables)
wait(for: [expectation])
}
func testPublisherBasedRequestHasBodyWithPagination() throws {
let testData = try TestData(testCase: .successHasBodyPaginated)
let expectation = XCTestExpectation()
var cancellables: Set<AnyCancellable> = []
testData.client.sendPublisher(testData.resources.bodyAndPaginatedResource, pageOptions: PageOptions(page: 1, perPage: 30))
.sink(receiveCompletion: {
if case let .failure(error) = $0 {
XCTFail(error.localizedDescription)
}
expectation.fulfill()
},
receiveValue: {
XCTAssertEqual(testData.resources.bodyAndPaginatedContent, $0.content)
XCTAssertNotNil($0.page)
})
.store(in: &cancellables)
wait(for: [expectation])
}
func testPublisherBasedRequestInvalidResponse() throws {
let testData = try TestData(testCase: .badResponse)
let expectation = XCTestExpectation()

View File

@ -1,35 +0,0 @@
//
// PaginationTests.swift
// Buildkite
//
// Created by Aaron Sky on 5/7/20.
// Copyright © 2020 Aaron Sky. All rights reserved.
//
import Foundation
import XCTest
@testable import Buildkite
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
class PaginationTests: XCTestCase {
func testPagination() throws {
let expected = [Pipeline(), Pipeline()]
let context = try MockContext(content: expected)
let expectation = XCTestExpectation()
context.client.send(Pipeline.Resources.List(organization: "buildkite"), pageOptions: PageOptions(page: 1, perPage: 30)) { result in
do {
let response = try result.get()
XCTAssertEqual(expected, response.content)
} catch {
XCTFail(error.localizedDescription)
}
expectation.fulfill()
}
wait(for: [expectation])
}
}

View File

@ -55,13 +55,51 @@ class JobsTests: XCTestCase {
}
func testJobsLogOutput() throws {
let context = MockContext()
let expected = Job.LogOutput()
let context = try MockContext(content: expected)
let expectation = XCTestExpectation()
context.client.send(Job.Resources.LogOutput(organization: "buildkite", pipeline: "my-pipeline", build: 1, job: UUID())) { result in
do {
_ = try result.get()
let response = try result.get()
XCTAssertEqual(expected, response.content)
} catch {
XCTFail(error.localizedDescription)
}
expectation.fulfill()
}
wait(for: [expectation])
}
func testJobsLogOutputAlternativePlainText() throws {
let expected = "hello friends"
let context = try MockContext(content: expected)
let expectation = XCTestExpectation()
context.client.send(Job.Resources.LogOutput.Alternative(organization: "buildkite", pipeline: "my-pipeline", build: 1, job: UUID(), format: .plainText)) { result in
do {
let response = try result.get()
XCTAssertEqual(expected, response.content)
} catch {
XCTFail(error.localizedDescription)
}
expectation.fulfill()
}
wait(for: [expectation])
}
func testJobsLogOutputAlternativeHTML() throws {
let expected = "hello friends"
let context = try MockContext(content: expected)
let expectation = XCTestExpectation()
context.client.send(Job.Resources.LogOutput.Alternative(organization: "buildkite", pipeline: "my-pipeline", build: 1, job: UUID(), format: .html)) { result in
do {
let response = try result.get()
XCTAssertEqual(expected, response.content)
} catch {
XCTFail(error.localizedDescription)
}
@ -104,3 +142,12 @@ class JobsTests: XCTestCase {
wait(for: [expectation])
}
}
extension Job.LogOutput {
init() {
self.init(url: URL(),
content: "hello friends",
size: 13,
headerTimes: [])
}
}

View File

@ -14,6 +14,13 @@ import FoundationNetworking
#endif
struct MockResources {
struct NoContentNoBody: Resource {
typealias Content = Void
let path = "mock"
}
var noContentNoBodyResource = NoContentNoBody()
struct HasContent: Resource, HasResponseBody {
struct Content: Codable, Equatable {
var name: String
@ -25,12 +32,49 @@ struct MockResources {
var contentResource = HasContent()
var content = HasContent.Content(name: "Jeff", age: 35)
struct NoContent: Resource {
typealias Content = Void
struct HasPaginatedContent: Resource, Paginated {
struct Content: Codable, Equatable {
var name: String
var age: Int
}
let path = "mock"
}
var noContentResource = NoContent()
var paginatedContentResource = HasPaginatedContent()
var paginatedContent = HasPaginatedContent.Content(name: "Jeff", age: 35)
struct HasBody: Resource, HasRequestBody {
struct Body: Codable, Equatable {
var name: String
var age: Int
}
var body: Body
let path = "mock"
}
var bodyResource = HasBody(body: HasBody.Body(name: "Jeff", age: 35))
struct HasBodyAndPaginated: Resource, HasRequestBody, Paginated {
struct Body: Codable, Equatable {
var name: String
var age: Int
}
struct Content: Codable, Equatable {
var name: String
var age: Int
}
var body: Body
let path = "mock"
}
var bodyAndPaginatedResource = HasBodyAndPaginated(body: HasBodyAndPaginated.Body(name: "Jeff", age: 35))
var bodyAndPaginatedContent = HasBodyAndPaginated.Content(name: "Jeff", age: 35)
}
enum MockData {
@ -57,7 +101,7 @@ extension MockData {
}
static func mockingUnsuccessfulResponse(for url: URL) -> (Data, URLResponse) {
return (Data(), urlResponse(for: url, status: .notFound))
return (#"{"message":"not found","errors": ["go away"]}"#.data(using: .utf8)!, urlResponse(for: url, status: .notFound))
}
static func mockingSuccessNoContent(for request: URLRequest) throws -> (Data, URLResponse) {
@ -76,7 +120,7 @@ extension MockData {
HTTPURLResponse(url: url,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: [:])!
headerFields: ["Link":#"<https://api.buildkite.com/v2/organizations/my-great-org/pipelines/my-pipeline/builds?api_key=f8582f070276d764ce3dd4c6d57be92574dccf86&page=3>; rel="prev",<https://api.buildkite.com/v2/organizations/my-great-org/pipelines/my-pipeline/builds?api_key=f8582f070276d764ce3dd4c6d57be92574dccf86&page=5>; rel="next",<https://api.buildkite.com/v2/organizations/my-great-org/pipelines/my-pipeline/builds?api_key=f8582f070276d764ce3dd4c6d57be92574dccf86&page=1>; rel="first", <https://api.buildkite.com/v2/organizations/my-great-org/pipelines/my-pipeline/builds?api_key=f8582f070276d764ce3dd4c6d57be92574dccf86&page=10>; rel="last""#])!
}
}