Change macro to work just with protocol

This commit is contained in:
Ailton Vieira
2023-06-16 21:02:44 -03:00
parent 57057c7dfa
commit 88a8086609
28 changed files with 835 additions and 676 deletions

View File

@@ -1,58 +0,0 @@
@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
}
}
@propertyWrapper
public struct FieldParam<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,20 @@
@attached(peer)
public macro GET(_ path: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "MethodMacro")
@attached(peer)
public macro POST(_ path: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "MethodMacro")
@attached(peer)
public macro PUT(_ path: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "MethodMacro")
@attached(peer)
public macro PATCH(_ path: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "MethodMacro")
@attached(peer)
public macro DELETE(_ path: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "MethodMacro")
@attached(peer)
public macro OPTIONS(_ path: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "MethodMacro")
@attached(peer)
public macro HEAD(_ path: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "MethodMacro")

View File

@@ -0,0 +1,14 @@
@attached(member)
public macro Path(_ name: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "ParamMacro")
@attached(member)
public macro QueryParam(_ name: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "ParamMacro")
@attached(member)
public macro Header(_ name: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "ParamMacro")
@attached(member)
public macro FormField(_ name: String = "") = #externalMacro(module: "SwiftRequestMacros", type: "ParamMacro")
@attached(member)
public macro Body() = #externalMacro(module: "SwiftRequestMacros", type: "BodyMacro")

View File

@@ -0,0 +1,5 @@
@attached(peer, names: suffixed(Impl))
public macro Service(resource: String) = #externalMacro(module: "SwiftRequestMacros", type: "ServiceMacro")
@attached(peer, names: suffixed(Impl))
public macro Service() = #externalMacro(module: "SwiftRequestMacros", type: "ServiceMacro")

View File

@@ -0,0 +1,74 @@
import Foundation
public struct Request {
let url: URL
let method: String
let queryParams: Params?
let headers: Params?
let formFields: Params?
let body: (any Encodable)?
public typealias Params = [String: (any CustomStringConvertible)?]
public init(
url: URL,
method: String = "GET",
queryParams: Params? = nil,
headers: Params? = nil,
formFields: Params? = nil,
body: (any Encodable)? = nil
) {
self.url = url
self.method = method
self.queryParams = queryParams
self.headers = headers
self.formFields = formFields
self.body = body
}
func getURLRequest() throws -> URLRequest {
let url: URL
if let queryParams {
var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false)!
components.queryItems = queryParams.compactMap { key, value in
guard let value = value else { return nil }
return URLQueryItem(name: key, value: value.description)
}
url = components.url!
} else {
url = self.url
}
var request = URLRequest(url: url)
request.httpMethod = self.method
if let formFields = self.formFields {
var form = URLComponents()
form.queryItems = formFields.compactMap { key, value in
guard let value = value else { return nil }
return URLQueryItem(name: key, value: value.description)
}
request.httpBody = form.query?.data(using: .utf8)
}
if let body {
if body is Data {
request.httpBody = body as? Data
} else {
request.httpBody = try JSONEncoder().encode(body)
}
}
if let headers {
for (key, value) in headers {
guard let value = value else { continue }
request.addValue(value.description, forHTTPHeaderField: key)
}
}
return request
}
}

View File

