Implement basic request methods and parameters

This commit is contained in:
Ailton Vieira Pinto Filho
2023-06-08 22:49:08 -03:00
committed by Ailton Vieira
parent 82f31c624f
commit a73ceb1aeb
15 changed files with 761 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

14
Package.resolved Normal file
View File

@@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-syntax.git",
"state" : {
"revision" : "83c2be9f6268e9f67622f130440cf43928c6bfb0",
"version" : "509.0.0-swift-DEVELOPMENT-SNAPSHOT-2023-05-20-a"
}
}
],
"version" : 2
}

52
Package.swift Normal file
View File

@@ -0,0 +1,52 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
import CompilerPluginSupport
let package = Package(
name: "SwiftRequest",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "SwiftRequest",
targets: ["SwiftRequest"]
),
.executable(
name: "SwiftRequestClient",
targets: ["SwiftRequestClient"]
),
],
dependencies: [
// Depend on the latest Swift 5.9 prerelease of SwiftSyntax
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0-swift-5.9-DEVELOPMENT-SNAPSHOT-2023-04-25-b"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
// Macro implementation that performs the source transformation of a macro.
.macro(
name: "SwiftRequestMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
// Library that exposes a macro as part of its API, which is used in client programs.
.target(name: "SwiftRequest", dependencies: ["SwiftRequestMacros"]),
// A client of the library, which is able to use the macro in its own code.
.executableTarget(name: "SwiftRequestClient", dependencies: ["SwiftRequest"]),
// A test target used to develop the macro implementation.
.testTarget(
name: "SwiftRequestTests",
dependencies: [
"SwiftRequestMacros",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
]
),
]
)

View File

@@ -0,0 +1,47 @@
@propertyWrapper
public struct Body<Value: Encodable> {
public var wrappedValue: Value
public init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}
@propertyWrapper
public struct PathParam<Value: CustomStringConvertible> {
public var wrappedValue: Value
let name: String?
public init(wrappedValue: Value, _ name: String? = nil) {
self.wrappedValue = wrappedValue
self.name = name
}
}
@propertyWrapper
public struct QueryParam<Value: CustomStringConvertible> {
public var wrappedValue: Value
let name: String?
public init(wrappedValue: Value, _ name: String? = nil) {
self.wrappedValue = wrappedValue
self.name = name
}
}
@propertyWrapper
public struct Header<Value: CustomStringConvertible> {
public var wrappedValue: Value
let name: String?
public init(wrappedValue: Value, _ name: String? = nil) {
self.wrappedValue = wrappedValue
self.name = name
}
}
extension Optional: CustomStringConvertible where Wrapped: CustomStringConvertible {
public var description: String {
self?.description ?? "nil"
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
public struct HTTPResponseError: Error {
public let data: Data
public let response: HTTPURLResponse
public init(data: Data, response: HTTPURLResponse) {
self.data = data
self.response = response
}
}

View File

@@ -0,0 +1,20 @@
@attached(member, names: named(baseURL), named(session), named(init(baseURL:session:)))
@attached(conformance)
public macro Service(_ route: String? = nil) = #externalMacro(module: "SwiftRequestMacros", type: "ServiceMacro")
public protocol Service {}
@attached(peer, names: overloaded)
public macro GET<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:]) = #externalMacro(module: "SwiftRequestMacros", type: "GetRequestMacro")
@attached(peer, names: overloaded)
public macro POST<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:], body: T) = #externalMacro(module: "SwiftRequestMacros", type: "PostRequestMacro")
@attached(peer, names: overloaded)
public macro PUT<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:], body: T) = #externalMacro(module: "SwiftRequestMacros", type: "PutRequestMacro")
@attached(peer, names: overloaded)
public macro PATCH<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:], body: T) = #externalMacro(module: "SwiftRequestMacros", type: "PatchRequestMacro")
@attached(peer, names: overloaded)
public macro DELETE<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:]) = #externalMacro(module: "SwiftRequestMacros", type: "DeleteRequestMacro")

