I built a Swift GraphQL client that generates the query using macros
4 months ago
7
No script writing, no code generating, no complex code
Sputnik allows you to write and execute complex GraphQL queries and mutations simply by writing them as structs in Swift. The idea is to write the struct as if you were writing a GraphQL query, select the exact data that you want to return and after executing the api request Sputnik automatically maps the response data into the same struct so you can use it in your code.
There's no need to write additional code for this. All you have to do is use a couple of Macros on the selected struct and Sputnik automatically generates the code needed for mapping the data, value selection and more.
Of course since this uses Swift Macros this is sadly only supported on projects which have a minimum version of iOS 17
As an example let's say we need to execute this query:
query findByType($type: MediaType, $userId: Int, $status: MediaListStatus){MediaListCollection(type: $type, userId: $userId, status: $status){
hasNextChunk
lists{
name
entries{media{
description
type
}
status
}}}}
All you have to do for it to work is write this code:
import SputnikMacros
@QueryOperation(name:"findByType")structAnimeQuery{@Property(name:"MediaListCollection")varmediaList:MediaListCollection@QuerySatelitestructMediaListCollection{@Variablevartype:MediaType?@VariablevaruserId:Int?@Variablevarstatus:MediaListStatus?varhasNextChunk:Boolvarlists:[MediaListGroup]@QuerySatelitestructMediaListGroup{varname:Stringvarentries:[MediaList]@QuerySatelitestructMediaList{varmedia:Mediavarstatus:MediaListStatus@QuerySatelitestructMedia{vardescription:Stringvartype:MediaType}}}}}@QueryEnumenumMediaType:String{case anime ="ANIME"case manga ="MANGA"}@QueryEnumenumMediaListStatus:String{case current ="CURRENT"case planning ="PLANNING"case completed ="COMPLETED"case dropped ="DROPPED"}
After that just create your API which conforms to SputnikGraphQLAPI that will contain the url endpoint and headers:
Sputnik will then generate the query string using the data in the object, make the call and return a new copy of the same struct but with the data from the response automatically mapped.
But wait there's more. Let's say that you would like to execute the same query but now from the Media type to only get the description. There's no need to rewrite the whole query just without the type. All you have to do is use the setSelections() functions which the QuerySatelite macro automatically generates for each struct to specify exactly what you want to return from every type:
QueryOperation and QueryMutation should only be attached once at the primary struct to indicate that we are initiating either a query or a mutation. The name parameter is just the name for the query or mutation used for debugging purposes. The variablesMapping is explained further down. As written in the query above, QuerySatelite must be added in every child struct othewise the mapping and setting up of the variables won't work. Also each child QuerySatelite struct has to be added nested inside of the QueryOperation or QueryMutation struct otherwise it won't work. Example:
This will not work since the query satelite is defined outside the query's scope.
The parameter schemaTypeName is used for mapping the variables mainly to get the type name of the variable as defined in the API schema if the QuerySatelite is a variable object and the type name is different from the type name defined in the schema:
In this example on the schema the type name is defined as "UserFilter" thus we also need to specify the schema type name here to match it. It can work without this parameter if both the type in the schema and the type in the satelite have the same name:
The macro then goes through all the structs, searches for the variables and creates a separate struct: Variables where the user, when initializing the query can set the required data for those same variables. QuerySatelite also generates an enum called SelectedValues and also an array where the developer can set the required values that they want to return:
@QuerySatelitestructCharacter{varid:IDvarname:Stringvarspecies:Stringvartype:Stringvargender:Stringvarcreated:Stringvarstatus:Statusvarorigin:Location
// Macro generated code.
enumSelectableValue:String,CaseIterable{case id
case name
case species
case type
case gender
case created
case status
case origin
var__selectionName:String{self.rawValue
}}var__selectedValues:[SelectableValue]=[.id,.name,.species,.type,.gender,.created,.status,.origin]mutatingfunc setSelections(_ selections:[SelectableValue]){
__selectedValues = selections
}}
With this we can set the values we want to return easily through the setSelections() function.
We have 3 property wrappers designed to help us write the query:
Variable
Property
Argument (Should only be used when writing a query with manual variable mapping)
@Variable should be used when we want to declare a property as a variable. The name parameter can be used if in the query string we want the variable to have a different name than the name of the var itself.
@Property is optional and only should be used when we want in the query string the property to have a different name than the name of the var itself so it can match the name defined in the schema.
@Argument is used in a query with manual argument mapping if we want to define an argument manually without attaching a variable to it. But if we want to have a variable we can have that with the variableKeyparameter.
The QueryEnummacro must be attached to every enum otherwise the mapping won't work.
Sputnik also supports scalar values alongside with mapping values that are expressible by String, Int, Double and Bool to the respected scalar type and serializing them to a JSON value:
In this code expressibleBy tells Sputnik by which literals can this scalar be expressed as, valueType is the actual type of the value generated within the scalar and transformer provides a class that has to initialize the object from a JSON value to the valueType and also serialize it back to a JSON value for when we make a request. Here is what the macro generates:
structDateScalar{publicvarvalue:Date?lettransformer=DateScalarTransformer.self
}extensionDateScalar:Dependencies.ScalarType,ExpressibleByStringLiteral,ExpressibleByIntegerLiteral,ExpressibleByFloatLiteral{publicmutatingfunc mapData(_ data:Any){iflet stringValue = data as?String{
value = transformer.initialize(stringValue: stringValue)}iflet intValue = data as?Int{
value = transformer.initialize(intValue: intValue)}iflet doubleValue = data as?Double{
value = transformer.initialize(doubleValue: doubleValue)}}publicinit(stringLiteral value:String){self.value = transformer.initialize(stringValue: value)}publicinit(integerLiteral value:Int){self.value = transformer.initialize(intValue: value)}publicinit(floatLiteral value:FloatLiteralType){self.value = transformer.initialize(doubleValue: value)}publicvarserializedValue:Dependencies.SerializedValue{
transformer.serializedValue(value: value)}}
Depending on the expressibleBy values the scalar will inherit from the ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, ExpressibleByDoubleLiteral or ExpressibleByBooleanLiteral so that the scalar can be written like this example let dateScalar: DateScalar = "2025-12-21". Sputnik then uses the provided transformer to initialize the value from the literal. By default sputnik has it's own tranformer: DefaultScalarTransformer which just tries to cast the value literal to the object. This is a very basic logic and it's reccomended to write a custom transofrmer that will have it's own transforming algorithm, like so:
Keep in mind when writing the transfomer if it can't be expressible by that literal just return nil in the initializer function.
Sputnik also supports error handling. By default errors are being handled according to the GraphQL documentation where an array of DefaultPartialError objects is created:
publicstructDefaultPartialError:ResponsePartialError{publicletmessage:Stringpublicletlocations:[ResponseErrorLocation]?publicletpath:[PathObject]?publicletextensions:ResponseErrorExtension?publicenumCodingKeys:CodingKey{case message
case locations
case path
case extensions
}publicinit(from decoder:anyDecoder)throws{letcontainer=try decoder.container(keyedBy:CodingKeys.self)
message =try container.decode(String.self, forKey:.message)
locations =try container.decodeIfPresent([ResponseErrorLocation].self, forKey:.locations)
path =try container.decodeIfPresent([PathObject].self, forKey:.path)
extensions =try container.decodeIfPresent(ResponseErrorExtension.self, forKey:.extensions)}publicvarerrorDescription:String?{
message
}}
There are two ways of handling the errors:
The response returns only the errors which means the api completely throws the errors as a SputnikError.responseError([ResponsePartialError]) meaning you will have to handle the casting yourself:
The response returns data along with errors. If this is the case the API client will not throw the errors but instead map them to the partialErrors property to the main query struct along with the rest of the data returned. Again the errors are of a ResponsePartialError type which means you will have to handle the casting to the default partial error:
This decision to manually cast it was made because there are some APIs which do not return the errors in the DefaultPartialError format so for them a special struct has to be made and also use a special transformer to convert them to partial error objects. As an example let's take a look at this custom partial error along with the custom transformers:
Curretntly for global configurations you can only have one response per query type. For testing setting global mock responses is not reccomended since you might want to test multiple response for the same query type which might introduce data races and flaky tests. Thus you can create a custom configuration and use that when initializing the request maker:
Sputnik also has a couple of debug options which can be set up either globaly for every request or add the configuration specifically per request when initializing the request maker. Currently the options are a bit limited, but for debugging, especially when the response returns an error and you don't know why, they get the job done:
As mentioned above, the @QueryOperation macro has a parameter variableMapping which is an enum:
publicenumVariableMapping:String{case automatic
case manual
}
By default this is always set to .automatic. Setting it to .manual means that you will have to define the arguments yourself, define the variables yourself in the main operation and map each argument to the variable using the variableKey parameter:
It also should be mentioned that if you go with this approach you don't need to nest the @QuerySatelite structs inside the operation, however this does risk a circular refferences error. You can also use this approach without define any variables and setting the arguments manually yourself whe instantiating the object:
Please note that this approach is not that very well tested and is not reccomended for use. I would always reccomend to use the first approach unless there are some small edge cases in your api where you can't use automatic variables and you absolutely have to use this method.