* Added graphql_id property
* renamed URL fields * WIP webhooks support * WIP webhooks example
This commit is contained in:
parent
2bd91f7315
commit
790f6e7453
|
@ -6,3 +6,4 @@
|
||||||
/*.xcodeproj
|
/*.xcodeproj
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
Package.resolved
|
Package.resolved
|
||||||
|
*.env*
|
|
@ -17,7 +17,8 @@ let package = Package(
|
||||||
.executable(name: "webhooks", targets: ["webhooks"]),
|
.executable(name: "webhooks", targets: ["webhooks"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(name: "Buildkite", path: "../")
|
.package(name: "Buildkite", path: "../"),
|
||||||
|
.package(url: "https://github.com/vapor/vapor", .upToNextMajor(from: "4.0.0"))
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
|
@ -51,7 +52,8 @@ let package = Package(
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "webhooks",
|
name: "webhooks",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "Buildkite", package: "Buildkite")
|
.product(name: "Buildkite", package: "Buildkite"),
|
||||||
|
.product(name: "Vapor", package: "vapor")
|
||||||
],
|
],
|
||||||
path: "webhooks"
|
path: "webhooks"
|
||||||
),
|
),
|
||||||
|
|
|
@ -27,6 +27,6 @@ import Foundation
|
||||||
)
|
)
|
||||||
.content
|
.content
|
||||||
|
|
||||||
print(result.runUrl)
|
print(result.runURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
//
|
||||||
|
// BuildkiteClient+Middleware.swift
|
||||||
|
// webhooks
|
||||||
|
//
|
||||||
|
// Created by Aaron Sky on 9/5/22.
|
||||||
|
// Copyright © 2022 Aaron Sky. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Buildkite
|
||||||
|
import Foundation
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
extension HTTPHeaders {
|
||||||
|
fileprivate var buildkiteEvent: String? {
|
||||||
|
self.first(name: WebhookEvent.HTTPHeaders.buildkiteEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate var buildkiteToken: String? {
|
||||||
|
self.first(name: WebhookEvent.HTTPHeaders.buildkiteToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate var buildkiteSignature: String? {
|
||||||
|
self.first(name: WebhookEvent.HTTPHeaders.buildkiteSignature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BuildkiteClient: ContentDecoder {
|
||||||
|
public nonisolated func decode<D>(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D where D : Decodable {
|
||||||
|
// FIXME: force cast
|
||||||
|
try decodeWebhook(from: Data(buffer: body)) as! D
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(macOS 12.0, *)
|
||||||
|
extension BuildkiteClient: AsyncMiddleware {
|
||||||
|
public func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Vapor.Response {
|
||||||
|
guard Environment.get("BUILDKITE_NO_VERIFY") == nil else {
|
||||||
|
return try await next.respond(to: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let secretKey = Environment
|
||||||
|
.get("BUILDKITE_WEBHOOK_SECRET")?
|
||||||
|
.data(using: .utf8)
|
||||||
|
else {
|
||||||
|
throw Abort(
|
||||||
|
.preconditionFailed,
|
||||||
|
reason: "Server not configured with webhook authentication token"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if
|
||||||
|
let signature = request.headers.buildkiteSignature,
|
||||||
|
let payload = request.body.data {
|
||||||
|
try self.validateWebhookPayload(
|
||||||
|
signatureHeader: signature,
|
||||||
|
body: Data(buffer: payload),
|
||||||
|
secretKey: secretKey
|
||||||
|
)
|
||||||
|
} else if let token = request.headers.buildkiteToken {
|
||||||
|
try self.validateWebhookPayload(tokenHeader: token, secretKey: secretKey)
|
||||||
|
} else {
|
||||||
|
throw Abort(.unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await next.respond(to: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BuildkiteClient: Middleware {
|
||||||
|
public nonisolated func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Vapor.Response> {
|
||||||
|
guard Environment.get("BUILDKITE_NO_VERIFY") == nil else {
|
||||||
|
return next.respond(to: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let secretKey = Environment
|
||||||
|
.get("BUILDKITE_WEBHOOK_SECRET")?
|
||||||
|
.data(using: .utf8)
|
||||||
|
else {
|
||||||
|
return request.eventLoop.future(
|
||||||
|
error: Abort(
|
||||||
|
.preconditionFailed,
|
||||||
|
reason: "Server not configured with webhook authentication token"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if
|
||||||
|
let signature = request.headers.buildkiteSignature,
|
||||||
|
let payload = request.body.data {
|
||||||
|
return request.eventLoop.tryFuture {
|
||||||
|
try self.validateWebhookPayload(
|
||||||
|
signatureHeader: signature,
|
||||||
|
body: Data(buffer: payload),
|
||||||
|
secretKey: secretKey
|
||||||
|
)
|
||||||
|
}.flatMap {
|
||||||
|
next.respond(to: request)
|
||||||
|
}
|
||||||
|
} else if let token = request.headers.buildkiteToken {
|
||||||
|
return request.eventLoop.tryFuture {
|
||||||
|
try self.validateWebhookPayload(
|
||||||
|
tokenHeader: token,
|
||||||
|
secretKey: secretKey
|
||||||
|
)
|
||||||
|
}.flatMap {
|
||||||
|
next.respond(to: request)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return request.eventLoop.future(
|
||||||
|
error: Abort(.unauthorized)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,14 +7,28 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Buildkite
|
import Buildkite
|
||||||
import Foundation
|
import Vapor
|
||||||
|
|
||||||
#if canImport(FoundationNetworking)
|
|
||||||
import FoundationNetworking
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
@available(macOS 12.0, *)
|
||||||
@main struct Example {
|
@main struct Example {
|
||||||
static func main() {
|
static func main() throws {
|
||||||
|
let app = try Application(.detect())
|
||||||
|
defer { app.shutdown() }
|
||||||
|
|
||||||
|
let buildkite = BuildkiteClient(/*transport: app.client*/)
|
||||||
|
|
||||||
|
app.group(buildkite) {
|
||||||
|
$0.post("buildkite_webhook") { req in
|
||||||
|
let event = try req.content.decode(WebhookEvent.self, using: buildkite)
|
||||||
|
print(event)
|
||||||
|
return HTTPStatus.ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("buildkite_webhook") { req in
|
||||||
|
"Hello world"
|
||||||
|
}
|
||||||
|
|
||||||
|
try app.run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,10 +16,18 @@ let package = Package(
|
||||||
targets: ["Buildkite"]
|
targets: ["Buildkite"]
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(
|
||||||
|
url: "https://github.com/apple/swift-crypto.git",
|
||||||
|
.upToNextMajor(from: "2.0.0")
|
||||||
|
)
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "Buildkite",
|
name: "Buildkite",
|
||||||
dependencies: []
|
dependencies: [
|
||||||
|
.product(name: "Crypto", package: "swift-crypto"),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "BuildkiteTests",
|
name: "BuildkiteTests",
|
||||||
|
|
|
@ -13,17 +13,15 @@ import FoundationNetworking
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public actor BuildkiteClient {
|
public actor BuildkiteClient {
|
||||||
private var encoder: JSONEncoder {
|
nonisolated var encoder: JSONEncoder {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .custom(Formatters.encodeISO8601)
|
encoder.dateEncodingStrategy = .custom(Formatters.encodeISO8601)
|
||||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
||||||
return encoder
|
return encoder
|
||||||
}
|
}
|
||||||
|
|
||||||
private var decoder: JSONDecoder {
|
nonisolated var decoder: JSONDecoder {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.dateDecodingStrategy = .custom(Formatters.decodeISO8601)
|
decoder.dateDecodingStrategy = .custom(Formatters.decodeISO8601)
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
return decoder
|
return decoder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,8 +14,9 @@ import FoundationNetworking
|
||||||
|
|
||||||
public struct Agent: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct Agent: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
public var url: Followable<Agent.Resources.Get>
|
public var url: Followable<Agent.Resources.Get>
|
||||||
public var webUrl: URL
|
public var webURL: URL
|
||||||
public var name: String
|
public var name: String
|
||||||
public var connectionState: String
|
public var connectionState: String
|
||||||
public var hostname: String
|
public var hostname: String
|
||||||
|
@ -28,4 +29,23 @@ public struct Agent: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var lastJobFinishedAt: Date?
|
public var lastJobFinishedAt: Date?
|
||||||
public var priority: Int?
|
public var priority: Int?
|
||||||
public var metaData: [String]
|
public var metaData: [String]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
case url
|
||||||
|
case webURL = "web_url"
|
||||||
|
case name
|
||||||
|
case connectionState = "connection_state"
|
||||||
|
case hostname
|
||||||
|
case ipAddress = "ip_address"
|
||||||
|
case userAgent = "user_agent"
|
||||||
|
case version
|
||||||
|
case creator
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case job
|
||||||
|
case lastJobFinishedAt = "last_job_finished_at"
|
||||||
|
case priority
|
||||||
|
case metaData = "meta_data"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,4 +26,13 @@ public struct Annotation: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var bodyHtml: String
|
public var bodyHtml: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case context
|
||||||
|
case style
|
||||||
|
case bodyHtml = "body_html"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ public struct Artifact: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
public var jobId: UUID
|
public var jobId: UUID
|
||||||
public var url: Followable<Artifact.Resources.Get>
|
public var url: Followable<Artifact.Resources.Get>
|
||||||
public var downloadUrl: Followable<Artifact.Resources.Download>
|
public var downloadURL: Followable<Artifact.Resources.Download>
|
||||||
public var state: State
|
public var state: State
|
||||||
public var path: String
|
public var path: String
|
||||||
public var dirname: String
|
public var dirname: String
|
||||||
|
@ -35,4 +35,18 @@ public struct Artifact: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public struct URLs: Codable, Equatable {
|
public struct URLs: Codable, Equatable {
|
||||||
public var url: URL
|
public var url: URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case jobId = "job_id"
|
||||||
|
case url
|
||||||
|
case downloadURL = "download_url"
|
||||||
|
case state
|
||||||
|
case path
|
||||||
|
case dirname
|
||||||
|
case filename
|
||||||
|
case mimeType = "mime_type"
|
||||||
|
case fileSize = "file_size"
|
||||||
|
case sha1sum
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,9 @@ import FoundationNetworking
|
||||||
|
|
||||||
public struct Build: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct Build: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
public var url: Followable<Build.Resources.Get>
|
public var url: Followable<Build.Resources.Get>
|
||||||
public var webUrl: URL
|
public var webURL: URL
|
||||||
public var number: Int
|
public var number: Int
|
||||||
public var state: State
|
public var state: State
|
||||||
public var blocked: Bool
|
public var blocked: Bool
|
||||||
|
@ -46,4 +47,28 @@ public struct Build: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
case notRun = "not_run"
|
case notRun = "not_run"
|
||||||
case finished
|
case finished
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
case url
|
||||||
|
case webURL = "web_url"
|
||||||
|
case number
|
||||||
|
case state
|
||||||
|
case blocked
|
||||||
|
case message
|
||||||
|
case commit
|
||||||
|
case branch
|
||||||
|
case env
|
||||||
|
case source
|
||||||
|
case creator
|
||||||
|
case jobs
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case scheduledAt = "scheduled_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case metaData = "meta_data"
|
||||||
|
case pullRequest = "pull_request"
|
||||||
|
case pipeline
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,15 +68,16 @@ public enum Job: Codable, Equatable, Hashable, Sendable {
|
||||||
|
|
||||||
public var type = "script"
|
public var type = "script"
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
public var name: String?
|
public var name: String?
|
||||||
public var state: String?
|
public var state: String?
|
||||||
public var command: String?
|
public var command: String?
|
||||||
public var stepKey: String?
|
public var stepKey: String?
|
||||||
public var buildUrl: URL
|
public var buildURL: URL
|
||||||
public var webUrl: URL
|
public var webURL: URL
|
||||||
public var logUrl: Followable<Job.Resources.LogOutput>
|
public var logURL: Followable<Job.Resources.LogOutput>
|
||||||
public var rawLogUrl: Followable<Job.Resources.LogOutput.Alternative>
|
public var rawLogURL: Followable<Job.Resources.LogOutput.Alternative>
|
||||||
public var artifactsUrl: URL
|
public var artifactsURL: URL
|
||||||
public var softFailed: Bool
|
public var softFailed: Bool
|
||||||
public var exitStatus: Int?
|
public var exitStatus: Int?
|
||||||
public var artifactPaths: String?
|
public var artifactPaths: String?
|
||||||
|
@ -92,23 +93,74 @@ public enum Job: Codable, Equatable, Hashable, Sendable {
|
||||||
public var retriesCount: Int?
|
public var retriesCount: Int?
|
||||||
public var parallelGroupIndex: Int?
|
public var parallelGroupIndex: Int?
|
||||||
public var parallelGroupTotal: Int?
|
public var parallelGroupTotal: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
case name
|
||||||
|
case state
|
||||||
|
case command
|
||||||
|
case stepKey = "step_key"
|
||||||
|
case buildURL = "build_url"
|
||||||
|
case webURL = "web_url"
|
||||||
|
case logURL = "log_url"
|
||||||
|
case rawLogURL = "raw_log_url"
|
||||||
|
case artifactsURL = "artifacts_url"
|
||||||
|
case softFailed = "soft_failed"
|
||||||
|
case exitStatus = "exit_status"
|
||||||
|
case artifactPaths = "artifact_paths"
|
||||||
|
case agentQueryRules = "agent_query_rules"
|
||||||
|
case agent
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case scheduledAt = "scheduled_at"
|
||||||
|
case runnableAt = "runnable_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case retried
|
||||||
|
case retriedInJobId = "retried_in_job_id"
|
||||||
|
case retriesCount = "retries_count"
|
||||||
|
case parallelGroupIndex = "parallel_group_index"
|
||||||
|
case parallelGroupTotal = "parallel_group_total"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Wait: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct Wait: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var type = "waiter"
|
public var type = "waiter"
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Block: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct Block: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var type = "manual"
|
public var type = "manual"
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
public var label: String
|
public var label: String
|
||||||
public var state: String
|
public var state: String
|
||||||
public var webUrl: URL?
|
public var webURL: URL?
|
||||||
public var unblockedBy: User?
|
public var unblockedBy: User?
|
||||||
public var unblockedAt: Date?
|
public var unblockedAt: Date?
|
||||||
public var unblockable: Bool
|
public var unblockable: Bool
|
||||||
public var unblockUrl: URL
|
public var unblockURL: URL
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
case label
|
||||||
|
case state
|
||||||
|
case webURL = "web_url"
|
||||||
|
case unblockedBy = "unblocked_by"
|
||||||
|
case unblockedAt = "unblocked_at"
|
||||||
|
case unblockable
|
||||||
|
case unblockURL = "unblock_url"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Trigger: Codable, Equatable, Hashable, Sendable {
|
public struct Trigger: Codable, Equatable, Hashable, Sendable {
|
||||||
|
@ -116,19 +168,39 @@ public enum Job: Codable, Equatable, Hashable, Sendable {
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
public var number: Int
|
public var number: Int
|
||||||
public var url: URL
|
public var url: URL
|
||||||
public var webUrl: URL
|
public var webURL: URL
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case number
|
||||||
|
case url
|
||||||
|
case webURL = "web_url"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var type = "trigger"
|
public var type = "trigger"
|
||||||
public var name: String?
|
public var name: String?
|
||||||
public var state: String?
|
public var state: String?
|
||||||
public var buildUrl: URL
|
public var buildURL: URL
|
||||||
public var webUrl: URL
|
public var webURL: URL
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var scheduledAt: Date?
|
public var scheduledAt: Date?
|
||||||
public var finishedAt: Date?
|
public var finishedAt: Date?
|
||||||
public var runnableAt: Date?
|
public var runnableAt: Date?
|
||||||
public var triggeredBuild: TriggeredBuild?
|
public var triggeredBuild: TriggeredBuild?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case name
|
||||||
|
case state
|
||||||
|
case buildURL = "build_url"
|
||||||
|
case webURL = "web_url"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case scheduledAt = "scheduled_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case runnableAt = "runnable_at"
|
||||||
|
case triggeredBuild = "triggered_build"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct LogOutput: Codable, Equatable, Hashable, Sendable {
|
public struct LogOutput: Codable, Equatable, Hashable, Sendable {
|
||||||
|
@ -136,6 +208,13 @@ public enum Job: Codable, Equatable, Hashable, Sendable {
|
||||||
public var content: String
|
public var content: String
|
||||||
public var size: Int
|
public var size: Int
|
||||||
public var headerTimes: [Int]
|
public var headerTimes: [Int]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case url
|
||||||
|
case content
|
||||||
|
case size
|
||||||
|
case headerTimes = "header_times"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct EnvironmentVariables: Codable, Equatable, Hashable, Sendable {
|
public struct EnvironmentVariables: Codable, Equatable, Hashable, Sendable {
|
||||||
|
|
|
@ -23,7 +23,6 @@ public struct Meta: Codable, Equatable, Hashable, Sendable {
|
||||||
public var webhookIPRanges: [String]
|
public var webhookIPRanges: [String]
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
// This corresponds to the key "webhook_ips" from the Buildkite payload.
|
case webhookIPRanges = "webhook_ips"
|
||||||
case webhookIPRanges = "webhookIps"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,12 +14,26 @@ import FoundationNetworking
|
||||||
|
|
||||||
public struct Organization: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct Organization: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
public var url: Followable<Organization.Resources.Get>
|
public var url: Followable<Organization.Resources.Get>
|
||||||
public var webUrl: URL
|
public var webURL: URL
|
||||||
public var name: String
|
public var name: String
|
||||||
public var slug: String
|
public var slug: String
|
||||||
public var pipelinesUrl: Followable<Pipeline.Resources.List>
|
public var pipelinesURL: Followable<Pipeline.Resources.List>
|
||||||
public var agentsUrl: Followable<Agent.Resources.List>
|
public var agentsURL: Followable<Agent.Resources.List>
|
||||||
public var emojisUrl: Followable<Emoji.Resources.List>
|
public var emojisURL: Followable<Emoji.Resources.List>
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
case url
|
||||||
|
case webURL = "web_url"
|
||||||
|
case name
|
||||||
|
case slug
|
||||||
|
case pipelinesURL = "pipelines_url"
|
||||||
|
case agentsURL = "agents_url"
|
||||||
|
case emojisURL = "emojis_url"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,9 @@ import FoundationNetworking
|
||||||
|
|
||||||
public struct Pipeline: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct Pipeline: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
public var url: Followable<Pipeline.Resources.Get>
|
public var url: Followable<Pipeline.Resources.Get>
|
||||||
public var webUrl: URL
|
public var webURL: URL
|
||||||
public var name: String
|
public var name: String
|
||||||
public var slug: String
|
public var slug: String
|
||||||
public var repository: String
|
public var repository: String
|
||||||
|
@ -26,8 +27,8 @@ public struct Pipeline: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var skipQueuedBranchBuildsFilter: String?
|
public var skipQueuedBranchBuildsFilter: String?
|
||||||
public var cancelRunningBranchBuilds: Bool
|
public var cancelRunningBranchBuilds: Bool
|
||||||
public var cancelRunningBranchBuildsFilter: String?
|
public var cancelRunningBranchBuildsFilter: String?
|
||||||
public var buildsUrl: Followable<Build.Resources.ListForPipeline>
|
public var buildsURL: Followable<Build.Resources.ListForPipeline>
|
||||||
public var badgeUrl: URL
|
public var badgeURL: URL
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var scheduledBuildsCount: Int
|
public var scheduledBuildsCount: Int
|
||||||
public var runningBuildsCount: Int
|
public var runningBuildsCount: Int
|
||||||
|
@ -40,8 +41,42 @@ public struct Pipeline: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
|
|
||||||
public struct Provider: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct Provider: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var webhookUrl: URL?
|
public var webhookURL: URL?
|
||||||
public var settings: Settings
|
public var settings: Settings
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case webhookURL = "webhook_url"
|
||||||
|
case settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
case url
|
||||||
|
case webURL = "web_url"
|
||||||
|
case name
|
||||||
|
case slug
|
||||||
|
case repository
|
||||||
|
case branchConfiguration = "branch_configuration"
|
||||||
|
case defaultBranch = "default_branch"
|
||||||
|
case provider
|
||||||
|
case skipQueuedBranchBuilds = "skip_queued_branch_builds"
|
||||||
|
case skipQueuedBranchBuildsFilter = "skip_queued_branch_builds_filter"
|
||||||
|
case cancelRunningBranchBuilds = "cancel_running_branch_builds"
|
||||||
|
case cancelRunningBranchBuildsFilter = "cancel_running_branch_builds_filter"
|
||||||
|
case buildsURL = "builds_url"
|
||||||
|
case badgeURL = "badge_url"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case scheduledBuildsCount = "scheduled_builds_count"
|
||||||
|
case runningBuildsCount = "running_builds_count"
|
||||||
|
case scheduledJobsCount = "scheduled_jobs_count"
|
||||||
|
case runningJobsCount = "running_jobs_count"
|
||||||
|
case waitingJobsCount = "waiting_jobs_count"
|
||||||
|
case visibility
|
||||||
|
case steps
|
||||||
|
case env
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,6 +111,24 @@ extension Pipeline.Provider {
|
||||||
public var separatePullRequestStatuses: Bool?
|
public var separatePullRequestStatuses: Bool?
|
||||||
/// The status to use for blocked builds. Pending can be used with required status checks to prevent merging pull requests with blocked builds.
|
/// The status to use for blocked builds. Pending can be used with required status checks to prevent merging pull requests with blocked builds.
|
||||||
public var publishBlockedAsPending: Bool?
|
public var publishBlockedAsPending: Bool?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case repository
|
||||||
|
case buildPullRequests = "build_pull_requests"
|
||||||
|
case pullRequestBranchFilterEnabled = "pull_request_branch_filter_enabled"
|
||||||
|
case pullRequestBranchFilterConfiguration = "pull_request_branch_filter_configuration"
|
||||||
|
case skipPullRequestBuildsForExistingCommits = "skip_pull_request_builds_for_existing_commits"
|
||||||
|
case buildTags = "build_tags"
|
||||||
|
case publishCommitStatus = "publish_commit_status"
|
||||||
|
case publishCommitStatusPerStep = "publish_commit_status_per_step"
|
||||||
|
case triggerMode = "trigger_mode"
|
||||||
|
case filterEnabled = "filter_enabled"
|
||||||
|
case filterCondition = "filter_condition"
|
||||||
|
case buildPullRequestForks = "build_pull_request_forks"
|
||||||
|
case prefixPullRequestForkBranchNames = "prefix_pull_request_fork_branch_names"
|
||||||
|
case separatePullRequestStatuses = "separate_pull_request_statuses"
|
||||||
|
case publishBlockedAsPending = "publish_blocked_as_pending"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,12 +193,33 @@ extension Pipeline {
|
||||||
public var async: Bool?
|
public var async: Bool?
|
||||||
public var concurrency: Int?
|
public var concurrency: Int?
|
||||||
public var parallelism: Int?
|
public var parallelism: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case name
|
||||||
|
case command
|
||||||
|
case label
|
||||||
|
case artifactPaths = "artifact_paths"
|
||||||
|
case branchConfiguration = "branch_configuration"
|
||||||
|
case env
|
||||||
|
case timeoutInMinutes = "timeout_in_minutes"
|
||||||
|
case agentQueryRules = "agent_query_rules"
|
||||||
|
case async
|
||||||
|
case concurrency
|
||||||
|
case parallelism
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Wait: Codable, Equatable, Hashable, Sendable {
|
public struct Wait: Codable, Equatable, Hashable, Sendable {
|
||||||
public var type = "waiter"
|
public var type = "waiter"
|
||||||
public var label: String?
|
public var label: String?
|
||||||
public var continueAfterFailure: Bool?
|
public var continueAfterFailure: Bool?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case label
|
||||||
|
case continueAfterFailure = "continue_after_failure"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Block: Codable, Equatable, Hashable, Sendable {
|
public struct Block: Codable, Equatable, Hashable, Sendable {
|
||||||
|
@ -160,6 +234,15 @@ extension Pipeline {
|
||||||
public var triggerCommit: String?
|
public var triggerCommit: String?
|
||||||
public var triggerBranch: String?
|
public var triggerBranch: String?
|
||||||
public var triggerAsync: Bool?
|
public var triggerAsync: Bool?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case triggerProjectSlug = "trigger_project_slug"
|
||||||
|
case label
|
||||||
|
case triggerCommit = "trigger_commit"
|
||||||
|
case triggerBranch = "trigger_branch"
|
||||||
|
case triggerAsync = "trigger_async"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import FoundationNetworking
|
||||||
public struct Team: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct Team: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
/// ID of the team
|
/// ID of the team
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
/// Name of the team
|
/// Name of the team
|
||||||
public var name: String
|
public var name: String
|
||||||
/// URL slug of the team
|
/// URL slug of the team
|
||||||
|
@ -34,4 +35,16 @@ public struct Team: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
case visible
|
case visible
|
||||||
case secret
|
case secret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
case name
|
||||||
|
case slug
|
||||||
|
case description
|
||||||
|
case privacy
|
||||||
|
case `default`
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case createdBy = "created_by"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,5 +96,26 @@ public struct Trace: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
self.detail = detail
|
self.detail = detail
|
||||||
self.children = children
|
self.children = children
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case section
|
||||||
|
case startAt = "start_at"
|
||||||
|
case endAt = "end_at"
|
||||||
|
case duration
|
||||||
|
case detail
|
||||||
|
case children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case scope
|
||||||
|
case name
|
||||||
|
case identifier
|
||||||
|
case location
|
||||||
|
case fileName = "file_name"
|
||||||
|
case result
|
||||||
|
case failureReason = "failure_reason"
|
||||||
|
case history
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,18 @@ import FoundationNetworking
|
||||||
|
|
||||||
public struct User: Codable, Equatable, Hashable, Identifiable, Sendable {
|
public struct User: Codable, Equatable, Hashable, Identifiable, Sendable {
|
||||||
public var id: UUID
|
public var id: UUID
|
||||||
|
public var graphqlId: String
|
||||||
public var name: String
|
public var name: String
|
||||||
public var email: String
|
public var email: String
|
||||||
public var avatarUrl: URL
|
public var avatarURL: URL
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case graphqlId = "graphql_id"
|
||||||
|
case name
|
||||||
|
case email
|
||||||
|
case avatarURL = "avatar_url"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,12 +84,17 @@ public enum WebhookEvent: Codable, Equatable, Hashable, Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Ping: Codable, Equatable, Hashable, Sendable {
|
public struct Ping: Codable, Equatable, Hashable, Sendable {
|
||||||
|
public var event: Event
|
||||||
/// The notification service that sent this webhook
|
/// The notification service that sent this webhook
|
||||||
public var service: Service
|
public var service: Service
|
||||||
/// The ``Organization`` this notification belongs to
|
/// The ``Organization`` this notification belongs to
|
||||||
public var organization: Organization
|
public var organization: Organization
|
||||||
/// The user who created the webhook
|
/// The user who created the webhook
|
||||||
public var sender: Sender
|
public var sender: Sender
|
||||||
|
|
||||||
|
public enum Event: String, Codable, Equatable, Hashable, Sendable {
|
||||||
|
case ping
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Build: Codable, Equatable, Hashable, Sendable {
|
public struct Build: Codable, Equatable, Hashable, Sendable {
|
||||||
|
@ -103,11 +108,11 @@ public enum WebhookEvent: Codable, Equatable, Hashable, Sendable {
|
||||||
|
|
||||||
public enum Event: String, Codable, Equatable, Hashable, Sendable {
|
public enum Event: String, Codable, Equatable, Hashable, Sendable {
|
||||||
/// A build has been scheduled
|
/// A build has been scheduled
|
||||||
case scheduled
|
case scheduled = "build.scheduled"
|
||||||
/// A build has started running
|
/// A build has started running
|
||||||
case running
|
case running = "build.running"
|
||||||
/// A build has finished
|
/// A build has finished
|
||||||
case finished
|
case finished = "build.finished"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,13 +129,13 @@ public enum WebhookEvent: Codable, Equatable, Hashable, Sendable {
|
||||||
|
|
||||||
public enum Event: String, Codable, Equatable, Hashable, Sendable {
|
public enum Event: String, Codable, Equatable, Hashable, Sendable {
|
||||||
/// A command step job has been scheduled to run on an agent
|
/// A command step job has been scheduled to run on an agent
|
||||||
case scheduled
|
case scheduled = "job.scheduled"
|
||||||
/// A command step job has started running on an agent
|
/// A command step job has started running on an agent
|
||||||
case started
|
case started = "job.started"
|
||||||
/// A job has finished
|
/// A job has finished
|
||||||
case finished
|
case finished = "job.finished"
|
||||||
/// A block step job has been unblocked using the web or API
|
/// A block step job has been unblocked using the web or API
|
||||||
case activated
|
case activated = "job.activated"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -143,15 +148,15 @@ public enum WebhookEvent: Codable, Equatable, Hashable, Sendable {
|
||||||
|
|
||||||
public enum Event: String, Codable, Equatable, Hashable, Sendable {
|
public enum Event: String, Codable, Equatable, Hashable, Sendable {
|
||||||
/// An agent has connected to the API
|
/// An agent has connected to the API
|
||||||
case connected
|
case connected = "agent.connected"
|
||||||
/// An agent has been marked as lost. This happens when Buildkite stops receiving pings from the agent
|
/// An agent has been marked as lost. This happens when Buildkite stops receiving pings from the agent
|
||||||
case lost
|
case lost = "agent.lost"
|
||||||
/// An agent has disconnected. This happens when the agent shuts down and disconnects from the API
|
/// An agent has disconnected. This happens when the agent shuts down and disconnects from the API
|
||||||
case disconnected
|
case disconnected = "agent.disconnected"
|
||||||
/// An agent is stopping. This happens when an agent is instructed to stop from the API. It first transitions to stopping and finishes any current jobs
|
/// An agent is stopping. This happens when an agent is instructed to stop from the API. It first transitions to stopping and finishes any current jobs
|
||||||
case stopping
|
case stopping = "agent.stopping"
|
||||||
/// An agent has stopped. This happens when an agent is instructed to stop from the API. It can be graceful or forceful
|
/// An agent has stopped. This happens when an agent is instructed to stop from the API. It can be graceful or forceful
|
||||||
case stopped
|
case stopped = "agent.stopped"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,145 @@
|
||||||
|
//
|
||||||
|
// BuildkiteClient+Webhooks.swift
|
||||||
|
// Buildkite
|
||||||
|
//
|
||||||
|
// Created by Aaron Sky on 9/10/22.
|
||||||
|
// Copyright © 2022 Aaron Sky. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Crypto
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extension WebhookEvent {
|
||||||
|
/// Header names present in every Buildkite webhook request.
|
||||||
|
///
|
||||||
|
/// - SeeAlso: https://buildkite.com/docs/apis/webhooks#http-headers
|
||||||
|
public enum HTTPHeaders {
|
||||||
|
/// The type of event
|
||||||
|
public static let buildkiteEvent = "X-Buildkite-Event"
|
||||||
|
/// The webhook's token.
|
||||||
|
public static let buildkiteToken = "X-Buildkite-Token"
|
||||||
|
/// The signature created from your webhook payload, webhook token, and the SHA-256 hash function.
|
||||||
|
public static let buildkiteSignature = "X-Buildkite-Signature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BuildkiteClient {
|
||||||
|
public enum WebhookValidationError: Error {
|
||||||
|
// Token
|
||||||
|
case tokenRefused
|
||||||
|
// Signature
|
||||||
|
case signatureFormatInvalid
|
||||||
|
case signatureCorrupted
|
||||||
|
case payloadCorrupted
|
||||||
|
case signatureRefused
|
||||||
|
// Replay Protection
|
||||||
|
case timestampRefused
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func decodeWebhook(from body: Data) throws -> WebhookEvent {
|
||||||
|
try decoder.decode(WebhookEvent.self, from: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a webhook payload using the token strategy.
|
||||||
|
///
|
||||||
|
/// - Warning: Buildkite passes the token in clear text. As a consequence, this strategy is less secure than the Buildkite Signature strategy, but easier for rapid testing and less computationally expensive.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - tokenHeader: Value for the header with the name at ``WebhookEvent/HTTPHeaders/buildkiteToken``
|
||||||
|
/// - secretKey: Token value provided by Buildkite
|
||||||
|
public nonisolated func validateWebhookPayload(
|
||||||
|
tokenHeader: String,
|
||||||
|
secretKey: Data
|
||||||
|
) throws {
|
||||||
|
guard secretKey == tokenHeader.data(using: .utf8) else {
|
||||||
|
throw WebhookValidationError.tokenRefused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the webhook event using the signature strategy.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - signatureHeader: Value for the header with the name at ``WebhookEvent/HTTPHeaders/buildkiteSignature``
|
||||||
|
/// - body: The request body from Buildkite
|
||||||
|
/// - secretKey: Token value provided by Buildkite
|
||||||
|
/// - replayLimit: Limit in seconds the period of time a webhook event will be accepted. This is used to defend against replay attacks, but is optional.
|
||||||
|
public nonisolated func validateWebhookPayload(
|
||||||
|
signatureHeader: String,
|
||||||
|
body: Data,
|
||||||
|
secretKey: Data,
|
||||||
|
replayLimit: TimeInterval? = nil
|
||||||
|
) throws {
|
||||||
|
let (timestamp, signature) = try getTimestampAndSignature(signatureHeader)
|
||||||
|
guard let macPayload = "\(timestamp).\(body)".data(using: .utf8) else {
|
||||||
|
throw WebhookValidationError.payloadCorrupted
|
||||||
|
}
|
||||||
|
|
||||||
|
try checkMAC(
|
||||||
|
message: macPayload,
|
||||||
|
signature: signature,
|
||||||
|
secretKey: secretKey
|
||||||
|
)
|
||||||
|
|
||||||
|
if let replayLimit = replayLimit {
|
||||||
|
try checkReplayLimit(time: timestamp, replayLimit: replayLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func getTimestampAndSignature(_ header: String) throws -> (timestamp: Date, signature: Data) {
|
||||||
|
let parts: [String: String] = Dictionary(
|
||||||
|
uniqueKeysWithValues: header
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { kv in
|
||||||
|
let kvp = kv.split(separator: "=", maxSplits: 2)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
return (kvp[0], kvp[1])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guard parts.count == 2 else {
|
||||||
|
throw WebhookValidationError.signatureFormatInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let timestamp = parts["timestamp"]
|
||||||
|
.flatMap(TimeInterval.init)
|
||||||
|
.flatMap(Date.init(timeIntervalSince1970:)),
|
||||||
|
// FIXME: Needs to be a hex-decoded
|
||||||
|
let signature = parts["signature"]?
|
||||||
|
.data(using: .utf8)
|
||||||
|
else {
|
||||||
|
throw WebhookValidationError.signatureCorrupted
|
||||||
|
}
|
||||||
|
|
||||||
|
return (timestamp, signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func checkMAC(message: Data, signature: Data, secretKey: Data) throws {
|
||||||
|
let key = SymmetricKey(data: secretKey)
|
||||||
|
let expectedMAC = HMAC<SHA256>.authenticationCode(
|
||||||
|
for: message,
|
||||||
|
using: key
|
||||||
|
)
|
||||||
|
guard
|
||||||
|
HMAC.isValidAuthenticationCode(
|
||||||
|
expectedMAC,
|
||||||
|
authenticating: signature,
|
||||||
|
using: key
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
throw WebhookValidationError.signatureRefused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func checkReplayLimit(
|
||||||
|
time: Date,
|
||||||
|
replayLimit: TimeInterval
|
||||||
|
) throws {
|
||||||
|
guard time.timeIntervalSinceNow <= replayLimit else {
|
||||||
|
throw WebhookValidationError.timestampRefused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,19 +25,10 @@ enum Formatters {
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
static func dateIfPossible(fromISO8601 string: String) -> Date? {
|
|
||||||
if let date = iso8601WithFractionalSeconds.date(from: string) {
|
|
||||||
return date
|
|
||||||
} else if let date = iso8601WithoutFractionalSeconds.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@Sendable
|
@Sendable
|
||||||
static func encodeISO8601(date: Date, encoder: Encoder) throws {
|
static func encodeISO8601(date: Date, encoder: Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
let dateString = iso8601WithoutFractionalSeconds.string(from: date)
|
let dateString = iso8601WithFractionalSeconds.string(from: date)
|
||||||
try container.encode(dateString)
|
try container.encode(dateString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,14 +36,24 @@ enum Formatters {
|
||||||
static func decodeISO8601(decoder: Decoder) throws -> Date {
|
static func decodeISO8601(decoder: Decoder) throws -> Date {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
let dateString = try container.decode(String.self)
|
let dateString = try container.decode(String.self)
|
||||||
guard let date = dateIfPossible(fromISO8601: dateString) else {
|
guard let date = Date(iso8601String: dateString) else {
|
||||||
throw DecodingError.dataCorrupted(
|
throw DecodingError.dataCorrupted(
|
||||||
DecodingError.Context(
|
DecodingError.Context(
|
||||||
codingPath: container.codingPath,
|
codingPath: container.codingPath,
|
||||||
debugDescription: "Expected date string to be ISO8601-formatted."
|
debugDescription: "Expected date string \"\(dateString)\" to be ISO8601-formatted."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
init?(iso8601String: String) {
|
||||||
|
guard
|
||||||
|
let date = Formatters.iso8601WithFractionalSeconds.date(from: iso8601String) ??
|
||||||
|
Formatters.iso8601WithoutFractionalSeconds.date(from: iso8601String)
|
||||||
|
else { return nil }
|
||||||
|
self = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -76,25 +76,3 @@ extension Array where Element == URLQueryItem {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//extension Date: LosslessStringConvertible {
|
|
||||||
// public init?(
|
|
||||||
// _ description: String
|
|
||||||
// ) {
|
|
||||||
// guard let date = Formatters.dateIfPossible(fromISO8601: description) else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
// self = date
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//extension UUID: LosslessStringConvertible {
|
|
||||||
// public init?(
|
|
||||||
// _ description: String
|
|
||||||
// ) {
|
|
||||||
// guard let id = UUID(uuidString: description) else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
// self = id
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ import FoundationNetworking
|
||||||
/// ```swift
|
/// ```swift
|
||||||
/// let client = BuildkiteClient(token: "...")
|
/// let client = BuildkiteClient(token: "...")
|
||||||
/// let organizationResponse = await client.send(.organization("buildkite")
|
/// let organizationResponse = await client.send(.organization("buildkite")
|
||||||
/// let agentsResponse = await client.send(organizationResponse.content.agentsUrl)
|
/// let agentsResponse = await client.send(organizationResponse.content.agentsURL)
|
||||||
/// print(agentsResponse.content) // Array<Agent>(...)
|
/// print(agentsResponse.content) // Array<Agent>(...)
|
||||||
/// ```
|
/// ```
|
||||||
public struct Followable<R: Resource>: Resource, Codable, Equatable, Hashable, Sendable {
|
public struct Followable<R: Resource>: Resource, Codable, Equatable, Hashable, Sendable {
|
||||||
|
|
|
@ -69,7 +69,16 @@ extension TestAnalytics.Resources {
|
||||||
public var queued: Int
|
public var queued: Int
|
||||||
public var skipped: Int
|
public var skipped: Int
|
||||||
public var errors: [String]
|
public var errors: [String]
|
||||||
public var runUrl: URL
|
public var runURL: URL
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case runId = "run_id"
|
||||||
|
case queued
|
||||||
|
case skipped
|
||||||
|
case errors
|
||||||
|
case runURL = "run_url"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var version: APIVersion {
|
public var version: APIVersion {
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// FormattersTests.swift
|
||||||
|
// Buildkite
|
||||||
|
//
|
||||||
|
// Copyright © 2022 Aaron Sky. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Buildkite
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class FormattersTests: XCTestCase {
|
||||||
|
func testDecode() throws {
|
||||||
|
// let expected = DateComponents(
|
||||||
|
// year: 2020,
|
||||||
|
// month: 3,
|
||||||
|
// day: 14,
|
||||||
|
// hour: 20,
|
||||||
|
// minute: 4,
|
||||||
|
// second: 43
|
||||||
|
// ).date!
|
||||||
|
// let actual = try XCTUnwrap(
|
||||||
|
// Formatters.dateIfPossible(
|
||||||
|
// fromISO8601: "2020-03-14T20:04:43.567Z"
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// XCTAssertEqual(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncode() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,15 +20,16 @@ extension Agent {
|
||||||
let job = Job.script(
|
let job = Job.script(
|
||||||
Job.Command(
|
Job.Command(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
graphqlId: "",
|
||||||
name: "📦",
|
name: "📦",
|
||||||
state: "passed",
|
state: "passed",
|
||||||
command: nil,
|
command: nil,
|
||||||
stepKey: nil,
|
stepKey: nil,
|
||||||
buildUrl: URL(),
|
buildURL: URL(),
|
||||||
webUrl: URL(),
|
webURL: URL(),
|
||||||
logUrl: Followable(),
|
logURL: Followable(),
|
||||||
rawLogUrl: Followable(),
|
rawLogURL: Followable(),
|
||||||
artifactsUrl: URL(),
|
artifactsURL: URL(),
|
||||||
softFailed: false,
|
softFailed: false,
|
||||||
exitStatus: 0,
|
exitStatus: 0,
|
||||||
artifactPaths: nil,
|
artifactPaths: nil,
|
||||||
|
@ -49,8 +50,9 @@ extension Agent {
|
||||||
|
|
||||||
self.init(
|
self.init(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
graphqlId: "",
|
||||||
url: Followable(),
|
url: Followable(),
|
||||||
webUrl: URL(),
|
webURL: URL(),
|
||||||
name: "jeffrey",
|
name: "jeffrey",
|
||||||
connectionState: "connected",
|
connectionState: "connected",
|
||||||
hostname: "jeffrey",
|
hostname: "jeffrey",
|
||||||
|
|
|
@ -21,7 +21,7 @@ extension Artifact {
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
jobId: UUID(),
|
jobId: UUID(),
|
||||||
url: Followable(),
|
url: Followable(),
|
||||||
downloadUrl: Followable(),
|
downloadURL: Followable(),
|
||||||
state: .new,
|
state: .new,
|
||||||
path: "",
|
path: "",
|
||||||
dirname: "",
|
dirname: "",
|
||||||
|
|
|
@ -19,8 +19,9 @@ extension Build {
|
||||||
init() {
|
init() {
|
||||||
self.init(
|
self.init(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
graphqlId: "",
|
||||||
url: Followable(),
|
url: Followable(),
|
||||||
webUrl: URL(),
|
webURL: URL(),
|
||||||
number: 1,
|
number: 1,
|
||||||
state: .passed,
|
state: .passed,
|
||||||
blocked: false,
|
blocked: false,
|
||||||
|
|
|
@ -17,7 +17,7 @@ import FoundationNetworking
|
||||||
|
|
||||||
class JobsTests: XCTestCase {
|
class JobsTests: XCTestCase {
|
||||||
func testJobsRetryWaiter() async throws {
|
func testJobsRetryWaiter() async throws {
|
||||||
let expected: Job = .waiter(Job.Wait(id: UUID()))
|
let expected: Job = .waiter(Job.Wait(id: UUID(), graphqlId: ""))
|
||||||
let context = try MockContext(content: expected)
|
let context = try MockContext(content: expected)
|
||||||
|
|
||||||
let response = try await context.client.send(
|
let response = try await context.client.send(
|
||||||
|
@ -32,8 +32,8 @@ class JobsTests: XCTestCase {
|
||||||
Job.Trigger(
|
Job.Trigger(
|
||||||
name: nil,
|
name: nil,
|
||||||
state: nil,
|
state: nil,
|
||||||
buildUrl: URL(),
|
buildURL: URL(),
|
||||||
webUrl: URL(),
|
webURL: URL(),
|
||||||
createdAt: Date(timeIntervalSince1970: 1000),
|
createdAt: Date(timeIntervalSince1970: 1000),
|
||||||
scheduledAt: nil,
|
scheduledAt: nil,
|
||||||
finishedAt: nil,
|
finishedAt: nil,
|
||||||
|
@ -42,7 +42,7 @@ class JobsTests: XCTestCase {
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
number: 0,
|
number: 0,
|
||||||
url: URL(),
|
url: URL(),
|
||||||
webUrl: URL()
|
webURL: URL()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -59,13 +59,14 @@ class JobsTests: XCTestCase {
|
||||||
let expected: Job = .manual(
|
let expected: Job = .manual(
|
||||||
Job.Block(
|
Job.Block(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
graphqlId: "",
|
||||||
label: "",
|
label: "",
|
||||||
state: "",
|
state: "",
|
||||||
webUrl: nil,
|
webURL: nil,
|
||||||
unblockedBy: User(),
|
unblockedBy: User(),
|
||||||
unblockedAt: Date(timeIntervalSince1970: 1000),
|
unblockedAt: Date(timeIntervalSince1970: 1000),
|
||||||
unblockable: true,
|
unblockable: true,
|
||||||
unblockUrl: URL()
|
unblockURL: URL()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
let context = try MockContext(content: expected)
|
let context = try MockContext(content: expected)
|
||||||
|
|
|
@ -19,13 +19,14 @@ extension Organization {
|
||||||
init() {
|
init() {
|
||||||
self.init(
|
self.init(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
graphqlId: "",
|
||||||
url: Followable(),
|
url: Followable(),
|
||||||
webUrl: URL(),
|
webURL: URL(),
|
||||||
name: "Buildkite",
|
name: "Buildkite",
|
||||||
slug: "buildkite",
|
slug: "buildkite",
|
||||||
pipelinesUrl: Followable(),
|
pipelinesURL: Followable(),
|
||||||
agentsUrl: Followable(),
|
agentsURL: Followable(),
|
||||||
emojisUrl: Followable(),
|
emojisURL: Followable(),
|
||||||
createdAt: Date(timeIntervalSince1970: 1000)
|
createdAt: Date(timeIntervalSince1970: 1000)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,8 +21,9 @@ extension Pipeline {
|
||||||
) {
|
) {
|
||||||
self.init(
|
self.init(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
graphqlId: "",
|
||||||
url: Followable(),
|
url: Followable(),
|
||||||
webUrl: URL(),
|
webURL: URL(),
|
||||||
name: "My Pipeline",
|
name: "My Pipeline",
|
||||||
slug: "my-pipeline",
|
slug: "my-pipeline",
|
||||||
repository: "git@github.com:buildkite/agent.git",
|
repository: "git@github.com:buildkite/agent.git",
|
||||||
|
@ -30,7 +31,7 @@ extension Pipeline {
|
||||||
defaultBranch: "master",
|
defaultBranch: "master",
|
||||||
provider: Provider(
|
provider: Provider(
|
||||||
id: "github",
|
id: "github",
|
||||||
webhookUrl: URL(),
|
webhookURL: URL(),
|
||||||
settings: Provider.Settings(
|
settings: Provider.Settings(
|
||||||
repository: nil,
|
repository: nil,
|
||||||
buildPullRequests: nil,
|
buildPullRequests: nil,
|
||||||
|
@ -53,8 +54,8 @@ extension Pipeline {
|
||||||
skipQueuedBranchBuildsFilter: nil,
|
skipQueuedBranchBuildsFilter: nil,
|
||||||
cancelRunningBranchBuilds: false,
|
cancelRunningBranchBuilds: false,
|
||||||
cancelRunningBranchBuildsFilter: nil,
|
cancelRunningBranchBuildsFilter: nil,
|
||||||
buildsUrl: Followable(),
|
buildsURL: Followable(),
|
||||||
badgeUrl: URL(),
|
badgeURL: URL(),
|
||||||
createdAt: Date(timeIntervalSince1970: 1000),
|
createdAt: Date(timeIntervalSince1970: 1000),
|
||||||
scheduledBuildsCount: 0,
|
scheduledBuildsCount: 0,
|
||||||
runningBuildsCount: 0,
|
runningBuildsCount: 0,
|
||||||
|
|
|
@ -19,6 +19,7 @@ extension Team {
|
||||||
fileprivate init() {
|
fileprivate init() {
|
||||||
self.init(
|
self.init(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
graphqlId: "",
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
|
@ -19,9 +19,10 @@ extension User {
|
||||||
init() {
|
init() {
|
||||||
self.init(
|
self.init(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
graphqlId: "",
|
||||||
name: "Jeff",
|
name: "Jeff",
|
||||||
email: "jeff@buildkite.com",
|
email: "jeff@buildkite.com",
|
||||||
avatarUrl: URL(),
|
avatarURL: URL(),
|
||||||
createdAt: Date(timeIntervalSince1970: 1000)
|
createdAt: Date(timeIntervalSince1970: 1000)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ class TestAnalyticsUpload: XCTestCase {
|
||||||
.init(id: .init(), history: .init(section: "http")),
|
.init(id: .init(), history: .init(section: "http")),
|
||||||
.init(id: .init(), history: .init(section: "http")),
|
.init(id: .init(), history: .init(section: "http")),
|
||||||
]
|
]
|
||||||
let expected = Resource.Content(id: .init(), runId: .init(), queued: 0, skipped: 0, errors: [], runUrl: .init())
|
let expected = Resource.Content(id: .init(), runId: .init(), queued: 0, skipped: 0, errors: [], runURL: .init())
|
||||||
let context = try MockContext(content: expected)
|
let context = try MockContext(content: expected)
|
||||||
|
|
||||||
let response = try await context.client.send(
|
let response = try await context.client.send(
|
||||||
|
|
Loading…
Reference in New Issue