git.psu.edu will be upgraded to critical security release 13.7.4 Monday, 11/18 between 9 and 10pm. Please email support@git.psu.edu if you have trouble with anything Gitlab-related. Please see the git.psu.edu Yammer group for more information.

Commit b56440fe authored by Ryan Diehl's avatar Ryan Diehl

Merge branch 'feature/cdn' into 'develop'

feat(cdn): adds cdn entrypoint, ported from @psu/cdn

See merge request !23
parents faff2782 2b2c3fc7
Pipeline #97190 passed with stages
in 4 minutes and 52 seconds
......@@ -262,7 +262,7 @@
"projectType": "library",
"root": "libs/utils/theming",
"sourceRoot": "libs/utils/theming/src",
"prefix": "psu",
"prefix": "ut",
"architect": {
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
......@@ -392,6 +392,34 @@
"styleext": "scss"
}
}
},
"utils-cdn": {
"projectType": "library",
"root": "libs/utils/cdn",
"sourceRoot": "libs/utils/cdn/src",
"prefix": "ut",
"architect": {
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["libs/utils/cdn/tsconfig.lib.json", "libs/utils/cdn/tsconfig.spec.json"],
"exclude": ["**/node_modules/**", "!libs/utils/cdn/**"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "libs/utils/cdn/jest.config.js",
"tsConfig": "libs/utils/cdn/tsconfig.spec.json",
"setupFile": "libs/utils/cdn/src/test-setup.ts"
}
}
},
"schematics": {
"@nrwl/angular:component": {
"styleext": "scss"
}
}
}
},
"cli": {
......
......@@ -4,4 +4,4 @@ export interface RequestTracingConfig {
applicationId: string;
}
export const REQUEST_TRACING_CONFIG = new InjectionToken<RequestTracingConfig>('request.tracing');
export const REQUEST_TRACING_CONFIG = new InjectionToken<RequestTracingConfig>('psu.utils.request.tracing');
# @psu/utils/cdn
Utitlity for easily resolving URIs for resources stored on the CDN (images, etc).
## Configuration
There is an application-level injection token for providing the base URL to the CDN. Set this up
in your AppModule/AppInitializer.
```typescript
import { CDN_CONFIG } from '@psu/utils/cdn';
...
providers: [
...
{ provide: CDN_CONFIG, useValue: {
baseUrl: 'http://my-api' // simple case
}}
]
```
If you're loading the CDN base URL dynamically from the app properties (likely), provide an empty object above,
then in the factory function override:
```typescript
export function initProperties(
propertiesService: PropertiesService,
cdnConfig: CdnConfig,
...
): () => Promise<MyAppProperties> {
return () => propertiesService.load('my-app').then(p => {
Object.assign(cdnConfig, p.cdn)
});
}
```
## CdnService
The main CdnService is `providedIn: 'root'`. You simply inject it to a component or service, and you
can pass relative paths to resources to the `uri()` method.
## CdnPipe
The `| psuCdn` pipe allows you to avoid injecting CdnService into your components. It supports both a
relative URI or a resource key, see below.
In its current form, the CDN pipe does not support responsive resources. PRs welcome.
### Providing Resources
There are some optional advanced features of the CdnService. Depending on the project, the remote CDN
may have both a high-res desktop image, and a corresponding lower-res mobile image (for faster download speed).
Managing those resources in code can become cumbersome and repetitive. We've added some injection tokens
that you can leverage to separate the relative path to the resource from the component that is going to
end up displaying the resource. These are optional.
Both of these injection tokens are designed to be used with Angular's multi providers functionality,
so you can provide these injection tokens many times throughout libraries or apps.
**You should use prefixes to make sure the keys used in these maps are unique.**
`CDN_RESOURCE_CONFIG` allows you to provide a map of `key: relativePath`.
`CDN_RESPONSIVE_RESOURCE_CONFIG` allows you to provide a map of `key: {mobile: 'path', desktop: 'path'}`.
Examples:
```typescript
@NgModule({
imports: [
CdnModule // for the CdnPipe
],
providers: [
...{
provide: CDN_RESOURCE_CONFIG,
multi: true, // <-- this is important
useValue: {
resources: {
// the 'AccountCreationLogo' key is later passed to CdnService.uri()
AccountCreationLogo: 'img/PSU_HOR_REV_RGB_2C.svg'
}
}
},
{
provide: CDN_RESPONSIVE_RESOURCE_CONFIG,
multi: true, // <-- this is important
useValue: {
responsiveResources: {
// the 'AccountCreationBg' key is later passed to CdnService.responsive()
AccountCreationBg: {
desktop: 'img/bg/aerial/d_UPAerial.jpg',
mobile: 'img/bg/aerial/m_UPAerial.jpg'
}
}
}
}
]
})
@Component({
template: `
<img [src]="'AccountCreationLogo' | psuCdn"
`
})
export class MyComponent {}
```
module.exports = {
name: 'utils-cdn',
preset: '../../../jest.config.js',
coverageDirectory: '../../../coverage/libs/utils/cdn',
snapshotSerializers: [
'jest-preset-angular/AngularSnapshotSerializer.js',
'jest-preset-angular/HTMLCommentSerializer.js'
]
};
{
"lib": {
"entryFile": "src/index.ts"
}
}
export * from './lib/cdn.config';
export * from './lib/cdn.model';
export * from './lib/cdn.module';
export * from './lib/cdn.pipe';
export * from './lib/cdn.service';
import { InjectionToken } from '@angular/core';
import { CdnUri } from './cdn.model';
export interface CdnConfig {
baseUrl: string;
}
export interface CdnResourceConfig {
resources: { [key: string]: string };
}
export interface CdnResponsiveResourceConfig {
responsiveResources: { [key: string]: CdnUri };
}
export const CDN_CONFIG = new InjectionToken<CdnConfig>('psu.utils.cdn.config');
export const CDN_RESOURCE_CONFIG = new InjectionToken<CdnResourceConfig>('psu.utils.cdn.resource.config');
export const CDN_RESPONSIVE_RESOURCE_CONFIG = new InjectionToken<CdnResponsiveResourceConfig>(
'psu.utils.cdn.responsive.resource.config'
);
import { CdnUri } from './cdn.model';
describe('cdn model', () => {
it('instantiation', () => {
const cdn: CdnUri = {
mobile: 'string',
desktop: 'string'
};
expect(cdn.mobile).toBe('string');
expect(cdn.desktop).toBe('string');
});
it('extends', () => {
class CdnExt extends CdnUri {
public tablet: string;
}
const cdnExt: CdnExt = {
mobile: 'string',
desktop: 'string',
tablet: 'string'
};
expect(cdnExt.mobile).toBe('string');
expect(cdnExt.desktop).toBe('string');
});
});
export class CdnUri {
public mobile: string;
public desktop: string;
}
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CdnPipe } from './cdn.pipe';
@NgModule({
imports: [CommonModule],
declarations: [CdnPipe],
exports: [CdnPipe]
})
export class CdnModule {}
import { Mock } from 'ts-mocks';
import { CdnPipe } from './cdn.pipe';
import { CdnService } from './cdn.service';
describe('CdnPipe', () => {
let pipe: CdnPipe;
let service: Mock<CdnService>;
beforeEach(() => {
service = new Mock<CdnService>({
uri: path => `http://my-cdn/${path === 'myKey' ? 'anotherResource/another.jpg' : path}`
});
pipe = new CdnPipe(service.Object);
});
it('should create', () => {
expect(pipe).toBeDefined();
});
it('transform with undefined input should return empty string', () => {
expect(pipe.transform(undefined)).toBe('');
});
it('transform with path should call cdn', () => {
expect(pipe.transform('myResource/something.png')).toEqual('http://my-cdn/myResource/something.png');
});
it('transform with key should call cdn', () => {
expect(pipe.transform('myKey')).toEqual('http://my-cdn/anotherResource/another.jpg');
});
});
import { Pipe, PipeTransform } from '@angular/core';
import { CdnService } from './cdn.service';
@Pipe({
name: 'psuCdn'
})
export class CdnPipe implements PipeTransform {
constructor(private cdnService: CdnService) {}
public transform(value: string): string {
return !!value ? this.cdnService.uri(value) : '';
}
}
import { TestBed } from '@angular/core/testing';
import {
CdnConfig,
CdnResourceConfig,
CdnResponsiveResourceConfig,
CDN_CONFIG,
CDN_RESOURCE_CONFIG,
CDN_RESPONSIVE_RESOURCE_CONFIG
} from './cdn.config';
import { CdnService } from './cdn.service';
describe('CdnService', () => {
let service: CdnService;
let config: CdnConfig;
function configFactory(): CdnConfig {
return config;
}
let resources1: CdnResourceConfig;
function resourceFactory1(): CdnResourceConfig {
return resources1;
}
let resources2: CdnResourceConfig;
function resourceFactory2(): CdnResourceConfig {
return resources2;
}
let responsiveResources1: CdnResponsiveResourceConfig;
function responsiveResourceFactory1(): CdnResponsiveResourceConfig {
return responsiveResources1;
}
let responsiveResources2: CdnResponsiveResourceConfig;
function responsiveResourceFactory2(): CdnResponsiveResourceConfig {
return responsiveResources2;
}
beforeEach(() => {
config = {
baseUrl: undefined
};
resources1 = {
resources: undefined
};
resources2 = {
resources: undefined
};
responsiveResources1 = {
responsiveResources: undefined
};
responsiveResources2 = {
responsiveResources: undefined
};
TestBed.configureTestingModule({
providers: [
CdnService,
{
provide: CDN_CONFIG,
useFactory: configFactory
},
{
provide: CDN_RESOURCE_CONFIG,
useFactory: resourceFactory1,
multi: true
},
{
provide: CDN_RESPONSIVE_RESOURCE_CONFIG,
useFactory: responsiveResourceFactory1,
multi: true
},
{
provide: CDN_RESOURCE_CONFIG,
useFactory: resourceFactory2,
multi: true
},
{
provide: CDN_RESPONSIVE_RESOURCE_CONFIG,
useFactory: responsiveResourceFactory2,
multi: true
}
]
});
service = TestBed.get(CdnService);
});
it('should create', () => {
expect(service).toBeDefined();
});
describe('when config.baseUrl is missing', () => {
it('uri should use default baseUrl', () => {
expect(service.uri('test')).toBe('https://static.apps.psu.edu/test');
});
});
describe('when config.baseUrl is set with no trailing slash', () => {
beforeEach(() => {
config.baseUrl = '/cdn';
});
it('uri should use baseUrl from config', () => {
expect(service.uri('test')).toBe('/cdn/test');
});
});
describe('when config.baseUrl is set', () => {
beforeEach(() => {
config.baseUrl = '/cdn/';
resources1.resources = {
Another: 'another.jpg'
};
resources2.resources = {
'2fa': '2fa.png'
};
});
it('unknown uri should use baseUrl', () => {
expect(service.uri('test')).toBe('/cdn/test');
});
it('known uri from group 1 should use baseUrl', () => {
expect(service.uri('Another')).toBe('/cdn/another.jpg');
});
it('known uri from group 2 should use baseUrl', () => {
expect(service.uri('2fa')).toBe('/cdn/2fa.png');
});
describe('responsiveResources', () => {
describe('when no config exists', () => {
describe('desktop', () => {
it('should use plain uri', () => {
expect(service.desktop('missing')).toBe('/cdn/missing');
});
});
describe('mobile', () => {
it('should use plain uri', () => {
expect(service.mobile('missing')).toBe('/cdn/missing');
});
});
describe('responsive', () => {
it('should return undefined', () => {
expect(service.responsive('missing')).toBeUndefined();
});
});
});
describe('when config exists', () => {
beforeEach(() => {
responsiveResources1.responsiveResources = {
a: {
mobile: 'am.png',
desktop: 'ad.png'
},
b: {
mobile: 'bm.png',
desktop: 'bd.png'
}
};
responsiveResources2.responsiveResources = {
c: {
mobile: 'cm.png',
desktop: 'dd.png'
},
d: {
mobile: 'dm.png',
desktop: 'dd.png'
}
};
});
describe('responsive', () => {
describe('when key exists in config 1', () => {
it('should return model with cdn baseUrl prepended', () => {
expect(service.responsive('a')).toEqual({
mobile: '/cdn/am.png',
desktop: '/cdn/ad.png'
});
expect(responsiveResources1.responsiveResources.a).toEqual({
mobile: 'am.png',
desktop: 'ad.png'
});
});
});
describe('when key exists in config 2', () => {
it('should return model with cdn baseUrl prepended', () => {
expect(service.responsive('d')).toEqual({
mobile: '/cdn/dm.png',
desktop: '/cdn/dd.png'
});
expect(responsiveResources2.responsiveResources.d).toEqual({
mobile: 'dm.png',
desktop: 'dd.png'
});
});
});
describe('when key does not exist in config', () => {
it('should return undefined', () => {
expect(service.responsive('missing')).toBeUndefined();
});
});
});
describe('mobile', () => {
describe('when key exists in config 1', () => {
it('should use value from config', () => {
expect(service.mobile('b')).toBe('/cdn/bm.png');
});
});
describe('when key exists in config 2', () => {
it('should use value from config', () => {
expect(service.mobile('c')).toBe('/cdn/cm.png');
});
});
describe('when key does not exist in config', () => {
it('should default to regular resource', () => {
expect(service.mobile('e')).toBe('/cdn/e');
});
});
});
describe('desktop', () => {
describe('when key exists in config 1', () => {
it('should use value from config', () => {
expect(service.desktop('a')).toBe('/cdn/ad.png');
});
});
describe('when key exists in config 2', () => {
it('should use value from config', () => {
expect(service.desktop('d')).toBe('/cdn/dd.png');
});
});
describe('when key does not exist in config', () => {
it('should default to regular resource', () => {
expect(service.desktop('f')).toBe('/cdn/f');
});
});
});
});
});
});
});
describe('CdnService with no CDN_CONFIG', () => {
let service: CdnService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CdnService]
});
service = TestBed.get(CdnService);
});
it('should create', () => {
expect(service).toBeDefined();
});
it('should use default baseUrl', () => {
expect(service.uri('test')).toBe('https://static.apps.psu.edu/test');
});
});
import { Inject, Injectable, Optional } from '@angular/core';
import {
CdnConfig,
CdnResourceConfig,
CdnResponsiveResourceConfig,
CDN_CONFIG,
CDN_RESOURCE_CONFIG,
CDN_RESPONSIVE_RESOURCE_CONFIG
} from './cdn.config';
import { CdnUri } from './cdn.model';
/**
* CdnService provides an API to resolve the full URI for resources stored on our remote CDN.
*/
@Injectable({ providedIn: 'root' })
export class CdnService {
constructor(
@Optional()
@Inject(CDN_CONFIG)
private config: CdnConfig,
@Optional()
@Inject(CDN_RESOURCE_CONFIG)
private resourceConfig: CdnResourceConfig[],
@Optional()
@Inject(CDN_RESPONSIVE_RESOURCE_CONFIG)
private responsiveResourceConfig: CdnResponsiveResourceConfig[]
) {}
private get cdnUrl(): string {
let url = 'https://static.apps.psu.edu';
if (this.config && this.config.baseUrl) {
url = this.config.baseUrl;
}
if (url && url.lastIndexOf('/') === url.length - 1) {
url = url.substr(0, url.length - 1);
}
return url;
}
/**
* Resolve the absolute URI for the mobile asset for the given resource key.
*
* @param key the name of the resource registered using CDN_RESPONSIVE_RESOURCE_CONFIG injection token
*/
public mobile(key: string): string {
return this.responsiveResource(key, 'mobile');
}
/**
* Resolve the absolute URI for the desktop asset for the given resource key.
*
* @param key the name of the resource registered using CDN_RESPONSIVE_RESOURCE_CONFIG injection token
*/
public desktop(key: string): string {