diff --git a/packages/example/src/core/user/model.ts b/packages/example/src/core/user/model.ts index c554657f..75009e92 100644 --- a/packages/example/src/core/user/model.ts +++ b/packages/example/src/core/user/model.ts @@ -4,18 +4,21 @@ import { model } from '@mondrian-framework/model' export type User = model.Infer export const User = () => - model.entity({ - id: idType, - firstName: model.string(), - lastName: model.string(), - email: model.email(), - posts: model.array(Post), - givenLikes: model.array(Like), - followers: model.array(Follower), - followeds: model.array(Follower), - registeredAt: model.datetime(), - loginAt: model.datetime(), - }) + model.entity( + { + id: idType, + firstName: model.string(), + lastName: model.string(), + email: model.email(), + posts: model.array(Post), + givenLikes: model.array(Like), + followers: model.describe(model.array(Follower), 'Users that follows me'), + followeds: model.describe(model.array(Follower), 'Users followed by me'), + registeredAt: model.datetime(), + loginAt: model.datetime(), + }, + { description: 'User of the system' }, + ) export const MyUser = () => User export type MyUser = User diff --git a/packages/graphql/src/graphql.ts b/packages/graphql/src/graphql.ts index e7f8bf23..482666b3 100644 --- a/packages/graphql/src/graphql.ts +++ b/packages/graphql/src/graphql.ts @@ -278,7 +278,10 @@ function objectToInputGraphQLType( ): GraphQLInputObjectType { const objectName = generateInputName(object, internalData) const fields = () => - mapObject(object.fields, typeToGraphQLInputObjectField({ ...internalData, defaultName: undefined }, objectName)) + mapObject( + object.fields, + typeToGraphQLInputObjectField({ ...internalData, defaultName: undefined }, objectName, object.options), + ) return new GraphQLInputObjectType({ name: checkNameOccurencies(objectName, internalData), fields, @@ -292,7 +295,10 @@ function entityToGraphQLType( ): GraphQLObjectType { const entityName = generateName(entity, internalData) const fields = () => - flatMapObject(entity.fields, typeToGraphQLObjectField({ ...internalData, defaultName: undefined }, entityName)) + flatMapObject( + entity.fields, + typeToGraphQLObjectField({ ...internalData, defaultName: undefined }, entityName, entity.options), + ) return new GraphQLObjectType({ name: checkNameOccurencies(entityName, internalData), fields, @@ -317,6 +323,7 @@ function entityToInputGraphQLType( function typeToGraphQLObjectField( internalData: InternalData, objectName: string, + objectOptions?: model.ObjectTypeOptions, ): (fieldName: string, fieldType: model.Type) => [string, GraphQLFieldConfig][] { return (fieldName, fieldType) => { const tags = model.concretise(fieldType).options?.tags ?? {} @@ -353,7 +360,7 @@ function typeToGraphQLObjectField( { type: canBeMissing ? graphQLType : new GraphQLNonNull(graphQLType), args: graphqlRetrieveArgs, - description: model.getFirstDescription(fieldType), + description: (objectOptions?.fields ?? {})[fieldName]?.description, }, ], ] @@ -380,6 +387,7 @@ function retrieveTypeToGraphqlArgs( function typeToGraphQLInputObjectField( internalData: InternalData, objectName: string, + objectOptions?: model.ObjectTypeOptions, ): (fieldName: string, fieldType: model.Type) => GraphQLInputFieldConfig { return (fieldName, fieldType) => { const fieldDefaultName = generateInputName(fieldType, { @@ -392,7 +400,10 @@ function typeToGraphQLInputObjectField( defaultName: fieldDefaultName, }) const canBeMissing = model.isOptional(fieldType) || model.isNullable(fieldType) - return { type: canBeMissing ? graphQLType : new GraphQLNonNull(graphQLType) } + return { + type: canBeMissing ? graphQLType : new GraphQLNonNull(graphQLType), + description: (objectOptions?.fields ?? {})[fieldName]?.description, + } } } diff --git a/packages/model/src/arbitrary/arbitrary.ts b/packages/model/src/arbitrary/arbitrary.ts index b136b7c4..64d38548 100644 --- a/packages/model/src/arbitrary/arbitrary.ts +++ b/packages/model/src/arbitrary/arbitrary.ts @@ -1,4 +1,4 @@ -import { model } from '../index' +import { model, utils } from '../index' import { forbiddenObjectFields } from '../utils' import gen from 'fast-check' @@ -304,7 +304,10 @@ export function objectTypeOptions(): gen.Arbitrary { */ export function object( fieldsGenerators: GeneratorsRecord, -): gen.Arbitrary | (() => model.ObjectType)> { +): gen.Arbitrary< + | model.ObjectType> + | (() => model.ObjectType>) +> { const objectGenerator = gen.oneof(immutableObject(fieldsGenerators), mutableObject(fieldsGenerators)) const makeLazy = (value: A) => { return () => value @@ -318,7 +321,10 @@ export function object( */ export function entity( fieldsGenerators: GeneratorsRecord, -): gen.Arbitrary | (() => model.EntityType)> { +): gen.Arbitrary< + | model.EntityType> + | (() => model.EntityType>) +> { const objectGenerator = gen.oneof(immutableEntity(fieldsGenerators), mutableEntity(fieldsGenerators)) const makeLazy = (value: A) => { return () => value @@ -332,7 +338,7 @@ export function entity( */ export function immutableObject( fieldsGenerators: GeneratorsRecord, -): gen.Arbitrary> { +): gen.Arbitrary>> { return orUndefined(objectTypeOptions()).chain((options) => { return gen.record(fieldsGenerators).map((fields) => { return model.object(fields, options) @@ -346,7 +352,7 @@ export function immutableObject( */ export function mutableObject( fieldsGenerators: GeneratorsRecord, -): gen.Arbitrary> { +): gen.Arbitrary>> { return orUndefined(objectTypeOptions()).chain((options) => { return gen.record(fieldsGenerators).map((fields) => { return model.mutableObject(fields, options) @@ -368,7 +374,7 @@ export function entityTypeOptions(): gen.Arbitrary { */ export function immutableEntity( fieldsGenerators: GeneratorsRecord, -): gen.Arbitrary> { +): gen.Arbitrary>> { return orUndefined(entityTypeOptions()).chain((options) => { return gen.record(fieldsGenerators).map((fields) => { return model.entity(fields, options) @@ -382,7 +388,7 @@ export function immutableEntity( */ export function mutableEntity( fieldsGenerators: GeneratorsRecord, -): gen.Arbitrary> { +): gen.Arbitrary>> { return orUndefined(entityTypeOptions()).chain((options) => { return gen.record(fieldsGenerators).map((fields) => { return model.mutableEntity(fields, options) diff --git a/packages/model/src/type-system.ts b/packages/model/src/type-system.ts index 93660f07..79004ec5 100644 --- a/packages/model/src/type-system.ts +++ b/packages/model/src/type-system.ts @@ -1,4 +1,4 @@ -import { decoding, validation, model, result, encoding } from './index' +import { decoding, validation, model, result, encoding, utils } from './index' import { NeverType } from './types-exports' import { memoizeTypeTransformation, memoizeTransformation } from './utils' import { JSONType, areJsonsEquals, mapObject, failWithInternalError, flatMapObject } from '@mondrian-framework/utils' @@ -1310,7 +1310,29 @@ export type ObjectType = { /** * The options that can be used to define an {@link ObjectType `ObjectType`}. */ -export type ObjectTypeOptions = BaseOptions +export type ObjectTypeOptions = BaseOptions & { + fields?: { [K in string]?: { description?: string } } +} + +/** + * Wraps a type inside a {@link utils.RichField RichField} + * It can be used to add a description to an object ot entity field + * ``` + * const User = () => model.entity( + * { + * id: model.number(), + * name: model.string(), + * friends: model.describe(model.array(User), "List of my friends") + * }, + * { + * description: "A user of the system" + * } + * ) + * ``` + */ +export function describe(type: T, description: string): utils.RichField { + return { field: type, description } +} /** * The model of an object in the Mondrian framework. @@ -1452,7 +1474,9 @@ export type EntityType = { /** * The options that can be used to define an {@link EntityType `EntityType`}. */ -export type EntityTypeOptions = BaseOptions +export type EntityTypeOptions = BaseOptions & { + fields?: { [K in string]?: { description?: string } } +} /** * The model of a sequence of elements in the Mondrian framework. @@ -2192,18 +2216,6 @@ export function isOptional(type: Type): boolean { return hasWrapper(type, Kind.Optional) } -/** - * @param type the type to check - * @returns the first description navigating this type. - */ -export function getFirstDescription(type: Type): string | undefined { - return match(type, { - array: (t) => t.options?.description, - wrapper: (t) => t.options?.description ?? getFirstDescription(t.wrappedType), - otherwise: (t) => t.options?.description, - }) -} - /** * @param type the type to check * @returns true if the type is a nullable type @@ -2234,8 +2246,8 @@ export function isNever(type: Type): type is NeverType { } /** - * Unwraps all wrappers around a {@link Type}. - * The wrappers are: {@link OptionalType}, {@link NullableType}, {@link ReferenceType}, {@link ArrayType} + * Unwraps all wrappers around a {@link Type} and concretise the result. + * The wrappers are: {@link OptionalType}, {@link NullableType}, {@link ArrayType} * @param type the type to unwrap. * @returns the unwrapped type. */ diff --git a/packages/model/src/type-system/native/entity.ts b/packages/model/src/type-system/native/entity.ts index 23f5312c..78c23934 100644 --- a/packages/model/src/type-system/native/entity.ts +++ b/packages/model/src/type-system/native/entity.ts @@ -1,4 +1,4 @@ -import { decoding, path, model, validation, encoding } from '../..' +import { decoding, path, model, validation, encoding, utils } from '../..' import { assertSafeObjectFields } from '../../utils' import { BaseType } from './base' import { JSONType, filterMapObject } from '@mondrian-framework/utils' @@ -33,18 +33,28 @@ import gen from 'fast-check' * } * ``` */ -export function entity( +export function entity( fields: Ts, - options?: model.EntityTypeOptions, -): model.EntityType { - return new EntityTypeImpl(model.Mutability.Immutable, fields, options) + options?: Omit, +): model.EntityType> { + const { fields: fieldsOptions, types } = utils.richFieldsToTypes(fields) + return new EntityTypeImpl( + model.Mutability.Immutable, + types, + fieldsOptions ? { ...options, fields: fieldsOptions } : options, + ) } -export function mutableEntity( +export function mutableEntity( fields: Ts, - options?: model.EntityTypeOptions, -): model.EntityType { - return new EntityTypeImpl(model.Mutability.Mutable, fields, options) + options?: Omit, +): model.EntityType> { + const { fields: fieldsOptions, types } = utils.richFieldsToTypes(fields) + return new EntityTypeImpl( + model.Mutability.Mutable, + types, + fieldsOptions ? { ...options, fields: fieldsOptions } : options, + ) } class EntityTypeImpl @@ -59,8 +69,8 @@ class EntityTypeImpl protected fromOptions = (options: model.EntityTypeOptions) => new EntityTypeImpl(this.mutability, this.fields, options) - immutable = () => entity(this.fields, this.options) - mutable = () => mutableEntity(this.fields, this.options) + immutable = () => entity(this.fields, this.options) as any + mutable = () => mutableEntity(this.fields, this.options) as any constructor(mutability: M, fields: Ts, options?: model.EntityTypeOptions) { super(options) diff --git a/packages/model/src/type-system/native/object.ts b/packages/model/src/type-system/native/object.ts index 6794020f..d3e4424a 100644 --- a/packages/model/src/type-system/native/object.ts +++ b/packages/model/src/type-system/native/object.ts @@ -1,4 +1,4 @@ -import { decoding, path, model, validation, encoding } from '../..' +import { decoding, path, model, validation, encoding, utils } from '../..' import { assertSafeObjectFields } from '../../utils' import { BaseType } from './base' import { JSONType, filterMapObject } from '@mondrian-framework/utils' @@ -33,18 +33,28 @@ import gen from 'fast-check' * } * ``` */ -export function object( +export function object( fields: Ts, - options?: model.ObjectTypeOptions, -): model.ObjectType { - return new ObjectTypeImpl(model.Mutability.Immutable, fields, options) + options?: Omit, +): model.ObjectType> { + const { fields: fieldsOptions, types } = utils.richFieldsToTypes(fields) + return new ObjectTypeImpl( + model.Mutability.Immutable, + types, + fieldsOptions ? { ...options, fields: fieldsOptions } : options, + ) } -export function mutableObject( +export function mutableObject( fields: Ts, - options?: model.ObjectTypeOptions, -): model.ObjectType { - return new ObjectTypeImpl(model.Mutability.Mutable, fields, options) + options?: Omit, +): model.ObjectType> { + const { fields: fieldsOptions, types } = utils.richFieldsToTypes(fields) + return new ObjectTypeImpl( + model.Mutability.Mutable, + types, + fieldsOptions ? { ...options, fields: fieldsOptions } : options, + ) } class ObjectTypeImpl @@ -59,8 +69,8 @@ class ObjectTypeImpl protected fromOptions = (options: model.ObjectTypeOptions) => new ObjectTypeImpl(this.mutability, this.fields, options) - immutable = () => object(this.fields, this.options) - mutable = () => mutableObject(this.fields, this.options) + immutable = () => object(this.fields, this.options) as any + mutable = () => mutableObject(this.fields, this.options) as any constructor(mutability: M, fields: Ts, options?: model.ObjectTypeOptions) { super(options) diff --git a/packages/model/src/utils.ts b/packages/model/src/utils.ts index fc450ac6..e919e271 100644 --- a/packages/model/src/utils.ts +++ b/packages/model/src/utils.ts @@ -1,4 +1,5 @@ import { model } from '.' +import { mapObject } from '@mondrian-framework/utils' type TypeTransformer = (type: T) => model.Type export function memoizeTypeTransformation(mapper: TypeTransformer): (type: T) => model.Type { @@ -52,3 +53,32 @@ export function assertSafeObjectFields(record: Record) { } } } + +export type RichField = { field: T; description?: string } +export type RichFields = { readonly [K in string]: model.Type | RichField } + +//prettier-ignore +export type RichFieldsToTypes = { + readonly [K in keyof Ts] + : Ts[K] extends { field: infer T extends model.Type } ? T + : Ts[K] extends model.Type ? Ts[K] + : any +} + +export function richFieldsToTypes( + richTypes: Ts, +): { + types: RichFieldsToTypes + fields?: model.ObjectTypeOptions['fields'] +} { + const fields: model.ObjectTypeOptions['fields'] = {} + const types = mapObject(richTypes, (k, t) => { + if ('field' in t) { + fields[k] = { description: t.description } + return t.field + } else { + return t + } + }) + return { types: types as RichFieldsToTypes, fields: Object.keys(fields).length === 0 ? undefined : fields } +} diff --git a/packages/model/tests/type-system/utilities.test.ts b/packages/model/tests/type-system/utilities.test.ts index d2bb88c1..66843821 100644 --- a/packages/model/tests/type-system/utilities.test.ts +++ b/packages/model/tests/type-system/utilities.test.ts @@ -2,7 +2,7 @@ import { arbitrary, model } from '../../src' import { object } from '../../src/types-exports' import { assertOk } from '../testing-utils' import { test } from '@fast-check/vitest' -import { describe, expect } from 'vitest' +import { describe, expect, expectTypeOf } from 'vitest' describe('Utilities', () => { test('isArray', () => { @@ -190,12 +190,6 @@ test('isType', () => { expect(model.assertType(union, 2.2)).toBe(undefined) }) -test('getFirstDescription', () => { - const m = model.string({ description: 'a' }).nullable({ description: 'b' }).optional() - expect(model.getFirstDescription(m.array())).toBe(undefined) - expect(model.getFirstDescription(m)).toBe('b') -}) - test('isNever', () => { expect(model.isNever(model.never().array())).toBe(false) expect(model.isNever(model.never().optional())).toBe(true) @@ -222,3 +216,47 @@ test('failing match', () => { '[Mondrian-Framework internal error] `model.match` with not exhaustive cases occurs\nIf you think this could be a bug in the framework, please report it at https://github.com/mondrian-framework/mondrian-framework/issues', ) }) + +test('object field descriptions', () => { + const f = model.number() + const m = model.object({ + a1: f, + a2: model.describe(f, 'integer'), + }) + expect(m.options).toEqual({ fields: { a2: { description: 'integer' } } }) + expect(m.fields).toEqual({ a1: f, a2: f }) + + const m1 = model.mutableObject({ + a1: f, + a2: model.describe(f, 'integer'), + }) + expect(m1.options).toEqual({ fields: { a2: { description: 'integer' } } }) + expect(m1.fields).toEqual({ a1: f, a2: f }) + + const User = () => + model.object({ + id: model.string(), + friends: model.describe(model.array(User), 'List of my friends'), + }) + type User = model.Infer + + type ExpectedUser = { readonly id: string; readonly friends: readonly ExpectedUser[] } + expectTypeOf().toEqualTypeOf() +}) + +test('entity field descriptions', () => { + const f = model.number() + const m = model.entity({ + a1: f, + a2: model.describe(f, 'integer'), + }) + expect(m.options).toEqual({ fields: { a2: { description: 'integer' } } }) + expect(m.fields).toEqual({ a1: f, a2: f }) + + const m1 = model.mutableEntity({ + a1: f, + a2: model.describe(f, 'integer'), + }) + expect(m1.options).toEqual({ fields: { a2: { description: 'integer' } } }) + expect(m1.fields).toEqual({ a1: f, a2: f }) +}) diff --git a/packages/rest/src/openapi.ts b/packages/rest/src/openapi.ts index 9c64c59d..d74a7530 100644 --- a/packages/rest/src/openapi.ts +++ b/packages/rest/src/openapi.ts @@ -583,13 +583,18 @@ function recordToOpenAPIComponent( const fields = Object.entries(type.fields).map(([fieldName, fieldType]) => { const hasToBeOptional = model.unwrap(fieldType).kind === model.Kind.Entity && !model.isOptional(fieldType) const schema = modelToSchema(hasToBeOptional ? model.optional(fieldType) : fieldType, internalData) - return [fieldName, schema] as const + //here we use the field description, if there is not description then we fallback to the type description + const descriptedSchema = { + ...schema, + description: (type.options?.fields ?? {})[fieldName]?.description ?? schema.description, + } + return [fieldName, descriptedSchema] as const }) const isOptional: ( type: OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject, ) => { optional: true; subtype: OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject } | false = (type) => 'anyOf' in type && type.anyOf && type.anyOf.length === 2 && type.anyOf[1].description === 'optional' - ? { optional: true, subtype: type.anyOf[0] } + ? { optional: true, subtype: { ...type.anyOf[0], description: type.anyOf[0].description ?? type.description } } : false const schema: OpenAPIV3_1.SchemaObject = { type: 'object',