Skip to content
This repository has been archived by the owner on Mar 23, 2021. It is now read-only.

nested resource urls macro #10

Closed
scootklein opened this issue Jun 4, 2012 · 15 comments
Closed

nested resource urls macro #10

scootklein opened this issue Jun 4, 2012 · 15 comments

Comments

@scootklein
Copy link
Contributor

nested resources in rails are exclusively accessed with the nested url (at least in my case?) and i'm struggling to figure out how to make this addition with NSRails. for example...

MySweetApp::Application.routes.draw do
  resources :users
    resources :invites
  end
end

Invites then are accessed in relation to some user.

GET /users/1/invites.json
POST /users/1/invites.json
DELETE /users/1/invites/3.json

At a cursory glance this would be handled by some macro with syntax recognition for an embedded property - example shown as NSRNestedResourcePrefix below. Let's pretend we have a User model and a UserInvite model to correspond to the above example.

#import "UserInvite.h"

#import "Challenge.h"
#import "User.h"

@implementation UserInvite
@synthesize challenge, user, phoneDigest, emailDigest, twitterHandleDigest;
NSRMap(*, challenge, user -b);
NSRNestedResourcePrefix("users", "[user remoteID]");

@end

Thoughts are appreciated.

@dingbat
Copy link
Owner

dingbat commented Jun 4, 2012

This would be definitely cool, and I can't say I've come across this in a Rails app.

Out of curiosity though, if you have a UserInvite class (and a User), why not simply nest userInvites within User and CRUD the UserInvite:

GET /invites/345.json
POST /invites/345.json
DELETE /invites/345.json

As you already have the user -b set up, the association should be automatically made.

@scootklein
Copy link
Contributor Author

If, for example, you had an Invite class and a UserInvite class you could run into conflicts in your REST paths. Another reason is that I feel strongly that REST APIs are to be as readable as possible. A path like /users/3/invites.json is descriptive in such a way that you know you're getting only the user invites for user 3 - it's a preference over doing something like /invites.json?type=user&user_id=3 or /user_invites.json?user_id=3. The UserInvite class only exists in relation to a User, and is never really presented in an of itself as a list to be operated on (ie. as /user_invites.json)

@dingbat
Copy link
Owner

dingbat commented Jun 5, 2012

I see.

Another thing - you say you wish to be able to GET or POST /users/1/invites.json, but this looks like it would be represented in NSRails as a static method on the UserInvite class - knowing nothing about which user to associate with. It may be tough to do this, even imagining we had that macro, and you may simply find it simpler to write your own wrapper methods.

NSRails exposes a lot of useful internal methods, so you'll find that this is quite easy to do. This implementation is from the User side, but one can equally be written from the perspective of UserInvite.

// User.m

- (NSArray *) allInvites
{
   // GET to /users/1/invites
   NSMutableArray *returnData = [self remoteGET:@"invites" error:&e];
   [returnData translateRemoteDictionariesIntoInstancesOfClass:[UserInvite class]];
   return returnData;
}

- (void) createInvite:(UserInvite *)invite
{
   NSDictionary *rep = [invite remoteDictionaryRepresentationWrapped:YES];
   // POST to /users/1/invites
   NSDictionary *dict = [self remoteRequest:@"POST" method:@"invites" body:rep error:&e];
   [invite setPropertiesUsingRemoteDictionary:dict];
}

Check out the NSRRemoteObject API for more.


However, I have another idea you might find interesting. Every remoteX (instance) method in NSRails eventually winds up in the following one method, and you are subclassing, so why not override? This would be in UserInvite:

//overriding NSRails method
- (id) remoteRequest:(NSString *)verb method:(NSString *)path body:(id)body error:(NSError **)error
{
   NSString *newPath = [NSString stringWithFormat:@"users/%@/%@",user.remoteID,path];
   return [[self getRelevantConfig] makeRequest:verb requestBody:body route:newPath error:error];
}

