Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import PackageDescription
let package = Package(
name: "SPI-Server",
platforms: [
.macOS(.v13)
.macOS(.v15)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS 15 is required for typed throw runtime support due to our use of

        @Dependency(\.github.fetchMetadata) var fetchMetadata

below.

],
products: [
.executable(name: "Run", targets: ["Run"]),
Expand Down
13 changes: 9 additions & 4 deletions Sources/App/Commands/Ingestion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ enum Ingestion {
// Even though we have a `Joined<Package, Repository>` as a parameter, we must not rely
// on `repository` for owner/name as it will be nil when a package is first ingested.
// The only way to get `owner` and `repository` here is by parsing them from the URL.
let (owner, repository) = try await run {
let (owner, repository) = try run {
if environment.shouldFail(failureMode: .invalidURL) {
throw Github.Error.invalidURL(package.model.url)
}
Expand Down Expand Up @@ -258,13 +258,18 @@ enum Ingestion {

static func fetchMetadata(client: Client, package: Package, owner: String, repository: String) async throws(Github.Error) -> (Github.Metadata, Github.License?, Github.Readme?) {
@Dependency(\.environment) var environment
@Dependency(\.github) var github
if environment.shouldFail(failureMode: .fetchMetadataFailed) {
throw Github.Error.requestFailed(.internalServerError)
}

async let metadata = try await Current.fetchMetadata(client, owner, repository)
async let license = await github.fetchLicense(owner, repository)
// Need to pull in github functions individually, because otherwise the `async let` will trigger a
// concurrency error if github gets used more than once:
// Sending 'github' into async let risks causing data races between async let uses and local uses
@Dependency(\.github.fetchMetadata) var fetchMetadata
@Dependency(\.github.fetchLicense) var fetchLicense

async let metadata = try await fetchMetadata(owner, repository)
async let license = await fetchLicense(owner, repository)
async let readme = await Current.fetchReadme(client, owner, repository)

do {
Expand Down
2 changes: 0 additions & 2 deletions Sources/App/Core/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import FoundationNetworking


struct AppEnvironment: Sendable {
var fetchMetadata: @Sendable (_ client: Client, _ owner: String, _ repository: String) async throws(Github.Error) -> Github.Metadata
var fetchReadme: @Sendable (_ client: Client, _ owner: String, _ repository: String) async -> Github.Readme?
var fetchS3Readme: @Sendable (_ client: Client, _ owner: String, _ repository: String) async throws -> String
var fileManager: FileManager
Expand Down Expand Up @@ -65,7 +64,6 @@ extension AppEnvironment {
nonisolated(unsafe) static var logger: Logger!

static let live = AppEnvironment(
fetchMetadata: { client, owner, repo throws(Github.Error) in try await Github.fetchMetadata(client:client, owner: owner, repository: repo) },
fetchReadme: { client, owner, repo in await Github.fetchReadme(client:client, owner: owner, repository: repo) },
fetchS3Readme: { client, owner, repo in try await S3Readme.fetchReadme(client:client, owner: owner, repository: repo) },
fileManager: .live,
Expand Down
17 changes: 13 additions & 4 deletions Sources/App/Core/Dependencies/GithubClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,35 @@


import Dependencies
import DependenciesMacros
import IssueReporting


@DependencyClient
// We currently cannot use @DependencyClient here due to
// https://github.com/pointfreeco/swift-dependencies/discussions/324
//@DependencyClient
struct GithubClient {
var fetchLicense: @Sendable (_ owner: String, _ repository: String) async -> Github.License?
var fetchMetadata: @Sendable (_ owner: String, _ repository: String) async throws(Github.Error) -> Github.Metadata = { _,_ in reportIssue("fetchMetadata"); return .init() }
}


extension GithubClient: DependencyKey {
static var liveValue: Self {
.init(
fetchLicense: { owner, repo in await Github.fetchLicense(owner: owner, repository: repo) }
fetchLicense: { owner, repo in await Github.fetchLicense(owner: owner, repository: repo) },
fetchMetadata: { owner, repo throws(Github.Error) in try await Github.fetchMetadata(owner: owner, repository: repo) }
)
}
}


extension GithubClient: TestDependencyKey {
static var testValue: Self { Self() }
static var testValue: Self {
.init(
fetchLicense: { _, _ in unimplemented("fetchLicense"); return nil },
fetchMetadata: { _, _ in unimplemented("fetchMetadata"); return .init() }
)
}
}


Expand Down
5 changes: 3 additions & 2 deletions Sources/App/Core/Dependencies/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ extension HTTPClient.Response {
self.init(host: "host", status: status, version: version, headers: headers, body: body)
}

static var ok: Self { .init(status: .ok) }
static var notFound: Self { .init(status: .notFound) }
static var badRequest: Self { .init(status: .badRequest) }
static var notFound: Self { .init(status: .notFound) }
static var tooManyRequests: Self { .init(status: .tooManyRequests) }
static var ok: Self { .init(status: .ok) }
}
#endif
12 changes: 12 additions & 0 deletions Sources/App/Core/ErrorHandlingHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,15 @@ func run<T, E1: Error, E2: Error>(_ operation: () async throws(E1) -> T,
throw transform(error)
}
}


@discardableResult
func run<T, E1: Error, E2: Error>(_ operation: () throws(E1) -> T,
rethrowing transform: (E1) -> E2) throws(E2) -> T {
do {
let result = try operation()
return result
} catch {
throw transform(error)
}
}
60 changes: 48 additions & 12 deletions Sources/App/Core/Github.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
enum Github {

enum Error: Swift.Error {
case decodeContentFailed(URI, Swift.Error)
case decodeContentFailed(_ url: String, Swift.Error)
case encodeContentFailed(_ url: String, Swift.Error)
case missingToken
case noBody
case invalidURL(String)
case postRequestFailed(URI, Swift.Error)
case postRequestFailed(_ url: String, Swift.Error)
case requestFailed(HTTPStatus)
}

Expand Down Expand Up @@ -108,7 +109,7 @@
return "https://api.github.com/repos/\(owner)/\(repository)/\(resource.rawValue)"
}
}

@available(*, deprecated)
static func fetch(client: Client, uri: URI, headers: [(String, String)] = []) async throws -> (content: String, etag: String?) {
guard let token = Current.githubToken() else {
Expand Down Expand Up @@ -181,10 +182,10 @@
}

static func fetchReadme(client: Client, owner: String, repository: String) async -> Readme? {
let uri = Github.apiUri(owner: owner, repository: repository, resource: .readme)

Check warning on line 185 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Query Performance Test

'apiUri(owner:repository:resource:)' is deprecated

Check warning on line 185 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Query Performance Test

'apiUri(owner:repository:resource:)' is deprecated

Check warning on line 185 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Test

'apiUri(owner:repository:resource:)' is deprecated

Check warning on line 185 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Test

'apiUri(owner:repository:resource:)' is deprecated

// Fetch readme html content
let readme = try? await Github.fetch(client: client, uri: uri, headers: [

Check warning on line 188 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Query Performance Test

'fetch(client:uri:headers:)' is deprecated

Check warning on line 188 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Query Performance Test

'fetch(client:uri:headers:)' is deprecated

Check warning on line 188 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Test

'fetch(client:uri:headers:)' is deprecated

Check warning on line 188 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Test

'fetch(client:uri:headers:)' is deprecated
("Accept", "application/vnd.github.html+json")
])
guard var html = readme?.content else { return nil }
Expand All @@ -194,7 +195,7 @@
struct Response: Decodable {
var htmlUrl: String
}
return try? await Github.fetchResource(Response.self, client: client, uri: uri).htmlUrl

Check warning on line 198 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Query Performance Test

'fetchResource(_:client:uri:)' is deprecated

Check warning on line 198 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Query Performance Test

'fetchResource(_:client:uri:)' is deprecated

Check warning on line 198 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Test

'fetchResource(_:client:uri:)' is deprecated

Check warning on line 198 in Sources/App/Core/Github.swift

View workflow job for this annotation

GitHub Actions / Test

'fetchResource(_:client:uri:)' is deprecated
}()
guard let htmlUrl else { return nil }

Expand All @@ -211,12 +212,15 @@

extension Github {

@available(*, deprecated)
static let graphQLApiUri = URI(string: "https://api.github.com/graphql")
static let graphQLApiURL = "https://api.github.com/graphql"

struct GraphQLQuery: Content {
var query: String
}

@available(*, deprecated)
static func fetchResource<T: Decodable>(_ type: T.Type, client: Client, query: GraphQLQuery) async throws(Github.Error) -> T {
guard let token = Current.githubToken() else {
throw Error.missingToken
Expand All @@ -228,7 +232,7 @@
try $0.content.encode(query)
}
} catch {
throw .postRequestFailed(Self.graphQLApiUri, error)
throw .postRequestFailed(graphQLApiUri.string, error)
}

guard !isRateLimited(response) else {
Expand All @@ -244,25 +248,57 @@
do {
return try response.content.decode(T.self, using: decoder)
} catch {
throw .decodeContentFailed(Self.graphQLApiUri, error)
throw .decodeContentFailed(graphQLApiUri.string, error)
}
}

static func fetchResource<T: Decodable>(_ type: T.Type, query: GraphQLQuery) async throws(Github.Error) -> T {
guard let token = Current.githubToken() else {
throw Error.missingToken
}

@Dependency(\.httpClient) var httpClient

let body = try run {
try JSONEncoder().encode(query)
} rethrowing: {
Error.encodeContentFailed(graphQLApiURL, $0)
}

let response = try await run {
try await httpClient.post(url: graphQLApiURL, headers: defaultHeaders(with: token), body: body)
} rethrowing: {
Error.postRequestFailed(graphQLApiURL, $0)
}

guard !isRateLimited(response) else {
Current.logger().critical("rate limited while fetching resource \(T.self)")
throw Error.requestFailed(.tooManyRequests)
}

guard response.status == .ok else {
Current.logger().warning("fetchResource<\(T.self)> request failed with status \(response.status)")
throw Error.requestFailed(response.status)
}

guard let body = response.body else { throw Github.Error.noBody }

return try run {
try decoder.decode(T.self, from: body)
} rethrowing: {
Error.decodeContentFailed(graphQLApiURL, $0)
}
}

static func fetchMetadata(client: Client, owner: String, repository: String) async throws(Github.Error) -> Metadata {
static func fetchMetadata(owner: String, repository: String) async throws(Github.Error) -> Metadata {
struct Response<T: Decodable & Equatable>: Decodable, Equatable {
var data: T
}
return try await fetchResource(Response<Metadata>.self,
client: client,
query: Metadata.query(owner: owner, repository: repository))
.data
}

static func fetchMetadata(client: Client, packageUrl: String) async throws -> Metadata {
let (owner, name) = try parseOwnerName(url: packageUrl)
return try await fetchMetadata(client: client, owner: owner, repository: name)
}

}


Expand Down
2 changes: 1 addition & 1 deletion Tests/AppTests/ErrorReportingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ class ErrorReportingTests: AppTestCase {
func test_Ingestion_error_reporting() async throws {
// setup
try await Package(id: .id0, url: "1", processingStage: .reconciliation).save(on: app.db)
Current.fetchMetadata = { _, _, _ throws(Github.Error) in throw Github.Error.invalidURL("1") }

try await withDependencies {
$0.date.now = .now
$0.github.fetchMetadata = { @Sendable _, _ throws(Github.Error) in throw Github.Error.invalidURL("1") }
} operation: {
// MUT
try await Ingestion.ingest(client: app.client, database: app.db, mode: .limit(10))
Expand Down
Loading
Loading