bustoutsolutions / siesta

The civilized way to write REST API clients for iOS / macOS
https://bustoutsolutions.github.io/siesta/
MIT License
2.19k stars 157 forks source link

How to handle multiple configurations in multiple Service objects ? #196

Closed erenkabakci closed 7 years ago

erenkabakci commented 7 years ago

Hi, I have already read similar issues and answers but I have a very specific question regarding Service and its behaviour.

Documentation clearly says that it is better to share one instance of Service to get full benefits of Siesta.

you’ll want to ensure that all the observers of an API share a single instance.

Also configuring services outside constructors is not preferred as stated here http://bustoutsolutions.github.io/siesta/guide/configuration/

Don’t do this! You are creating an ever-growing list of configuration blocks, every one of which will run every time you touch a new resource.

There is also a very good sample project here proving all these correct. https://github.com/bustoutsolutions/siesta/tree/master/Examples/GithubBrowser So I am trying to stick to these 2 rules in my implementations.

As you can see in GithubAPI.swift Service objects could get messy very quickly. In this example there are only 4-5 requests but the constructor is already too long IMO.

The scale of the project I am working on is much bigger than this so I would like to separate my Services like UserService.Swift PaymentService.Swift, otherwise my one and only Service will be extremely long when you think about all relative paths, custom transformers for parsing etc.

How can we achieve this without going against to those 2 rules ?

I can imagine something like BaseService just for headers or authentication configuration in init() and every subclass will do further service.configure or service.configureTransformer in their init(). This looks clean enough but I will lose the ability to share one and only service since I will have different instances from one shared BaseService and questions like setting auth token for all children are causing more problems.

If I go with singleton BaseService or just a Service then I will lose the flexibility of configuring outside init() in children since this is not suggested as documentation says. (or is this actually not that a big deal)

We are very new to Siesta but we loved it already ! Documentation is very good but maybe we missed the point of applying these kind of things using best practises.

Thanks !

erenkabakci commented 7 years ago

Since there is no absolute right or wrong, I will just write down what kind of solution I have chosen. Maybe someone with the same issue can benefit.

Somehow singletons are defined as semi-evil in the community. While most of the reasoning is true, there are still very valid cases to use singletons. If you are comfortable with this and Siesta's preferred way of doing this then the only problem is code localization, ease of maintenance, code readability etc.

I have a subclassed Service object which acts like one-and-only base for the same API. Because of configuration lifecycle, this class has to know how to act for all possible endpoints, parsing behaviours etc. beforehand.

I have managed to overcome this with Swift extensions. For now it looks both maintainable and clean.

class myApiClient: Service {
  static func sharedClient() -> myApiClient
   // ...
  private init() {
     generalConfiguration()
     userServiceConfiguration()
     paymentConfiguration()
     .....
  }
}

As seen in the example, singleton ApiClient knows how to configure itself according to the different feature sets. (user, payment etc. in our case). These feature specific configurations live in public extensions under protocols.

protocol ApiUserResourceDispatcher {
  func userData() -> Resource
}

typealias UserServiceClient = myApiClient
extension UserServiceClient: ApiUserResourceDispatcher {
  func userServiceConfiguration() {
    // User service based configuration comes here. Transformers, endpoints etc.
  }

  func userData () -> Resource {
    // return resource to be observed
  }
}

This setup allows me to separate feature based needs without losing one central Service object and polluting the same class.

Any external resource can basically process this data:

UserServiceClient.sharedClient().userData().addObserver .....

Behind the scenes it is only one myApiClient singleton but external resources only reach it via public extensions. They don't even need to interact with myApiClient.sharedClient()

I am not sure if this solution will be valid for complex needs in the future but wanted to share as an alternative.

Siesta is very powerful but it lacks complex architectural examples like this one. Example app is also very clear but again uses only basic MVC approach.

Thanks for creating such a great library !

pcantrell commented 7 years ago

@erenkabakci, I’d say your solution is a good one if the problem is that your service API surface is getting too large. You’ve managed to keep only a single Service instance per API, but (1) split initialization code into multiple files and (2) not expose all those methods at once. Clever!

I’m not sure the typealias is buying you anything. If you define an extension on a typealias, it also applies to the original type:

class Foo { }

protocol Bar {
  func bar()
}

typealias FooBar = Foo
extension FooBar: Bar {  // extending FooBar, not Foo
  func bar() { }
}

let f = Foo()  // f is a Foo and not a FooBar, but...
f.bar()        // ...this still compiles

I may well be missing something about your approach, but I think you could remove one layer of indirection by keeping your underlying Service subclass internal to an API module…

internal class MyApiClient: Service {
  // Notice that static let is the Swift-preferred way to create singletons.
  // (Unlike Java, this _does_ guarantee unique initialization, even across threads.)
  static let instance = MyApiClient()

  override init() {
    super.init()
    generalConfiguration()
    userServiceConfiguration()
    paymentConfiguration()
    //.....
  }
}

…then adding conformance to a public protocol:

public protocol UserServiceClient {
  func userData() -> Resource
}

extension MyApiClient: UserServiceClient {
  func userServiceConfiguration() { … }
  func userData () -> Resource { … }
}

…and publicly exposing the same singleton via different public protocols:

// Protocols don’t (yet) allow static members, alas,
// so this needs to be a free global function
public func userServiceClient() -> UserServiceClient {
  return MyApiClient.instance
}

(Or just keep things simple and make it a variable instead of a function:)

public let userServiceClient: UserServiceClient = MyApiClient.instance

(Closing this for bookkeeping purposes, but feel free to continue the conversation.)

erenkabakci commented 7 years ago

Hey !

Coming back after a long time. Sorry :) Thanks for the detailed reply and suggestions. I applied what you also suggested.

The only idea behind the typealias was to keep code clean in a way that I define different domains. As you said they already point to the same class anyways. The consumer of the API only knows about the typealias not the hidden service behind. That was just for accessor convenience.