Or, override this guy (not documented):

- (NSString *) routeForInstanceMethod:(NSString *)customRESTMethod
{
   NSString *newPath = [NSString stringWithFormat:@"users/%@/invites/%@",user.remoteID,self.remoteID];
   if (customRESTMethod)
        newPath = [newPath stringByAppendingPathComponent:customRESTMethod];
   return newPath;
}

And thus:

// DELETE to /users/1/invites/3, if userInvite.user.id = 1
[userInvite remoteDestroy:&e];

Using this, though, I also want to remind you about the NSRUseModelName macro that'll make UserInvite go to the invites controller rather than user_invites: NSRUseModelName(@"invite")

@dingbat
Copy link
Owner

dingbat commented Jun 5, 2012

I'm realizing now that both the override implementations I gave were "undocumented" - just using private methods. They're fine for a hack now, but I would recommend doing the helper methods as I explained before the break.

And now that I'm giving it more thought, you may be right in including this in a macro. I would make it a full path though, in case there are other special behaviors, and so it could even obsolete NSRUseModelName. Something like:

NSRResourcePath(@"/users/:user/invites/:id");

Where ":user" would be interpolated to user.remoteID, and ":id" just remoteID.

Again, though, I am curious about how you planned on implementing this with class methods like the "get all", where you wouldn't have a user instance.

@scootklein
Copy link
Contributor Author

So I started writing code for this with a new instance method called resourcePrefix that returns an (NSString *).

New in NSRMacros.h

//define NSRUseResourcePrefix to create a method called resourcePrefix
#define NSRUseResourcePrefix(member) \
- (NSString*) resourcePrefix { return [NSString stringWithFormat:@"/%@/%d", [[member class] masterPluralName], member.remoteID]; };

And using it in a sample class file.

@implementation PSUserInvite
@synthesize challenge, user, phoneDigest, emailDigest, twitterHandleDigest;
NSRMap(*, challenge, user -b);
NSRUseResourcePrefix(user);

@end

The side effect is that now all of the class methods cannot be class methods since they are affected by the instance's possible necessity for a prefix to the controller route. This departure is too large to just include in a pull request, so i'm wondering what avenues we have if we wanted to incorporate this back into the mainline.

@dingbat
Copy link
Owner

dingbat commented Jun 5, 2012

Class methods could throw an exception if this macro is defined.

And I suppose the "remote all" and "create" methods could be implemented as I outlined above.

By the way, remoteID is an NSNumber so you'll need %@ in the format. Also, I prefer to keep the macro logic in the implementation and just use the macro to simply return the value passed in - it makes debugging a lot easier.

Now I'm actually thinking of allowing a variadic macro, which could allow for a lot of flexibility. I'm not sure yet, but maybe something like this:

NSRUseResourcePath(@"users", user, @"invites"); 
// means "users/[self.user.remoteID]/invites"
//    or "users/[self.user.remoteID]/invites/[self.remoteID]"

@dingbat
Copy link
Owner

dingbat commented Jun 5, 2012

Actually, sorry - just took another look at your macro implementation and it's a lot better than I thought. I didn't even think of using the real instance variable within the macro expansion.

Let's go with your way, but just with returning the object. As I said, this makes it easier to write code/debug, especially since masterPluralName is a private method, which would not compile when the macro is expanded. Moreover, this will give a clear warning to the developer if a non-NSR object is passed in.

#define NSRUseResourcePrefix(member) \
    - (NSRRemoteObject *) NSRUseResourcePrefix { return member; };

and leaving the logic in the caller.


I just wanna point out, too, that this could support several levels of nesting. If you then had a nested class from invite,

@implementation Something
@synthesize invite;
NSRUseResourcePrefix(invite);

@end

This resource would first map invite (users/1/invites/1), and then use that as its prefix: users/1/invites/1/somethings/1.json. This solves my previous desire for a more flexible macro while still keeping NSRUseModelName useful, so thanks.

