Any client-side storage (cookies, localStorage
, indexedDb
...) is not secure by nature,
as the client can forge the value (intentionally to attack your app, or unintentionally because it is affected by a virus or a XSS attack).
It can cause obvious security issues, but also errors and thus crashes (as the received data type may not be what you expected).
Then, any data coming from client-side storage should be checked before used.
JSON Schema is a standard to describe the structure of a JSON data. You can see this as an equivalent to the DTD in XML, the Doctype in HTML or interfaces in TypeScript.
It can have many uses (it's why you have autocompletion in some JSON files in Visual Studio Code). In this lib, JSON schemas are used to validate the data retrieved from client-side storage.
As a general recommendation, we recommend to keep your data structures as simple as possible, as you'll see the more complex it is, the more complex is validation too.
this.storage.get('test', { type: 'boolean' })
this.storage.get('test', { type: 'integer' })
this.storage.get('test', { type: 'number' })
this.storage.get('test', { type: 'string' })
this.storage.get('test', {
type: 'array',
items: { type: 'boolean' },
})
this.storage.get('test', {
type: 'array',
items: { type: 'integer' },
})
this.storage.get('test', {
type: 'array',
items: { type: 'number' },
})
this.storage.get('test', {
type: 'array',
items: { type: 'string' },
})
What's expected in items
is another JSON schema.
In most cases, an array is for a list with values of the same type.
In special cases, it can be useful to use arrays with values of different types.
It's called tuples in TypeScript. For example: ['test', 1]
this.storage.get('test', {
type: 'array',
items: [
{ type: 'string' },
{ type: 'number' },
],
})
Note a tuple has a fixed length: the number of values in the array and the number of schemas provided in items
must be exactly the same, otherwise the validation fails.
For example:
import { JSONSchema } from '@ngx-builders/pwa-local-storage';
interface User {
firstName: string;
lastName: string;
age?: number;
}
const schema: JSONSchema = {
type: 'object',
properties: {
firstName: { type: 'string' },
lastName: { type: 'string' },
age: { type: 'number' },
},
required: ['firstName', 'lastName']
};
this.storage.get<User>('test', schema)
What's expected for each property is another JSON schema.
For special structures like Map
, Set
or Blob
,
see the serialization guide.
You may ask why we have to define a TypeScript cast with get<User>()
and a JSON schema with schema
.
It's because they happen at different steps:
- a cast (
get<User>()
) just says "TypeScript, trust me, I'm telling you it will be aUser
", but it only happens at compilation time, and given it's client-side storage (reminder: it can be forged), it's not true you known it will be aUser
. - the JSON schema (
schema
) will be used at runtime when getting data in local storage for real.
So they each serve a different purpose:
- casting allows you to retrieve the data with the good type instead of
any
- the schema allows the lib to validate the data at runtime
For now, the library is able to infer the return type based on the JSON schema
for primitives (string
, number
, integer
, boolean
and array
of these),
but not for more complex structures like objects.
Be aware you are responsible the casted type (User
) describes the same structure as the JSON schema.
For the same reason, the lib can't check that.
While validation is only required when reading storage,
when the data is complex, you could store a wrongly structured object by error without noticing,
and then get()
will fail.
So when storing complex objects, it's better to check the structure when writing too:
this.storage.set('test', user, schema)
You can also use your environnements to do this check only in development:
this.storage.set('test', user, (!environment.production) ? schema : undefined)
const
const
enum
multipleOf
maximum
exclusiveMaximum
minimum
exclusiveMinimum
For example:
this.storage.get('test', {
type: 'number',
maximum: 5
})
const
enum
maxLength
minLength
pattern
For example:
this.storage.get('test', {
type: 'string',
maxLength: 10
})
maxItems
minItems
uniqueItems
For example:
this.storage.get('test', {
type: 'array',
items: { type: 'string' },
maxItems: 5
})
import { JSONSchema } from '@ngx-builders/pwa-local-storage';
interface User {
firstName: string;
lastName: string;
}
const schema: JSONSchema = {
type: 'array',
items: {
type: 'object',
properties: {
firstName: {
type: 'string',
maxLength: 10
},
lastName: { type: 'string' }
},
required: ['firstName', 'lastName']
}
};
this.storage.get<User[]>('test', schema)
If validation fails, it'll go in the error callback:
this.storage.get('existing', { type: 'string' })
.subscribe({
next: (result) => { /* Called if data is valid or null or undefined */ },
error: (error) => { /* Called if data is invalid */ },
});
But as usual (like when you do a database request), not finding an item is not an error.
It succeeds but returns undefined
:
this.storage.get('notExisting', { type: 'string' })
.subscribe({
next: (result) => { result; /* undefined */ },
error: (error) => { /* Not called */ },
});
The role of the validation feature in this lib is to check the data against corruption, so it needs to be a strict checking. Then there are important differences with the JSON schema standards.
Types are enforced: each value MUST have a type
.
The following features available in the JSON schema standard are not available in this lib:
additionalItems
additionalProperties
propertyNames
maxProperties
minProperties
patternProperties
not
contains
allOf
anyOf
oneOf
- array for
type
Validating via this lib is recommended but not required. You can use all the native JavaScript operators and functions to validate by yourself. For example:
this.storage.get('test').subscribe((result) => {
result; // type: unknown
if (typeof result === 'string') {
result; // type: string
result.substr(0, 2); // OK
}
});
TypeScript will narrow the data type as you validate.
You can also use any other library to validate your data. But in this case, TypeScript may not be able to narrow the data type automatically. You can help TypeScript like this:
import { isString } from 'some-library';
this.storage.get('test').subscribe((unsafeResult) => {
if (isString(unsafeResult)) {
unsafeResult; // type: still unknown
const result = unsafeResult as string;
result; // type: string
result.substr(0, 2); // OK
}
});