Skip to content

Commit

Permalink
Finish typing of v3
Browse files Browse the repository at this point in the history
  • Loading branch information
manuelmeister committed Aug 18, 2024
1 parent a9255d5 commit 484b275
Show file tree
Hide file tree
Showing 12 changed files with 130 additions and 113 deletions.
38 changes: 19 additions & 19 deletions src/Collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,60 @@ import { isEntityReference } from './halHelpers'
import LoadingCollection from './LoadingCollection'
import ResourceInterface from './interfaces/ResourceInterface'
import CollectionInterface from './interfaces/CollectionInterface'
import { Link, StoreDataCollection } from './interfaces/StoreData'
import Resource from './Resource'
import { Link, StoreDataCollection } from '@/interfaces/StoreData'

/**
* Filter out items that are marked as deleting (eager removal)
*/
function filterDeleting<StoreType> (array: Array<ResourceInterface<StoreType>>): Array<ResourceInterface<StoreType>> {
function filterDeleting<T extends ResourceInterface> (array: Array<T>): Array<T> {
return array.filter(entry => !entry._meta.deleting)
}

class Collection<StoreType> extends Resource<StoreType, StoreDataCollection<StoreType>> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class Collection<ItemType extends ResourceInterface, ResourceType extends CollectionInterface<ItemType, ResourceType> = any> extends Resource<ResourceType, StoreDataCollection<ResourceType>> implements CollectionInterface<ItemType, ResourceType> {
/**
* Get items excluding ones marked as 'deleting' (eager remove)
* The items property should always be a getter, in order to make the call to mapArrayOfEntityReferences
* lazy, since that potentially fetches a large number of entities from the API.
*/
public get items (): Array<ResourceInterface<StoreType>> {
return filterDeleting(this._mapArrayOfEntityReferences(this._storeData.items))
public get items (): Array<ItemType> {
return filterDeleting<ItemType>(this._mapArrayOfEntityReferences(this._storeData.items))
}

/**
* Get all items including ones marked as 'deleting' (lazy remove)
*/
public get allItems (): Array<ResourceInterface<StoreType>> {
public get allItems (): Array<ItemType> {
return this._mapArrayOfEntityReferences(this._storeData.items)
}

/**
* Returns a promise that resolves to the collection object, once all items have been loaded
*/
public $loadItems () :Promise<CollectionInterface<StoreType>> {
public $loadItems () :Promise<this> {
return this._itemLoader(this._storeData.items)
}

/**
* Returns a promise that resolves to the collection object, once all items have been loaded
*/
private _itemLoader (array: Array<Link>) : Promise<CollectionInterface<StoreType>> {
private _itemLoader (array: Array<ItemType>) : Promise<this> {
if (!this._containsUnknownEntityReference(array)) {
return Promise.resolve(this as unknown as CollectionInterface<StoreType>) // we know that this object must be of type CollectionInterface
return Promise.resolve(this) // we know that this object must be of type CollectionInterface
}

// eager loading of 'fetchAllUri' (e.g. parent for embedded collections)
if (this.config.avoidNPlusOneRequests) {
return this.apiActions.reload(this as unknown as CollectionInterface<StoreType>) as Promise<CollectionInterface<StoreType>> // we know that reload resolves to a type CollectionInterface
return this.apiActions.reload<ItemType>(this) as unknown as Promise<this> // we know that reload resolves to a type CollectionInterface

// no eager loading: replace each reference (Link) with a Resource (ResourceInterface)
} else {
const arrayWithReplacedReferences = this._replaceEntityReferences(array)

return Promise.all(
arrayWithReplacedReferences.map(entry => entry._meta.load)
).then(() => this as unknown as CollectionInterface<StoreType>) // we know that this object must be of type CollectionInterface
).then(() => this) // we know that this object must be of type CollectionInterface
}
}

Expand All @@ -68,7 +69,7 @@ class Collection<StoreType> extends Resource<StoreType, StoreDataCollection<Stor
* @returns array the new array with replaced items, or a LoadingCollection if any of the array
* elements is still loading.
*/
private _mapArrayOfEntityReferences (array: Array<Link>): Array<ResourceInterface<StoreType>> {
private _mapArrayOfEntityReferences (array: Array<ItemType>): Array<ItemType> {
if (!this._containsUnknownEntityReference(array)) {
return this._replaceEntityReferences(array)
}
Expand All @@ -77,27 +78,26 @@ class Collection<StoreType> extends Resource<StoreType, StoreDataCollection<Stor

// eager loading of 'fetchAllUri' (e.g. parent for embedded collections)
if (this.config.avoidNPlusOneRequests) {
return LoadingCollection.create(itemsLoaded)
return LoadingCollection.create<ItemType>(itemsLoaded)

// no eager loading: replace each reference (Link) with a Resource (ResourceInterface)
} else {
return LoadingCollection.create(itemsLoaded, this._replaceEntityReferences(array))
return LoadingCollection.create<ItemType>(itemsLoaded, this._replaceEntityReferences(array))
}
}

/**
* Replace each item in array with a proper Resource (or LoadingResource)
*/
private _replaceEntityReferences (array: Array<Link>): Array<ResourceInterface<StoreType>> {
return array
.filter(entry => isEntityReference(entry))
.map(entry => this.apiActions.get(entry.href))
private _replaceEntityReferences (array: Array<ItemType>): Array<ItemType> {
const links = array.filter(entry => isEntityReference(entry)) as unknown as Link[]
return links.map(entry => this.apiActions.get<ItemType>(entry.href) as ItemType)
}

/**
* Returns true if any of the items within 'array' is not yet known to the API (meaning it has never been loaded)
*/
private _containsUnknownEntityReference (array: Array<Link>): boolean {
private _containsUnknownEntityReference (array: Array<ItemType>): boolean {
return array.some(entry => isEntityReference(entry) && this.apiActions.isUnknown(entry.href))
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/LoadingCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ class LoadingCollection {
* @param loadArray Promise that resolves once the array has finished loading
* @param existingContent optionally set the elements that are already known, for random access
*/
static create<StoreType> (loadArray: Promise<Array<ResourceInterface<StoreType>> | undefined>, existingContent: Array<ResourceInterface<StoreType>> = []): Array<ResourceInterface<StoreType>> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static create<ItemType extends ResourceInterface> (loadArray: Promise<Array<ItemType> | undefined>, existingContent: Array<ItemType> = []): Array<ItemType> {
// if Promsise resolves to undefined, provide empty array
// this could happen if items is accessed from a LoadingResource, which resolves to a normal entity without 'items'
const loadArraySafely = loadArray.then(array => array ?? [])
Expand All @@ -19,7 +20,7 @@ class LoadingCollection {
singleResultFunctions.forEach(func => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existingContent[func] = (...args: any[]) => {
const resultLoaded = loadArraySafely.then(array => array[func](...args) as ResourceInterface<StoreType>)
const resultLoaded = loadArraySafely.then(array => array[func](...args) as ItemType)
return new LoadingResource(resultLoaded)
}
})
Expand All @@ -29,7 +30,7 @@ class LoadingCollection {
arrayResultFunctions.forEach(func => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existingContent[func] = (...args: any[]) => {
const resultLoaded = loadArraySafely.then(array => array[func](...args) as Array<ResourceInterface<StoreType>>) // TODO: return type for .map() is not necessarily an Array<ResourceInterface>
const resultLoaded = loadArraySafely.then(array => array[func](...args) as Array<ItemType>) // TODO: return type for .map() is not necessarily an Array<ResourceInterface>
return LoadingCollection.create(resultLoaded)
}
})
Expand Down
34 changes: 20 additions & 14 deletions src/LoadingResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import LoadingCollection from './LoadingCollection'
import ResourceInterface from './interfaces/ResourceInterface'
import CollectionInterface from './interfaces/CollectionInterface'
import { InternalConfig } from './interfaces/Config'
import { isCollectionInterface } from './halHelpers'

/**
* Creates a placeholder for an entity which has not yet finished loading from the API.
Expand All @@ -15,15 +16,15 @@ import { InternalConfig } from './interfaces/Config'
* let user = new LoadingResource(...)
* 'The "' + user + '" is called "' + user.name + '"' // gives 'The "" is called ""'
*/
class LoadingResource<StoreType> implements ResourceInterface<StoreType> {
class LoadingResource<ResourceType extends (ResourceInterface | CollectionInterface<ResourceType>)> implements ResourceInterface<ResourceType> {
public _meta: {
self: string | null,
selfUrl: string | null,
load: Promise<ResourceInterface<StoreType>>
load: Promise<ResourceType>
loading: boolean
}

private loadResource: Promise<ResourceInterface<StoreType>>
private loadResource: Promise<ResourceType>

/**
* @param loadResource a Promise that resolves to a Resource when the entity has finished
Expand All @@ -32,7 +33,7 @@ class LoadingResource<StoreType> implements ResourceInterface<StoreType> {
* returned LoadingResource will return it in calls to .self and ._meta.self
* @param config configuration of this instance of hal-json-vuex
*/
constructor (loadResource: Promise<ResourceInterface<StoreType>>, self: string | null = null, config: InternalConfig | null = null) {
constructor (loadResource: Promise<ResourceType>, self: string | null = null, config: InternalConfig | null = null) {
this._meta = {
self: self,
selfUrl: self ? config?.apiRoot + self : null,
Expand All @@ -43,7 +44,7 @@ class LoadingResource<StoreType> implements ResourceInterface<StoreType> {
this.loadResource = loadResource

const handler = {
get: function (target: LoadingResource<StoreType>, prop: string | number | symbol) {
get: function (target: LoadingResource<ResourceType>, prop: string | number | symbol) {
// This is necessary so that Vue's reactivity system understands to treat this LoadingResource
// like a normal object.
if (prop === Symbol.toPrimitive) {
Expand Down Expand Up @@ -80,28 +81,33 @@ class LoadingResource<StoreType> implements ResourceInterface<StoreType> {
return new Proxy(this, handler)
}

get items (): Array<ResourceInterface<StoreType>> {
return LoadingCollection.create(this.loadResource.then(resource => (resource as CollectionInterface<StoreType>).items))
get items (): Array<ResourceType> {
return LoadingCollection.create(this.loadResource.then(resource => (resource as CollectionInterface<ResourceType>).items))
}

get allItems (): Array<ResourceInterface<StoreType>> {
return LoadingCollection.create(this.loadResource.then(resource => (resource as CollectionInterface<StoreType>).allItems))
get allItems (): Array<ResourceType> {
return LoadingCollection.create(this.loadResource.then(resource => (resource as CollectionInterface<ResourceType>).allItems))
}

public $reload (): Promise<ResourceInterface<StoreType>> {
public $reload (): Promise<ResourceType> {
// Skip reloading entities that are already loading
return this._meta.load
}

public $loadItems (): Promise<CollectionInterface<StoreType>> {
return this._meta.load.then(resource => (resource as CollectionInterface<StoreType>).$loadItems())
public $loadItems (): Promise<CollectionInterface<ResourceType>> {
return this._meta.load.then((resource) => {
if (isCollectionInterface<ResourceType>(resource)) {
return resource.$loadItems()
}
throw new Error('This LoadingResource is not a collection')
})
}

public $post (data: unknown): Promise<ResourceInterface<StoreType> | null> {
public $post (data: unknown): Promise<ResourceType | null> {
return this._meta.load.then(resource => resource.$post(data))
}

public $patch (data: unknown): Promise<ResourceInterface<StoreType>> {
public $patch (data: unknown): Promise<ResourceType> {
return this._meta.load.then(resource => resource.$patch(data))
}

Expand Down
19 changes: 10 additions & 9 deletions src/Resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ import { InternalConfig } from './interfaces/Config'
* If the storeData has been loaded into the store before but is currently reloading, the old storeData will be
* returned, along with a ._meta.load promise that resolves when the reload is complete.
*/
class Resource<StoreType, Store extends StoreData<StoreType> = StoreDataEntity<StoreType>> implements ResourceInterface<StoreType> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class Resource<ResourceType extends ResourceInterface, StoreType extends StoreData<ResourceType> = StoreDataEntity<ResourceType>> implements ResourceInterface<ResourceType> {
public _meta: {
self: string,
selfUrl: string,
load: Promise<ResourceInterface<StoreType>>
load: Promise<ResourceType>
loading: boolean
}

_storeData: Store
_storeData: StoreType
config: InternalConfig
apiActions: ApiActions

Expand All @@ -29,7 +30,7 @@ class Resource<StoreType, Store extends StoreData<StoreType> = StoreDataEntity<S
* @param resourceCreator inject dependency Resource factory
* @param config inject dependency: config options
*/
constructor (storeData: Store, apiActions: ApiActions, resourceCreator: ResourceCreator, config: InternalConfig) {
constructor (storeData: StoreType, apiActions: ApiActions, resourceCreator: ResourceCreator, config: InternalConfig) {
this.apiActions = apiActions
this.config = config
this._storeData = storeData
Expand Down Expand Up @@ -59,7 +60,7 @@ class Resource<StoreType, Store extends StoreData<StoreType> = StoreDataEntity<S

// Use a trivial load promise to break endless recursion, except if we are currently reloading the storeData from the API
const loadResource = storeData._meta.reloading
? (storeData._meta.load as Promise<Store>).then(reloadedData => resourceCreator.wrap(reloadedData))
? storeData._meta.load.then(reloadedData => resourceCreator.wrap(reloadedData))
: Promise.resolve(this)

// Use a shallow clone of _meta, since we don't want to overwrite the ._meta.load promise or self link in the Vuex store
Expand All @@ -71,15 +72,15 @@ class Resource<StoreType, Store extends StoreData<StoreType> = StoreDataEntity<S
}
}

$reload (): Promise<ResourceInterface<StoreType>> {
$reload (): Promise<ResourceType> {
return this.apiActions.reload(this)
}

$post (data: unknown): Promise<ResourceInterface<StoreType> | null> {
$post (data: unknown): Promise<ResourceType | null> {
return this.apiActions.post(this._meta.self, data)
}

$patch (data: unknown): Promise<ResourceInterface<StoreType>> {
$patch (data: unknown): Promise<ResourceType> {
return this.apiActions.patch(this._meta.self, data)
}

Expand All @@ -93,7 +94,7 @@ class Resource<StoreType, Store extends StoreData<StoreType> = StoreDataEntity<S

/**
* Serialize object to JSON
* this avoid warnings in Nuxt "Cannot stringify arbitrary non-POJOs"
* this avoids warnings in Nuxt "Cannot stringify arbitrary non-POJOs"
*/
toJSON (): string {
// for the lack of any better alternative, return store data as JSON
Expand Down
19 changes: 9 additions & 10 deletions src/ResourceCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import Resource from './Resource'
import LoadingResource from './LoadingResource'
import ApiActions from './interfaces/ApiActions'
import { InternalConfig } from './interfaces/Config'
import { StoreData } from './interfaces/StoreData'
import { StoreData, StoreDataCollection, StoreDataEntity } from './interfaces/StoreData'
import ResourceInterface from './interfaces/ResourceInterface'
import Collection from './Collection'
import { isCollection } from './halHelpers'
import CollectionInterface from '@/interfaces/CollectionInterface'

class ResourceCreator {
private config: InternalConfig
Expand Down Expand Up @@ -44,28 +43,28 @@ class ResourceCreator {
* @param data entity data from the Vuex store
* @returns object wrapped entity ready for use in a frontend component
*/
wrap<StoreType> (data: StoreData<StoreType>): ResourceInterface<StoreType> {
wrap<ResourceType extends ResourceInterface> (data: StoreData<ResourceType>): ResourceType {
const meta = data._meta || { load: Promise.resolve(), loading: false }

// Resource is loading --> return LoadingResource
if (meta.loading) {
const loadResource = (meta.load as Promise<StoreData<StoreType>>).then(storeData => this.wrapData<StoreType>(storeData))
return new LoadingResource(loadResource, meta.self, this.config)
const loadResource = (meta.load as Promise<StoreData<ResourceType>>).then(storeData => this.wrapData<ResourceType>(storeData))
return new LoadingResource<ResourceType>(loadResource, meta.self, this.config) as unknown as ResourceType

// Resource is not loading --> wrap actual data
} else {
return this.wrapData<StoreType>(data)
return this.wrapData<ResourceType>(data)
}
}

wrapData<StoreType> (data: StoreData<StoreType>): ResourceInterface<StoreType> | CollectionInterface<StoreType> {
wrapData<ResourceType extends ResourceInterface<ResourceType>> (data: StoreData<ResourceType>): ResourceType {
// Store data looks like a collection --> return CollectionInterface
if (isCollection<StoreType>(data)) {
return new Collection<StoreType>(data, this.apiActions, this, this.config) // these parameters are passed to Resource constructor
if (isCollection<ResourceType>(data)) {
return new Collection<ResourceType>(data as StoreDataCollection<ResourceType>, this.apiActions, this, this.config) as unknown as ResourceType// these parameters are passed to Resource constructor

// else Store Data looks like an entity --> return normal Resource
} else {
return new Resource<StoreType>(data, this.apiActions, this, this.config)
return new Resource<ResourceType>(data as StoreDataEntity<ResourceType>, this.apiActions, this, this.config) as unknown as ResourceType
}
}
}
Expand Down
Loading

0 comments on commit 484b275

Please sign in to comment.