@@ -1,15 +1,15 @@
import Foundation
public protocol Service {
init(baseURL: URL, session: URLSession)
}
extension Service {
open class Service {
public let baseURL: URL
public let session: URLSession
public init(baseURL: URL, session: URLSession = .shared) {
self.init(baseURL: baseURL, session: session)
self.baseURL = baseURL
self.session = session
}
public init(baseURL: String, session: URLSession = .shared) {
convenience public init(baseURL: String, session: URLSession = .shared) {
guard let baseURL = URL(string: baseURL) else {
fatalError("Invalid baseURL")
}

View File

@@ -1,28 +0,0 @@
@attached(member, names: named(baseURL), named(session), named(init(baseURL:session:)))
@attached(conformance)
public macro Service() = #externalMacro(module: "SwiftRequestMacros", type: "ServiceMacro")
@attached(member, names: named(baseURL), named(session), named(init(baseURL:session:)))
@attached(conformance)
public macro Service(resource: String) = #externalMacro(module: "SwiftRequestMacros", type: "ServiceMacro")
@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] = [:]) = #externalMacro(module: "SwiftRequestMacros", type: "PostRequestMacro")
@attached(peer, names: overloaded)
public macro PUT<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:]) = #externalMacro(module: "SwiftRequestMacros", type: "PutRequestMacro")
@attached(peer, names: overloaded)
public macro PATCH<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:]) = #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")
@attached(peer, names: overloaded)
public macro OPTIONS<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:]) = #externalMacro(module: "SwiftRequestMacros", type: "OptionsRequestMacro")
@attached(peer, names: overloaded)
public macro HEAD<T: Decodable>(_ path: String, headers: [String: any CustomStringConvertible] = [:]) = #externalMacro(module: "SwiftRequestMacros", type: "HeadRequestMacro")

View File

@@ -0,0 +1,28 @@
import Foundation
extension URLSession {
public func execute<Output: Decodable>(_ request: Request) async throws -> Output {
let (output, _): (Output, _) = try await self.execute(request)
return output
}
public func execute<Output: Decodable>(_ request: Request) async throws -> (Output, HTTPURLResponse) {
let (data, response) = try await self.execute(request)
let output = try JSONDecoder().decode(Output.self, from: data)
return (output, response)
}
public func execute(_ request: Request) async throws -> Data {
let (data, _) = try await self.execute(request)
return data
}
public func execute(_ request: Request) async throws -> (Data, HTTPURLResponse) {
let urlRequest = try request.getURLRequest()
let (data, response) = try await URLSession.shared.data(for: urlRequest) as! (Data, HTTPURLResponse)
guard (200..<300).contains(response.statusCode) else {
throw HTTPResponseError(data: data, response: response)
}
return (data, response)
}
}

View File

