* Added graphql_id property

* renamed URL fields
* WIP webhooks support
* WIP webhooks example
This commit is contained in:
Aaron Sky 2022-09-10 20:09:04 -04:00
parent 2bd91f7315
commit 790f6e7453
34 changed files with 720 additions and 110 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@
/*.xcodeproj /*.xcodeproj
xcuserdata/ xcuserdata/
Package.resolved Package.resolved
*.env*

View File

@ -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"
), ),

View File

@ -27,6 +27,6 @@ import Foundation
) )
.content .content
print(result.runUrl) print(result.runURL)
} }
} }

View File

@ -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)
)
}
}
}

View File

@ -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()
} }
} }

View File

@ -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",

View File

@ -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
} }

View File

@ -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"
}
} }

View File

@ -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"
}
} }

View File

@ -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
}
} }

View File

@ -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
}
} }

View File

@ -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 {

View File

@ -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"
} }
} }

View File

@ -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"
}
} }

View File

@ -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"
}
} }
} }
} }

View File

@ -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"
}
} }

View File

@ -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
} }
} }

View File

@ -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"
}
} }

View File

@ -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"
} }
} }
} }

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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
// }
//}

View File

@ -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 {

View File

@ -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 {

View File

@ -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() {
}
}

View File

@ -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",

View File

@ -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: "",

View File

@ -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,

View File

@ -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)

View File

@ -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)
) )
} }

View File

@ -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,

View File

@ -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: "",

View File

@ -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)
) )
} }

View File

@ -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(