diff --git a/Example/Sources/PetStoreWithMacros/PetStore.swift b/Example/Sources/PetStoreWithMacros/PetStore.swift index 7a533cc..77d9dc5 100644 --- a/Example/Sources/PetStoreWithMacros/PetStore.swift +++ b/Example/Sources/PetStoreWithMacros/PetStore.swift @@ -36,8 +36,8 @@ public extension PetStore { @Path("{id}") public struct PetByID { - #GET(PetModel) - #DELETE + @GET("/") public func get() -> PetModel {} + @DELETE("/") public func delete() {} @POST("/") public func update(@Query name: String?, @Query status: PetStatus?) -> PetModel {} @POST public func uploadImage(_ body: Data, @Query additionalMetadata: String? = nil) {} } @@ -61,8 +61,8 @@ public extension PetStore { @Path("order", "{id}") public struct Order { - #GET(OrderModel) - #DELETE(OrderModel) + @GET("/") public func get() -> OrderModel {} + @DELETE("/") public func delete() {} } } } @@ -89,8 +89,8 @@ extension PetStore { @Path("{username}") public struct UserByUsername { - #GET(UserModel) - #DELETE(UserModel) + @GET("/") public func get() -> UserModel {} + @DELETE("/") public func delete() {} @PUT("/") public func update(_ body: UserModel) -> UserModel {} } } diff --git a/Package.swift b/Package.swift index d9292e3..cd7045e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,7 +1,6 @@ -// swift-tools-version:5.9 +// swift-tools-version:5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. -import CompilerPluginSupport import PackageDescription var package = Package( @@ -17,14 +16,12 @@ var package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.5.3"), - .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.3"), - .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.2"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.3") ], targets: [ .target( name: "SwiftAPIClient", dependencies: [ - .target(name: "SwiftAPIClientMacros"), .product(name: "Logging", package: "swift-log"), .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "HTTPTypesFoundation", package: "swift-http-types"), @@ -33,13 +30,6 @@ var package = Package( .testTarget( name: "SwiftAPIClientTests", dependencies: [.target(name: "SwiftAPIClient")] - ), - .macro( - name: "SwiftAPIClientMacros", - dependencies: [ - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - ] - ), + ) ] ) diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000..e74d2a5 --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,52 @@ +// swift-tools-version:5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import CompilerPluginSupport +import PackageDescription + +var package = Package( + name: "swift-api-client", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v5), + .tvOS(.v13), + ], + products: [ + .library(name: "SwiftAPIClient", targets: ["SwiftAPIClient"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.3"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.3"), + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.2"), + ], + targets: [ + .target( + name: "SwiftAPIClient", + dependencies: [ + .target(name: "SwiftAPIClientMacros"), + .product(name: "Logging", package: "swift-log"), + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "HTTPTypesFoundation", package: "swift-http-types"), + ] + ), + .testTarget( + name: "SwiftAPIClientTests", + dependencies: [.target(name: "SwiftAPIClient")] + ), + .macro( + name: "SwiftAPIClientMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .testTarget( + name: "SwiftAPIClientMacrosTests", + dependencies: [ + .target(name: "SwiftAPIClientMacros"), + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax") + ] + ), + ] +) diff --git a/README.md b/README.md index 0626c74..fb2ee2a 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,17 @@ Also, you can use macros for API declaration: @Path struct Pet { + /// PUT /pet + @PUT("/") public func update(_ body: PetModel) -> PetModel {} + + /// POST /pet + @POST("/") public func add(_ body: PetModel) -> PetModel {} + /// GET /pet/findByStatus - @GET func findByStatus(@Query _ status: PetStatus) -> [PetModel] {} + @GET public func findByStatus(@Query _ status: PetStatus) -> [PetModel] {} + + /// GET /pet/findByTags + @GET public func findByTags(@Query _ tags: [String]) -> [PetModel] {} } ``` @@ -255,6 +264,9 @@ print(configs.intValue) // 3 ## Macros `swift-api-client` provides a set of macros for easier API declarations. +- `API` macro that generates an API client struct. +- `Path` macro that generates an API client scope for the path. +- `Cal(_ method:)`, `GET`, `POST`, `PUT`, etc macros for declaring API methods. Example: ```swift /// /pet @@ -291,7 +303,7 @@ struct Pet { } } ``` -Macros are not necessary for using `swift-api-client`; they are just syntax sugar. In general, it's not recommended to use a lot of macros for large projects when compilation time becomes a problem; core library syntax is minimalistic enough. +Macros are not necessary for using `swift-api-client`; they are just syntax sugar. ## Introducing `swift-api-client-addons` diff --git a/Sources/SwiftAPIClient/Macros.swift b/Sources/SwiftAPIClient/Macros.swift index 248fa75..96d5f98 100644 --- a/Sources/SwiftAPIClient/Macros.swift +++ b/Sources/SwiftAPIClient/Macros.swift @@ -2,57 +2,295 @@ import Foundation import HTTPTypes +/// A macro that generates an HTTP call to the API client. +/// +/// - Parameters: +/// - method: The HTTP method to be used for the request. +/// - path: The path components to be appended to the base URL. If omitted, the path will be equal to the function name. You can include arguments in the path using the following syntax: `{argument}` or, if you want to specify the type, `{argument:Int}`. +/// +/// Must be used with a function within a struct attributed with `@API` or `@Path` macro. +/// +/// The function must have an empty body `{}` and return either a `Decodable` type, tuple, `String`, `Data`, or `Void`. +/// +/// You can specify the body and query in two ways: +/// - `@Body` and `@Query` parameter attributes. These attributes add a parameter to the body or query with the same name as the parameter. If you specify two names, the second name will be used for the parameter name. +/// - Encodable or tuple parameters named `body` or `query`. These parameters will be encoded and used as body or query parameters as is. +/// +/// Examples: +/// ```swift +/// @API +/// struct API { +/// +/// /// GET /pets +/// @Call(.get) +/// func pets(@Query name: String? = nil) -> [Pets] {} +/// +/// /// GET /users/{id} +/// @Call(.get, "/users/{id:Int}") +/// func getUser() -> User {} +/// } +/// ``` +/// +/// You can add custom APIClient modifiers such as `.header(_:)`, `.auth(enabled:)`, etc., in the function body using the `client` property. +/// ```swift +/// /// PUT /user +/// @Call(.put) +/// func user(_ body: User) { +/// client +/// .auth(enabled: true) +/// } +/// ``` +/// - Warning: If you use swiftformat disable unusedArguments rule: `--disable unusedArguments` or `//swiftformat:disable:unusedArguments` @attached(peer, names: arbitrary) public macro Call( _ method: HTTPRequest.Method, _ path: String... ) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientCallMacro") +/// A macro that generates an HTTP **GET** call to the API client. +/// +/// - Parameters: +/// - path: The path components to be appended to the base URL. If omitted, the path will be equal to the function name. You can include arguments in the path using the following syntax: `{argument}` or, if you want to specify the type, `{argument:Int}`. +/// +/// Must be used with a function within a struct attributed with `@API` or `@Path` macro. +/// +/// The function must have an empty body `{}` and return either a `Decodable` type, tuple, `String`, `Data`, or `Void`. +/// +/// You can specify the body and query in two ways: +/// - `@Body` and `@Query` parameter attributes. These attributes add a parameter to the body or query with the same name as the parameter. If you specify two names, the second name will be used for the parameter name. +/// - Encodable or tuple parameters named `body` or `query`. These parameters will be encoded and used as body or query parameters as is. +/// +/// Examples: +/// ```swift +/// @API +/// struct API { +/// +/// /// GET /pets +/// @GET +/// func pets(@Query name: String? = nil) -> [Pets] {} +/// +/// /// GET /users/{id} +/// @GET("/users/{id:Int}") +/// func getUser() -> User {} +/// } +/// ``` +/// +/// You can add custom APIClient modifiers such as `.header(_:)`, `.auth(enabled:)`, etc., in the function body using the `client` property. +/// ```swift +/// /// PUT /user +/// @PUT +/// func user(_ body: User) { +/// client +/// .auth(enabled: true) +/// } +/// ``` +/// - Warning: If you use swiftformat disable unusedArguments rule: `--disable unusedArguments` or `//swiftformat:disable:unusedArguments` @attached(peer, names: arbitrary) public macro GET(_ path: String...) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientCallMacro") + +/// A macro that generates an HTTP **POST** call to the API client. +/// +/// - Parameters: +/// - path: The path components to be appended to the base URL. If omitted, the path will be equal to the function name. You can include arguments in the path using the following syntax: `{argument}` or, if you want to specify the type, `{argument:Int}`. +/// +/// Must be used with a function within a struct attributed with `@API` or `@Path` macro. +/// +/// The function must have an empty body `{}` and return either a `Decodable` type, tuple, `String`, `Data`, or `Void`. +/// +/// You can specify the body and query in two ways: +/// - `@Body` and `@Query` parameter attributes. These attributes add a parameter to the body or query with the same name as the parameter. If you specify two names, the second name will be used for the parameter name. +/// - Encodable or tuple parameters named `body` or `query`. These parameters will be encoded and used as body or query parameters as is. +/// +/// Examples: +/// ```swift +/// @API +/// struct API { +/// +/// /// GET /pets +/// @GET +/// func pets(@Query name: String? = nil) -> [Pets] {} +/// +/// /// GET /users/{id} +/// @GET("/users/{id:Int}") +/// func getUser() -> User {} +/// } +/// ``` +/// +/// You can add custom APIClient modifiers such as `.header(_:)`, `.auth(enabled:)`, etc., in the function body using the `client` property. +/// ```swift +/// /// PUT /user +/// @PUT +/// func user(_ body: User) { +/// client +/// .auth(enabled: true) +/// } +/// ``` +/// - Warning: If you use swiftformat disable unusedArguments rule: `--disable unusedArguments` or `//swiftformat:disable:unusedArguments` @attached(peer, names: arbitrary) public macro POST(_ path: String...) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientCallMacro") + +/// A macro that generates an HTTP **PUT** call to the API client. +/// +/// - Parameters: +/// - path: The path components to be appended to the base URL. If omitted, the path will be equal to the function name. You can include arguments in the path using the following syntax: `{argument}` or, if you want to specify the type, `{argument:Int}`. +/// +/// Must be used with a function within a struct attributed with `@API` or `@Path` macro. +/// +/// The function must have an empty body `{}` and return either a `Decodable` type, tuple, `String`, `Data`, or `Void`. +/// +/// You can specify the body and query in two ways: +/// - `@Body` and `@Query` parameter attributes. These attributes add a parameter to the body or query with the same name as the parameter. If you specify two names, the second name will be used for the parameter name. +/// - Encodable or tuple parameters named `body` or `query`. These parameters will be encoded and used as body or query parameters as is. +/// +/// Examples: +/// ```swift +/// @API +/// struct API { +/// +/// /// GET /pets +/// @GET +/// func pets(@Query name: String? = nil) -> [Pets] {} +/// +/// /// GET /users/{id} +/// @GET("/users/{id:Int}") +/// func getUser() -> User {} +/// } +/// ``` +/// +/// You can add custom APIClient modifiers such as `.header(_:)`, `.auth(enabled:)`, etc., in the function body using the `client` property. +/// ```swift +/// /// PUT /user +/// @PUT +/// func user(_ body: User) { +/// client +/// .auth(enabled: true) +/// } +/// ``` +/// - Warning: If you use swiftformat disable unusedArguments rule: `--disable unusedArguments` or `//swiftformat:disable:unusedArguments` @attached(peer, names: arbitrary) public macro PUT(_ path: String...) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientCallMacro") + +/// A macro that generates an HTTP **DELETE** call to the API client. +/// +/// - Parameters: +/// - path: The path components to be appended to the base URL. If omitted, the path will be equal to the function name. You can include arguments in the path using the following syntax: `{argument}` or, if you want to specify the type, `{argument:Int}`. +/// +/// Must be used with a function within a struct attributed with `@API` or `@Path` macro. +/// +/// The function must have an empty body `{}` and return either a `Decodable` type, tuple, `String`, `Data`, or `Void`. +/// +/// You can specify the body and query in two ways: +/// - `@Body` and `@Query` parameter attributes. These attributes add a parameter to the body or query with the same name as the parameter. If you specify two names, the second name will be used for the parameter name. +/// - Encodable or tuple parameters named `body` or `query`. These parameters will be encoded and used as body or query parameters as is. +/// +/// Examples: +/// ```swift +/// @API +/// struct API { +/// +/// /// GET /pets +/// @GET +/// func pets(@Query name: String? = nil) -> [Pets] {} +/// +/// /// GET /users/{id} +/// @GET("/users/{id:Int}") +/// func getUser() -> User {} +/// } +/// ``` +/// +/// You can add custom APIClient modifiers such as `.header(_:)`, `.auth(enabled:)`, etc., in the function body using the `client` property. +/// ```swift +/// /// PUT /user +/// @PUT +/// func user(_ body: User) { +/// client +/// .auth(enabled: true) +/// } +/// ``` +/// - Warning: If you use swiftformat disable unusedArguments rule: `--disable unusedArguments` or `//swiftformat:disable:unusedArguments` @attached(peer, names: arbitrary) public macro DELETE(_ path: String...) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientCallMacro") + +/// A macro that generates an HTTP **PATCH** call to the API client. +/// +/// - Parameters: +/// - path: The path components to be appended to the base URL. If omitted, the path will be equal to the function name. You can include arguments in the path using the following syntax: `{argument}` or, if you want to specify the type, `{argument:Int}`. +/// +/// Must be used with a function within a struct attributed with `@API` or `@Path` macro. +/// +/// The function must have an empty body `{}` and return either a `Decodable` type, tuple, `String`, `Data`, or `Void`. +/// +/// You can specify the body and query in two ways: +/// - `@Body` and `@Query` parameter attributes. These attributes add a parameter to the body or query with the same name as the parameter. If you specify two names, the second name will be used for the parameter name. +/// - Encodable or tuple parameters named `body` or `query`. These parameters will be encoded and used as body or query parameters as is. +/// +/// Examples: +/// ```swift +/// @API +/// struct API { +/// +/// /// GET /pets +/// @GET +/// func pets(@Query name: String? = nil) -> [Pets] {} +/// +/// /// GET /users/{id} +/// @GET("/users/{id:Int}") +/// func getUser() -> User {} +/// } +/// ``` +/// +/// You can add custom APIClient modifiers such as `.header(_:)`, `.auth(enabled:)`, etc., in the function body using the `client` property. +/// ```swift +/// /// PUT /user +/// @PUT +/// func user(_ body: User) { +/// client +/// .auth(enabled: true) +/// } +/// ``` +/// - Warning: If you use swiftformat disable unusedArguments rule: `--disable unusedArguments` or `//swiftformat:disable:unusedArguments` @attached(peer, names: arbitrary) public macro PATCH(_ path: String...) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientCallMacro") +/// Macro that generates an API client scope for the path. +/// This macro synthesizes a variable or function that returns the path struct. +/// +/// - Parameters: +/// - path: The path components to be appended to the base URL. If omitted, the path will be equal to the struct name. You can include arguments in the path using the following syntax: `{argument}` or, if you want to specify the type, `{argument:Int}`. +/// +/// The struct can contain functions with `Call`, `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` macros, as well as structs with the `Path` macro. +/// All included function paths will be resolved relative to the path specified in the `Path` macro. +/// If you specify a custom initializer, you must initialize the `client` property. +/// +/// Examples: +/// ```swift +/// /// /pets +/// @Path +/// struct Pets { +/// /// DELETE /pets/{id} +/// @DELETE("{id}") +/// func deletePet() +/// } +/// ``` +/// +/// You can add custom APIClient modifiers for all methods in the struct, such as `.header(_:)`, `.auth(enabled:)`, etc., by implementing a custom init: +/// ```swift +/// init(client: APIClient) { +/// self.client = client.auth(enabled: true) +/// } +/// ``` @attached(peer, names: arbitrary) @attached(memberAttribute) @attached(member, names: arbitrary) public macro Path(_ path: String...) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientPathMacro") +/// Macro that generates an API client struct. +/// The struct can contain functions with `Call`, `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` macros, as well as structs with the `Path` macro. +/// If you specify a custom init, you must initialize the `client` property. @attached(memberAttribute) @attached(member, names: arbitrary) public macro API() = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientPathMacro") -@freestanding(declaration, names: arbitrary) -public macro GET(_ type: T.Type = T.self) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") -@freestanding(declaration, names: arbitrary) -public macro GET() = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") - -@freestanding(declaration, names: arbitrary) -public macro POST(_ type: T.Type = T.self) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") -@freestanding(declaration, names: arbitrary) -public macro POST() = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") - -@freestanding(declaration, names: arbitrary) -public macro PUT(_ type: T.Type = T.self) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") -@freestanding(declaration, names: arbitrary) -public macro PUT() = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") - -@freestanding(declaration, names: arbitrary) -public macro DELETE(_ type: T.Type = T.self) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") -@freestanding(declaration, names: arbitrary) -public macro DELETE() = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") - -@freestanding(declaration, names: arbitrary) -public macro PATCH(_ type: T.Type = T.self) = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") -@freestanding(declaration, names: arbitrary) -public macro PATCH() = #externalMacro(module: "SwiftAPIClientMacros", type: "SwiftAPIClientFreestandingMacro") - @propertyWrapper public struct _APIParameterWrapper { diff --git a/Sources/SwiftAPIClientMacros/String++.swift b/Sources/SwiftAPIClientMacros/String++.swift index 73dc4f0..9f83c5c 100644 --- a/Sources/SwiftAPIClientMacros/String++.swift +++ b/Sources/SwiftAPIClientMacros/String++.swift @@ -13,4 +13,8 @@ extension String { var isOptional: Bool { hasSuffix("?") || hasPrefix("Optional<") && hasSuffix(">") } + + func removeCharacters(in set: CharacterSet) -> String { + components(separatedBy: set).joined() + } } diff --git a/Sources/SwiftAPIClientMacros/SwiftAPIClientMacros.swift b/Sources/SwiftAPIClientMacros/SwiftAPIClientMacros.swift index 4aa104f..c3174a2 100644 --- a/Sources/SwiftAPIClientMacros/SwiftAPIClientMacros.swift +++ b/Sources/SwiftAPIClientMacros/SwiftAPIClientMacros.swift @@ -17,21 +17,6 @@ struct SwiftAPIClientMacrosPlugin: CompilerPlugin { ] } -public struct SwiftAPIClientFreestandingMacro: DeclarationMacro { - - public static func expansion(of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] { - let name = node.macro.text.lowercased() - var type = node.argumentList.first?.expression.trimmed.description ?? "" - if type.hasSuffix(".self") { - type.removeLast(5) - } - if !type.isEmpty { - type = " -> \(type)" - } - return ["public func \(raw: name)() async throws\(raw: type) { try await client.\(raw: name)() }"] - } -} - public struct SwiftAPIClientCallMacro: PeerMacro { public static func expansion( @@ -70,18 +55,14 @@ public struct SwiftAPIClientCallMacro: PeerMacro { } var queryParams: ([String], [(String, String)]) = ([], []) var bodyParams: ([String], [(String, String)]) = ([], []) - for var param in funcDecl.signature.parameterClause.parameters { + for param in funcDecl.signature.parameterClause.parameters { let name = (param.secondName ?? param.firstName).trimmed.text - if param.attributes.contains("Body"), attribute.method == ".get" { + if param.attributes.contains("Body") || name == "body", attribute.method == ".get" { throw MacroError("Body parameter is not allowed with GET method") } - try scanParameters(name: name, type: "Query", param: ¶m, into: &queryParams) - try scanParameters(name: name, type: "Body", param: ¶m, into: &bodyParams) - - param.trailingComma = .commaToken() - param.leadingTrivia = .newline - params.append(param) + params += try scanParameters(name: name, type: "Query", param: param, into: &queryParams) + params += try scanParameters(name: name, type: "Body", param: param, into: &bodyParams) } params += [ @@ -116,17 +97,14 @@ public struct SwiftAPIClientCallMacro: PeerMacro { } if let tuple = funcDecl.signature.returnClause?.type.as(TupleTypeSyntax.self) { - let props: [(String, String)] = try tuple.elements.map { - guard let label = ($0.firstName ?? $0.secondName) else { - throw MacroError("Tuple elements must have labels") - } - return (label.text, $0.type.trimmed.description) + let props: [(String, String)] = tuple.elements.map { + ($0.labelName, $0.type.trimmed.description) } let name = "\(funcDecl.name.text.firstUppercased)Response" funcDecl.signature.returnClause = ReturnClauseSyntax(type: TypeSyntax(stringLiteral: name)) result.append( """ - public struct \(raw: name): Codable { + public struct \(raw: name): Codable, Equatable { \(raw: props.map { "public var \($0.0): \($0.1)" }.joined(separator: "\n")) public init(\(raw: props.map { "\($0.0): \($0.1)\($0.1.isOptional ? " = nil" : "")" }.joined(separator: ", "))) { \(raw: props.map { "self.\($0.0) = \($0.0)" }.joined(separator: "\n")) @@ -147,7 +125,7 @@ public struct SwiftAPIClientCallMacro: PeerMacro { default: serializer } } - body.statements += [" .call(.\(raw: attribute.caller), as: Serializer.\(raw: serializer), fileID: fileID, line: line)"] + body.statements += [" .call(.\(raw: attribute.caller), as: .\(raw: serializer), fileID: fileID, line: line)"] funcDecl.body = body result.insert(DeclSyntax(funcDecl), at: 0) return result @@ -184,9 +162,11 @@ public struct SwiftAPIClientPathMacro: MemberMacro, MemberAttributeMacro, PeerMa let path = path(node: node, name: structDecl.name) let pathArguments = pathArguments(path: path) let isVar = pathArguments.isEmpty - let pathName = path.compactMap { $0.hasPrefix("{") ? nil : $0.firstUppercased }.joined().firstLowercased + let pathName = structDecl.name.trimmed.text.firstLowercased let name = path.count > pathArguments.count ? pathName : "callAsFunction" - let args = pathArguments.map { "_ \($0.0): \($0.1)" }.joined(separator: ", ") + let args = pathArguments.enumerated() + .map { "\($0.offset == 0 ? "_ " : "")\($0.element.0): \($0.element.1)" } + .joined(separator: ", ") var client = "client" if !path.isEmpty { @@ -220,16 +200,16 @@ public struct SwiftAPIClientPathMacro: MemberMacro, MemberAttributeMacro, PeerMa providingMembersOf declaration: StructDeclSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { + let isPath = node.description.contains("Path") + var result: [DeclSyntax] = [ "public typealias Body = _APIParameterWrapper", "public typealias Query = _APIParameterWrapper", - "public var client: APIClient", + "\(raw: isPath ? "private let" : "public var") client: APIClient", ] var hasRightInit = false var hasOtherInits = false - let isPath = node.description.contains("Path") - for member in declaration.memberBlock.members { if let initDecl = member.decl.as(InitializerDeclSyntax.self) { let params = initDecl.signature.parameterClause.parameters @@ -259,6 +239,21 @@ public struct SwiftAPIClientPathMacro: MemberMacro, MemberAttributeMacro, PeerMa } } +public struct SwiftAPIClientFreestandingMacro: DeclarationMacro { + + public static func expansion(of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] { + let name = node.macro.text.lowercased() + var type = node.argumentList.first?.expression.trimmed.description ?? "" + if type.hasSuffix(".self") { + type.removeLast(5) + } + if !type.isEmpty { + type = " -> \(type)" + } + return ["public func \(raw: name)() async throws\(raw: type) { try await client.\(raw: name)() }"] + } +} + struct CallAttribute { let method: String let path: [String] @@ -370,7 +365,7 @@ private func path>( private func pathString(path: [String], arguments: [(String, String, Int)]) -> String { let string = path.enumerated().map { offset, item in if let arg = arguments.first(where: { $0.2 == offset }) { - return arg.0 + return "\"\\(\(arg.0))\"" } else { return "\"\(item)\"" } @@ -389,26 +384,54 @@ private func pathArguments( private func scanParameters( name: String, type: String, - param: inout FunctionParameterListSyntax.Element, + param: FunctionParameterListSyntax.Element, into list: inout ([String], [(String, String)]) -) throws { +) throws -> [FunctionParameterListSyntax.Element] { + var param = param + param.trailingComma = .commaToken() + param.leadingTrivia = .newline if param.attributes.remove(type.firstUppercased) { list.1.append((name, name)) - return + return [param] } // let typeName = param.firstName.text.count > 1 ? param.firstName.text : name if name.firstLowercased == type.firstLowercased { if let tuple = param.type.as(TupleTypeSyntax.self) { - list.1 += try tuple.elements.map { - guard let label = ($0.firstName ?? $0.secondName) else { - throw MacroError("Tuple elements must have labels") - } - return (label.text, "\(name).\(label.text)") - } + var result: [FunctionParameterListSyntax.Element] = [] + for element in tuple.elements { + guard !element.type.is(TupleTypeSyntax.self) else { + throw MacroError("Tuple within tuple is not supported yet") + } + let label = element.labelName + let secondName = "\(name.firstLowercased)\(label.firstUppercased)" + list.1.append((label, secondName)) + + result.append( + FunctionParameterSyntax( + leadingTrivia: .newline, + firstName: .identifier(label), + secondName: .identifier(secondName), + type: element.type, + trailingComma: .commaToken() + ) + ) + } + return result } else { list.0.append(name) } + return [param] } + return [] } +private extension TupleTypeElementListSyntax.Element { + + var labelName: String { + (firstName ?? secondName)?.text ?? type + .trimmed.description + .firstLowercased + .removeCharacters(in: ["?", ".", "<", ">"]) + } +} #endif diff --git a/Tests/SwiftAPIClientMacrosTests/CallMacroTests.swift b/Tests/SwiftAPIClientMacrosTests/CallMacroTests.swift new file mode 100644 index 0000000..9fda1e4 --- /dev/null +++ b/Tests/SwiftAPIClientMacrosTests/CallMacroTests.swift @@ -0,0 +1,365 @@ +import SwiftAPIClientMacros +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +final class CallMacroTests: XCTestCase { + + private let macros: [String: Macro.Type] = [ + "Call": SwiftAPIClientCallMacro.self, + "GET": SwiftAPIClientCallMacro.self, + "POST": SwiftAPIClientCallMacro.self, + "PUT": SwiftAPIClientCallMacro.self, + "DELETE": SwiftAPIClientCallMacro.self, + "PATCH": SwiftAPIClientCallMacro.self + ] + + func testExpansionCallMacro() { + assertMacroExpansion( + """ + @Call(.get) + func pets() -> Pet { + } + """, + expandedSource: """ + func pets() -> Pet { + } + + func pets( + fileID: String = #fileID, + line: UInt = #line + ) async throws -> Pet { + try await client + .path("pets") + .method(.get) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionEmptyGetMacro() { + assertMacroExpansion( + """ + @GET + func pets() -> Pet { + } + """, + expandedSource: """ + func pets() -> Pet { + } + + func pets( + fileID: String = #fileID, + line: UInt = #line + ) async throws -> Pet { + try await client + .path("pets") + .method(.get) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionGetMacroWithEmptyString() { + assertMacroExpansion( + """ + @GET("/") + func pets() -> Pet { + } + """, + expandedSource: """ + func pets() -> Pet { + } + + func pets( + fileID: String = #fileID, + line: UInt = #line + ) async throws -> Pet { + try await client + .method(.get) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionGetMacroWithNonEmptyString() { + assertMacroExpansion( + """ + @GET("/pets/{id:UUID}") + func pets() -> Pet { + } + """, + expandedSource: """ + func pets() -> Pet { + } + + func pets(id: UUID, + fileID: String = #fileID, + line: UInt = #line + ) async throws -> Pet { + try await client + .path("pets", "\\(id)") + .method(.get) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionGetMacroWithBody() { + assertMacroExpansion( + """ + @GET + func pets(body: PetBody) -> Pet { + } + """, + expandedSource: """ + func pets(body: PetBody) -> Pet { + } + """, + diagnostics: [DiagnosticSpec(message: "Body parameter is not allowed with GET method", line: 1, column: 1)], + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionPostMacroWithNonEmptyString() { + assertMacroExpansion( + """ + @POST + func pets(body: Pet) -> Pet { + } + """, + expandedSource: """ + func pets(body: Pet) -> Pet { + } + + func pets( + body: Pet, + fileID: String = #fileID, + line: UInt = #line + ) async throws -> Pet { + try await client + .path("pets") + .method(.post) + .body(body) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionGetMacroReturningVoid() { + assertMacroExpansion( + """ + @GET + func pets() { + } + """, + expandedSource: """ + func pets() { + } + + func pets( + fileID: String = #fileID, + line: UInt = #line + ) async throws { + try await client + .path("pets") + .method(.get) + .call(.http, as: .void, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionPutMacroWithPathParameters() { + assertMacroExpansion( + """ + @PUT("/pets/{id}") + func updatePet(body: PetUpdateBody) -> Pet { + } + """, + expandedSource: """ + func updatePet(body: PetUpdateBody) -> Pet { + } + + func updatePet(id: String, + body: PetUpdateBody, + fileID: String = #fileID, + line: UInt = #line + ) async throws -> Pet { + try await client + .path("pets", "\\(id)") + .method(.put) + .body(body) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionPatchMacroWithQueryParameters() { + assertMacroExpansion( + """ + @PATCH("/pets/{id}") + func partiallyUpdatePet(@Query name: String) -> Pet { + } + """, + expandedSource: """ + func partiallyUpdatePet(@Query name: String) -> Pet { + } + + func partiallyUpdatePet(id: String, name: String, + fileID: String = #fileID, + line: UInt = #line + ) async throws -> Pet { + try await client + .path("pets", "\\(id)") + .method(.patch) + .query(["name": name]) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionDeleteMacro() { + assertMacroExpansion( + """ + @DELETE("/pets/{id}") + func deletePet() { + } + """, + expandedSource: """ + func deletePet() { + } + + func deletePet(id: String, + fileID: String = #fileID, + line: UInt = #line + ) async throws { + try await client + .path("pets", "\\(id)") + .method(.delete) + .call(.http, as: .void, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionForFunctionReturningTuple() { + assertMacroExpansion( + """ + @GET("/users/{id}") + func getUser() -> (User, prefs: Preferences) { + } + """, + expandedSource: """ + func getUser() -> (User, prefs: Preferences) { + } + + func getUser(id: String, + fileID: String = #fileID, + line: UInt = #line + ) async throws -> GetUserResponse { + try await client + .path("users", "\\(id)") + .method(.get) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + + public struct GetUserResponse: Codable, Equatable { + public var user: User + public var prefs: Preferences + public init(user: User, prefs: Preferences) { + self.user = user + self.prefs = prefs + } + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionForFunctionAcceptingTupleAsBody() { + assertMacroExpansion( + """ + @POST("/users") + func createUser(body: (name: String, email: String)) -> User { + } + """, + expandedSource: """ + func createUser(body: (name: String, email: String)) -> User { + } + + func createUser( + name bodyName: String, + email bodyEmail: String, + fileID: String = #fileID, + line: UInt = #line + ) async throws -> User { + try await client + .path("users") + .method(.post) + .body(["name": bodyName, "email": bodyEmail]) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionForFunctionAcceptingTupleAsQuery() { + assertMacroExpansion( + """ + @GET + func search(query: (term: String, limit: Int)) -> [Result] { + } + """, + expandedSource: """ + func search(query: (term: String, limit: Int)) -> [Result] { + } + + func search( + term queryTerm: String, + limit queryLimit: Int, + fileID: String = #fileID, + line: UInt = #line + ) async throws -> [Result] { + try await client + .path("search") + .method(.get) + .query(["term": queryTerm, "limit": queryLimit]) + .call(.http, as: .decodable, fileID: fileID, line: line) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + +} diff --git a/Tests/SwiftAPIClientMacrosTests/PathMacroTests.swift b/Tests/SwiftAPIClientMacrosTests/PathMacroTests.swift new file mode 100644 index 0000000..f9269b1 --- /dev/null +++ b/Tests/SwiftAPIClientMacrosTests/PathMacroTests.swift @@ -0,0 +1,140 @@ +import SwiftAPIClientMacros +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +final class PathMacroTests: XCTestCase { + + private let macros: [String: Macro.Type] = [ + "Path": SwiftAPIClientPathMacro.self + ] + + func testExpansionEmptyPath() { + assertMacroExpansion( + """ + @Path + struct Pets { + } + """, + expandedSource: """ + struct Pets { + + public typealias Body = _APIParameterWrapper + + public typealias Query = _APIParameterWrapper + + private var client: APIClient + + fileprivate init(client: APIClient) { + self.client = client + } + } + + /// /pets + var pets: Pets { + Pets (client: client.path("pets")) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionPathWithString() { + assertMacroExpansion( + """ + @Path("/some/long", "path", "/") + struct Pets { + } + """, + expandedSource: """ + struct Pets { + + public typealias Body = _APIParameterWrapper + + public typealias Query = _APIParameterWrapper + + private var client: APIClient + + fileprivate init(client: APIClient) { + self.client = client + } + } + + /// /some/long/path + var pets: Pets { + Pets (client: client.path("some", "long", "path")) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionPathWithArguments() { + assertMacroExpansion( + """ + @Path("/some/{long}", "path", "{id: UUID}") + struct Pets { + } + """, + expandedSource: """ + struct Pets { + + public typealias Body = _APIParameterWrapper + + public typealias Query = _APIParameterWrapper + + private var client: APIClient + + fileprivate init(client: APIClient) { + self.client = client + } + } + + /// /some/{long}/path/{id: UUID} + func pets(_ long: String, id: UUID) -> Pets { + Pets (client: client.path("some", "\\(long)", "path", "\\(id)")) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } + + func testExpansionPathWithFunctions() { + assertMacroExpansion( + """ + @Path + struct Pets { + @GET + func pet() -> Pet {} + } + """, + expandedSource: """ + struct Pets { + @GET + @available(*, unavailable) @APICallFakeBuilder + func pet() -> Pet {} + + public typealias Body = _APIParameterWrapper + + public typealias Query = _APIParameterWrapper + + private var client: APIClient + + fileprivate init(client: APIClient) { + self.client = client + } + } + + /// /pets + var pets: Pets { + Pets (client: client.path("pets")) + } + """, + macros: macros, + indentationWidth: .spaces(2) + ) + } +}