@@ -1,4 +1,4 @@
struct Quote: Decodable {
struct Quote: Codable {
let id: String
let content: String
let author: String

View File

@@ -2,10 +2,10 @@ import Foundation
import SwiftRequest
@Service(resource: "quotes")
class QuoteService {
@GET<[Quote]>("random")
private func getRandomQuotes(@QueryParam limit: Int? = nil) {}
protocol QuoteService {
@GET("random")
func getRandomQuotes(@QueryParam limit: Int?) async throws -> [Quote]
@GET<Quote>("{id}")
private func getQuote(@PathParam by id: String) {}
@GET("{id}")
func getQuote(@Path by id: String) async throws -> Quote
}

View File

@@ -0,0 +1,8 @@
import SwiftRequest
import Foundation
@Service(resource: "sample")
protocol SampleService {
@POST("hello/{id}/{limit}")
func hello(@Path id: String?, @Path limit: Int) async throws -> [Int]
}

View File

@@ -1,12 +1,13 @@
import Foundation
import SwiftRequest
let service = QuoteService(baseURL: "https://api.quotable.io")
let service = QuoteServiceImpl(baseURL: "https://api.quotable.io")
do {
let (quotes, _) = try await service.getRandomQuotes(limit: 3)
let quotes = try await service.getRandomQuotes(limit: 3)
print(quotes)
let (quote, _) = try await service.getQuote(by: "69Ldsxcdm-")
let quote = try await service.getQuote(by: "69Ldsxcdm-")
print(quote)
} catch {
print(error)

View File

@@ -0,0 +1,45 @@
import SwiftSyntax
import SwiftDiagnostics
protocol Diagnostics {
static var domain: String { get }
}
extension Diagnostics {
func diagnostic(
for node: Syntax,
message: String,
id: String,
severity: DiagnosticSeverity = .error,
fixIts: [FixIt] = []
) -> Diagnostic {
Diagnostic(
node: node,
message: CustomMessage(message, id: .init(domain: Self.domain, id: id), severity: severity),
fixIts: fixIts
)
}
func diagnostic(
for node: SyntaxProtocol,
message: String,
id: String,
severity: DiagnosticSeverity = .error,
fixIts: [FixIt] = []
) -> Diagnostic {
diagnostic(for: Syntax(node), message: message, id: id, severity: severity, fixIts: fixIts)
}
}
struct CustomMessage: DiagnosticMessage, Error {
let message: String
let diagnosticID: MessageID
let severity: DiagnosticSeverity
init(_ message: String, id: MessageID, severity: DiagnosticSeverity) {
self.message = message
self.diagnosticID = id
self.severity = severity
}
}

View File

@@ -0,0 +1,34 @@
import SwiftSyntax
class FunctionParameter {
let syntax: FunctionParameterSyntax
let value: TokenSyntax
let optional: Bool
required init(_ syntax: FunctionParameterSyntax) {
self.syntax = syntax
self.value = syntax.secondName ?? syntax.firstName
self.optional = syntax.type.as(OptionalTypeSyntax.self) != nil
}
static func getArguments(from syntax: FunctionParameterSyntax) -> TupleExprElementListSyntax? {
return syntax.attributes?.first?.as(AttributeSyntax.self)?.argument?.as(TupleExprElementListSyntax.self)
}
}
class FunctionNamedParameter: FunctionParameter {
let name: String
required init(_ syntax: FunctionParameterSyntax) {
if let name = Self.getArguments(from: syntax)?.first {
self.name = name.expression.as(StringLiteralExprSyntax.self)?.segments.first?.description ?? ""
} else {
self.name = (syntax.secondName ?? syntax.firstName).text
}
super.init(syntax)
}
}
class FunctionPathParameter: FunctionNamedParameter {
}

View File

@@ -0,0 +1,94 @@
import SwiftSyntax
import SwiftDiagnostics
import SwiftSyntaxBuilder
struct CustomFixItMessage: FixItMessage {
let message: String
let fixItID: MessageID
static var removeStaticModifier: Self {
.init(message: "remove static keyword", fixItID: .init(domain: "fix", id: "removeStaticModifier"))
}
}
struct MethodDiagnostics: Diagnostics {
static var domain: String { "httpMethod" }
let method: String
func justFunction(node: some DeclSyntaxProtocol) -> Diagnostic {
diagnostic(for: node, message: "@\(method) requires a function", id: "justFunction")
}
func nonStaticFunction(node: FunctionDeclSyntax, staticModifier: DeclModifierSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) requires a non static function",
id: "nonStaticFunction"
)
}
func asyncAndThrowsRequired(node: FunctionDeclSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) requires async and throws",
id: "asyncAndThrowsRequired"
)
}
func outputTypeRequired(node: FunctionDeclSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) requires a ouput type",
id: "returnTypeRequired"
)
}
func outputTypeMustBeTypeOrTuple(node: FunctionDeclSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) requires a ouput type explicit or a tuple (T, HTTPURLResponse)",
id: "returnTypeMustBeTypeOrTuple"
)
}
func pathLiteralRequired(node: ExprSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) requires a path literal",
id: "pathLiteralRequired"
)
}
func pathInterpolationNotSupported(node: ExprSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) does not support path interpolation",
id: "pathInterpolationNotSupported"
)
}
func missing(parameters: [String], in node: FunctionDeclSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) requires parameters: \(parameters.joined(separator: ", "))",
id: "missingParameters"
)
}
func extra(parameters: [String], in node: FunctionDeclSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) does not support parameters: \(parameters.joined(separator: ", "))",
id: "extraParameters"
)
}
func tooManyBodyParameters(in node: FunctionDeclSyntax) -> Diagnostic {
diagnostic(
for: node,
message: "@\(method) requires only one body parameter",
id: "tooManyBodyParameters"
)
}
}

