Do you want to extend existing entities to add custom fields? Do you want to implement your own feature or extend existing one in a module way? Did you ever wanted to build something more than a single store? Well, this project has been made to help you reach you goal. It is now possible to customise Medusa in a way you will be able to enjoy all the awesome features that Medusa provides you but with the possibility to take your e-commerce project to the next level π
Access the website Documentation
- Getting started
- Full documentation including API
- Features
- Demo: Products scoped per store (Marketplace)
- Starters
- Usage
- Decorators API
- Monitoring
The usage of the extender does not break any features from the original medusa.
npm i medusa-extender
- π§βπ» Decorators and full typing support
Makes DX easy with the usage of decorators for modular architecture and full typing support for a better DX
- ποΈ Flexible architecture.
You can organize your code as modules and group your modules by domains.
- π Create or extend entities (Custom fields)
Some of the problems that developers encounter are that when you want to add custom fields to an entity, it is not that easy. You can't extend a typeorm entity and adding custom fields through configuration makes you lose the typings and the domains in which they exist. Here, you can now extend a typeorm entity just like any other object.
- π Create or extend services
If you need to extend a service to manage your new fields or update the business logic according to your new needs, you only need to extend the original service from medusa and that's it.
- π Create or extend repositories
When you extend an entity and you want to manipulate that entity in a service, you need to do it through a repository. In order for that repository to reflect your extended entities, while still getting access to the base repository methods, you are provided with the right tools to do so.
- π Create custom middlewares that are applied before/after authentication
Do you want to apply custom middlewares to load data on the requests or add some custom checks or any other situations? Then what are you waiting for?
- π Create custom route and attach custom handler to it.
Do you need to add new routes for new features? Do you want to receive webhooks? Create a new route, attach an handler and enjoy.
- π Override existing validators.
Really useful when your adding custom fields.
- π‘ Handle entity events from subscribers as smoothly as possible.
Emit an event (async/sync) from your subscriber and then register a new handler in any of your files. Just use the
OnMedusaEntityEvent
decorator.
- π¦ Build sharable modules
Build a module, export it and share it with the community.
- π Monitor your app
Using swagger stats you can access all the stats from the ui in your app or use the raw stats to show with grafana, elasticsearch or event kibana.
For the purpose of the examples that will follow in the next sections, I will organise my files in the following manner (You can organise it as you want, there is no restrictions to your architecture).
Let's create a scenario.
As a user, I want to add a new field to the existing product entity to manage some custom data. For that I will need:
- To extend an entity (for the example we will use the product);
- To extend the custom repository in order to reflect the extended entity through the repository;
- To extend the service, in order to take that new field in count;
- To create a validator that will extend and existing one to add the custom field.
- To create a migration that will add the field in the database.
For the purpose of the example, I will want to be able to register an handler on an entity event that I will implement in the extended service. That subscriber will be request scoped, which means a middleware will attach the subscriber to the connection for each request (This is only for the purpose of showing some features).
The idea here, is that we will import the medusa product entity that we will extend in order to add our new field. Of course, you can do everything typeorm provides (if you need to add a custom relationships, then follow the typeorm doc.).
Click to see the raw example!
// src/modules/product/product.entity.ts
import { Column, Entity } from "typeorm";
import { Product as MedusaProduct } from '@medusa/medusa/dist';
import { Entity as MedusaEntity } from "medusa-extender";
@MedusaEntity({ override: MedusaProduct })
@Entity()
export class Product extends MedusaProduct {
@Column()
customField: string;
}
The idea here, is that we will import the medusa product repository that we will extend in order to reflect our custom entity.
Click to see the raw example!
// src/modules/product/product.repository.ts
import { ProductRepository as MedusaProductRepository } from '@medusa/medusa/dist/repositories/order';
import { EntityRepository } from "typeorm";
import { Repository as MedusaRepository, Utils } from "medusa-extender";
import { Product } from "./product.entity";
@MedusaRepository({ override: MedusaProductRepository })
@EntityRepository(Product)
export class ProductRepository extends Utils.repositoryMixin<Product, MedusaProductRepository>(MedusaProductRepository) {
/* You can implement custom repository methods here. */
}
The idea here, is that we will import the medusa product service that we will extend in order to override the product creation method of the base class in order to take in count the new field of our extended product entity.
Click to see the raw example!
// src/modules/product/product.service.ts
import { Service, OnMedusaEntityEvent, MedusaEventHandlerParams, EntityEventType } from 'medusa-extender';
import { ProductService as MedusaProductService } from '@medusa/medusa/dist/services';
import { EntityManager } from "typeorm";
type ConstructorParams = /* ... */
@Service({ scope: 'SCOPED', override: MedusaProductService })
export class ProductService extends MedusaProductService {
readonly #manager: EntityManager;
constructor(private readonly container: ConstructorParams) {
super(container);
this.#manager = container.manager;
}
/**
* In that example, the customField could represent a static value
* such as a store_id which depends on the loggedInUser store_id.
**/
@OnMedusaEntityEvent.Before.Insert(Product, { async: true })
public async attachStoreToProduct(
params: MedusaEventHandlerParams<Product, 'Insert'>
): Promise<EntityEventType<Product, 'Insert'>> {
const { event } = params;
event.entity.customField = 'custom_value';
return event;
}
/**
* This is an example. you must not necessarly keep that implementation.
* Here, we are overriding the existing method to add a custom constraint.
* For example, if you add a store_id on a product, that value
* will probably depends on the loggedInUser store_id which is a static
* value.
**/
public prepareListQuery_(selector: Record<string, any>, config: FindConfig<Product>): object {
selector['customField'] = 'custom_value';
return super.prepareListQuery_(selector, config);
}
}
When adding a new field, the class validator of the end point handler is not aware about it. In order to handle that, it is possible to extend the validator to add the constraint on the new custom field.
Click to see the raw example!
// src/modules/product/adminPostProductsReq.validator.ts
@Validator({ override: AdminPostProductsReq })
class ExtendedClassValidator extends AdminPostProductsReq {
@IsString()
customField: string;
}
To persist your custom field, you need to add it to the corresponding table.
As normal, write a new migration, except this time, you decorate it with the @Migration()
decorator.
Click to see the raw example!
// src/modules/product/customField.migration.ts
import { Migration } from 'medusa-extender';
import { MigrationInterface, QueryRunner } from 'typeorm';
@Migration()
export default class addCustomFieldToProduct1611063162649 implements MigrationInterface {
name = 'addCustomFieldToProduct1611063162649';
public async up(queryRunner: QueryRunner): Promise<void> {
/* Write your query there. */
}
public async down(queryRunner: QueryRunner): Promise<void> {
/* Write your query there. */
}
}
Now that we have done the job, we will import the entity, repository and service into a module that will be loaded by Medusa.
Click to see the raw example!
// src/modules/product/product.module.ts
import { Module } from 'medusa-extender';
import { Product } from './product.entity';
import { ProductRepository } from './product.repository';
import { ProductService } from './product.service';
import { ExtendedClassValidator } from './adminPostProductsReq.validator';
import { addCustomFieldToProduct1611063162649 } from './customField.migration';
@Module({
imports: [
Product,
ProductRepository,
ProductService,
ExtendedClassValidator,
addCustomFieldToProduct1611063162649
]
})
export class ProductModule {}
One of the feature out the box is the ability to emit (sync/async) events from your entity subscriber and to be able to handle those events easily.
To be able to achieve this, here is an example.
Click to see the example!
// src/modules/product/product.subscriber.ts
import { Connection, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
import { eventEmitter, Utils, OnMedusaEntityEvent } from 'medusa-extender';
import { Product } from './product.entity';
@EventSubscriber()
export default class ProductSubscriber implements EntitySubscriberInterface<Product> {
static attachTo(connection: Connection): void {
Utils.attachOrReplaceEntitySubscriber(connection, ProductSubscriber);
}
public listenTo(): typeof Product {
return Product;
}
/**
* Relay the event to the handlers.
* @param event Event to pass to the event handler
*/
public async beforeInsert(event: InsertEvent<Product>): Promise<void> {
return await eventEmitter.emitAsync(OnMedusaEntityEvent.Before.InsertEvent(Product), {
event,
transactionalEntityManager: event.manager,
});
}
}
And then create a new handler.
Click to see the example!
// src/modules/product/product.service.ts
import { Service, OnMedusaEntityEvent } from 'medusa-extender';
/* ... */
interface ConstructorParams { /* ... */ }
@Service({ scope: 'SCOPED', override: MedusaProductService })
export default class ProductService extends MedusaProductService {
readonly #manager: EntityManager;
constructor(private readonly container: ConstructorParams) {
super(container);
this.#manager = container.manager;
}
@OnMedusaEntityEvent.Before.Insert(Product, { async: true })
public async attachStoreToProduct(
params: MedusaEventHandlerParams<Product, 'Insert'>
): Promise<EntityEventType<Product, 'Insert'>> {
const { event } = params;
event.entity.customField = 'custom_value';
return event;
}
}
And finally, we need to add the subscriber to the connection. There are different ways to achieve this. We will see, as an example below, a way to attach a request scoped subscribers.
Every middleware decorated with the @Middleware
decorator will be applied globally on the specified route
before/after medusa authentication. Otherwise, to apply a middleware directly to a route you can have a look to the @Router
decorator.
Click to see the example!
// src/modules/product/attachSubscriber.middleware.ts
import { NextFunction, Request, Response } from 'express';
import {
Middleware,
MedusaAuthenticatedRequest,
Utils as MedusaUtils,
MedusaMiddleware
} from 'medusa-extender';
import UserSubscriber from './product.subscriber';
@Middleware({ requireAuth: true, routes: [{ method: 'post', path: '/admin/products/' }] })
export default class AttachProductSubscribersMiddleware implements MedusaMiddleware {
public consume(req: MedusaAuthenticatedRequest | Request, res: Response, next: NextFunction): void | Promise<void> {
MedusaUtils.attachOrReplaceEntitySubscriber(connection, UserSubscriber);
return next();
}
}
Now, you only need to add that middleware to the previous module we've created.
Click to see the example!
// src/modules/products/product.module.ts
import { Module } from 'medusa-extender';
import { AttachProductSubscribersMiddleware } from './attachSubscriber.middleware'
@Module({
imports: [
/* ... */
AttachProductSubscribersMiddleware
]
})
export class ProductModule {}
This is the same principle as overriding an existing feature. Instead of giving an
override
options to the decorators, you'll have to use the resolutionKey
in order
to register them into the container using that key. You'll be then able
to retrieve them using the custom resolutionKey
to resolve through the container.
Building a shareable module is nothing more that the previous section. to achieve that you can start using the plugin-module starter.
Each service is resolve by the container. One of the object that the container holds is,
the configModule
. Which means that in any service, you are able to retrieve everything
that is in your medusa-config
file. In other word, all the config you need to access
in a service, can be added to your medusa-config
file.
To benefit from all the features that the extender offers you, the usage of typescript is recommended.
If you have already an existing project scaffold with the command medusa new ...
here is how are the following steps to integrate
the extender in your project.
follow the next steps yo be ready to launch π
npm i -D typescript
echo '{
"compilerOptions": {
"module": "CommonJS",
"declaration": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"target": "es2017",
"sourceMap": true,
"skipLibCheck": true,
"allowJs": true,
"outDir": "dist",
"rootDir": ".",
"esModuleInterop": true
},
"include": ["src", "medusa-config.js"],
"exclude": ["dist", "node_modules", "**/*.spec.ts"]
}' > tsconfig.json
update the scripts in your package.json
{
"scripts": {
"build": "rm -rf dist && tsc",
"start": "npm run build && node dist/src/main.js"
}
}
add a main file in the src
directory
// src/main.ts
import express = require('express');
import { Medusa } from 'medusa-extender';
import { resolve } from 'path';
async function bootstrap() {
const expressInstance = express();
const rootDir = resolve(__dirname) + '/../';
await new Medusa(rootDir, expressInstance).load([]);
expressInstance.listen(9000, () => {
console.info('Server successfully started on port 9000');
});
}
bootstrap();
And finally update the develop.sh
script with the following
# develop.sh
#!/bin/bash
#Run migrations to ensure the database is updated
medusa migrations run
#Start development environment
npm run start
Here is the list of the provided decorators.
Decorator | Description | Option |
---|---|---|
@Entity(/*...*/) |
Decorate an entity | { resolutionKey?: string; override?: Type<TOverride>; }; |
@Repository(/*...*/) |
Decorate a repository | { resolutionKey?: string; override?: Type<TOverride>; }; |
@Service(/*...*/) |
Decorate a service | { scope?: LifetimeType; resolutionKey?: string; override?: Type<TOverride>; }; |
@Middleware(/*...*/) |
Decorate a middleware | { requireAuth: boolean; string; routes: MedusaRouteOptions[]; }; |
@Router(/*...*/) |
Decorate a router, can provide a list of handlers that can include route related middleware | { router: RoutesInjectionRouterConfiguration[]; }; |
@Validator(/*...*/) |
Decorate a validator | { override: Type<TOverride>; }; |
@Migration(/*...*/) |
Decorate a migration | |
@OnMedusaEntityEvent.\*.\*(/*...*/) |
Can be used to send the right event type or register the handler to an event | (entity: TEntity, { async? boolean; metatype?: Type<unknown> }) |
If you want to monitor whats going on on your app, you must specify the config
in your medusa-config
file.
Here are the expected config
interface MonitoringOptions {
version?: string;
hostname?: string;
ip?: string;
timelineBucketDuration?: number;
swaggerSpec?: string | OpenAPI.Document;
uriPath: string;
durationBuckets?: number[];
requestSizeBuckets?: number[];
responseSizeBuckets?: number[];
apdexThreshold?: number;
onResponseFinish?: (req: Request, res: Response, next: NextFunction) => void | Promise<void>;
authentication?: boolean;
sessionMaxAge?: number;
elasticsearch?: string;
onAuthenticate?: (req: Request, username: string, password: string) => boolean | Promise<boolean>;
}
so your medusa-config.js
will looks like
const config = {
/* ... */
monitoring: {
uriPath: '/monitoring'
},
/* ... */
};
Now, run your app and go to /monitoring url to get access to your dashboard.
For more information on the configuration, you can have a look at the documentation
Contributions are welcome! You can look at the contribution guidelines