View File

@@ -0,0 +1,38 @@
import SwiftRequest
import Foundation
struct Quote: Decodable {
let id: String
let content: String
let author: String
let tags: [String]
enum CodingKeys: String, CodingKey {
case id = "_id"
case content
case author
case tags
}
}
@Service
class QuoteService {
@GET<[Quote]>("quotes/random")
private func getRandomQuotes(@QueryParam("author") authorSlug: String? = nil) {}
@GET<Quote>("quotes/{id}")
private func getQuote(@PathParam by id: String) {}
}
let baseURL = URL(string: "https://api.quotable.io")!
let service = QuoteService(baseURL: baseURL)
do {
let (quotes, _) = try await service.getRandomQuotes(authorSlug: "agatha-christie")
print(quotes[0])
let (quote, _) = try await service.getQuote(by: quotes[0].id)
print(quote)
} catch {
print(error)
}

View File

@@ -0,0 +1,96 @@
import SwiftSyntax
struct FunctionParameter {
let attribute: Attribute
let label: String?
let name: String
let type: String
let optional: Bool
}
struct Attribute {
let name: String
let arguments: [AttributeArgument]
}
struct AttributeArgument {
let label: String?
let value: String
}
extension FunctionParameter {
init(from syntax: FunctionParameterSyntax) {
let attributes = syntax.attributes?.compactMap(Attribute.init) ?? []
let label: String?
let name: String
if let secondName = syntax.secondName {
label = syntax.firstName.text
name = secondName.text
} else {
label = nil
name = syntax.firstName.text
}
let type = syntax.type.description
let optional = syntax.type.as(OptionalTypeSyntax.self) != nil
self.init(
attribute: attributes[0],
label: label,
name: name,
type: type,
optional: optional
)
}
}
extension Attribute {
init(from syntax: AttributeSyntax) {
let name = syntax.attributeName.as(SimpleTypeIdentifierSyntax.self)?.name.text ?? syntax.attributeName.description
let arguments = syntax.argument?.as(TupleExprElementListSyntax.self)?.map(AttributeArgument.init) ?? []
self.init(name: name, arguments: arguments)
}
init?(from syntax: AttributeListSyntax.Element) {
guard let syntax = syntax.as(AttributeSyntax.self) else {
return nil
}
self.init(from: syntax)
}
}
extension AttributeArgument {
init(from syntax: TupleExprElementSyntax) {
let label = syntax.label?.text
let value = syntax.expression.as(StringLiteralExprSyntax.self)?.segments.first?.description
self.init(label: label, value: value!)
}
}
typealias FunctionParameters = [FunctionParameter]
extension FunctionParameters {
init(from syntax: FunctionParameterListSyntax) {
self.init(syntax.map(FunctionParameter.init))
}
func getHeaders() -> [FunctionParameter] {
filter { $0.attribute.name == "Header" }
}
func getQueryParams() -> [FunctionParameter] {
filter { $0.attribute.name == "QueryParam" }
}
func getPathParams() -> [FunctionParameter] {
filter { $0.attribute.name == "PathParam" }
}
func getBody() -> FunctionParameter? {
first { $0.attribute.name == "Body" }
}
}

View File

@@ -0,0 +1,44 @@
import SwiftSyntax
import SwiftDiagnostics
struct RequestDiagnostics{
let method: String
private static let domain = "endpoint"
private func diagnostic(for node: Syntax, text: String, id: String, severity: DiagnosticSeverity = .error) -> Diagnostic {
Diagnostic(
node: node,
message: SimpleDiagnosticMessage(
message: text,
diagnosticID: .init(
domain: Self.domain,
id: id
),
severity: severity
)
)
}
func justFunction(node: Syntax) -> Diagnostic {
diagnostic(for: node, text: "@\(method) requires a function", id: "justFunction")
}
func justPrivate(node: Syntax) -> Diagnostic {
diagnostic(for: node, text: "@\(method) requires a private function", id: "justPrivate")
}
func pathRequired(node: Syntax) -> Diagnostic {
diagnostic(for: node, text: "@\(method) requires a path", id: "pathRequired")
}
func outputTypeRequired(node: Syntax) -> Diagnostic {
diagnostic(for: node, text: "@\(method) requires a type as a generic argument", id: "outputTypeRequired")
}
func uniquePathParams(node: Syntax) -> Diagnostic {
diagnostic(for: node, text: "@\(method) requires unique path params", id: "uniquePathParams")
}
}
struct MacroExpansionError: Error {}

