Refreshing the browser bypasses angular on routerProvider based URLs - grails

I have a grails app backing an angularjs front-end. They are deployed as a single WAR. I've removed the context path from the app so that it runs on http://localhost:8080.
I have a list of articles and I have the $routeProvider setup to redirect / to /articles at which point the controller takes over and pulls the list via $http. Pretty standard stuff.
Initially, I was using the default location provider config in that hashes (#) are used in the URL. I've changed it via
$locationProvider.html5Mode(true);
and everything still works. However, if I change the URL directly in the address bar and hit enter, or if I just refresh the browser when it is at /articles, the server side takes over and I just get my list of articles as json. No angular. I understand why this happens and for now what I've done is detected a non-ajax request on the server and am issuing a redirect to / which will allow angular to kick into gear.
I'm wondering if this is the right thing. Or is there something else I can do that is a better practice.

Redirecting is the right solution.
I was able to make it work using url mapping. So far it works :-)
I started with something like this:
"/**" (controller: 'app', action: 'index')
with app/index being the angular app page. But this will also match everything else (e.g. /$controller/$action). I had to explicitly map each $controller/$action to the correct controller. Not so good... ;-)
To solve this problem I'm prefixing all uris with /client for angular routes and /server for grails uris. This makes url mapping easy and it helps to distinguish angular routes from template uris etc.
My final url mapping looks like this:
class UrlMappings {
static excludes = [
"/lib/**",
"/css/**",
"/js/**"
]
static mappings = {
// - all client uris (routes) will start with '/client/',
// - all server uris (load) will start with '/server/'
// redirect /$appName/ to /$appName/client
"/" (controller: 'redirect', action: 'redirectTo') {
to = '/client/'
permanent = true
}
// redirect any angular route
"/client/**" (controller: 'app', action: 'index')
// standard controller/action mapping
"/server/$controller/$action/$id?" {
constraints {
}
}
}
}
You can't redirect directly in the url mapping, so I use a simple controller:
class RedirectController {
def redirectTo () {
redirect (uri: params.to, permanent: params.permanent)
}
}
The routing entries look like this:
$routeProvider.when ('/client/login', {templateUrl: './server/security/login'});

just answered on this question here
angularjs html5mode refresh page get 404
be sure that you rewrite rules on server work correctly

Related

Multiple SPA Using MVC Areas - Navigate Outside SPA Router Routes

