mirror of
https://github.com/zhigang1992/swift-request.git
synced 2026-04-29 09:45:56 +08:00
Change macro to work just with protocol
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
20
Sources/SwiftRequest/Macros/MethodMacros.swift
Normal file
20
Sources/SwiftRequest/Macros/MethodMacros.swift
Normal 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")
|
||||
14
Sources/SwiftRequest/Macros/ParamMacros.swift
Normal file
14
Sources/SwiftRequest/Macros/ParamMacros.swift
Normal 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")
|
||||
5
Sources/SwiftRequest/Macros/ServiceMacros.swift
Normal file
5
Sources/SwiftRequest/Macros/ServiceMacros.swift
Normal 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")
|
||||
74
Sources/SwiftRequest/Request.swift
Normal file
74
Sources/SwiftRequest/Request.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
28
Sources/SwiftRequest/URLSession+execute.swift
Normal file
28
Sources/SwiftRequest/URLSession+execute.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
struct Quote: Decodable {
|
||||
struct Quote: Codable {
|
||||
let id: String
|
||||
let content: String
|
||||
let author: String
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
8
Sources/SwiftRequestClient/TestService.swift
Normal file
8
Sources/SwiftRequestClient/TestService.swift
Normal 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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
45
Sources/SwiftRequestMacros/Diagnostics.swift
Normal file
45
Sources/SwiftRequestMacros/Diagnostics.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
34
Sources/SwiftRequestMacros/FunctionParameter.swift
Normal file
34
Sources/SwiftRequestMacros/FunctionParameter.swift
Normal 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 {
|
||||
|
||||
}
|
||||
94
Sources/SwiftRequestMacros/MethodDiagnostics.swift
Normal file
94
Sources/SwiftRequestMacros/MethodDiagnostics.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
69
Sources/SwiftRequestMacros/MethodMacro.swift
Normal file
69
Sources/SwiftRequestMacros/MethodMacro.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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!)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
Sources/SwiftRequestMacros/ParamMacro.swift
Normal file
14
Sources/SwiftRequestMacros/ParamMacro.swift
Normal 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 []
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
14
Sources/SwiftRequestMacros/ServiceDiagnostics.swift
Normal file
14
Sources/SwiftRequestMacros/ServiceDiagnostics.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
243
Sources/SwiftRequestMacros/ServiceMethodExpander.swift
Normal file
243
Sources/SwiftRequestMacros/ServiceMethodExpander.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
73
Tests/SwiftRequestTests/MethodMacroTests.swift
Normal file
73
Tests/SwiftRequestTests/MethodMacroTests.swift
Normal 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
|
||||
// )
|
||||
// }
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
""",
|
||||
|
||||
Reference in New Issue
Block a user