View File

@@ -0,0 +1,69 @@
import SwiftSyntax
import SwiftSyntaxMacros
import Foundation
import SwiftDiagnostics
public class MethodMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let method = node.attributeName.description
let diagnostics = MethodDiagnostics(method: method)
guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
context.diagnose(diagnostics.justFunction(node: declaration))
return []
}
if let staticModifier = funcDecl.modifiers?.first(where: {
$0.name.tokenKind == TokenKind.keyword(.static)
})?.as(DeclModifierSyntax.self) {
context.diagnose(diagnostics.nonStaticFunction(node: funcDecl, staticModifier: staticModifier))
return []
}
let effectSpecifiers = funcDecl.signature.effectSpecifiers
guard effectSpecifiers?.asyncSpecifier != nil, effectSpecifiers?.throwsSpecifier != nil else {
context.diagnose(diagnostics.asyncAndThrowsRequired(node: funcDecl))
return []
}
guard let returnType = funcDecl.signature.output?.returnType else {
context.diagnose(diagnostics.outputTypeRequired(node: funcDecl))
return []
}
guard validate(outputType: returnType, in: context) else {
context.diagnose(diagnostics.outputTypeMustBeTypeOrTuple(node: funcDecl))
return []
}
return []
}
private static func validate(
outputType: TypeSyntax,
in context: some MacroExpansionContext
) -> Bool {
if outputType.is(SimpleTypeIdentifierSyntax.self) {
return true
}
if let arrayType = outputType.as(ArrayTypeSyntax.self) {
return validate(outputType: arrayType.elementType, in: context)
}
guard let tupleSyntax = outputType.as(TupleTypeSyntax.self),
let tupleElements = tupleSyntax.elements.as(TupleTypeElementListSyntax.self),
tupleElements.count == 2,
let secondElement = tupleElements.last?.as(TupleTypeElementSyntax.self),
secondElement.type.description == "HTTPURLResponse" else {
return false
}
return true
}
}

View File

@@ -1,26 +0,0 @@
import SwiftSyntax
struct Attribute {
let name: String
let arguments: [AttributeArgument]
init(name: String, arguments: [AttributeArgument]) {
self.name = name
self.arguments = arguments
}
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)
}
}

View File

@@ -1,18 +0,0 @@
import SwiftSyntax
struct AttributeArgument {
let label: String?
let value: String
init(label: String?, value: String) {
self.label = label
self.value = value
}
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!)
}
}

View File

@@ -1,70 +0,0 @@
import SwiftSyntax
struct FunctionParameter {
let attribute: Attribute
let label: String?
let name: String
let type: String
let optional: Bool
init(attribute: Attribute, label: String?, name: String, type: String, optional: Bool) {
self.attribute = attribute
self.label = label
self.name = name
self.type = type
self.optional = optional
}
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
)
}
}
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" }
}
func getFieldParams() -> [FunctionParameter] {
filter { $0.attribute.name == "FieldParam" }
}
}

View File

@@ -0,0 +1,14 @@
import SwiftSyntax
import SwiftSyntaxMacros
import Foundation
import SwiftDiagnostics
public class ParamMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
return []
}
}

View File