@scootklein
Copy link
Contributor Author

I've been going back and forth with this one for the last hour because it breaks all of the class methods currently defined. Ugh.

I really do like your solution as presented above...where it would be up to the author to define a method like allInvites in the User model, but you're left with both the sync and async variations to define and it's not very DRY.

Rails handles this by dynamically adding methods to a class when you specify a has_ or belongs_to relationship. The link below describes how to add methods at runtime for objc, but I haven't the foggiest idea what implications it would have for the project.

http://theocacao.com/document.page/327

I'm becoming more comfortable with a middle ground of GET and POST happening as a nested resource and PATCH and DELETE happening on the resource itself. As mentioned above, I'm all about the readability of an API and considering what "verbiage" you're expressing with your network calls.

GET /users/3/invites.json "get all the invites for user 3"
POST /users/3/invites.json "create an invite for user 3"
PATCH /user_invites/28.json "update user invite 28"
DELETE /user_invites/28.json "delete user invite 28"

GET and POST maintain their context of making the request against a user, while PATCH and DELETE happen on the user_invite record invidually. This is pretty and all, but I'm not sure how it helps out the situation.

I guess a better way to frame the question is "how do you specify foreign key for a fetch". With the current code it doesn't even look like there's a specific method - you'd have to use remoteRequest:method:body and use the body to specify something like {:user_id => 3} (borrowing Ruby syntax there, NSDictionary implied). The current code looks to only implement body a la PUT or POST and there would be no way to implement GET query params for filtering purposes (ex. /user_invites.json?user_id=3).

@dingbat
Copy link
Owner

dingbat commented Jun 5, 2012

Check out the latest commit - I implemented everything we talked about. The way I handled class methods is to simply route them ignoring the NSRUseResourcePrefix declaration (so doing a remoteAll on a UserInvite would go to /user_invites.json.) I figured since this is instance-specific anyway, might as well offer as much functionality as possible. Let me know if it works for you.


In terms of specifying foreign key on fetch, I believe that's entirely up to your Rails back-end to decide. If you are willing to nest your object within the URL, your app could easily find what user it associates with via the 3 in /users/3/invites.json.

And if not, that's what the -b is for! If user is flagged with belongs-to in the NSRMap (-b), NSRails will automatically send {:user_id => X} instead of {:user_attributes => {blah} }.


"Method missing" magic is something I've definitely considered but will absolutely not implement :) It's fun to play around with but is too much of a hack in Objective-C and might get Xcode angry when giving it bad selectors. If you're interested though, I've played around extensively with it in a recent project: DHUserDefaults.

@dingbat
Copy link
Owner

dingbat commented Jun 5, 2012

Sorry - I think I misunderstood the question about foreign keys on fetch before. I now see, but why is this an issue if you use GET /users/3/invites.json?

In regards to using the traditional construction for DELETE/PATCH and the nested one for GET/POST, that may also be a user setting in NSRConfig. Perhaps even optional parameters to the NSRUseResourcePrefix macro?

NSRUseResourcePrefix(user, @"GET", @"POST");

I particularly like that.

@scootklein
Copy link
Contributor Author

What then is left to do to fetch an association? Not all of the associations are eager-loaded and sent on a fetch from the API.

GET /users/1.json # won't return any invites
GET /users/1/invites.json # fills in the invites array on the object

Your solution above still holds true, issue a remoteGET and then translate the dictionaries to an instance of the class, but maybe a new method along the lines of...

- (BOOL)remoteFetchAllNested:(Class)class error:(NSError *)error;
User* user = [User remoteObjectWithID:[NSNumber numberWithInt:3]]
user.invites; //returns nil
[user remoteFetchAllNested:[UserInvite class] error:nil];
user.invites; //returns array of user invites