View File

@@ -0,0 +1,305 @@
import SwiftSyntax
import SwiftSyntaxMacros
import RegexBuilder
import Foundation
import SwiftDiagnostics
public class RequestMacro: PeerMacro {
class var httpMethod: String { fatalError() }
static var diagnostics : RequestDiagnostics { RequestDiagnostics(method: httpMethod) }
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Require attached to a function
guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
context.diagnose(diagnostics.justFunction(node: Syntax(node)))
return []
}
// Require a private function
guard funcDecl.modifiers?.count == 1 && funcDecl.modifiers?.first?.as(DeclModifierSyntax.self)?.name.text == "private" else {
context.diagnose(diagnostics.justPrivate(node: Syntax(fromProtocol: funcDecl.modifiers ?? funcDecl.funcKeyword)))
return []
}
let (path, outputType, pathParams, defaultHeaders) = try getRequestParameters(for: node, in: context)
// Generate the new function call
let parameterList = funcDecl.signature.input.parameterList
let newParameterList = FunctionParameterListSyntax(parameterList.map { param in
var newParam = param
newParam.attributes = newParam.attributes?.removingFirst()
return newParam
})
let newCall: ExprSyntax = "\(funcDecl.identifier)(\(newParameterList))"
let functionParameters = FunctionParameters(from: parameterList)
let endpointDecl = try generateEndpointExpr(from: functionParameters, path: path, pathParams: pathParams, in: context)
let queryParamsDecl = generateQueryParamsDecl(from: functionParameters)
let urlDecl = generateUrlDecl(endpoint: endpointDecl, queryParams: queryParamsDecl)
let requestDecl = generateRequestDecl()
let headersExpr = generateHeadersDecl(from: functionParameters, defaultHeaders: defaultHeaders)
let bodyExpr = generateBodyDecl(from: functionParameters)
let allDeclarations: [DeclSyntax] = [
urlDecl,
requestDecl,
headersExpr,
bodyExpr
].compactMap { $0 }
let allDeclarationsExpr: DeclSyntax = """
\(raw: allDeclarations.map { expr in
"""
\(expr)
"""
}.joined(separator: "\n"))
"""
let callExpr = generateCallDecl(of: outputType)
let newFunc: DeclSyntax = """
func \(newCall) async throws -> (\(outputType), HTTPURLResponse) {
\(allDeclarationsExpr)
\(callExpr)
}
"""
return [newFunc]
}
private static func generateRequestDecl() -> DeclSyntax {
"""
var request = URLRequest(url: url)
request.httpMethod = "\(raw: httpMethod)"
"""
}
private static func generateCallDecl(of outputType: TypeSyntax) -> DeclSyntax {
let requestCall: DeclSyntax = """
let (data, response) = try await session.data(for: request) as! (Data, HTTPURLResponse)
guard (200..<300).contains(response.statusCode) else {
throw HTTPResponseError(data: data, response: response)
}
"""
let returnData: DeclSyntax = if outputType.description != "Data" {
"""
\(requestCall)
let decoder = JSONDecoder()
return (try decoder.decode(\(outputType).self, from: data), response)
"""
} else {
"""
\(requestCall)
return (data, response)
"""
}
return returnData
}
private static func generateBodyDecl(from parameters: FunctionParameters) -> DeclSyntax? {
if let body = parameters.getBody() {
"""
let encoder = JSONEncoder()
request.httpBody = try encoder.encode(\(raw: body.name))
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
"""
} else {
nil
}
}
private static func generateHeadersDecl(from parameters: FunctionParameters, defaultHeaders: ExprSyntax?) -> DeclSyntax? {
let headers = parameters.getHeaders().map { param in
let key = param.attribute.arguments.first?.value ?? param.name
return (key: key, value: param.name, optional: param.optional)
}
if headers.isEmpty {
return nil
}
var expressions: [String] = []
if let defaultHeaders {
expressions.append("""
let defaultHeaders: [String: (any CustomStringConvertible)?] = \(defaultHeaders)
defaultHeaders.forEach { request.setValue($0.value?.description, forHTTPHeaderField: $0.key) }
"""
)
}
expressions.append(contentsOf: headers.map { (key, value, optional) -> String in
let setExpr = """
headers["\(key)"] = \(value).description
"""
if optional {
return """
if let \(value) {
\(setExpr)
}
"""
} else {
return setExpr
}
})
let headerExprs: DeclSyntax = """
\(raw: expressions.joined(separator: "\n"))
"""
return headerExprs
}
private static func generateUrlDecl(
endpoint endpointExpr: ExprSyntax,
queryParams queryParamsDecl: DeclSyntax?
) -> DeclSyntax {
return if let queryParamsDecl {
"""
let urlWithPath = \(endpointExpr)
var urlComponents = URLComponents(url: urlWithPath, resolvingAgainstBaseURL: false)!
var queryItems: [URLQueryItem] = []
\(queryParamsDecl)
urlComponents.queryItems = queryItems
let url = urlComponents.url!
"""
} else {
"""
let url = \(endpointExpr)
"""
}
}
private static func generateQueryParamsDecl(from parameters: FunctionParameters) -> DeclSyntax? {
let queryParams = parameters.getQueryParams().map { param in
let key = param.attribute.arguments.first?.value ?? param.name
return (key: key, value: param.name, optional: param.optional)
}
if queryParams.isEmpty {
return nil
}
let queryParamExpr: DeclSyntax = """
\(raw: queryParams.map { (key, value, optional) -> String in
let appendExpr = """
queryItems.append(URLQueryItem(name: "\(key)", value: \(value).description))
"""
if optional {
return """
if let \(value) {
\(appendExpr)
}
"""
} else {
return appendExpr
}
}.joined(separator: "\n"))
"""
return queryParamExpr
}
private static func generateEndpointExpr(
from parameters: FunctionParameters,
path: TokenSyntax,
pathParams: [String],
in context: some MacroExpansionContext
) throws -> ExprSyntax {
let argumentsPathParams: [(name: String, argument: String)] = parameters.getPathParams().map { pathParam in
let name = pathParam.attribute.arguments.first?.value ?? pathParam.name
return (name: name, argument: pathParam.name)
}
// Required matching path params
guard pathParams.count == argumentsPathParams.count,
Set(pathParams) == Set(argumentsPathParams.map(\.name)) else {
context.diagnose(diagnostics.pathRequired(node: Syntax(path)))
throw MacroExpansionError()
}
// Replace path params in path with arguments
let pathString = argumentsPathParams.reduce(path.text) { path, pathParam in
path.replacingOccurrences(of: "{\(pathParam.name)}", with: "\\(\(pathParam.argument))")
}
return """
baseURL.appendingPathComponent("\(raw: pathString)")
"""
}
private static func getRequestParameters(
for node: AttributeSyntax,
in context: some MacroExpansionContext
) throws -> (path: TokenSyntax, outputType: TypeSyntax, pathParams: [String], defaultsHeaders: ExprSyntax?) {
// Require a type as a generic argument
guard let outputType = node.attributeName.as(SimpleTypeIdentifierSyntax.self)?.genericArgumentClause?.arguments.first?.argumentType else {
context.diagnose(RequestMacro.diagnostics.outputTypeRequired(node: Syntax(node)))
throw MacroExpansionError()
}
// Require a path without interpolation
guard let path = node.argument?.as(TupleExprElementListSyntax.self)?.first?.expression.as(StringLiteralExprSyntax.self)?.segments.first?.as(StringSegmentSyntax.self)?.content else {
context.diagnose(diagnostics.pathRequired(node: Syntax(node)))
throw MacroExpansionError()
}
// Extract path params
let pathParams = path.text
.split(separator: "/")
.filter { $0.hasPrefix("{") && $0.hasSuffix("}") }
.map { $0.dropFirst().dropLast() }
.map(String.init)
// Require unique path params
guard Set(pathParams).count == pathParams.count else {
context.diagnose(diagnostics.uniquePathParams(node: Syntax(node)))
throw MacroExpansionError()
}
// Get the optional defaults headers
let defaultHeadersExpr = getExpr(for: "headers", in: node)
return (path, outputType, pathParams, defaultHeadersExpr)
}
private static func getExpr(for argument: String, in node: AttributeSyntax) -> ExprSyntax? {
node.argument?
.as(TupleExprElementListSyntax.self)?
.first(where: {
$0.as(TupleExprElementSyntax.self)?.label?.text == argument
})?.expression
}
}
public final class GetRequestMacro: RequestMacro {
override class var httpMethod: String { "GET" }
}
public final class PostRequestMacro: RequestMacro {
override class var httpMethod: String { "POST" }
}
public final class PutRequestMacro: RequestMacro {
override class var httpMethod: String { "PUT" }
}
public final class PatchRequestMacro: RequestMacro {
override class var httpMethod: String { "PATCH" }
}
public final class DeleteRequestMacro: RequestMacro {
override class var httpMethod: String { "DELETE" }
}

View File

@@ -0,0 +1,32 @@
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct ServiceMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
return [
"""
private let baseURL: URL
private let session: URLSession
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
"""
]
}
}
extension ServiceMacro: ConformanceMacro {
public static func expansion(
of node: AttributeSyntax,
providingConformancesOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] {
[("Service", nil)]
}
}

View File

@@ -0,0 +1,8 @@
import SwiftSyntax
import SwiftDiagnostics
struct SimpleDiagnosticMessage: DiagnosticMessage, Error {
let message: String
let diagnosticID: MessageID
let severity: DiagnosticSeverity
}

View File

@@ -0,0 +1,14 @@
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct SwiftRequestPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
ServiceMacro.self,
GetRequestMacro.self,
PostRequestMacro.self,
PutRequestMacro.self,
PatchRequestMacro.self,
DeleteRequestMacro.self
]
}

View File

@@ -0,0 +1,40 @@
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
import SwiftRequestMacros
final class RequestTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"GET": GetRequestMacro.self,
"POST": PostRequestMacro.self,
"PUT": PutRequestMacro.self,
"PATCH": PatchRequestMacro.self,
"DELETE": DeleteRequestMacro.self,
]
func testGetMacroWithSimplePathAndData() {
assertMacroExpansion(
"""
@GET<Data>("hello")
private func hello() {}
""",
expandedSource: """
private func hello() {
}
func hello() async throws -> (Data, HTTPURLResponse) {
let url = baseURL.appendingPathComponent("hello")
var request = URLRequest(url: url)
request.httpMethod = "GET"
let (data, response) = try await session.data(for: request) as! (Data, HTTPURLResponse)
guard (200 ..< 300).contains(response.statusCode) else {
throw HTTPResponseError(data: data, response: response)
}
return (data, response)
}
""",
macros: testMacros
)
}
}

View File

@@ -0,0 +1,32 @@
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
import SwiftRequestMacros
final class ServiceTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"Service": ServiceMacro.self,
]
func testMacro() {
assertMacroExpansion(
"""
@Service
class MyService {
}
""",
expandedSource: """
class MyService {
private let baseURL: URL
private let session: URLSession
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
}
""",
macros: testMacros
)
}
}