This is an automated email from the ASF dual-hosted git repository.
shamrick pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git
The following commit(s) were added to refs/heads/master by this push:
new a5b2bd9281 TPv2 Date Revival (#7344)
a5b2bd9281 is described below
commit a5b2bd9281e65d4422138b23cd9ce013260751ec
Author: ocket8888 <[email protected]>
AuthorDate: Tue Feb 14 14:18:21 2023 -0700
TPv2 Date Revival (#7344)
* Add date utilities
* Add date reviver interceptor
* Remove now-unnecessary manual datestring handling
* Add restrictions on what properties will be parsed
Togglable via environment value. May be necessary in production
environments, where attempting to parse non-dates may take significant
processing time.
* Add strong environment typing
* Fix cg service unable to get a single Division
* Fix unparsed HTTP errors resulting in no snackbar alerts
---
.../src/app/api/cache-group.service.ts | 47 ++---
.../src/app/api/delivery-service.service.ts | 14 +-
.../src/app/api/invalidation-job.service.ts | 9 +-
.../src/app/api/physical-location.service.ts | 19 +-
.../traffic-portal/src/app/api/profile.service.ts | 23 +--
.../traffic-portal/src/app/api/server.service.ts | 95 +++------
.../traffic-portal/src/app/api/type.service.ts | 7 +-
.../traffic-portal/src/app/api/user.service.ts | 93 ++-------
.../server-details/server-details.component.html | 3 +-
.../interceptor/date-reviver.interceptor.spec.ts | 123 ++++++++++++
.../shared/interceptor/date-reviver.interceptor.ts | 72 +++++++
.../app/shared/interceptor/error.interceptor.ts | 26 ++-
.../traffic-portal/src/app/shared/shared.module.ts | 4 +-
.../traffic-portal/src/app/utils/date.spec.ts | 220 +++++++++++++++++++++
experimental/traffic-portal/src/app/utils/date.ts | 189 ++++++++++++++++++
.../src/environments/environment.prod.ts | 4 +-
.../traffic-portal/src/environments/environment.ts | 5 +-
.../src/environments/environment.type.d.ts | 34 ++++
18 files changed, 738 insertions(+), 249 deletions(-)
diff --git a/experimental/traffic-portal/src/app/api/cache-group.service.ts
b/experimental/traffic-portal/src/app/api/cache-group.service.ts
index 6bdb28f48f..3b4982746d 100644
--- a/experimental/traffic-portal/src/app/api/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/cache-group.service.ts
@@ -84,12 +84,9 @@ export class CacheGroupService extends APIService {
if (resp.length !== 1) {
throw new Error(`Traffic Ops returned wrong
number of results for Cache Group identifier: ${params}`);
}
- const cg = resp[0];
- // lastUpdated comes in as a string
- return {...cg, lastUpdated: new Date((cg.lastUpdated as
unknown as string).replace("+00", "Z"))};
+ return resp[0];
}
- const r = await
this.get<Array<ResponseCacheGroup>>(path).toPromise();
- return r.map(cg => ({...cg, lastUpdated: new
Date((cg.lastUpdated as unknown as string).replace("+00", "Z"))}));
+ return this.get<Array<ResponseCacheGroup>>(path).toPromise();
}
/**
@@ -249,14 +246,11 @@ export class CacheGroupService extends APIService {
case "number":
params = {id: String(nameOrID)};
}
- const r = await this.get<[ResponseDivision]>(path,
undefined, params).toPromise();
- return {...r[0], lastUpdated: new
Date((r[0].lastUpdated as unknown as string).replace("+00", "Z"))};
+ const div = await this.get<[ResponseDivision]>(path,
undefined, params).toPromise();
+ return div[0];
}
- const divisions = await
this.get<Array<ResponseDivision>>(path).toPromise();
- return divisions.map(
- d => ({...d, lastUpdated: new Date((d.lastUpdated as
unknown as string).replace("+00", "Z"))})
- );
+ return this.get<Array<ResponseDivision>>(path).toPromise();
}
/**
@@ -267,11 +261,7 @@ export class CacheGroupService extends APIService {
*/
public async updateDivision(division: ResponseDivision):
Promise<ResponseDivision> {
const path = `divisions/${division.id}`;
- const response = await this.put<ResponseDivision>(path,
division).toPromise();
- return {
- ...response,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z"))
- };
+ return this.put<ResponseDivision>(path, division).toPromise();
}
/**
@@ -281,11 +271,7 @@ export class CacheGroupService extends APIService {
* @returns The created division.
*/
public async createDivision(division: RequestDivision):
Promise<ResponseDivision> {
- const response = await this.post<ResponseDivision>("divisions",
division).toPromise();
- return {
- ...response,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z"))
- };
+ return this.post<ResponseDivision>("divisions",
division).toPromise();
}
/**
@@ -321,13 +307,10 @@ export class CacheGroupService extends APIService {
params = {id: String(nameOrID)};
}
const r = await this.get<[ResponseRegion]>(path,
undefined, params).toPromise();
- return {...r[0], lastUpdated: new
Date((r[0].lastUpdated as unknown as string).replace("+00", "Z"))};
+ return r[0];
}
- const regions = await
this.get<Array<ResponseRegion>>(path).toPromise();
- return regions.map(
- d => ({...d, lastUpdated: new Date((d.lastUpdated as
unknown as string).replace("+00", "Z"))})
- );
+ return this.get<Array<ResponseRegion>>(path).toPromise();
}
/**
@@ -338,11 +321,7 @@ export class CacheGroupService extends APIService {
*/
public async updateRegion(region: ResponseRegion):
Promise<ResponseRegion> {
const path = `regions/${region.id}`;
- const response = await this.put<ResponseRegion>(path,
region).toPromise();
- return {
- ...response,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z"))
- };
+ return this.put<ResponseRegion>(path, region).toPromise();
}
/**
@@ -352,11 +331,7 @@ export class CacheGroupService extends APIService {
* @returns The created region.
*/
public async createRegion(region: RequestRegion):
Promise<ResponseRegion> {
- const response = await this.post<ResponseRegion>("regions",
region).toPromise();
- return {
- ...response,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z"))
- };
+ return this.post<ResponseRegion>("regions", region).toPromise();
}
/**
diff --git
a/experimental/traffic-portal/src/app/api/delivery-service.service.ts
b/experimental/traffic-portal/src/app/api/delivery-service.service.ts
index 7938d00662..74be3c9ced 100644
--- a/experimental/traffic-portal/src/app/api/delivery-service.service.ts
+++ b/experimental/traffic-portal/src/app/api/delivery-service.service.ts
@@ -193,19 +193,9 @@ export class DeliveryServiceService extends APIService {
params = {id: String(id)};
}
const r = await
this.get<[ResponseDeliveryService]>(path, undefined, params).toPromise();
- const ds = r[0];
- return {
- ...ds,
- lastUpdated: new Date((ds.lastUpdated as
unknown as string).replace("+00", "Z"))
- };
+ return r[0];
}
- const resp = await
this.get<Array<ResponseDeliveryService>>(path).toPromise();
- return resp.map(
- ds => ({
- ...ds,
- lastUpdated: new Date((ds.lastUpdated as
unknown as string).replace("+00", "Z"))
- })
- );
+ return
this.get<Array<ResponseDeliveryService>>(path).toPromise();
}
/**
diff --git
a/experimental/traffic-portal/src/app/api/invalidation-job.service.ts
b/experimental/traffic-portal/src/app/api/invalidation-job.service.ts
index 1992e1e0a3..87d37ae645 100644
--- a/experimental/traffic-portal/src/app/api/invalidation-job.service.ts
+++ b/experimental/traffic-portal/src/app/api/invalidation-job.service.ts
@@ -70,14 +70,7 @@ export class InvalidationJobService extends APIService {
params.userId = String(opts.user.id);
}
}
- const js = await this.get<Array<ResponseInvalidationJob>>(path,
undefined, params).toPromise();
- const jobs = new Array<ResponseInvalidationJob>();
- for (const j of js) {
- const tmp = String(j.startTime).replace(" ",
"T").replace("+00", "Z");
- j.startTime = new Date(tmp);
- jobs.push(j);
- }
- return jobs;
+ return this.get<Array<ResponseInvalidationJob>>(path,
undefined, params).toPromise();
}
/**
diff --git
a/experimental/traffic-portal/src/app/api/physical-location.service.ts
b/experimental/traffic-portal/src/app/api/physical-location.service.ts
index c1bfd6baa4..4d9f5eab32 100644
--- a/experimental/traffic-portal/src/app/api/physical-location.service.ts
+++ b/experimental/traffic-portal/src/app/api/physical-location.service.ts
@@ -45,13 +45,10 @@ export class PhysicalLocationService extends APIService {
params = {id: String(nameOrID)};
}
const r = await
this.get<[ResponsePhysicalLocation]>(path, undefined, params).toPromise();
- return {...r[0], lastUpdated: new
Date((r[0].lastUpdated as unknown as string).replace("+00", "Z"))};
+ return r[0];
}
- const physicalLocations = await
this.get<Array<ResponsePhysicalLocation>>(path).toPromise();
- return physicalLocations.map(
- d => ({...d, lastUpdated: new Date((d.lastUpdated as
unknown as string).replace("+00", "Z"))})
- );
+ return
this.get<Array<ResponsePhysicalLocation>>(path).toPromise();
}
/**
@@ -62,11 +59,7 @@ export class PhysicalLocationService extends APIService {
*/
public async updatePhysicalLocation(physicalLocation:
ResponsePhysicalLocation): Promise<ResponsePhysicalLocation> {
const path = `phys_locations/${physicalLocation.id}`;
- const response = await this.put<ResponsePhysicalLocation>(path,
physicalLocation).toPromise();
- return {
- ...response,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z"))
- };
+ return this.put<ResponsePhysicalLocation>(path,
physicalLocation).toPromise();
}
/**
@@ -76,11 +69,7 @@ export class PhysicalLocationService extends APIService {
* @returns The created Physical Location.
*/
public async createPhysicalLocation(physicalLocation:
RequestPhysicalLocation): Promise<ResponsePhysicalLocation> {
- const response = await
this.post<ResponsePhysicalLocation>("physicalLocations",
physicalLocation).toPromise();
- return {
- ...response,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z"))
- };
+ return this.post<ResponsePhysicalLocation>("physicalLocations",
physicalLocation).toPromise();
}
/**
diff --git a/experimental/traffic-portal/src/app/api/profile.service.ts
b/experimental/traffic-portal/src/app/api/profile.service.ts
index 45661d5d82..bf638dbbf1 100644
--- a/experimental/traffic-portal/src/app/api/profile.service.ts
+++ b/experimental/traffic-portal/src/app/api/profile.service.ts
@@ -18,21 +18,6 @@ import { ResponseProfile } from "trafficops-types";
import { APIService } from "./base-api.service";
-/**
- * Shared mapping function for converting Profile 'lastUpdated' fields to
- * actual dates, as well as the `lastUpdated` property of any and all
- * constituent Parameters thereof.
- *
- * @param p The Profile being converted.
- * @returns the converted Profile.
- */
-function profileMap(p: ResponseProfile): ResponseProfile {
- return {
- ...p,
- lastUpdated: new Date((p.lastUpdated as unknown as
string).replace("+00", "Z"))
- };
-}
-
/**
* ProfileService exposes API functionality related to Profiles.
*/
@@ -58,7 +43,6 @@ export class ProfileService extends APIService {
*/
public async getProfiles(idOrName?: number | string):
Promise<Array<ResponseProfile> | ResponseProfile> {
const path = "profiles";
- let prom;
if (idOrName !== undefined) {
let params;
switch (typeof idOrName) {
@@ -68,10 +52,9 @@ export class ProfileService extends APIService {
case "string":
params = {name: idOrName};
}
- prom = this.get<[ResponseProfile]>(path, undefined,
params).toPromise().then(r=>r[0]).then(profileMap);
- } else {
- prom =
this.get<Array<ResponseProfile>>(path).toPromise().then(r=>r.map(profileMap));
+ const r = await this.get<[ResponseProfile]>(path,
undefined, params).toPromise();
+ return r[0];
}
- return prom;
+ return this.get<Array<ResponseProfile>>(path).toPromise();
}
}
diff --git a/experimental/traffic-portal/src/app/api/server.service.ts
b/experimental/traffic-portal/src/app/api/server.service.ts
index 83f315d87a..4411056d03 100644
--- a/experimental/traffic-portal/src/app/api/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/server.service.ts
@@ -18,33 +18,6 @@ import type { RequestServer, ResponseServer, ResponseStatus,
Servercheck } from
import { APIService } from "./base-api.service";
-/**
- * Shared mapping for massaging Status requests.
- *
- * @param s The Status to format.
- * @returns The Status object with a proper Date lastUpdated time.
- */
-function statusMap(s: ResponseStatus): ResponseStatus {
- return {
- ...s,
- lastUpdated: new Date((s.lastUpdated as unknown as
string).replace("+00", "Z"))
- };
-}
-
-/**
- * Shared mapping for massaging Server requests.
- *
- * @param s The Server to massage.
- * @returns A Server that is identical to `s` except that its date/time fields
are now actual Date objects.
- */
-function serverMap(s: ResponseServer): ResponseServer {
- return {
- ...s,
- lastUpdated: !s.lastUpdated ? s.lastUpdated : new
Date((s.lastUpdated as unknown as string).replace("+00", "Z")),
- statusLastUpdated: !s.statusLastUpdated ? s.statusLastUpdated :
new Date(s.statusLastUpdated as unknown as string)
- };
-}
-
/**
* ServerService exposes API functionality related to Servers.
*/
@@ -70,34 +43,30 @@ export class ServerService extends APIService {
*/
public async getServers(idOrName?: number | string):
Promise<Array<ResponseServer> | ResponseServer> {
const path = "servers";
- let prom;
if (idOrName !== undefined) {
+ let servers;
switch (typeof idOrName) {
case "number":
- prom = this.get<[ResponseServer]>(path,
undefined, {id: String(idOrName)}).toPromise();
+ servers = await
this.get<[ResponseServer]>(path, undefined, {id: String(idOrName)}).toPromise();
break;
case "string":
- prom =
this.get<Array<ResponseServer>>(path, undefined, {hostName:
idOrName}).toPromise();
+ servers = await
this.get<Array<ResponseServer>>(path, undefined, {hostName:
idOrName}).toPromise();
}
- prom = prom.then(
- servers => {
- if (servers.length < 1) {
- throw new Error(`no such server
'${idOrName}'`);
- }
- if (servers.length > 1) {
- console.warn(
- "Traffic Ops returned",
- servers.length,
- `servers with host name
'${idOrName}' - selecting the first arbitrarily`
- );
- }
- return servers[0];
- }
- ).then(serverMap);
- } else {
- prom =
this.get<Array<ResponseServer>>(path).toPromise().then(ss=>ss.map(serverMap));
+ if (servers.length < 1) {
+ throw new Error(`no such server '${idOrName}'`);
+ }
+ // This is, unfortunately, possible, despite the many
assumptions to
+ // the contrary.
+ if (servers.length > 1) {
+ console.warn(
+ "Traffic Ops returned",
+ servers.length,
+ `servers with host name '${idOrName}' -
selecting the first arbitrarily`
+ );
+ }
+ return servers[0];
}
- return prom;;
+ return this.get<Array<ResponseServer>>(path).toPromise();
}
/**
@@ -107,7 +76,7 @@ export class ServerService extends APIService {
* @returns The server as created and returned by the API.
*/
public async createServer(s: RequestServer): Promise<ResponseServer> {
- return this.post<ResponseServer>("servers",
s).toPromise().then(serverMap);
+ return this.post<ResponseServer>("servers", s).toPromise();
}
public async getServerChecks(): Promise<Servercheck[]>;
@@ -149,13 +118,13 @@ export class ServerService extends APIService {
let ret;
switch (typeof idOrName) {
case "number":
- ret = this.get<[ResponseStatus]>(path, {params:
{id: String(idOrName)}}).toPromise().then(r=>r[0]).then(statusMap);
+ ret = this.get<[ResponseStatus]>(path, {params:
{id: String(idOrName)}}).toPromise();
break;
case "string":
- ret = this.get<[ResponseStatus]>(path, {params:
{name: idOrName}}).toPromise().then(r=>r[0]).then(statusMap);
+ ret = this.get<[ResponseStatus]>(path, {params:
{name: idOrName}}).toPromise();
break;
default:
- ret =
this.get<Array<ResponseStatus>>(path).toPromise().then(ss=>ss.map(statusMap));
+ ret =
this.get<Array<ResponseStatus>>(path).toPromise();
}
return ret;
}
@@ -176,12 +145,7 @@ export class ServerService extends APIService {
id = server.id;
}
- return this.post<{serverId: number; action:
"queue"}>(`servers/${id}/queue_update`, {action: "queue"}).toPromise().catch(
- e => {
- console.error("Failed to queue updates:", e);
- return {action: "queue", serverId: -1};
- }
- );
+ return this.post<{serverId: number; action:
"queue"}>(`servers/${id}/queue_update`, {action: "queue"}).toPromise();
}
/**
@@ -200,12 +164,7 @@ export class ServerService extends APIService {
id = server.id;
}
- return this.post<{serverId: number; action:
"dequeue"}>(`servers/${id}/queue_update`, {action:
"dequeue"}).toPromise().catch(
- e => {
- console.error("Failed to clear updates:", e);
- return {action: "dequeue", serverId: -1};
- }
- );
+ return this.post<{serverId: number; action:
"dequeue"}>(`servers/${id}/queue_update`, {action: "dequeue"}).toPromise();
}
/**
@@ -214,7 +173,6 @@ export class ServerService extends APIService {
* @param server Either the server that will have its status changed,
or the integral, unique identifier thereof.
* @param status The name of the status to which to set the server.
* @param offlineReason The reason why the server was placed into a
non-ONLINE or REPORTED status.
- * @returns Nothing.
*/
public async updateStatus(server: number | ResponseServer, status:
string, offlineReason?: string): Promise<undefined> {
let id: number;
@@ -226,11 +184,6 @@ export class ServerService extends APIService {
id = server.id;
}
- return this.put(`servers/${id}/status`, {offlineReason,
status}).toPromise().catch(
- e=> {
- console.error("Failed to update server
status:", e);
- return undefined;
- }
- );
+ return this.put(`servers/${id}/status`, {offlineReason,
status}).toPromise();
}
}
diff --git a/experimental/traffic-portal/src/app/api/type.service.ts
b/experimental/traffic-portal/src/app/api/type.service.ts
index 91f525280b..2fd0e20093 100644
--- a/experimental/traffic-portal/src/app/api/type.service.ts
+++ b/experimental/traffic-portal/src/app/api/type.service.ts
@@ -67,12 +67,7 @@ export class TypeService extends APIService {
* @returns The requested Types.
*/
public async getTypesInTable(useInTable: UseInTable):
Promise<Array<TypeFromResponse>> {
- return this.get<Array<TypeFromResponse>>("types", undefined,
{useInTable}).toPromise().catch(
- (e) => {
- console.error("Failed to get Types:", e);
- return [];
- }
- );
+ return this.get<Array<TypeFromResponse>>("types", undefined,
{useInTable}).toPromise();
}
/**
diff --git a/experimental/traffic-portal/src/app/api/user.service.ts
b/experimental/traffic-portal/src/app/api/user.service.ts
index f53306a4ab..1c68efbab7 100644
--- a/experimental/traffic-portal/src/app/api/user.service.ts
+++ b/experimental/traffic-portal/src/app/api/user.service.ts
@@ -12,18 +12,18 @@
* limitations under the License.
*/
-import { HttpClient, HttpResponse } from "@angular/common/http";
+import { HttpClient, type HttpResponse } from "@angular/common/http";
import { Injectable } from "@angular/core";
import {
- Capability,
- ResponseUser,
- PostRequestUser,
- RequestTenant,
- ResponseCurrentUser,
- ResponseRole,
- ResponseTenant,
- PutRequestUser,
- RegistrationRequest,
+ type Capability,
+ type ResponseUser,
+ type PostRequestUser,
+ type RequestTenant,
+ type ResponseCurrentUser,
+ type ResponseRole,
+ type ResponseTenant,
+ type PutRequestUser,
+ type RegistrationRequest,
userEmailIsValid
} from "trafficops-types";
@@ -50,20 +50,10 @@ export class UserService extends APIService {
public async login(uOrT: string, p?: string):
Promise<HttpResponse<object> | null> {
let path = `/api/${this.apiVersion}/user/login`;
if (p !== undefined) {
- return this.http.post(path, {p, u: uOrT},
this.defaultOptions).toPromise().catch(
- e => {
- console.error("Failed to login:", e);
- return null;
- }
- );
+ return this.http.post(path, {p, u: uOrT},
this.defaultOptions).toPromise();
}
path += "/token";
- return this.http.post(path, {t: uOrT},
this.defaultOptions).toPromise().catch(
- e => {
- console.error("Failed to login with token:", e);
- return null;
- }
- );
+ return this.http.post(path, {t: uOrT},
this.defaultOptions).toPromise();
}
/**
@@ -74,12 +64,7 @@ export class UserService extends APIService {
*/
public async logout(): Promise<HttpResponse<object> | null> {
const path = `/api/${this.apiVersion}/user/logout`;
- return this.http.post(path, undefined,
this.defaultOptions).toPromise().catch(
- e => {
- console.error("Failed to logout:", e);
- return null;
- }
- );
+ return this.http.post(path, undefined,
this.defaultOptions).toPromise();
}
/**
@@ -89,11 +74,7 @@ export class UserService extends APIService {
*/
public async getCurrentUser(): Promise<ResponseCurrentUser> {
const path = "user/current";
- const r = await this.get<ResponseCurrentUser>(path).toPromise();
- return {
- ...r,
- lastUpdated: new Date((r.lastUpdated as unknown as
string).replace("+00", "Z"))
- };
+ return this.get<ResponseCurrentUser>(path).toPromise();
}
/**
@@ -149,22 +130,9 @@ export class UserService extends APIService {
params = {id: String(nameOrID)};
}
const r = await this.get<[ResponseUser]>(path,
undefined, params).toPromise();
- return {
- ...r[0],
- lastAuthenticated: r[0].lastAuthenticated ? new
Date((r[0].lastAuthenticated as unknown as string)) : null,
- lastUpdated: new Date((r[0].lastUpdated as
unknown as string).replace(" ", "T").replace("+00", "Z")),
- registrationSent: r[0].registrationSent ? new
Date((r[0].registrationSent as unknown as string)) : null
- };
+ return r[0];
}
- const users = await
this.get<Array<ResponseUser>>(path).toPromise();
- return users.map(
- u => ({
- ...u,
- lastAuthenticated: u.lastAuthenticated ? new
Date((u.lastAuthenticated as unknown as string)) : null,
- lastUpdated: new Date((u.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z")),
- registrationSent: u.registrationSent ? new
Date((u.registrationSent as unknown as string)) : null
- })
- );
+ return this.get<Array<ResponseUser>>(path).toPromise();
}
/**
@@ -204,13 +172,7 @@ export class UserService extends APIService {
id = user.id;
}
const path = `users/${id}`;
- const response = await this.put<ResponseUser>(path,
body).toPromise();
- return {
- ...response,
- lastAuthenticated: response.lastAuthenticated ? new
Date((response.lastAuthenticated as unknown as string)) : null,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z")),
- registrationSent: response.registrationSent ? new
Date((response.registrationSent as unknown as string)) : null
- };
+ return this.put<ResponseUser>(path, body).toPromise();
}
/**
@@ -220,13 +182,7 @@ export class UserService extends APIService {
* @returns The created user.
*/
public async createUser(user: PostRequestUser): Promise<ResponseUser> {
- const response = await this.post<ResponseUser>("users",
user).toPromise();
- return {
- ...response,
- lastAuthenticated: response.lastAuthenticated ? new
Date((response.lastAuthenticated as unknown as string)) : null,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z")),
- registrationSent: response.registrationSent ? new
Date((response.registrationSent as unknown as string)) : null
- };
+ return this.post<ResponseUser>("users", user).toPromise();
}
/**
@@ -354,11 +310,7 @@ export class UserService extends APIService {
* @returns The created tenant.
*/
public async createTenant(tenant: RequestTenant):
Promise<ResponseTenant> {
- const response = await this.post<ResponseTenant>("tenants",
tenant).toPromise();
- return {
- ...response,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z"))
- };
+ return this.post<ResponseTenant>("tenants", tenant).toPromise();
}
/**
@@ -368,12 +320,7 @@ export class UserService extends APIService {
* @returns The updated tenant.
*/
public async updateTenant(tenant: ResponseTenant):
Promise<ResponseTenant> {
- const response = await
this.put<ResponseTenant>(`tenants/${tenant.id}`, tenant).toPromise();
-
- return {
- ...response,
- lastUpdated: new Date((response.lastUpdated as unknown
as string).replace(" ", "T").replace("+00", "Z"))
- };
+ return this.put<ResponseTenant>(`tenants/${tenant.id}`,
tenant).toPromise();
}
/**
diff --git
a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
index a61eafe371..2d09e79d2b 100644
---
a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
+++
b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
@@ -12,7 +12,8 @@ See the License for the specific language governing
permissions and
limitations under the License.
-->
<mat-card>
- <mat-card-content>
+ <tp-loading *ngIf="!server"></tp-loading>
+ <mat-card-content *ngIf="server">
<div class="actions-container" *ngIf="!isNew">
<button [disabled]="!isCache()" mat-button
type="button">Manage Capabilities</button>
<button [disabled]="!isCache()" mat-button
type="button">Manage Delivery Services</button>
diff --git
a/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.spec.ts
b/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.spec.ts
new file mode 100644
index 0000000000..1b65063fd6
--- /dev/null
+++
b/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.spec.ts
@@ -0,0 +1,123 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import { HttpClient, HTTP_INTERCEPTORS } from "@angular/common/http";
+import { HttpClientTestingModule, HttpTestingController } from
"@angular/common/http/testing";
+import { TestBed } from "@angular/core/testing";
+
+import { DateReviverInterceptor } from "./date-reviver.interceptor";
+
+/**
+ * Holds some Date instance data fields for testing Date revival.
+ */
+interface TestResponse {
+ lastAuthenticated: Date;
+ lastUpdated: Date;
+ statusLastUpdated: Date;
+}
+
+describe("DateReviverInterceptor", () => {
+ let httpClient: HttpClient;
+ let httpTestingController: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ HttpClientTestingModule
+ ],
+ providers: [
+ DateReviverInterceptor,
+ { multi: true, provide: HTTP_INTERCEPTORS,
useClass: DateReviverInterceptor}
+ ]
+ });
+ // Inject the http service and test controller for each test
+ httpClient = TestBed.inject(HttpClient);
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ it("should be created", () => {
+ const interceptor: DateReviverInterceptor =
TestBed.inject(DateReviverInterceptor);
+ expect(interceptor).toBeTruthy();
+ });
+
+ it("revives dates sent in responses", () => {
+ const testData = {
+ lastAuthenticated: "2022-03-31T08:01:02.000+00",
+ lastUpdated: "2022-03-31 08:01:02+00",
+ statusLastUpdated: "2022-03-31T08:01:02Z"
+ };
+
+ httpClient.get<TestResponse>("/api/data").subscribe(
+ data => {
+ expect(typeof data).toBe("object");
+
+ const dataProperties = Object.keys(data);
+ expect(dataProperties.length).toBe(3);
+
expect(dataProperties).toContain("lastAuthenticated");
+
expect(data.lastAuthenticated).toBeInstanceOf(Date);
+ expect(dataProperties).toContain("lastUpdated");
+ expect(data.lastUpdated).toBeInstanceOf(Date);
+
expect(dataProperties).toContain("statusLastUpdated");
+
expect(data.statusLastUpdated).toBeInstanceOf(Date);
+
+ const expectedDate = new Date(Date.UTC(2022, 2,
31, 8, 1, 2, 0));
+
+
expect(data.lastAuthenticated).toEqual(expectedDate);
+ expect(data.lastUpdated).toEqual(expectedDate);
+
expect(data.statusLastUpdated).toEqual(expectedDate);
+ }
+ );
+
+ httpTestingController.expectOne("/api/data").flush(testData);
+ });
+
+ it("doesn't modify non-date properties of responses", () => {
+ const testData = {
+ arr: [],
+ bool: true,
+ num: 1,
+ obj: {},
+ str: "some string that doesn't look like a date",
+ strNum: "12345"
+ };
+
+ httpClient.get<typeof testData>("/api/data").subscribe(
+ data => {
+ expect(data).toEqual(testData);
+ }
+ );
+
+ httpTestingController.expectOne("/api/data").flush(testData);
+ });
+
+ it("doesn't modify responses where JSON parsing wasn't requested", ()=>{
+ const testData = JSON.stringify({
+ lastAuthenticated: "2022-03-31T08:01:02.000+00",
+ lastUpdated: "2022-03-31 08:01:02+00",
+ statusLastUpdated: "2022-03-31T08:01:02Z"
+ });
+
+ httpClient.get("/api/data", {responseType: "text"}).subscribe(
+ data => {
+ expect(data).toEqual(testData);
+ }
+ );
+
+ httpTestingController.expectOne("/api/data").flush(testData);
+ });
+
+ afterEach(() => {
+ httpTestingController.verify();
+ });
+});
diff --git
a/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.ts
b/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.ts
new file mode 100644
index 0000000000..723ecd2ddd
--- /dev/null
+++
b/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.ts
@@ -0,0 +1,72 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import {
+ type HttpRequest,
+ type HttpHandler,
+ type HttpEvent,
+ type HttpInterceptor,
+ HttpResponse
+} from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import type { Observable } from "rxjs";
+import { map } from "rxjs/operators";
+
+import { dateReviver } from "src/app/utils/date";
+
+/**
+ * The DateReviverInterceptor adds custom JSON parsing to all HTTP requests
that
+ * converts date strings to Date instances.
+ */
+@Injectable()
+export class DateReviverInterceptor implements HttpInterceptor {
+
+ /**
+ * Parses the eventually received response (if indeed one is received)
as a
+ * JSON payload, reviving string values that look like dates into Date
+ * instances.
+ *
+ * @param request The outgoing request.
+ * @param next The next step in the request process.
+ * @returns The response events. Non-response events or those without
+ * response bodies are untouched.
+ */
+ private parseResponseJSON(request: HttpRequest<unknown>, next:
HttpHandler): Observable<HttpEvent<unknown>> {
+ request = request.clone({responseType: "text"});
+ return next.handle(request).pipe(map(
+ event => {
+ if (event instanceof HttpResponse && typeof
event.body === "string") {
+ return event.clone({body:
JSON.parse(event.body, dateReviver)});
+ }
+ return event;
+ }));
+ }
+
+ /**
+ * Intercepts requests with the "json" response type to add a custom
parser
+ * for date strings.
+ *
+ * @param request The outgoing request, before being sent.
+ * @param next The next step in the HTTP handler stack.
+ * @returns The response events. Non-response events or those without
+ * response bodies are untouched.
+ */
+ public intercept(request: HttpRequest<unknown>, next: HttpHandler):
Observable<HttpEvent<unknown>> {
+ if (request.responseType === "json") {
+ // If the expected response type is JSON then handle it
here.
+ return this.parseResponseJSON(request, next);
+ }
+ return next.handle(request);
+ }
+}
diff --git
a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
index 3d2ce2ac94..55bc33993e 100644
---
a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
+++
b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
@@ -32,6 +32,17 @@ export class ErrorInterceptor implements HttpInterceptor {
private readonly router: Router
) {}
+ /**
+ * Raises all passed alerts to the AlertService.
+ *
+ * @param alerts The alerts to be raised.
+ */
+ private raiseAlerts(alerts: Alert[]): void {
+ for (const alert of alerts) {
+ this.alerts.newAlert(alert);
+ }
+ }
+
/**
* Intercepts HTTP responses and checks for erroneous responses,
displaying
* appropriate error Alerts and redirecting unauthenticated users to the
@@ -42,13 +53,20 @@ export class ErrorInterceptor implements HttpInterceptor {
* @returns An Observable that will emit an event if the request fails.
*/
public intercept(request: HttpRequest<unknown>, next: HttpHandler):
Observable<HttpEvent<unknown>> {
- return next.handle(request).pipe(catchError((err) => {
+ return next.handle(request).pipe(catchError((err:
HttpErrorResponse) => {
console.error("HTTP Error: ", err);
- if (err.hasOwnProperty("error") && (err as {error:
object}).error.hasOwnProperty("alerts")) {
- for (const a of (err as {error: {alerts:
Alert[]}}).error.alerts) {
- this.alerts.newAlert(a);
+ if (typeof(err.error) === "string") {
+ try {
+ const body: {alerts: Alert[] |
undefined} = JSON.parse(err.error);
+ if (Array.isArray(body.alerts)) {
+ this.raiseAlerts(body.alerts);
+ }
+ } catch (e) {
+ console.error("non-JSON HTTP error
response:", e);
}
+ } else if (typeof(err.error) === "object" &&
Array.isArray(err.error.alerts)) {
+ this.raiseAlerts(err.error.alerts);
}
if (err instanceof HttpErrorResponse && err.status ===
401 && this.router.getCurrentNavigation() === null) {
diff --git a/experimental/traffic-portal/src/app/shared/shared.module.ts
b/experimental/traffic-portal/src/app/shared/shared.module.ts
index a3c514913e..3e7076a163 100644
--- a/experimental/traffic-portal/src/app/shared/shared.module.ts
+++ b/experimental/traffic-portal/src/app/shared/shared.module.ts
@@ -28,6 +28,7 @@ import { DecisionDialogComponent } from
"./dialogs/decision-dialog/decision-dial
import { TextDialogComponent } from
"./dialogs/text-dialog/text-dialog.component";
import { GenericTableComponent } from
"./generic-table/generic-table.component";
import { AlertInterceptor } from "./interceptor/alerts.interceptor";
+import { DateReviverInterceptor } from
"./interceptor/date-reviver.interceptor";
import { ErrorInterceptor } from "./interceptor/error.interceptor";
import { LoadingComponent } from "./loading/loading.component";
import { ObscuredTextInputComponent } from
"./obscured-text-input/obscured-text-input.component";
@@ -83,7 +84,8 @@ import { CustomvalidityDirective } from
"./validation/customvalidity.directive";
],
providers: [
{ multi: true, provide: HTTP_INTERCEPTORS, useClass:
ErrorInterceptor },
- { multi: true, provide: HTTP_INTERCEPTORS, useClass:
AlertInterceptor }
+ { multi: true, provide: HTTP_INTERCEPTORS, useClass:
AlertInterceptor },
+ { multi: true, provide: HTTP_INTERCEPTORS, useClass:
DateReviverInterceptor}
]
})
export class SharedModule { }
diff --git a/experimental/traffic-portal/src/app/utils/date.spec.ts
b/experimental/traffic-portal/src/app/utils/date.spec.ts
new file mode 100644
index 0000000000..4e40032840
--- /dev/null
+++ b/experimental/traffic-portal/src/app/utils/date.spec.ts
@@ -0,0 +1,220 @@
+/*
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import { dateReviver, parseHTTPDate } from "./date";
+
+describe("date utilities", () => {
+ it("revives legacy, custom TO timestamps into dates", ()=>{
+ const data = '{"notADate": "testquest", "myDate": "2022-01-02
03:04:05+00"}';
+ const parsed = JSON.parse(data, dateReviver);
+ const keys = Object.keys(parsed);
+ expect(keys.length).toBe(2);
+ expect(keys).toContain("notADate");
+ expect(keys).toContain("myDate");
+ expect(typeof(parsed.notADate)).toBe("string");
+
+ const parsedDate = parsed.myDate;
+ if (!(parsedDate instanceof Date)) {
+ return fail(`expected "${parsedDate}" to be a Date`);
+ }
+ expect(parsedDate.getUTCFullYear()).toBe(2022);
+ expect(parsedDate.getUTCMonth()).toBe(0);
+ expect(parsedDate.getUTCDate()).toBe(2);
+ expect(parsedDate.getUTCHours()).toBe(3);
+ expect(parsedDate.getUTCMinutes()).toBe(4);
+ expect(parsedDate.getUTCSeconds()).toBe(5);
+ expect(parsedDate.getUTCMilliseconds()).toBe(0);
+ });
+
+ it("revives RFC3339 timestamps into dates", () => {
+ const data = '{"notADate": "testquest", "myDate":
"2022-01-02T03:04:05Z"}';
+ const parsed = JSON.parse(data, dateReviver);
+ const keys = Object.keys(parsed);
+ expect(keys.length).toBe(2);
+ expect(keys).toContain("notADate");
+ expect(keys).toContain("myDate");
+ expect(typeof(parsed.notADate)).toBe("string");
+
+ const parsedDate = parsed.myDate;
+ if (!(parsedDate instanceof Date)) {
+ return fail(`expected "${parsedDate}" to be a Date`);
+ }
+ expect(parsedDate.getUTCFullYear()).toBe(2022);
+ expect(parsedDate.getUTCMonth()).toBe(0);
+ expect(parsedDate.getUTCDate()).toBe(2);
+ expect(parsedDate.getUTCHours()).toBe(3);
+ expect(parsedDate.getUTCMinutes()).toBe(4);
+ expect(parsedDate.getUTCSeconds()).toBe(5);
+ expect(parsedDate.getUTCMilliseconds()).toBe(0);
+ });
+
+ it("revives RFC3339 timestamps with sub-second precision into dates",
() => {
+ const data = '{"notADate": "testquest", "myDate":
"2022-01-02T03:04:05.6789Z"}';
+ const parsed = JSON.parse(data, dateReviver);
+ const keys = Object.keys(parsed);
+ expect(keys.length).toBe(2);
+ expect(keys).toContain("notADate");
+ expect(keys).toContain("myDate");
+ expect(typeof(parsed.notADate)).toBe("string");
+
+ const parsedDate = parsed.myDate;
+ if (!(parsedDate instanceof Date)) {
+ return fail(`expected "${parsedDate}" to be a Date`);
+ }
+ expect(parsedDate.getUTCFullYear()).toBe(2022);
+ expect(parsedDate.getUTCMonth()).toBe(0);
+ expect(parsedDate.getUTCDate()).toBe(2);
+ expect(parsedDate.getUTCHours()).toBe(3);
+ expect(parsedDate.getUTCMinutes()).toBe(4);
+ expect(parsedDate.getUTCSeconds()).toBe(5);
+ expect(parsedDate.getUTCMilliseconds()).toBe(678);
+ });
+
+ it("leaves unparsable dates alone", () => {
+ const data = {
+ lastAuthenticated: "not a valid date",
+ lastUpdated: "9999-99-99T99:99:99.99Z"
+ };
+ const parsed = JSON.parse(JSON.stringify(data), dateReviver);
+ expect(parsed).toEqual(data);
+ });
+
+ it("parses HTTP header dates", () => {
+ let date = "Sun, 02 Jan 2022 03:04:05 GMT";
+ let parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(0);
+ expect(parsed.getUTCDate()).toBe(2);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Mon, 07 Feb 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(1);
+ expect(parsed.getUTCDate()).toBe(7);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Tue, 01 Mar 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(2);
+ expect(parsed.getUTCDate()).toBe(1);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Wed, 06 Apr 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(3);
+ expect(parsed.getUTCDate()).toBe(6);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Thu, 05 May 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(4);
+ expect(parsed.getUTCDate()).toBe(5);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Fri, 03 Jun 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(5);
+ expect(parsed.getUTCDate()).toBe(3);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Sat, 02 Jul 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(6);
+ expect(parsed.getUTCDate()).toBe(2);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Mon, 01 Aug 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(7);
+ expect(parsed.getUTCDate()).toBe(1);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Thu, 01 Sep 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(8);
+ expect(parsed.getUTCDate()).toBe(1);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Sat, 01 Oct 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(9);
+ expect(parsed.getUTCDate()).toBe(1);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Tue, 01 Nov 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(10);
+ expect(parsed.getUTCDate()).toBe(1);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+
+ date = "Thu, 01 Dec 2022 03:04:05 GMT";
+ parsed = parseHTTPDate(date);
+ expect(parsed.getUTCFullYear()).toBe(2022);
+ expect(parsed.getUTCMonth()).toBe(11);
+ expect(parsed.getUTCDate()).toBe(1);
+ expect(parsed.getUTCHours()).toBe(3);
+ expect(parsed.getUTCMinutes()).toBe(4);
+ expect(parsed.getUTCSeconds()).toBe(5);
+ expect(parsed.getUTCMilliseconds()).toBe(0);
+ });
+
+ it("fails to parse invalid dates", ()=>{
+ expect(()=>parseHTTPDate("not a date")).toThrow();
+ expect(()=>parseHTTPDate("Thu, 01 NaN 2022 03:04:05
GMT")).toThrow();
+ });
+});
diff --git a/experimental/traffic-portal/src/app/utils/date.ts
b/experimental/traffic-portal/src/app/utils/date.ts
new file mode 100644
index 0000000000..4c0975b3da
--- /dev/null
+++ b/experimental/traffic-portal/src/app/utils/date.ts
@@ -0,0 +1,189 @@
+/*
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import { environment } from "src/environments/environment";
+
+/**
+ * MalformedDateError is an Error that provides the raw date that caused it as
a
+ * readable property. Other than that, it's just like an Error.
+ */
+export class MalformedDateError extends Error {
+
+ public readonly date: string;
+
+ constructor(date: string) {
+ super();
+ this.message = `malformed date: ${date}`;
+ this.date = date;
+ }
+}
+
+/**
+ * Matches both the legacy, custom TO timestamp strings and RFC3339 with
+ * optional sub-second precision. This is, unfortunately, necessary, because
+ * Chrome's overly permissive Date parser will attempt to recognize strings
+ * containing numbers as years, and parse them as 01-01 of that year at
+ * 00:00:00.
+ */
+const datePattern = /^(\d{4})-(\d{2})-(\d{2})[T
](\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)(?:[\+-]00|Z)$/;
+
+const knownDateProps = new Set([
+ "configApplyTime",
+ "configUpdateTime",
+ "createdAt",
+ "effectiveDate",
+ "expiration",
+ "lastAuthenticated",
+ "lastUpdated",
+ "registrationSent",
+ "revalApplyTime",
+ "revalUpdateTime",
+ "startTime",
+ "statusLastUpdated",
+ "summaryTime",
+
+ // UNIX Epoch timestamp in seconds
+ // "date",
+
+ // UNIX Epoch timestamp in seconds - but as a string
+ // "expirationDate",
+ // "inceptionDate",
+]);
+
+const exhaustiveCheck = (_: PropertyKey, v: unknown): v is string => typeof(v)
=== "string" && datePattern.test(v);
+const restrictiveCheck = (k: PropertyKey, v: unknown): v is string =>
knownDateProps.has(String(k)) && typeof(v) === "string";
+
+// TODO: figure out a way to do this that doesn't involve recalculating on
every
+// property of every object on every API call, and yet allows testing.
+const check = environment.useExhaustiveDates ? exhaustiveCheck :
restrictiveCheck;
+
+/**
+ * dateReviver is meant to be passed into a JSON.parse call as a "reviver"
+ * callback. It causes strings that look like dates to be converted to Date
+ * objects.
+ *
+ * This only supports UTC timestamps (which is all Traffic Ops is capable of
+ * producing).
+ *
+ * If a timestamp has sub-milisecond precision, the trailing digits beyond the
+ * thousandths place are truncated before parsing.
+ *
+ * Note that this will do this for **all** strings that look like dates! If,
for
+ * example, a Delivery Service's LongDescription contains only an RFC3339
+ * datestamp, it will be improperly converted!
+ *
+ * @todo Find a way to specify object keys that should be left alone.
+ *
+ * @example
+ *
+ * const data = `{"notADate": "testquest", "myDate": "2022-01-01T00:00:00Z"}`;
+ * const parsed = JSON.parse(data, dateReviver);
+ * console.log(typeof parsed.notADate); // prints "string"
+ * console.log(parsed.myDate instanceof Date); // prints true
+ *
+ * @param k The name of the property being parsed.
+ * @param v The value of the property being parsed.
+ * @returns Either the parsed date, or just whatever the value is if it's not a
+ * string that looks like a date.
+ */
+export function dateReviver(k: PropertyKey, v: unknown): Date | unknown {
+ if (!check(k, v)) {
+ return v;
+ }
+
+ const date = new Date(v.replace(" ", "T").replace("+00", "Z"));
+ if (!Number.isNaN(date.valueOf())) {
+ return date;
+ }
+ return v;
+}
+
+/** A MonthName is the abbreviated name of a month as it appears in HTTP
header dates. */
+type MonthName =
"Jan"|"Feb"|"Mar"|"Apr"|"May"|"Jun"|"Jul"|"Aug"|"Sep"|"Oct"|"Nov"|"Dec";
+
+/**
+ * Checks if a string is a valid abbreviated month name.
+ *
+ * @param s The string to check.
+ * @returns `true` if `s` is a valid abbreviated month name, `false` otherwise.
+ */
+function isMonthName(s: string): s is MonthName {
+ switch(s) {
+ case "Jan":
+ case "Feb":
+ case "Mar":
+ case "Apr":
+ case "May":
+ case "Jun":
+ case "Jul":
+ case "Aug":
+ case "Sep":
+ case "Oct":
+ case "Nov":
+ case "Dec":
+ return true;
+ }
+ return false;
+}
+
+/** Index with an abbreviated month name to obtain its number. */
+const monthNumbers: Readonly<Record<MonthName, number>> = {
+ // Month names are decided by RFC specs, can't be changed to match our
conventions.
+ // They should also be in the actual order of months in a year, not
lexical order.
+ /* eslint-disable @typescript-eslint/naming-convention */
+ /* eslint-disable sort-keys */
+ Jan: 0,
+ Feb: 1,
+ Mar: 2,
+ Apr: 3,
+ May: 4,
+ Jun: 5,
+ Jul: 6,
+ Aug: 7,
+ Sep: 8,
+ Oct: 9,
+ Nov: 10,
+ Dec: 11
+ /* eslint-enable @typescript-eslint/naming-convention */
+ /* eslint-enable sort-keys */
+};
+
+/** Matches dates as formatted in HTTP headers e.g. "Date", "Last-Modified"
etc. */
+const httpDatePattern =
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*(\d{2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT$/;
+
+/**
+ * Parses a date as formatted in HTTP headers to a Javascript Date.
+ *
+ * @param raw The raw value of the header (or part of a header) being parsed.
+ * @returns The Date represented by `raw`.
+ * @throws {MalformedDateError} if `raw` fails to parse.
+ */
+export function parseHTTPDate(raw: string): Date {
+ const matches = httpDatePattern.exec(raw.trim().replace(/\s\s+/g, " "));
+ if (!matches || matches.length !== 7 || matches.some(x=>x===undefined))
{
+ throw new MalformedDateError(raw);
+ }
+ const [, d, M, y, h, m, s] = matches;
+ const [day, year, hour, minute, second] = [d, y, h, m, s].map(Number);
+ if (!isMonthName(M)) {
+ throw new MalformedDateError(raw);
+ }
+ const month = monthNumbers[M];
+
+ const date = new Date(0);
+ date.setUTCFullYear(year, month, day);
+ date.setUTCHours(hour, minute, second);
+ return date;
+}
diff --git a/experimental/traffic-portal/src/environments/environment.prod.ts
b/experimental/traffic-portal/src/environments/environment.prod.ts
index 49b581105e..27807dd4fa 100644
--- a/experimental/traffic-portal/src/environments/environment.prod.ts
+++ b/experimental/traffic-portal/src/environments/environment.prod.ts
@@ -13,10 +13,12 @@
* limitations under the License.
*/
+import type { Environment } from "./environment.type";
+
/**
* environment contains information about the running environment.
*/
-export const environment = {
+export const environment: Environment = {
apiVersion: "4.0",
customModule: false,
production: true
diff --git a/experimental/traffic-portal/src/environments/environment.ts
b/experimental/traffic-portal/src/environments/environment.ts
index 959bf92c68..325ba4fcf2 100644
--- a/experimental/traffic-portal/src/environments/environment.ts
+++ b/experimental/traffic-portal/src/environments/environment.ts
@@ -13,6 +13,8 @@
* limitations under the License.
*/
+import { Environment } from "./environment.type";
+
// This file can be replaced during build by using the `fileReplacements`
array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
@@ -20,8 +22,9 @@
/**
* environment contains information about the running environment.
*/
-export const environment = {
+export const environment: Environment = {
apiVersion: "4.0",
customModule: false,
production: false,
+ useExhaustiveDates: true
};
diff --git a/experimental/traffic-portal/src/environments/environment.type.d.ts
b/experimental/traffic-portal/src/environments/environment.type.d.ts
new file mode 100644
index 0000000000..e82304f2f7
--- /dev/null
+++ b/experimental/traffic-portal/src/environments/environment.type.d.ts
@@ -0,0 +1,34 @@
+/*
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+/**
+ * Environment is the type of an Angular deployment environment.
+ */
+export interface Environment {
+ /** The version of the Traffic Ops API to be used */
+ apiVersion: `${number}.${number}`;
+ /** Whether the "Custom" module should be loaded. */
+ customModule?: boolean;
+ /**
+ * Whether the environment should be treated as a "production"
environment.
+ */
+ production?: boolean;
+ /**
+ * If defined and `true`, the date-reviving HTTP interceptor will
attempt to
+ * convert anything that looks like a date into a `Date`. Otherwise, it
uses
+ * a specific list of property names known to contain Date information.
+ */
+ useExhaustiveDates?: boolean;
+}