Not sure I like that name per se, but having something in this vein will allow using existing methods on UserInvite (such as masterPluralName) to construct the path. The implementation of remoteFetchAllNested would likely use remoteGET but could dynamically construct the path so that it is NOT hard-wired into the code as remoteGET:@"invites".

Any other ideas I'm open to, but I feel like this is going to be a common-enough idiom that it's worth exploring.

UPDATE: cleaned up example code

@dingbat
Copy link
Owner

dingbat commented Jun 5, 2012

I'd turn it around and add a class method (name not binding):

+ (NSArray *) remoteAllViaObject:(NSRRemoteObject *)obj error:(NSError **)e

Then, maybe add a property flag in NSRMap,

@implementation User
NSRMap(*, invites:UserInvite -???);

that would indicate to use UserInvite's nested path construction when retrieving invites.

Then, yes, perhaps an additional customized remoteFetch method,

- (BOOL) remoteFetchWithNestedProperties:(BOOL)yn error:(NSError *)error

would retrieve any properties declared with this flag using remoteFetchAllViaObject:, passing in self.

The issue with this is that you don't have precision on which nested properties you'd want to fetch if your class defines multiple. But if this is desired, it'd require manually entering in a property either way, so I don't think hardwiring remoteGET:@"invites" is that bad.

This also solves the issue in your proposition where remoteFetchAllNested: wouldn't know into which property to store the data.

Just my first thoughts - let me know what you think.

@scootklein
Copy link
Contributor Author

Check out this example. I "log in" by creating an authentication token, fetch the user corresponding to the authentication token created (getting my own profile), and then trying to create a user invite for that user.

NSError* e = [NSError new];
PSAuthenticationToken* authToken = [PSAuthenticationToken new];
authToken.email = @"scott@scottkle.in";
authToken.password = @"seedpw";
BOOL createSuccess = [authToken remoteCreate:&e];

[NSRConfig defaultConfig].appOAuthToken = authToken.key;

PSUser* u = [PSUser remoteObjectWithID:authToken.user.remoteID error:&e];

PSUserInvite* ui = [PSUserInvite new];
ui.twitterHandle = @"github";
[ui remoteCreate:&e];

And the PSUserInvite header.

@implementation PSUserInvite

@synthesize challenge, user, phoneCountry, phoneNumber, email, facebookUserID, twitterHandle, phoneDigest, emailDigest, facebookUserIDDigest, twitterHandleDigest;
NSRMap(*, challenge, user -b, phoneCountry -s, phoneNumber -s, email -s, facebookUserID -s, twitterHandle -s);
NSRUseResourcePrefix(user);
NSRUseModelName(@"invite")

@end

In the remoteCreate method called above it ends up at...

NSDictionary *jsonResponse = [[self class] remoteRequest:@"POST" method:nil bodyAsObject:self error:error];

ie. a class method that doesn't have access to the prefix. I believe everything else is routed through routeForInstanceMethod, which is an instance level method and does have access to the prefix.

This example ends up posting to /invites, which bounces with a 404 because of the required nesting.

@dingbat
Copy link
Owner

dingbat commented Jun 6, 2012

Fixed this last night but didn't push sorry

@dingbat
Copy link
Owner

dingbat commented Jun 9, 2012

Closing, as I believe there's as much support for this as needed. I'm still considering adding an NSRMap flag to allow an instance method like remoteFetchNestedResources, instead of having to do like:

user.invites = [UserInvite remoteAllViaObject:user error:&e];

although I guess it's not that bad.

I also want to mention here that the NSRUseResourcePrefix macro has been since deprecated. Just override objectUsedToPrefixRequest::

@implementation User

- (NSRRemoteObject *) objectUsedToPrefixRequest:(NSRRequest *)request
{
    return user;
}

@end

The request is passed in if you want to do it conditionally based on the HTTP method, just return nil.

@dingbat dingbat closed this as completed Jun 9, 2012
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants