strapi / rfcs

RFCs for Strapi future changes
68 stars 33 forks source link

Add v1 draft of content type definition file syntax refactor #28

Closed alexandrebodin closed 2 years ago

alexandrebodin commented 3 years ago

Content Type attribute defintion syntax

This PR introduces some changes to the content type definition files.

You can read it here

derrickmehaffy commented 3 years ago

This pull request has been mentioned on Strapi Community Forum. There might be relevant details there:

https://forum.strapi.io/t/new-version-questions-thoughts-v4/4824/1

gu-stav commented 3 years ago

Many plugins I've seen for gatsby extend in some way these definitions, so I'm wondering whether it would make sense the prefix internal properties. E.g.:

const articleDef = {
  attributes: {
    title: {
      $type: 'string',
    },
    seo: {
      $type: 'component',
      $component: 'seo',
    },
  },
};

This would make it easier for you to introduce new properties in the future, without breaking anything. Otherwise you could provide an interface, which plugins then could use for extending the definitions with their own properties. This would also limit the risk of two plugins overwriting the same property ...

alexandrebodin commented 3 years ago

Many plugins I've seen for gatsby extend in some way these definitions, so I'm wondering whether it would make sense the prefix internal properties. E.g.:

const articleDef = {
  attributes: {
    title: {
      $type: 'string',
    },
    seo: {
      $type: 'component',
      $component: 'seo',
    },
  },
};

This would make it easier for you to introduce new properties in the future, without breaking anything. Otherwise you could provide an interface, which plugins then could use for extending the definitions with their own properties. This would also limit the risk of two plugins overwriting the same property ...

We already have a way to avoid plugins from creating conflicts with a specific property for each plugin to have their own scope. It makes it verbose for sure but avoids using special prefix or characters and keep each property with their original meaning.

const def = {
  attributes: {
    title: {
      pluginOptions : {
        pluginName: {
          ...
        }
      }
    }
}
gu-stav commented 3 years ago

@alexandrebodin Ah, nice. Didn't know about this. Maybe it would make sense to lint the file for non-core attributes and to output warnings for plugin developers. I think pluginName needs some definition though: should it use it's full name (e.g. strapi-plugin-algolia) or a shortcut (e.g. algolia). I think this could be a convention, but should be mentioned somewhere in the docs :)

stafyniaksacha commented 3 years ago

Hello,

Even if Strapi v4 will be written in JavaScript, I think it could be a good idea to design this feature with Typescript (or Jsdoc equivalent) :

import { defineModel } from 'strapi'

export default defineModel({
  attributes: {
    title: {
      type: 'string',
    },
    images: {
      type: 'media',
    },
  },
})

/**
 * same as:
 * 
 * export default {
 *   attributes: {
 *     title: {
 *       type: 'string',
 *       unique: false,
 *       required: false,
 *     },
 *     images: {
 *       type: 'media',
 *       multiple: false,
 *       required: false,
 *     },
 *   }
 * }
 */

note: this is inspired by vitejs defineConfig / vue3 defineComponent syntax see more here: https://vitejs.dev/config/

this is only sugar syntax, but it enables excellent DX

The idea behind defineModel is it takes a configuration object defined by user, which allow undefined values where it is relevant (see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L67) and resolve it with a frozen object with all default values set (see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L195)

Using typescript interfaces (and not types) to describes attributes allow plugins to extend their definitions:

// in strapi-core
interface MediaAttribute {
  type: 'media'
  multiple: boolean
}

// in custom plugin "strapi-plugin-media-sharp"
interface MediaAttribute {
  format: {
    [ name: string ]: {
      width: number
      height: number
      fit: 'cover' | 'contain' | 'fill'  | 'inside'  | 'outside' 
    }
  }
}

we may have to handle a custom hook in the defineModel function to allow plugins to extend / check the model

Here is another approach to the idea

/**
 * Internal API
 */

// internal core
interface Strapi {
  start: () => Promise<void>;
  use: <T>(model: Model<T>) => void;
}
function createStrapi(): Strapi {
  return {
    use: () => {},
    start: async () => {},
  };
}

// internal model api
type Model<T> = Partial<{
  attributes: T;
  lifecycle: ModelLifecycle<T>;
  controllers: any;
  services: any;
}>;

interface ModelLifecycle<T> {
  beforeCreated?: (entity: T) => T | Promise<T>;
  afterCreated?: (entity: T) => void | Promise<void>;
}

function defineModel<T>(definition: { (): T }): Model<T> {
  return definition();
}

// internal attributes api
type Attributes = {
  [identifier: string]:
    | MediaAttribute
    | StringAttribute
    | string
    | NumberAttribute
    | number
    | BooleanAttribute
    | boolean
    | ComponentAttribute
    | DynamicZoneAttribute;
};

function useAttributes<T extends Attributes>(t?: Partial<T>): Partial<T> {
  // return type should be Required<Readonly<T>>
  return t || {};
}

interface MediaAttribute {
  type: "media";
  multiple: boolean;
}

interface StringAttribute {
  type: "string";
  required?: boolean;
}
interface NumberAttribute {
  type: "number";
  required?: boolean;
  min?: number;
  max?: number;
}
interface BooleanAttribute {
  type: "boolean";
  default?: boolean;
}
interface ComponentAttribute {
  type: "component";
  component: string;
}
interface DynamicZoneAttribute {
  type: "dynamiczone";
  components: string[];
}

// internal lifecycle api
function useLifecycle<T>(definition: ModelLifecycle<T>): ModelLifecycle<T> {
  return definition;
}

// internal meta api
function useMeta<T>(definition: { (): T }): Partial<T> {
  // return type should be Required<T>
  return definition();
}
function primaryKey(): string {
  return "";
}
function createdAt(): Date {
  return new Date();
}
function updatedAt(): Date {
  return new Date();
}
function relationTo<T>(model: T): any {
  return {};
}

/**
 * User API
 */

// custom category model
const category = defineModel(() => {
  // define model attributes
  const attributes =
    useAttributes<{
      slug: string;
      name: string;
    }>();

  return {
    ...attributes,
  };
});

// custom product model
const product = defineModel(() => {
  // define model attributes
  const attributes = useAttributes<{
    slug: string; // may default to StringAttrubute
    name: string;
    price: number;
    isFeatured: boolean;
    picture?: MediaAttribute; // media attribute
    description?: string; // optional attributes
  }>({
    isFeatured: false, // default value
  });

  // define model meta
  const meta = useMeta(() => ({
    id: primaryKey(),
    createdAt: createdAt(),
    updatedAt: updatedAt(),
    categories: relationTo(category),
  }));

  // define model lifecycle
  const lifecycle = useLifecycle<typeof attributes>({
    beforeCreated(request) {
      if (!request.slug) {
        throw new Error("Slug required");
      }

      delete request.isFeatured;

      return request;
    },
    async afterCreated(entity) {
      // send entity to algolia ...
      // await algolia.create(entity)
    },
  });

  return {
    meta,
    lifecycle,
    ...attributes,
  };
});

// create backend api
const strapi = createStrapi();
strapi.use(category);
strapi.use(product);

// start strapi
strapi.start();