@@ -1,355 +0,0 @@
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 headersDecl = generateHeadersDecl(from: functionParameters, defaultHeaders: defaultHeaders)
if functionParameters.getFieldParams().count > 0 && functionParameters.getBody() != nil {
context.diagnose(diagnostics.bodyAndFields(node: Syntax(node)))
return []
}
let fieldsDecl = generateFieldParamsDecl(from: functionParameters)
let bodyDecl = generateBodyDecl(from: functionParameters)
let allDeclarations: [DeclSyntax] = [
urlDecl,
requestDecl,
headersDecl,
bodyDecl ?? fieldsDecl
].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 headersDecl: DeclSyntax = """
\(raw: expressions.joined(separator: "\n"))
"""
return headersDecl
}
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 queryParamDecl: 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 queryParamDecl
}
private static func generateFieldParamsDecl(from parameters: FunctionParameters) -> DeclSyntax? {
let fieldParams = parameters.getFieldParams().map { param in
let key = param.attribute.arguments.first?.value ?? param.name
return (key: key, value: param.name, optional: param.optional)
}
if fieldParams.isEmpty {
return nil
}
let fieldParamDecl: DeclSyntax = """
var form = URLComponents()
var formFields: [URLQueryItem] = []
\(raw: fieldParams.map { (key, value, optional) -> String in
let appendExpr = """
formFields.append(URLQueryItem(name: "\(key)", value: \(value).description))
"""
if optional {
return """
if let \(value) {
\(appendExpr)
}
"""
} else {
return appendExpr
}
}.joined(separator: "\n"))
form.queryItems = formFields
request.httpBody = form.query?.data(using: .utf8)
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
"""
return fieldParamDecl
}
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" }
}
public final class HeadRequestMacro: RequestMacro {
override class var httpMethod: String { "HEAD" }
}
public final class OptionsRequestMacro: RequestMacro {
override class var httpMethod: String { "OPTIONS" }
}

View File

@@ -0,0 +1,14 @@
import SwiftSyntax
import SwiftDiagnostics
struct ServiceDiagnostics: Diagnostics {
static var domain: String { "service" }
func protocolRequired(node: some SyntaxProtocol) -> Diagnostic {
diagnostic(for: node, message: "Service requires a protocol", id: "protocolRequired")
}
func methodRequired(node: some SyntaxProtocol) -> Diagnostic {
diagnostic(for: node, message: "Function requires a HTTP method", id: "methodRequired")
}
}

View File

