Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Heavily revised handling of response types #7

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: test
on:
pull_request:
env:
SWIFT_DETERMINISTIC_HASHING: 1
LOG_LEVEL: info

jobs:
linux:
Expand Down
19 changes: 9 additions & 10 deletions Sources/OneRoster/Client/OneRosterClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ public struct OneRosterClient {
/// To use OAuth, specify an `OAuth1.Client` or `OAuth2.Client` when creating the `OneRosterClient`.
public func request<T: OneRosterResponse>(
_ endpoint: OneRosterAPI.Endpoint, as type: T.Type = T.self, filter: String? = nil
) async throws -> T {
self.logger.info("[OneRoster] Start request \(T.self) from \(self.baseUrl) @ \(endpoint.endpoint)")
) async throws -> T.InnerType where T.InnerType: OneRosterBase {
self.logger.info("[OneRoster] Start request \(T.InnerType.self) from \(self.baseUrl) @ \(endpoint.endpoint)")

guard let fullUrl = endpoint.makeRequestUrl(from: self.baseUrl, filterString: filter) else {
self.logger.error("[OneRoster] Unable to generate request URL!") // this should really never happen
Expand All @@ -46,16 +46,15 @@ public struct OneRosterClient {
}

// response content type will be JSON, so the configured default JSON decoder will be used
return try response.content.decode(T.self)
return try response.content.decode(T.self).oneRosterDataKey
}

public func request<T: OneRosterResponse>(
/// To use OAuth, specify an `OAuth1.Client` or `OAuth2.Client` when creating the `OneRosterClient`.
public func request<T: OneRosterResponse, E>(
_ endpoint: OneRosterAPI.Endpoint, as type: T.Type = T.self,
offset: Int = 0, limit: Int = 100, filter: String? = nil
) async throws -> [T.InnerType] {
precondition(T.dataKey != nil, "Multiple-item request must be for a type with a data key")

self.logger.info("[OneRoster] Start request [\(T.InnerType.self)][\(offset)..<+\(limit)] from \(self.baseUrl) @ \(endpoint.endpoint)")
) async throws -> T.InnerType where T.InnerType == Array<E> {
self.logger.info("[OneRoster] Start request \(T.InnerType.self)[\(offset)..<+\(limit)] from \(self.baseUrl) @ \(endpoint.endpoint)")

// OneRoster implementations are not strictly required by the 1.1 spec to provide the `X-Total-Count` response
// header, nor next/last URLs in the `Link` header (per OneRoster 1.1 v2.0, § 3.4.1). For any given response, we
Expand All @@ -73,7 +72,7 @@ public struct OneRosterClient {
// caught in a loop caused by faulty results from the implementation (such as sending rel="next" links which
// actually point backwards) and return an error.
// 6. Otherwise, add the limit to the last offset and use the result as the current offset in the next request.
var results: [T.InnerType] = []
var results: T.InnerType = .init()
var currentOffset = offset
var nextUrl = endpoint.makeRequestUrl(from: self.baseUrl, limit: limit, offset: currentOffset, filterString: filter)

Expand All @@ -86,7 +85,7 @@ public struct OneRosterClient {

let response = try await self.client.get(.init(string: fullUrl.absoluteString))
guard response.status == .ok else { throw OneRosterError(from: response) }
let currentResults = try response.content.decode(T.self).oneRosterDataKey! // already checked that the type has a dataKey
let currentResults = try response.content.decode(T.self).oneRosterDataKey // already checked that the type has a dataKey

results.append(contentsOf: currentResults)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,85 @@ public enum OAuth1 {
self.nonce = nonce
}
}

/// Generate an OAuth signature and parameter set.
///
/// See `generateSignature(for:method:body:using:)` below for important details.
internal static func generateSignature(
for request: URI,
method: HTTPMethod,
body: ByteBuffer? = nil,
using parameters: Parameters
) -> String {
guard let url = URL(string: request.string) else {
fatalError("A Vapor.URI can't be parsed as a Foundation.URL, this is a pretty bad thing: \(request)")
}
return Self.generateSignature(for: url, method: method, body: body, using: parameters)
}

/// Calculate the OAuth parameter set and signature for a given request URL, content, and method using the given
/// parameters. Return the calculated parameter set, including the signature, as a combined string suitable for
/// inclusion in an `Authorization` header using the `OAuth` type.
///
/// - Warning: Does not correctly handle url-encoded form request bodies as RFC 5849 § 3.4.1.3.1 requires.
///
/// - Note: `internal` rather than `fileprivate` for the benefit of tests. Ensuure `SWIFT_DETERMINISTIC_HASHING` is
/// set in the test environment to guarantee reproducible ordering of the parameter set.
internal static func generateSignature(
for request: URL,
method: HTTPMethod,
body: ByteBuffer? = nil,
using parameters: Parameters
) -> String {
guard let parts = URLComponents(url: request, resolvingAgainstBaseURL: true) else {
fatalError("A URL can't be parsed into its components, this is a pretty bad thing: \(request)")
}

// RFC 5849 § 3.3
let timestamp = UInt64((parameters.timestamp ?? Date()).timeIntervalSince1970)
let nonce = parameters.nonce ?? UUID().uuidString

// RFC 5849 § 3.1
var oauthParams = [
"oauth_consumer_key": parameters.clientId,
"oauth_signature_method": parameters.signatureMethod.rawValue,
"oauth_timestamp": "\(timestamp)",
"oauth_nonce": nonce,
"oauth_version": "1.0",
"oauth_token": parameters.userKey
].compactMapValues { $0 }

// RFC 5849 § 3.4.1.3.1
let rawParams = oauthParams.map { $0 } + (parts.queryItems ?? []).map { ($0.name, $0.value ?? "") }
// RFC 5849 § 3.4.1.3.2
let encodedParams = rawParams.map { (name: $0.rfc5849Encoded, value: $1.rfc5849Encoded) }
let sortedParams = encodedParams.sorted {
if $0.name.utf8 != $1.name.utf8 {
return $0.name.utf8 < $1.name.utf8
} else {
return $0.value.utf8 < $1.value.utf8
}
}
let allParams = sortedParams.map { "\($0)=\($1)" }.joined(separator: "&")

// RFC 5849 § 3.4.1.1
let signatureBase = [method.rawValue, parts.rfc5849BaseString ?? "", allParams]
.map(\.rfc5849Encoded).joined(separator: "&")

// RFC 5849 $ 3.4.2
let signatureKey = [parameters.clientSecret, parameters.userSecret ?? ""]
.map(\.rfc5849Encoded).joined(separator: "&")

oauthParams["oauth_signature"] = Data(HMAC<SHA256>.authenticationCode(
for: Data(signatureBase.utf8),
using: .init(data: Data(signatureKey.utf8))
)).base64EncodedString()

// RFC 5849 § 3.5
return oauthParams
.map { "\($0.rfc5849Encoded)=\"\($1.rfc5849Encoded)\"" }
.joined(separator: ", ")
}

/// An implementation of Vapor's `Client` protocol which applies OAuth 1-based authorization to all outgoing
/// requests automatically.
Expand Down Expand Up @@ -114,65 +193,18 @@ public enum OAuth1 {
public func send(_ request: ClientRequest) -> EventLoopFuture<ClientResponse> {
var finalRequest = request

// Allow requests to override automatic OAuth authorization (but then why use an OAuth client at all, though?)
// Allow requests to override automatic OAuth authorization.
if !finalRequest.headers.contains(name: .authorization) {
finalRequest.headers.replaceOrAdd(name: .authorization, value: "OAuth \(self.generateAuthorizationHeader(for: request))")
let signature = OAuth1.generateSignature(for: request.url, method: request.method, body: request.body, using: self.parameters)

finalRequest.headers.replaceOrAdd(name: .authorization, value: "OAuth \(signature)")
}
return self.client.send(finalRequest)
}

/// Calculate the OAuth parameter set and signature for a request based on the configured parameters and
/// request content. Return the result as the content part of an OAuth authorization header.
///
/// - Warning: Does not correctly handle url-encoded form request bodies as RFC 5849 § 3.4.1.3.1 requires.
///
/// - Note: `internal` rather than `private` for the benefit of tests.
internal func generateAuthorizationHeader(for request: ClientRequest) -> String {
guard let parts = URLComponents(string: request.url.string) else {
fatalError("A Vapor.URI can't be parsed as a Foundation.URL, this is a pretty bad thing: \(request.url)")
}

// RFC 5849 § 3.3
let timestamp = UInt64((self.parameters.timestamp ?? Date()).timeIntervalSince1970)
let nonce = self.parameters.nonce ?? UUID().uuidString

// RFC 5849 § 3.1
var oauthParams = [
"oauth_consumer_key": self.parameters.clientId,
"oauth_signature_method": self.parameters.signatureMethod.rawValue,
"oauth_timestamp": "\(timestamp)",
"oauth_nonce": nonce,
"oauth_version": "1.0",
"oauth_token": self.parameters.userKey
].compactMapValues { $0 }

// RFC 5849 § 3.4.1.3.1
let rawParams = (oauthParams.map { $0 } + (parts.queryItems ?? []).map { ($0.name, $0.value ?? "") })
// RFC 5849 § 3.4.1.3.2
let encodedParams = rawParams.map { (name: $0.rfc5849Encoded, value: $1.rfc5849Encoded) }
let sortedParams = encodedParams.sorted { $0.name.utf8 == $1.name.utf8 ? $0.value.utf8 < $1.value.utf8 : $0.name.utf8 < $1.name.utf8 }
let allParams = sortedParams.map { "\($0)=\($1)" }.joined(separator: "&")

// RFC 5849 § 3.4.1.1
let sigbase = [request.method.rawValue, parts.baseString ?? "", allParams]
.map(\.rfc5849Encoded).joined(separator: "&")

// RFC 5849 $ 3.4.2
let sigkey = [self.parameters.clientSecret, self.parameters.userSecret ?? ""]
.map(\.rfc5849Encoded).joined(separator: "&")

oauthParams["oauth_signature"] = HMAC<SHA256>.authenticationCode(for: Data(sigbase.utf8), using: .init(data: Data(sigkey.utf8))).base64

// RFC 5849 § 3.5
return oauthParams
.map { (name: $0.rfc5849Encoded, value: $1.rfc5849Encoded) }
.sorted(by: { $0.name.utf8 < $1.name.utf8 })
.map { "\($0)=\"\($1)\"" }.joined(separator: ", ")
}
}
}

/// Something that is a sequence of contiguous bytes which can be compared to other like sequences.
/// Something that is a sequence of individual bytes which can be compared to other like sequences.
public protocol ComparableCollection: Comparable, Collection where Element == UInt8 {}

extension ComparableCollection {
Expand All @@ -198,29 +230,26 @@ extension StringProtocol {
}

extension URLComponents {
/// Returns the result of constructing a URL string from _only_ the scheme, authority, and path components.
///
/// - Note: The "authority" includes any specified credentials, and follows the rules of RFC 5849 § 3.4.1.2
/// regarding case normalization and the inclusion of the port number.
fileprivate var baseString: String? {
/// Returns the result of constructing a URL string from _only_ the scheme, authority, and path components, using
/// the semantics specified by RFC 5849 § 3.4.1.2 for case normalization and port number inclusion.
fileprivate var rfc5849BaseString: String? {
/// This list containly _only_ schemes and ports defined by the RFC. Do not add more entries to it!
let knownSchemes: Set<String> = [
"http:80",
"https:443",
]

var partial = URLComponents()
partial.scheme = self.scheme?.lowercased()
partial.user = self.user
partial.password = self.password
partial.host = self.host?.lowercased()
partial.port = ((self.port == 80 && partial.scheme == "http") || (self.port == 443 && partial.scheme == "https")) ? nil : self.port
partial.port = knownSchemes.contains("\(partial.scheme ?? ""):\(self.port ?? 0)") ? nil : self.port
partial.path = self.path
return partial.string
}
}

extension MessageAuthenticationCode {
/// Returns the MAC encoded with Base64, as a `String`.
fileprivate var base64: String {
return Data(self).base64EncodedString()
}
}

extension Application {
/// Get an `OAuth1Client` suitable for automatically calculating an OAuth 1 signature for each request.
///
Expand Down
2 changes: 1 addition & 1 deletion Sources/OneRoster/Protocols/OneRosterBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
//===----------------------------------------------------------------------===//

/// Represents the base model that all OneRoster entities inherit from
public protocol OneRosterBase {
public protocol OneRosterBase: OneRosterResponseData {
/// For example: 9877728989-ABF-0001
var sourcedId: String { get set }

Expand Down
23 changes: 9 additions & 14 deletions Sources/OneRoster/Protocols/OneRosterResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,19 @@
//
//===----------------------------------------------------------------------===//

public protocol OneRosterResponseData {}

extension Array: OneRosterResponseData where Element: OneRosterBase {}

public protocol OneRosterResponse: Codable {
associatedtype InnerType: OneRosterBase
typealias DataKey = KeyPath<Self, Array<InnerType>>
associatedtype InnerType: OneRosterResponseData
typealias DataKey = KeyPath<Self, InnerType>

static var dataKey: DataKey? { get }
static var dataKey: DataKey { get }
}

extension OneRosterResponse {
public static var dataKey: DataKey? {
return nil
}

public var oneRosterDataKey: Array<InnerType>? {
get {
guard let key = Self.dataKey else {
return nil
}
return self[keyPath: key]
}
public var oneRosterDataKey: InnerType {
return self[keyPath: Self.dataKey]
}
}
6 changes: 3 additions & 3 deletions Sources/OneRoster/Responses/ClassesResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
/// See `Class`
public struct ClassesResponse: Codable, OneRosterResponse {
/// The inner data type
public typealias InnerType = Class
public typealias InnerType = [Class]

/// The key for the data
public static var dataKey: DataKey? = \.classes
public static var dataKey: DataKey = \.classes

/// An array of `Course` responses
public let classes: [Class]
public let classes: InnerType
}
6 changes: 3 additions & 3 deletions Sources/OneRoster/Responses/CoursesResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
/// See `Course`
public struct CoursesResponse: Codable, OneRosterResponse {
/// The inner data type
public typealias InnerType = Course
public typealias InnerType = [Course]

/// The key for the data
public static var dataKey: DataKey? = \.courses
public static var dataKey: DataKey = \.courses

/// An array of `Course` responses
public let courses: [Course]
public let courses: InnerType
}
3 changes: 3 additions & 0 deletions Sources/OneRoster/Responses/DemographicResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public struct DemographicResponse: Codable, OneRosterResponse {
/// The inner data type
public typealias InnerType = DemographicData

/// The key for the data
public static var dataKey: DataKey = \.demographic

/// The `DemographicData` response
public let demographic: InnerType
}
7 changes: 3 additions & 4 deletions Sources/OneRoster/Responses/DemographicsResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@

/// See `DemographicData`
public struct DemographicsResponse: Codable, OneRosterResponse {

/// The inner data type
public typealias InnerType = DemographicData
public typealias InnerType = [DemographicData]

/// The key for the data
public static var dataKey: DataKey? = \.demographics
public static var dataKey: DataKey = \.demographics

/// An array of `DemographicData` responses
public let demographics: [InnerType]
public let demographics: InnerType
}
3 changes: 3 additions & 0 deletions Sources/OneRoster/Responses/EnrollmentResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public struct EnrollmentResponse: Codable, OneRosterResponse {
/// The inner data type
public typealias InnerType = Enrollment

/// The key for the data
public static var dataKey: DataKey = \.enrollment

/// The `Enrollment` response
public let enrollment: InnerType
}
6 changes: 3 additions & 3 deletions Sources/OneRoster/Responses/EnrollmentsResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
/// See `Enrollment`
public struct EnrollmentsResponse: Codable, OneRosterResponse {
/// The inner data type
public typealias InnerType = Enrollment
public typealias InnerType = [Enrollment]

/// The key for the data
public static var dataKey: DataKey? = \.enrollments
public static var dataKey: DataKey = \.enrollments

/// An array of `Enrollment` responses
public let enrollments: [Enrollment]
public let enrollments: InnerType
}
3 changes: 3 additions & 0 deletions Sources/OneRoster/Responses/OrgResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public struct OrgResponse: Codable, OneRosterResponse {
/// The inner data type
public typealias InnerType = Org

/// The key for the data
public static var dataKey: DataKey = \.org

/// The `Org` response
public let org: InnerType
}
Loading