diff --git a/README.md b/README.md index 1d27c31..cf73a3b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,23 @@ yarn: yarn add @ftrack/api ``` +## TypeScript: Generated Schema Types + +You can generate schema types for your own workspace with [@ftrack/ts-schema-generator](https://github.com/ftrackhq/ftrack-ts-schema-generator). + +Once generated, you can integrate them with @ftrack/api by passing them as a type variable: + +```ts +import SchemaTypes from "./__generated__/schema.ts" +import { Session } from @ftrack/api + +const session = new Session(...); + +// user will now be of type User +// and provide all available properties for its entity type. +const user = await session.query<"User">("..."); +``` + ## Tutorial The API uses sessions to manage communication with an ftrack server. Create a session that connects to your ftrack server (changing the passed values as appropriate): diff --git a/source/operation.ts b/source/operation.ts index af40637..ca295f8 100644 --- a/source/operation.ts +++ b/source/operation.ts @@ -4,9 +4,9 @@ * @namespace operation */ -export interface CreateOperation { +export interface CreateOperation { action: "create"; - entity_type: string; + entity_type: TEntityType; entity_data: any; } @@ -15,33 +15,33 @@ export interface QueryOperation { expression: string; } -export interface SearchOperationOptions { +export interface SearchOperationOptions { expression?: string; - entityType?: string; + entityType?: TEntityType; terms?: string[]; contextId?: string; objectTypeIds?: string[]; } -export interface SearchOperation { +export interface SearchOperation { action: "search"; expression?: string; - entity_type?: string; + entity_type?: TEntityType; terms?: string[]; context_id?: string; object_type_ids?: string[]; } -export interface UpdateOperation { +export interface UpdateOperation { action: "update"; - entity_type: string; + entity_type: TEntityType; entity_key: string[] | string; entity_data: any; } -export interface DeleteOperation { +export interface DeleteOperation { action: "delete"; - entity_type: string; + entity_type: TEntityType; entity_key: string[] | string; } @@ -61,12 +61,12 @@ export interface GetUploadMetadataOperation { component_id: string; } -export type Operation = - | CreateOperation +export type Operation = + | CreateOperation | QueryOperation - | SearchOperation - | UpdateOperation - | DeleteOperation + | SearchOperation + | UpdateOperation + | DeleteOperation | QueryServerInformationOperation | QuerySchemasOperation | GetUploadMetadataOperation @@ -81,7 +81,10 @@ export type Operation = * @param {Object} data Entity data to use for creation * @return {Object} API operation */ -export function create(type: string, data: any): CreateOperation { +export function create( + type: TEntityType, + data: any, +): CreateOperation { return { action: "create", entity_type: type, @@ -109,13 +112,13 @@ export function query(expression: string): QueryOperation { * @param {string} expression API query expression * @return {Object} API operation */ -export function search({ +export function search({ expression, entityType, terms, contextId, objectTypeIds, -}: SearchOperationOptions): SearchOperation { +}: SearchOperationOptions): SearchOperation { return { action: "search", expression, @@ -136,11 +139,11 @@ export function search({ * @param {Object} data values to update * @return {Object} API operation */ -export function update( - type: string, +export function update( + type: TEntityType, keys: string[] | string, data: any, -): UpdateOperation { +): UpdateOperation { return { action: "update", entity_type: type, @@ -158,10 +161,10 @@ export function update( * @param {Array} keys Identifying keys, typically [] * @return {Object} API operation */ -function deleteOperation( - type: string, +function deleteOperation( + type: TEntityType, keys: string[] | string, -): DeleteOperation { +): DeleteOperation { return { action: "delete", entity_type: type, diff --git a/source/project_schema.ts b/source/project_schema.ts index 7105c95..b15a2da 100644 --- a/source/project_schema.ts +++ b/source/project_schema.ts @@ -2,7 +2,7 @@ import * as operation from "./operation.js"; import { Session } from "./session.js"; -import type { Data, QueryResponse } from "./types.js"; +import type { QueryResponse } from "./types.js"; /** * Project schema namespace * @namespace project_schema @@ -18,8 +18,8 @@ import type { Data, QueryResponse } from "./types.js"; * * @memberof project_schema */ -export function getStatuses( - session: Session, +export function getStatuses>( + session: Session, projectSchemaId: string, entityType: string, typeId: string | null = null, @@ -71,7 +71,8 @@ export function getStatuses( ), ); - response = session.call>(operations); + response = + session.call>(operations); response = response.then((results) => { // Since the operations where performed in one batched call, // the result will be merged into a single entity. diff --git a/source/session.ts b/source/session.ts index 4fe3e37..ca06f57 100644 --- a/source/session.ts +++ b/source/session.ts @@ -18,8 +18,8 @@ import type { CreateComponentOptions, CreateResponse, Data, + DefaultEntityTypeMap, DeleteResponse, - Entity, GetUploadMetadataResponse, IsTuple, MutationOptions, @@ -48,7 +48,9 @@ const ENCODE_DATETIME_FORMAT = "YYYY-MM-DDTHH:mm:ss"; * @class Session * */ -export class Session { +export class Session< + TEntityTypeMap extends Record = DefaultEntityTypeMap, +> { apiUser: string; apiKey: string; serverUrl: string; @@ -56,17 +58,19 @@ export class Session { eventHub: EventHub; clientToken: string | null; initialized: boolean; - initializing: Promise; + initializing: Promise>; additionalHeaders: Data; - schemas?: Schema[]; + schemas?: Schema[]; serverInformation?: QueryServerInformationResponse; serverVersion?: string; private ensureSerializableResponse: boolean; private decodeDatesAsIso: boolean; - private schemasPromise?: Promise; + private schemasPromise?: Promise[]>; private serverInformationPromise?: Promise; private serverInformationValues?: string[]; - private schemaMapping?: Record; + private schemaMapping?: { + [TEntityType in keyof TEntityTypeMap]: Schema; + }; /** * Construct Session instance with API credentials. @@ -187,7 +191,10 @@ export class Session { } // TODO: remove these operations from session initialization in next major - const operations: operation.Operation[] = [ + const operations: [ + operation.QueryServerInformationOperation, + operation.QuerySchemasOperation, + ] = [ { action: "query_server_information", values: serverInformationValues, @@ -216,9 +223,9 @@ export class Session { this.initialized = false; const initializingPromise = - this.call<[QueryServerInformationResponse, QuerySchemasResponse]>( - operations, - ); + this.call< + [QueryServerInformationResponse, QuerySchemasResponse] + >(operations); /** * Resolved once session is initialized. @@ -244,7 +251,7 @@ export class Session { this.schemasPromise = initializingPromise .then((responses) => responses[1]) - .catch(() => [] as Schema[]); + .catch(() => [] as Schema[]); } /** @@ -252,7 +259,7 @@ export class Session { * * @return {Array|null} List of primary key attributes. */ - getPrimaryKeyAttributes(entityType: string): string[] | null { + getPrimaryKeyAttributes(entityType: keyof TEntityTypeMap): string[] | null { // Todo: make this async in next major if (!this.schemas) { logger.warn("Schemas not available."); @@ -549,7 +556,7 @@ export class Session { } /** Return encoded *operations*. */ - encodeOperations(operations: operation.Operation[]) { + encodeOperations(operations: operation.Operation[]) { return JSON.stringify(this.encode(operations)); } @@ -587,12 +594,14 @@ export class Session { * This is cached after the first call, and assumes that the schemas will not change during the session. * @returns Promise with the API schemas for the session. */ - async getSchemas(): Promise { + async getSchemas(): Promise[]> { if (!this.schemasPromise) { - this.schemasPromise = this.call([ + this.schemasPromise = this.call>([ { action: "query_schemas" }, ]).then((responses) => { - this.schemaMapping = getSchemaMappingFromSchemas(responses[0]); + this.schemaMapping = getSchemaMappingFromSchemas< + TEntityTypeMap[keyof TEntityTypeMap] + >(responses[0]); return responses[0]; }); } @@ -624,8 +633,8 @@ export class Session { * @param {string} options.decodeDatesAsIso - Return dates as ISO strings instead of moment objects * */ - async call( - operations: operation.Operation[], + async call>( + operations: operation.Operation[], { abortController, pushToken, @@ -725,41 +734,45 @@ export class Session { * Return update or create promise. */ - ensure( - entityType: string, - data: T, - identifyingKeys: Array = [], - ): Promise { + ensure( + entityType: TEntityType, + data: TEntityTypeMap[TEntityType], + identifyingKeys: Array = [], + ): Promise { let keys = identifyingKeys as string[]; + const anyData = data as any; + logger.info( "Ensuring entity with data using identifying keys: ", entityType, - data, + anyData, keys, ); if (!keys.length) { - keys = Object.keys(data); + keys = Object.keys(anyData); } if (!keys.length) { throw new Error( "Could not determine any identifying data to check against " + - `when ensuring ${entityType} with data ${data}. ` + + `when ensuring ${String(entityType)} with data ${anyData}. ` + `Identifying keys: ${identifyingKeys}`, ); } const primaryKeys = this.getPrimaryKeyAttributes(entityType); if (primaryKeys === null || primaryKeys.length === 0) { - throw new Error(`Primary key could not be found for: ${entityType}`); + throw new Error( + `Primary key could not be found for: ${String(entityType)}`, + ); } let expression = `select ${primaryKeys.join( ", ", - )} from ${entityType} where`; + )} from ${String(entityType)} where`; const criteria = keys.map((identifyingKey) => { - let value = data[identifyingKey]; + let value = anyData[identifyingKey]; if (value != null && typeof value.valueOf() === "string") { value = `"${value}"`; @@ -772,48 +785,51 @@ export class Session { expression = `${expression} ${criteria.join(" and ")}`; - return this.query(expression).then((response) => { + return this.query(expression).then((response) => { if (response.data.length === 0) { - return this.create(entityType, data).then(({ data: responseData }) => - Promise.resolve(responseData), + return this.create(entityType, anyData).then( + ({ data: responseData }) => Promise.resolve(responseData), ); } if (response.data.length !== 1) { throw new Error( "Expected single or no item to be found but got multiple " + - `when ensuring ${entityType} with data ${data}. ` + + `when ensuring ${String(entityType)} with data ${anyData}. ` + `Identifying keys: ${identifyingKeys}`, ); } - const updateEntity = response.data[0]; + const updateEntity = response.data[0] as any; // Update entity if required. let updated = false; - Object.keys(data).forEach((key: keyof (T & Entity)) => { - if (data[key] !== updateEntity[key]) { - updateEntity[key] = data[key]; + Object.keys(anyData).forEach((key) => { + if (anyData[key] !== updateEntity[key]) { + updateEntity[key] = anyData[key]; updated = true; } }); if (updated) { - return this.update( + return this.update( entityType, primaryKeys.map((key: string) => updateEntity[key]), - Object.keys(data).reduce((accumulator, key: keyof T) => { - if (primaryKeys.indexOf(key.toString()) === -1) { - accumulator[key] = data[key]; - } - return accumulator; - }, {} as T), + Object.keys(anyData).reduce( + (accumulator, key) => { + if (primaryKeys.indexOf(key.toString()) === -1) { + accumulator[key] = anyData[key]; + } + return accumulator; + }, + {} as TEntityTypeMap[TEntityType], + ), ).then(({ data: responseData }) => - Promise.resolve(responseData as T & Entity), + Promise.resolve(responseData as TEntityTypeMap[TEntityType]), ); } - return Promise.resolve(response.data[0]); + return Promise.resolve(response.data[0]) as any; }); } @@ -822,7 +838,9 @@ export class Session { * @param {string} schemaId Id of schema model, e.g. `AssetVersion`. * @return {Object|null} Schema definition */ - getSchema(schemaId: string): Schema | null { + getSchema( + schemaId: TEntityType, + ): Schema | null { // TODO: make this async in next major const schema = this.schemaMapping?.[schemaId]; return schema ?? null; @@ -841,15 +859,14 @@ export class Session { * @return {Promise} Promise which will be resolved with an object * containing action, data and metadata */ - async query( + async query( expression: string, options: QueryOptions = {}, ) { logger.debug("Query", expression); - const responses = await this.call<[QueryResponse]>( - [operation.query(expression)], - options, - ); + const responses = await this.call< + [QueryResponse] + >([operation.query(expression)], options); return responses[0]; } @@ -871,14 +888,14 @@ export class Session { * @return {Promise} Promise which will be resolved with an object * containing data and metadata */ - async search( + async search( { expression, entityType, terms = [], contextId, objectTypeIds, - }: SearchOptions, + }: SearchOptions, options: QueryOptions = {}, ) { logger.debug("Search", { @@ -889,7 +906,7 @@ export class Session { objectTypeIds, }); - const responses = await this.call<[SearchResponse]>( + const responses = await this.call<[SearchResponse]>( [ operation.search({ expression, @@ -916,17 +933,16 @@ export class Session { * @param {object} options.ensureSerializableResponse - Disable normalization of response data * @return {Promise} Promise which will be resolved with the response. */ - async create( - entityType: string, - data: T, + async create( + entityType: TEntityType, + data: Partial, options: MutationOptions = {}, ) { logger.debug("Create", entityType, data, options); - const responses = await this.call<[CreateResponse]>( - [operation.create(entityType, data)], - options, - ); + const responses = await this.call< + [CreateResponse] + >([operation.create(entityType, data)], options); return responses[0]; } @@ -943,18 +959,17 @@ export class Session { * @param {object} options.ensureSerializableResponse - Disable normalization of response data * @return {Promise} Promise resolved with the response. */ - async update( - type: string, + async update( + type: TEntityType, keys: string[] | string, - data: T, + data: Partial, options: MutationOptions = {}, ) { logger.debug("Update", type, keys, data, options); - const responses = await this.call<[UpdateResponse]>( - [operation.update(type, keys, data)], - options, - ); + const responses = await this.call< + [UpdateResponse] + >([operation.update(type, keys, data)], options); return responses[0]; } @@ -969,8 +984,8 @@ export class Session { * @param {object} options.decodeDatesAsIso - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise resolved with the response. */ - async delete( - type: string, + async delete( + type: TEntityType, keys: string[] | string, options: MutationOptions = {}, ) { @@ -1053,10 +1068,14 @@ export class Session { file: Blob, options: CreateComponentOptions = {}, ): Promise< - readonly [CreateResponse, CreateResponse, GetUploadMetadataResponse] + readonly [ + CreateResponse, + CreateResponse, + GetUploadMetadataResponse, + ] > { return new Promise((resolve, reject) => { - const uploader = new Uploader(this, file, { + const uploader = new Uploader(this, file, { ...options, onError(error) { reject(error); diff --git a/source/types.ts b/source/types.ts index d880b14..b986634 100644 --- a/source/types.ts +++ b/source/types.ts @@ -25,43 +25,39 @@ export interface CreateComponentOptions { onAborted?: () => unknown; } -export interface Entity { - id: string; - __entity_type__: string; -} interface ResponseMetadata { next: { offset: number | null; }; } -export interface SearchOptions { +export interface SearchOptions { expression: string; - entityType: string; + entityType: TEntityType; terms?: string[]; contextId?: string; objectTypeIds?: string[]; } -export interface QueryResponse { - data: T[]; +export interface QueryResponse { + data: TEntityData[]; action: "query"; metadata: ResponseMetadata; } -export interface CreateResponse { - data: T; +export interface CreateResponse { + data: TEntityData; action: "create"; } -export interface UpdateResponse { - data: T; +export interface UpdateResponse { + data: TEntityData; action: "update"; } export interface DeleteResponse { data: true; action: "delete"; } -export interface SearchResponse { - data: T[]; +export interface SearchResponse { + data: TEntityData[]; action: "search"; metadata: ResponseMetadata; } @@ -69,7 +65,7 @@ export interface ResetRemoteResponse { action: "reset_remote"; data: Data; } -export type QuerySchemasResponse = Schema[]; +export type QuerySchemasResponse = Schema[]; export type QueryServerInformationResponse = ServerInformation; export interface ServerInformation { @@ -149,14 +145,18 @@ export interface PermissionsResponse { data: Data; } -export type ActionResponse = - | QueryResponse - | CreateResponse - | UpdateResponse +export type ActionResponse< + TEntityTypeMap, + TEntityType extends keyof TEntityTypeMap = keyof TEntityTypeMap, + TEntityData = TEntityTypeMap[TEntityType], +> = + | QueryResponse + | CreateResponse + | UpdateResponse | DeleteResponse - | SearchResponse + | SearchResponse | ResetRemoteResponse - | QuerySchemasResponse + | QuerySchemasResponse | QueryServerInformationResponse | GetWidgetUrlResponse | DelayedJobResponse @@ -201,21 +201,24 @@ export type TypedSchemaProperty = export type RefSchemaProperty = { ["$ref"]: string; }; -export type SchemaProperties = { - [key: string]: TypedSchemaProperty | RefSchemaProperty; +export type SchemaProperties = { + [key in keyof TEntityData]: TypedSchemaProperty | RefSchemaProperty; }; export type SchemaMixin = { $ref: string; }; export type SchemaMetadata = { entity_event: boolean }; -export interface Schema { - properties: SchemaProperties; +export interface Schema< + TEntityTypeMap, + TEntityType extends keyof TEntityTypeMap = keyof TEntityTypeMap, +> { + properties: SchemaProperties; default_projections: string[]; primary_key: string[]; required: string[]; immutable: string[]; type?: string; - id: string; + id: TEntityType; computed?: string[]; system_projections?: string[]; alias_for?: string | Data; @@ -231,3 +234,10 @@ export interface QueryOptions { } export interface CallOptions extends MutationOptions, QueryOptions {} + +export interface ExtendibleEntityTypeMap {} +export interface DefaultEntityTypeMap { + [key: string]: { + [key: string]: any; + }; +} diff --git a/source/uploader.ts b/source/uploader.ts index ba54470..2e16c42 100644 --- a/source/uploader.ts +++ b/source/uploader.ts @@ -14,7 +14,6 @@ import type { } from "./types.js"; import normalizeString from "./util/normalize_string.js"; import { splitFileExtension } from "./util/split_file_extension.js"; -import type { Data } from "./types.js"; import { getChunkSize } from "./util/get_chunk_size.js"; import { backOff } from "./util/back_off.js"; @@ -50,11 +49,11 @@ declare global { * 3. Completion - Multi-part uploads are completed, the component is * registered in the server location and an event is published. */ -export class Uploader { +export class Uploader> { /** Component id */ componentId: string; /** Session instance */ - private session: Session; + private session: Session; /** File to upload */ private file: Blob; /** Called on upload progress with percentage */ @@ -98,13 +97,21 @@ export class Uploader { /** Additional data for Component entity */ private data: CreateComponentOptions["data"]; /** @deprecated - Remove once Session.createComponent signature is updated. */ - createComponentResponse: CreateResponse | null; + createComponentResponse: CreateResponse< + TEntityTypeMap["FileComponent"] + > | null; /** @deprecated - Remove once Session.createComponent signature is updated. */ uploadMetadata: GetUploadMetadataResponse | null; /** @deprecated - Remove once Session.createComponent signature is updated. */ - createComponentLocationResponse: CreateResponse | null; + createComponentLocationResponse: CreateResponse< + TEntityTypeMap["ComponentLocation"] + > | null; - constructor(session: Session, file: Blob, options: UploaderOptions) { + constructor( + session: Session, + file: Blob, + options: UploaderOptions, + ) { this.session = session; this.file = file; const componentName = options.name ?? (file as File).name; @@ -196,7 +203,10 @@ export class Uploader { size: this.fileSize, }; const response = await this.session.call< - [CreateResponse, GetUploadMetadataResponse] + [ + CreateResponse, + GetUploadMetadataResponse, + ] >([ operation.create("FileComponent", component), { @@ -531,7 +541,10 @@ export class Uploader { }), ); - const response = await this.session.call(operations); + const response = + await this.session.call< + CreateResponse + >(operations); this.createComponentLocationResponse = response[response.length - 1]; // Emit event so that clients can perform additional work on uploaded diff --git a/source/util/get_schema_mapping.ts b/source/util/get_schema_mapping.ts index 4dea004..b7d8ffc 100644 --- a/source/util/get_schema_mapping.ts +++ b/source/util/get_schema_mapping.ts @@ -2,8 +2,14 @@ import type { Schema } from "../types.js"; -export default function getSchemaMappingFromSchemas(schemas: Schema[]) { - const schemaMapping = {} as Record; +export default function getSchemaMappingFromSchemas( + schemas: Schema[], +): { + [TEntityType in keyof TEntityTypeMap]: Schema; +} { + const schemaMapping = {} as { + [TEntityType in keyof TEntityTypeMap]: Schema; + }; for (const schema of schemas) { schemaMapping[schema.id] = schema; } diff --git a/test/session.test.ts b/test/session.test.ts index 6f2849f..b2427b7 100755 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -290,7 +290,9 @@ describe("Session", () => { }); it("Should decode batched query operations", async () => { - const responses = await session.call([ + const responses = await session.call< + QueryResponse<{ status: { state: { short: string } } }> + >([ operation.query( "select status.state.short from Task where status.state.short is NOT_STARTED limit 1", ),