@@ -2,44 +2,72 @@ import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct ServiceMacro: MemberMacro {
public struct ServiceMacro: PeerMacro {
static var diagnostics : ServiceDiagnostics { ServiceDiagnostics() }
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let attribute = Attribute(from: node)
let resource = attribute.arguments.first?.value
let baseUrlExpr = if let resource {
"baseURL.appendingPathComponent(\"\(resource)\")"
} else {
"baseURL"
guard let protocolDecl = declaration.as(ProtocolDeclSyntax.self) else {
return []
}
let declarations: [DeclSyntax] = [
"""
private let baseURL: URL
private let session: URLSession
""",
"""
required init(baseURL: URL, session: URLSession) {
self.baseURL = \(raw: baseUrlExpr)
self.session = session
}
"""
]
guard let protocolName = getProtocolName(from: declaration, in: context) else {
return []
}
return declarations
let resource = node.argument?.as(TupleExprElementListSyntax.self)?.first?.as(TupleExprElementSyntax.self)?.expression
let declarations = protocolDecl.memberBlock.members
.compactMap { $0.decl.as(FunctionDeclSyntax.self) }
.map { expansion(of: $0, with: resource, in: context) }
.compactMap { $0 }
let implementationSyntax = try ClassDeclSyntax("class \(raw: protocolName)Impl: Service, \(raw: protocolName)") {
if let resource {
"""
private lazy var resourceURL: URL = baseURL.appendingPathComponent(\(resource))
""" as DeclSyntax
}
for declaration in declarations {
declaration
}
}
return [
implementationSyntax.as(DeclSyntax.self)
].compactMap { $0 }
}
}
extension ServiceMacro: ConformanceMacro {
public static func expansion(
of node: AttributeSyntax,
providingConformancesOf declaration: some DeclGroupSyntax,
private static func expansion(
of declaration: FunctionDeclSyntax,
with resource: ExprSyntax?,
in context: some MacroExpansionContext
) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] {
[("Service", nil)]
) -> FunctionDeclSyntax? {
// TODO: require method
guard let method = declaration.attributes?.first?.as(AttributeSyntax.self)?.attributeName.description else {
context.diagnose(diagnostics.methodRequired(node: declaration))
return nil
}
let expander = ServiceMethodExpander(
method: method,
resource: resource
)
return try? expander.expand(declaration: declaration, in: context)
}
private static func getProtocolName(
from declaration: some SyntaxProtocol,
in context: some MacroExpansionContext
) -> String? {
guard let protocolDecl = declaration.as(ProtocolDeclSyntax.self) else {
context.diagnose(diagnostics.protocolRequired(node: declaration))
return nil
}
return protocolDecl.identifier.text
}
}

View File

@@ -0,0 +1,243 @@
import Foundation
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
class ServiceMethodExpander {
let method: String
let resource: ExprSyntax?
let diagnostics: MethodDiagnostics
init(method: String, resource: ExprSyntax?) {
self.method = method
self.resource = resource
self.diagnostics = MethodDiagnostics(method: method)
}
func expand(
declaration: FunctionDeclSyntax,
in context: some MacroExpansionContext
) throws -> FunctionDeclSyntax? {
let methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]
var newAttributes: [AttributeListSyntax.Element] = []
var methodAttribute: AttributeSyntax!
if let attributes = declaration.attributes {
for attributeElement in attributes {
guard let attribute = attributeElement.as(AttributeSyntax.self),
let attributeType = attribute.attributeName.as(SimpleTypeIdentifierSyntax.self)
else {
continue
}
if methods.contains(where: { $0 == attributeType.description }) {
methodAttribute = attribute
continue
}
newAttributes.append(attributeElement)
print(attribute)
}
}
let newListAttributes = AttributeListSyntax(newAttributes)
guard let endpointExpr = expandEndpoint(from: methodAttribute, of: declaration, in: context) else {
return nil
}
let request = expandRequest(endpoint: endpointExpr, of: declaration, in: context)
let codeBlock = CodeBlockSyntax {
VariableDeclSyntax(Keyword.let, name: "request", initializer: .init(value: request))
ReturnStmtSyntax(expression: "try await session.execute(request)" as ExprSyntax)
}
let newDeclaration = declaration
.with(\.funcKeyword,
declaration.funcKeyword
.with(\.leadingTrivia, .newlines(2))
)
.with(\.signature.input.parameterList, convert(declaration.signature.input.parameterList))
.with(\.attributes, newListAttributes.isEmpty ? nil : newListAttributes)
.with(\.body, codeBlock)
return newDeclaration
}
private func convert(_ parameters: FunctionParameterListSyntax) -> FunctionParameterListSyntax {
FunctionParameterListSyntax(parameters.map { parameter in
var newParameter = parameter.with(\.attributes, nil)
if parameter.type.is(OptionalTypeSyntax.self) {
newParameter = newParameter.with(\.defaultArgument, .init(value: "nil" as ExprSyntax))
}
return newParameter
})
}
private func expandRequest(
endpoint: ExprSyntax,
of declaration: FunctionDeclSyntax,
in context: some MacroExpansionContext
) -> FunctionCallExprSyntax{
FunctionCallExprSyntax(callee: "Request" as ExprSyntax) {
TupleExprElementSyntax(label: "url", expression: endpoint)
if method != "GET" {
TupleExprElementSyntax(label: "method", expression: StringLiteralExprSyntax(content: method))
}
if let queryParams = expandParameter("QueryParam", of: declaration, in: context) {
TupleExprElementSyntax(label: "queryParams", expression: queryParams)
}
if let headers = expandParameter("Header", of: declaration, in: context) {
TupleExprElementSyntax(label: "headers", expression: headers)
}
if let formFields = expandParameter("FormField", of: declaration, in: context) {
TupleExprElementSyntax(label: "formFields", expression: formFields)
}
if let body = expandBody(of: declaration, in: context) {
TupleExprElementSyntax(label: "body", expression: IdentifierExprSyntax(identifier: body))
}
}
}
private func expandBody(
of declaration: FunctionDeclSyntax,
in context: some MacroExpansionContext
) -> TokenSyntax? {
let parameters: [FunctionPathParameter] = getParameters(from: declaration, with: "Body")
guard parameters.count <= 1 else {
context.diagnose(diagnostics.tooManyBodyParameters(in: declaration))
return nil
}
guard let body = parameters.first else {
return nil
}
return body.value
}
private func expandParameter(
_ name: String,
of declaration: FunctionDeclSyntax,
in context: some MacroExpansionContext
) -> DictionaryExprSyntax? {
let parameters: [FunctionPathParameter] = getParameters(from: declaration, with: name)
if parameters.isEmpty {
return nil
}
let dict = DictionaryElementListSyntax {
for parameter in parameters {
DictionaryElementSyntax(
keyExpression: StringLiteralExprSyntax(content: parameter.name),
valueExpression: "\(raw: parameter.value)" as ExprSyntax
)
}
}
return DictionaryExprSyntax(content: .elements(dict))
}
private func expandEndpoint(
from attribute: AttributeSyntax,
of declaration: FunctionDeclSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax? {
let arguments = attribute.argument?.as(TupleExprElementListSyntax.self)
let parameters: [FunctionPathParameter] = getParameters(from: declaration, with: "Path")
guard let path = arguments?.first?.expression else {
guard parameters.isEmpty else {
context.diagnose(diagnostics.extra(parameters: parameters.map(\.name), in: declaration))
return nil
}
return resource != nil ? "resourceURL" : "baseURL"
}
guard let pathLiteral = path.as(StringLiteralExprSyntax.self) else {
context.diagnose(diagnostics.pathLiteralRequired(node: path))
return nil
}
guard pathLiteral.segments.count == 1 else {
context.diagnose(diagnostics.pathInterpolationNotSupported(node: path))
return nil
}
let replacementBlocks = getReplacementBlocks(from: path)
let parameterNames = parameters.map(\.name)
let missingParameters = replacementBlocks.filter { !parameterNames.contains($0) }
guard missingParameters.isEmpty else {
context.diagnose(diagnostics.missing(parameters: missingParameters, in: declaration))
return nil
}
let extraParameters = parameterNames.filter { !replacementBlocks.contains($0) }
guard extraParameters.isEmpty else {
context.diagnose(diagnostics.extra(parameters: extraParameters, in: declaration))
return nil
}
// replace
let newPath = pathLiteral.with(\.segments, StringLiteralSegmentsSyntax(
pathLiteral.segments.compactMap { segment in
guard let stringSegment = segment.as(StringSegmentSyntax.self) else {
return nil
}
let newPath = parameters.reduce(stringSegment.content.text) { content, parameter in
content.replacingOccurrences(of: "{\(parameter.name)}", with: "\\(\(parameter.value.text))")
}
return StringLiteralSegmentsSyntax.Element(StringSegmentSyntax(content: .stringSegment(newPath)))
}
))
let baseURLExpr: ExprSyntax = resource != nil ? "resourceURL" : "baseURL"
return "\(baseURLExpr).appendingPathComponent(\(newPath))"
}
private func getParameters<T: FunctionParameter>(from declaration: FunctionDeclSyntax, with attributeName: String) -> [T] {
return declaration.signature.input.parameterList.filter { parameter in
parameter.attributes?.contains(where: { attribute in
attribute.as(AttributeSyntax.self)?.attributeName.as(SimpleTypeIdentifierSyntax.self)?.name.text == attributeName
}) ?? false
}.map(T.init)
}
private func getReplacementBlocks(from path: ExprSyntax) -> [String] {
let pattern = "\\{([^}]*)\\}"
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return []
}
let results = regex.matches(in: path.description, range: NSRange(location: 0, length: path.description.count))
let matches = results.map { result -> String in
let range = Range(result.range(at: 1), in: path.description)!
return String(path.description[range])
}
return matches
}
}