How can I escape client side routing when wanting to navigate outside the set list of routes setup in the router?
I created a project using Durandal.js and have created SPA's inside different Areas. The problem I ran into is that when I want to navigate outside the current SPA and into another or say back to the home page of the entire application which is not an SPA at all but simply a cshtml page.
What I have tried to do is use Durandal's mapUnknownRoutes handler to intercept and then use window.location.href to navigate out. This works, but when I want to go the home page of the application ("/"), the router matches the "root" of the SPA and doesn't navigate out to the home page but instead the SPA's root route.
The area route in this example is "/spaHome" whose MVC route looks like:
context.MapRoute(
"spaHome_default",
"spaHome/{*catchall}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
Here's what I've done to set up the Durandal router:
var routes = [
{ route: ['spaHome', ''], moduleId: 'spaHome', title: "spaHome", hash: "#spaHome"},
{ route: 'one/anotherPage', moduleId: 'one/anotherPage', title: "one/anotherPage", hash: "#one/anotherPage"}
];
router.makeRelative({ moduleId: 'viewmodels' });
router.map(routes)
.mapUnknownRoutes(function (instruction) {
utils.navigateToPath(instruction.fragment);
return false;
})
.activate({ pushState: true, root: "/spaHome" });
Any pointers or leads into the right direction for this would be much appreciated!
After some trial and error I was able to come up with a solution to what I was trying accomplish. It looks like a solid solution, so hopefully it doesn't cause any issues down the road.
I changed the routes array and removed the default route of '' from the first route. This allowed me to have a unknown route to go off of when wanting to hit the normal MVC homepage. I also had to remove the "root" property from the activate function of the router. Which in turn meant I had to explicitly declare the routes in the route array with the extra area portion or the URL ("spaHome/").
Then in my mapUnknownRoutes handler I checked the route for the area portion of the URL, and if that existed, I used the SPA router to show a notfound page for that SPA. Else I assumed that the route exists outside the area and I need to hard navigate to the URL using window.location.href.
var routes = [
{ route: ['spaHome'], moduleId: 'spaHome', title: "spaHome", hash: "#spaHome"},
{ route: 'spaHome/one/anotherPage', moduleId: 'one/anotherPage', title: "one/anotherPage", hash: "#spaHome/one/anotherPage"}
];
router.makeRelative({ moduleId: 'viewmodels' });
router.map(routes)
.mapUnknownRoutes(function (instruction) {
if (instruction.fragment.toLowerCase().indexOf("spaHome/", 0) === -1) {
utils.navigateToPath(instruction.fragment);
} else {
var notfoundRoute = "notfound";
instruction.config.moduleId = notfoundRoute;
history.navigate(notfoundRoute, { trigger: false, replace: true });
}
})
.activate({ pushState: true });
If anyone has a better solution please let me know!
EDIT
Ok, I ran into an issue with the history while doing this. I had to add a line to the Durandal router.js file to stop the previous route from being added to the history queue.
if (!instruction.cancelQueue) {
router.trigger('router:route:before-config', instruction.config, router);
router.trigger('router:route:after-config', instruction.config, router);
queueInstruction(instruction);
}
Edit 2
I also ran into an issue with this method where the navigation doesn't work quite right for IE9 and below.

ASP.net MVC SPA routing

I'm planning to build a SPA with asp.net MVC4 but I'm not quite sure how I have to Setup my Project because of the Routing. Most SPA's work with hashrouting like this mypage/#/News/today for instance.
What would happen if the browses directly to mypage/News/today if I haven't specified a Controller named News with an action today?
The App should handle both types of Routing, how can I achieve this?
Do I have to build my App in a classic way, like Adding several Controllers with appropriate Actions and views and also build a clientside MVC structure with knockout, jquery etc?
You'll have to let all routes to "pages" fall through to let your SPA handle them (including essentially fake 404s if it's not to a real page in your SPA), but at the same time, need to make sure that you get the correct responses for API calls and/or file requests.
Below is the setup I have (I am using Vue as the js framework but that doesn't matter much for this, and not at all for the server-side piece).
First, add this to your Startup.cs, in addition to your default route setup:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
...
app.Use(async (context, next) =>
{
await next();
var path = context.Request.Path.Value;
// If there's no available file and the request doesn't contain an extension, we're probably trying to access a page
if (context.Response.StatusCode == (int)HttpStatusCode.NotFound && !Path.HasExtension(path) && !path.StartsWith("/api"))
{
context.Request.Path = "/Home/SpaRedirect"; // attempts to redirect to the URL within the SPA
context.Response.StatusCode = (int)HttpStatusCode.OK; // Make sure we update the status code, otherwise it returns 404
await next();
}
});
...
}
So the newly added SpaRedirect to HomeController looks like this, and just stores the requested URL in ViewData...
public IActionResult SpaRedirect()
{
ViewData["RequestUrl"] = HttpContext.Request.Path;
return View("Index");
}
Then in Index.cshtml, just capture that requested url in session storage so we have it available on the client-side:
<script src="~/dist/main.js" asp-append-version="true">
sessionStorage.setItem("redirectAttempt", #ViewData["RequestUrl"]);
</script>
Then in your boot script file (the entry-point for your SPA), add something like:
let redirectAttemptUrl = sessionStorage.getItem("redirectAttempt");
if (redirectAttemptUrl) {
router.push(redirectAttemptUrl);
sessionStorage.removeItem("redirectAttempt");
}
Which just checks for the presence of a requested url, and then the SPA's router attempts to navigate to it (in the example above it is a vue-router), then removes it from storage.
So this way, if a user attempts to navigate directly to a URL by entering it in the url bar (or via a bookmark) the app will load and take them to the right place, IF it exists... which takes us to the last piece...
Finally, you have to handle "404s" within your SPA, which is done by adding a catch-all route to your routes defs that takes user to a 404 component page you set up, which for Vue would look like this:
// adding an explicit 404 path as well for programmatically handling when something is not found within the app, i.e. return this.$router.push('/404')
{ path: '/404', component: NotFound, name: '404', alias: '*' }, // remove alias to not show the actual url that resulted in our little 404 here
{ path: '*', redirect: '/404' }, // this is the catch-all path to take us to our 404 page
Caveat: I'm no expert so could be missing something, would love additional comments on how to improve this. One thing that this doesn't handle is if the user is ALREADY in the SPA and for some reason edits the URL directly to navigate to someplace else, it would still trigger a server call and full reload, which ideally wouldn't be the case, but this is a pretty trivial issue I'd say.

How to route without a templateUrl?

Ok. I have a url setup to log a user out. On the server, there is no html. The session on the server simply gets destroyed, and then the user is redirected to an address.
This works fine with plain html, but with Angular i am having issues. I've been routing all main routes using $routeProvider.when('/foo', {templateUrl: '/foo.html', controller: 'Ctrl'}) and that works fine for normal templated routes.. however, if there is no template it will not work.
So, how do i support the route /logout in the same fashion as above, when there is no html template?
A workaround is to use template instead of templateUrl. From the Angular docs:
template – {string=} – html template as a string that should be used
by ngView or ngInclude directives. this property takes precedence over
templateUrl.
This can be used as follows:
$routeProvider.when("/foo", {template: " ", controller: "Ctrl"});
Note: You must use " " instead of an empty string "" because Angular uses an if (template) check before firing the controller, and an empty string evaluates to false.
-- EDIT --
A better way to do it is to use the resolve map. See the Angular Docs:
resolve - {Object.=} - An optional map of
dependencies which should be injected into the controller.
This can be used like this:
$routeProvider.when('/foo', {resolve: {redirect: 'RedirectService'}});
Note: I've changed it from "Ctrl" to "RedirectService", because what you're describing in the question isn't really a "controller" in the Angular sense. It doesn't set up scope for a view. Instead, it's more like a service, which ends up redirecting.
I am writing the solution based on the already accepted answer and the github issue mentioned in it's comments.
The approach I am using is a resolve parameter in the $routeProvider. In my case I was trying to create a nice solution to logout in my application, when user goes to /logout.
Example code of $routeProvider:
app.config(['$routeProvider', function ($routeProvider) {
$routeProvider.
...
when('/logout', {
resolve: {
logout: ['logoutService', function (logoutService) {
logoutService();
}]
},
}).
...
}]);
In the resolve part you specify a service (factory) by name and later on you have to call it. Still it is the nicest solution around.
To make the example complete I present my logoutService:
angular.module('xxx').factory('logoutService', function ($location, Auth) {
return function () {
Auth.setUser(undefined);
$location.path('/');
}
});
Works great!

Grails UrlMappings to external remote files

In Grails, I'm trying to make it so that any requests to /images/* actually goes to another website on another host.
I know how to do it in Apache with Mod Rewrite, but how can I achieve this with UrlMappings?
I want
/images/* to go to http://somedomain/images/*
You can't directly. You'll have to map it to a controller which will in turn redirect to the desired location. Something like:
"/images/$urlTail**" (controller: "image", action: "external")
And then in the external method of the ImageController:
def actualUri = request.forwardURI.replace("/images/", "")
redirect "http://example.com/" + actualUri;
Another workaround I've found:
String url = <your-url>
java.net.URI uri = new java.net.URI(url)
return Response.seeOther(uri).build()

What is the correct response to an HTTP POST request?

For a POST method, the W3 specs say:
If a resource has been created on the origin server, the response
SHOULD be 201 (Created) and contain an entity which describes the
status of the request and refers to the new resource, and a Location
header (see Section 10.4).
http://www.ietf.org/internet-drafts/draft-ietf-httpbis-p2-semantics-05.txt (section 8.5)
The standard response actually seems to be to send a Redirect to the newly created resource.
I'm building my site with ASP.NET MVC, and tried to follow the spec, so created a ResourceCreatedResult class:
public class ResourceCreatedResult : ActionResult
{
public string Location { get; set; }
public override void ExecuteResult(ControllerContext context)
{
context.HttpContext.Response.Clear();
context.HttpContext.Response.StatusCode = 201;
context.HttpContext.Response.ClearHeaders();
context.HttpContext.Response.AddHeader("Location", Location);
}
}
And my action looks something like this:
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult CreateNew(string entityStuff)
{
Entity newEntity = new Entity(entityStuff);
IEntityRepository entityRepository = ObjectFactory.GetInstance<IEntityRepository>();
entityRepository.Add(newEntity);
ActionResult result = new ResourceCreatedResult()
{ Location = Url.Action("Show", new { id = newEntity.Id }) };
return result;
}
However, IE, Firefox and Chrome all fail to redirect to the new resource. Have I messed up generating the correct response, or do web browsers not expect this type of response, instead relying on servers to send a Redirect response?
To be explicit, browsers (including modern browsers like Firefox 3 and IE8) do not "take the hint" and follow up an HTTP 201: Created response with a GET request to the URI supplied in the Location header.
If you want browsers to go to the URI supplied in the Location header, you should send an HTTP 303: See Other status instead.
Redirect after post or post/redirect/get is something your application must do to be user friendly.
Edit. This is above and beyond the HTTP specifications. If we simply return a 201 after a POST, the browser back button behaves badly.
Note that Web Services requests (which do NOT respond to a browser) follow the standard completely and do NOT redirect after post.
It works like this.
The browser POSTS the data.
Your application validates the data. If it's invalid, you respond with the form so they can fix it and POST.
Your application responds with a redirect.
The browser gets the redirect and does a GET.
Your application sees the GET and responds.
Now -- hey presto! -- the back button works.
My solution is to respond with a '201 Created' containing a simple page with a link to the new resource, and a javascript redirect using location.replace().
This lets the same code work for API and browser requests, plays nicely with Back and Refresh buttons, and degrades gracefully in old browsers.
As stated in the spec the response SHOULD be a HTTP 201 with redirect. So it isn't mandatory for a browser vendor to implement the correct answer...
You should try to change to a 30x code to see if it is correctly redirected. If so, it's a browser problem, else it may come from your code (I don't know anything in ASP.NET so I can't "validate" your code)
Shouldn't that only count for when something is "Created" and therefore a simple redirect to action should be genuinely sufficient?

Resources