fix(aio): strip leading slashes from path (and improve DRY-ness) (#16238)

Previously, the path returned by `LocationService.path()` preserved leading
slashes, which resulted in requests with consequtive slashes in the URL. Such
requests would fail (with a 404) on staging.

This commit fixes it, by removing leading slashes from the path. It also
refactors `LocationService` a bit, converting path to an observable,
`currentPath` (similar to `currentUrl`), and applies certain clean-ups (e.g.
stripping slashes, query, hash) in one place, which simplifies consumption.

Closes #16230
This commit is contained in:
George Kalpakas 2017-04-21 23:35:40 +03:00 committed by Miško Hevery
parent 062fc4afee
commit 9c1318d731
9 changed files with 228 additions and 97 deletions

View File

@ -76,7 +76,7 @@ export class AppComponent implements OnInit {
});
// scroll even if only the hash fragment changed
this.locationService.currentUrl.subscribe(url => this.autoScroll());
this.locationService.currentUrl.subscribe(() => this.autoScroll());
this.navigationService.currentNode.subscribe(currentNode => {
this.currentNode = currentNode;

View File

@ -39,7 +39,7 @@ export class DocumentService {
private http: Http,
location: LocationService) {
// Whenever the URL changes we try to get the appropriate doc
this.currentDocument = location.currentUrl.switchMap(() => this.getDocument(location.path()));
this.currentDocument = location.currentPath.switchMap(path => this.getDocument(path));
}
private getDocument(url: string) {

View File

@ -1,18 +1,19 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
import { CurrentLocationComponent } from './current-location.component';
let currentPath: string;
class MockLocation {
path() { return currentPath; }
}
describe('CurrentLocationComponent', () => {
let locationService: MockLocationService;
beforeEach(async(() => {
locationService = new MockLocationService('initial/url');
TestBed.configureTestingModule({
declarations: [ CurrentLocationComponent ],
providers: [
{ provide: LocationService, useClass: MockLocation }
{ provide: LocationService, useValue: locationService }
]
});
TestBed.compileComponents();
@ -21,8 +22,13 @@ describe('CurrentLocationComponent', () => {
it('should render the current location', () => {
const fixture = TestBed.createComponent(CurrentLocationComponent);
const element: HTMLElement = fixture.nativeElement;
currentPath = 'a/b/c';
fixture.detectChanges();
expect(element.innerText).toEqual('a/b/c');
expect(element.innerText).toEqual('initial/url');
locationService.urlSubject.next('next/url');
fixture.detectChanges();
expect(element.innerText).toEqual('next/url');
});
});

View File

@ -7,7 +7,7 @@ import { LocationService } from 'app/shared/location.service';
*/
@Component({
selector: 'current-location',
template: '{{location.path()}}'
template: '{{ location.currentPath | async }}'
})
export class CurrentLocationComponent {
constructor(public location: LocationService) {

View File

@ -178,9 +178,6 @@ describe('NavigationService', () => {
locationService.go('c');
expect(currentNode).toEqual(cnode, 'location: c');
locationService.go('c/');
expect(currentNode).toEqual(cnode, 'location: c/');
locationService.go('c#foo');
expect(currentNode).toEqual(cnode, 'location: c#foo');

View File

@ -16,14 +16,6 @@ export { CurrentNode, NavigationNode, NavigationResponse, NavigationViews, Versi
const navigationPath = 'content/navigation.json';
const urlParser = document.createElement('a');
function cleanUrl(url: string) {
// remove hash (#) and query params (?)
urlParser.href = '/' + url;
// strip leading and trailing slashes
return urlParser.pathname.replace(/^\/+|\/$/g, '');
}
@Injectable()
export class NavigationService {
/**
@ -91,10 +83,9 @@ export class NavigationService {
private getCurrentNode(navigationViews: Observable<NavigationViews>): Observable<CurrentNode> {
const currentNode = combineLatest(
navigationViews.map(this.computeUrlToNavNodesMap),
this.location.currentUrl,
this.location.currentPath,
(navMap, url) => {
let urlKey = cleanUrl(url);
urlKey = urlKey.startsWith('api/') ? 'api' : urlKey;
const urlKey = url.startsWith('api/') ? 'api' : url;
return navMap[urlKey] || { view: '', url: urlKey, nodes: [] };
})
.publishReplay(1);

View File

@ -19,7 +19,7 @@ describe('LocationService', () => {
]);
});
describe('urlStream', () => {
describe('currentUrl', () => {
it('should emit the latest url at the time it is subscribed to', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
@ -93,6 +93,170 @@ describe('LocationService', () => {
'next-url3'
]);
});
it('should strip leading and trailing slashes', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const urls: string[] = [];
service.currentUrl.subscribe(u => urls.push(u));
location.simulatePopState('///some/url1///');
location.simulatePopState('///some/url2///?foo=bar');
location.simulatePopState('///some/url3///#baz');
location.simulatePopState('///some/url4///?foo=bar#baz');
expect(urls.slice(-4)).toEqual([
'some/url1',
'some/url2?foo=bar',
'some/url3#baz',
'some/url4?foo=bar#baz'
]);
});
});
describe('currentPath', () => {
it('should strip leading and trailing slashes off the url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const paths: string[] = [];
service.currentPath.subscribe(p => paths.push(p));
location.simulatePopState('///initial/url1///');
location.simulatePopState('///initial/url2///?foo=bar');
location.simulatePopState('///initial/url3///#baz');
location.simulatePopState('///initial/url4///?foo=bar#baz');
expect(paths.slice(-4)).toEqual([
'initial/url1',
'initial/url2',
'initial/url3',
'initial/url4'
]);
});
it('should not strip other slashes off the url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const paths: string[] = [];
service.currentPath.subscribe(p => paths.push(p));
location.simulatePopState('initial///url1');
location.simulatePopState('initial///url2?foo=bar');
location.simulatePopState('initial///url3#baz');
location.simulatePopState('initial///url4?foo=bar#baz');
expect(paths.slice(-4)).toEqual([
'initial///url1',
'initial///url2',
'initial///url3',
'initial///url4'
]);
});
it('should strip the query off the url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
let path: string;
service.currentPath.subscribe(p => path = p);
location.simulatePopState('/initial/url1?foo=bar');
expect(path).toBe('initial/url1');
});
it('should strip the hash fragment off the url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const paths: string[] = [];
service.currentPath.subscribe(p => paths.push(p));
location.simulatePopState('/initial/url1#foo');
location.simulatePopState('/initial/url2?foo=bar#baz');
expect(paths.slice(-2)).toEqual([
'initial/url1',
'initial/url2'
]);
});
it('should emit the latest path at the time it is subscribed to', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
location.simulatePopState('/initial/url1');
location.simulatePopState('/initial/url2');
location.simulatePopState('/initial/url3');
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/next/url1');
location.simulatePopState('/next/url2');
location.simulatePopState('/next/url3');
let initialPath: string;
service.currentPath.subscribe(path => initialPath = path);
expect(initialPath).toEqual('next/url3');
});
it('should emit all location changes after it has been subscribed to', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/initial/url1');
location.simulatePopState('/initial/url2');
location.simulatePopState('/initial/url3');
const paths: string[] = [];
service.currentPath.subscribe(path => paths.push(path));
location.simulatePopState('/next/url1');
location.simulatePopState('/next/url2');
location.simulatePopState('/next/url3');
expect(paths).toEqual([
'initial/url3',
'next/url1',
'next/url2',
'next/url3'
]);
});
it('should pass only the latest and later paths to each subscriber', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/initial/url1');
location.simulatePopState('/initial/url2');
location.simulatePopState('/initial/url3');
const paths1: string[] = [];
service.currentPath.subscribe(path => paths1.push(path));
location.simulatePopState('/next/url1');
location.simulatePopState('/next/url2');
const paths2: string[] = [];
service.currentPath.subscribe(path => paths2.push(path));
location.simulatePopState('/next/url3');
expect(paths1).toEqual([
'initial/url3',
'next/url1',
'next/url2',
'next/url3'
]);
expect(paths2).toEqual([
'next/url2',
'next/url3'
]);
});
});
describe('go', () => {
@ -122,36 +286,18 @@ describe('LocationService', () => {
'some-new-url'
]);
});
});
describe('path', () => {
it('should ask Location for the current path without the hash', () => {
it('should strip leading and trailing slashes', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
let url: string;
// We cannot test this directly in the following test because the `MockLocationStrategy`
// does not remove the hash from the path, even if we do pass `false`.
service.currentUrl.subscribe(u => url = u);
service.go('/some/url/');
spyOn(location, 'path').and.callThrough();
service.path();
expect(location.path).toHaveBeenCalledWith(false);
});
it('should return the current location.path, with the query and trailing slash stripped off', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('a/b/c?foo=bar&moo=car');
expect(service.path()).toEqual('a/b/c');
location.simulatePopState('c/d/e');
expect(service.path()).toEqual('c/d/e');
location.simulatePopState('a/b/c/?foo=bar&moo=car');
expect(service.path()).toEqual('a/b/c');
location.simulatePopState('c/d/e/');
expect(service.path()).toEqual('c/d/e');
expect(location.internalPath).toEqual('some/url');
expect(location.path(true)).toEqual('some/url');
expect(url).toBe('some/url');
});
});
@ -263,7 +409,7 @@ describe('LocationService', () => {
anchor.href = 'some/local/url';
spyOn(service, 'go');
const result = service.handleAnchorClick(anchor, 0, false, false);
expect(service.go).toHaveBeenCalledWith('some/local/url');
expect(service.go).toHaveBeenCalledWith('/some/local/url');
expect(result).toBe(false);
});
@ -271,7 +417,7 @@ describe('LocationService', () => {
anchor.href = '/some/local/url';
spyOn(service, 'go');
const result = service.handleAnchorClick(anchor, 0, false, false);
expect(service.go).toHaveBeenCalledWith('some/local/url');
expect(service.go).toHaveBeenCalledWith('/some/local/url');
expect(result).toBe(false);
});
@ -279,7 +425,7 @@ describe('LocationService', () => {
anchor.href = 'some/local/url?query=xxx&other=yyy';
spyOn(service, 'go');
const result = service.handleAnchorClick(anchor, 0, false, false);
expect(service.go).toHaveBeenCalledWith('some/local/url?query=xxx&other=yyy');
expect(service.go).toHaveBeenCalledWith('/some/local/url?query=xxx&other=yyy');
expect(result).toBe(false);
});
@ -287,7 +433,7 @@ describe('LocationService', () => {
anchor.href = 'some/local/url#somefragment';
spyOn(service, 'go');
const result = service.handleAnchorClick(anchor, 0, false, false);
expect(service.go).toHaveBeenCalledWith('some/local/url#somefragment');
expect(service.go).toHaveBeenCalledWith('/some/local/url#somefragment');
expect(result).toBe(false);
});
@ -295,7 +441,7 @@ describe('LocationService', () => {
anchor.href = 'some/local/url?query=xxx&other=yyy#somefragment';
spyOn(service, 'go');
const result = service.handleAnchorClick(anchor, 0, false, false);
expect(service.go).toHaveBeenCalledWith('some/local/url?query=xxx&other=yyy#somefragment');
expect(service.go).toHaveBeenCalledWith('/some/local/url?query=xxx&other=yyy#somefragment');
expect(result).toBe(false);
});
});
@ -365,7 +511,7 @@ describe('LocationService', () => {
anchor.target = '_self';
result = service.handleAnchorClick(anchor, 0, false, false);
expect(service.go).toHaveBeenCalledWith('some/local/url');
expect(service.go).toHaveBeenCalledWith('/some/local/url');
expect(result).toBe(false);
});
});

View File

@ -14,8 +14,11 @@ export class LocationService {
private readonly urlParser = document.createElement('a');
private urlSubject = new Subject<string>();
currentUrl = this.urlSubject
.map(url => this.stripSlashes(url))
.do(url => this.gaService.locationChanged(url))
.publishReplay(1);
currentPath = this.currentUrl
.map(url => url.match(/[^?#]*/)[0]); // strip off query and hash
constructor(
private gaService: GaService,
@ -23,33 +26,22 @@ export class LocationService {
private platformLocation: PlatformLocation) {
this.currentUrl.connect();
const initialUrl = this.stripLeadingSlashes(location.path(true));
this.urlSubject.next(initialUrl);
this.urlSubject.next(location.path(true));
this.location.subscribe(state => {
const url = this.stripLeadingSlashes(state.url);
return this.urlSubject.next(url);
return this.urlSubject.next(state.url);
});
}
// TODO?: ignore if url-without-hash-or-search matches current location?
go(url: string) {
url = this.stripSlashes(url);
this.location.go(url);
this.urlSubject.next(url);
}
private stripLeadingSlashes(url: string) {
return url.replace(/^\/+/, '');
}
/**
* Get the current path, without trailing slash, hash fragment or query params
*/
path(): string {
let path = this.location.path(false);
path = path.match(/[^?]*/)[0]; // strip off query
path = path.replace(/\/$/, ''); // strip off trailing slash
return path;
private stripSlashes(url: string) {
return url.replace(/^\/+/, '').replace(/\/+(\?|#|$)/, '$1');
}
search(): { [index: string]: string; } {
@ -122,7 +114,7 @@ export class LocationService {
return true;
}
this.go(this.stripLeadingSlashes(relativeUrl));
this.go(relativeUrl);
return false;
}
}

View File

@ -3,12 +3,11 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject';
export class MockLocationService {
urlSubject = new BehaviorSubject<string>(this.initialUrl);
currentUrl = this.urlSubject.asObservable();
currentPath = this.currentUrl.map(url => url.match(/[^?#]*/)[0]);
search = jasmine.createSpy('search').and.returnValue({});
setSearch = jasmine.createSpy('setSearch');
go = jasmine.createSpy('Location.go').and
.callFake((url: string) => this.urlSubject.next(url));
path = jasmine.createSpy('Location.path').and
.callFake(() => this.urlSubject.getValue().split('?')[0]);
handleAnchorClick = jasmine.createSpy('Location.handleAnchorClick')
.and.returnValue(false); // prevent click from causing a browser navigation