View File

@@ -5,10 +5,7 @@ import SwiftSyntaxMacros
struct SwiftRequestPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
ServiceMacro.self,
GetRequestMacro.self,
PostRequestMacro.self,
PutRequestMacro.self,
PatchRequestMacro.self,
DeleteRequestMacro.self
MethodMacro.self,
ParamMacro.self
]
}

View File

@@ -0,0 +1,73 @@
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
import SwiftRequestMacros
final class MethodMacroTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"GET": MethodMacro.self,
"POST": MethodMacro.self,
"PUT": MethodMacro.self,
"PATCH": MethodMacro.self,
"DELETE": MethodMacro.self,
"HEAD": MethodMacro.self,
"OPTIONS": MethodMacro.self,
]
// func testMacro() {
// assertMacroExpansion(
// """
// @GET
// func hello() async throws -> String
// """,
// expandedSource: """
//
// func hello() async throws -> String
// """,
// macros: testMacros
// )
// }
//
// func testMacroRequiresFunction() {
// assertMacroExpansion(
// """
// @GET
// var hello: String
// """,
// expandedSource: """
//
// var hello: String
// """,
// diagnostics: [
// .init(message: "@GET requires a function", line: 1, column: 1)
// ],
// macros: testMacros
// )
// }
//
// func testMacroRequiresANonStaticFunction() {
// assertMacroExpansion(
// """
// @GET
// static func hello() async throws -> String
// """,
// expandedSource: """
//
// static func hello() async throws -> String
// """,
// diagnostics: [
// .init(
// message: "@GET requires a non static function",
// line: 1,
// column: 1,
// fixIts: [
// .init(message: "remove static keyword")
// ]
// )
// ],
// macros: testMacros
// )
// }
}

View File

@@ -1,40 +0,0 @@
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

@@ -3,48 +3,41 @@ import SwiftSyntaxMacrosTestSupport
import XCTest
import SwiftRequestMacros
final class ServiceTests: XCTestCase {
final class ServiceTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"Service": ServiceMacro.self,
"GET": MethodMacro.self,
]
func testMacro() {
assertMacroExpansion(
"""
@Service
class MyService {
}
""",
expandedSource: """
class MyService {
private let baseURL: URL
private let session: URLSession
required init(baseURL: URL, session: URLSession) {
self.baseURL = baseURL
self.session = session
}
}
""",
macros: testMacros
)
}
func testMacroWithResource() {
assertMacroExpansion(
"""
@Service(resource: "quotes")
class MyService {
protocol QuoteService {
@GET("random")
func getRandomQuotes(@QueryParam limit: Int?) async throws -> [Quote]
@GET("{id}")
func getQuote(@Path by id: String) async throws -> Quote
}
""",
expandedSource: """
class MyService {
private let baseURL: URL
private let session: URLSession
required init(baseURL: URL, session: URLSession) {
self.baseURL = baseURL.appendingPathComponent("quotes")
self.session = session
protocol QuoteService {
func getRandomQuotes(@QueryParam limit: Int?) async throws -> [Quote]
func getQuote(@Path by id: String) async throws -> Quote
}
class QuoteServiceImpl: Service, QuoteService {
private lazy var resourceURL: URL = baseURL.appendingPathComponent("quotes")
func getRandomQuotes(limit: Int? = nil) async throws -> [Quote] {
let request = Request(url: resourceURL.appendingPathComponent("random"), queryParams: ["limit": limit])
return try await session.execute(request)
}
func getQuote(by id: String) async throws -> Quote {
let request = Request(url: resourceURL.appendingPathComponent("\\(id)"))
return try await session.execute(request)
}
}
""",