Skip to content

πŸ’‰ Medusa on steroid, take your medusa project to the next level with some badass features πŸš€

License

Notifications You must be signed in to change notification settings

adrien2p/medusa-extender

Repository files navigation

Medusa

Extend medusa with badass features

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 πŸš€

Buy Me A Coffee


Support us on Product Hunt

Medusa-extender - Badass modules for medusa (extend, monitor, marketplace) | Product Hunt


Access the website Documentation

Table of contents

Getting started

The usage of the extender does not break any features from the original medusa.

npm i medusa-extender

Features

  • πŸ§‘β€πŸ’» 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.

Demo: Products scoped per store (Marketplace)

Video demo: scoped products per store

Usage

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).

Scenario 1 module architecture

Extending an existing feature

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).

Step 1: Extend the product entity

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.).

Step 1 Extend the product entity

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;
}

Step 2: Extend the product repository

The idea here, is that we will import the medusa product repository that we will extend in order to reflect our custom entity.

Step 2: Extend the product repository

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. */
}

Step 3: Extend the product service to manage our custom entity field

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.

Step 3: Extend the product service

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);
    }
}

Step 4: Extend the product validator class to reflect the new field

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.

Step 4: Extend the product validator class to reflect the new field

Click to see the raw example!
// src/modules/product/adminPostProductsReq.validator.ts

@Validator({ override: AdminPostProductsReq })
class ExtendedClassValidator extends AdminPostProductsReq {
  @IsString()
  customField: string;
}

Step 5: Create the migration

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.

Step 5: Create the migration

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. */
    }
}

Step 6: Wrapping everything in a module

Now that we have done the job, we will import the entity, repository and service into a module that will be loaded by Medusa.

Step 4: Create the product module

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 {}

Handling entity subscribers

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 {}

Create a custom feature module

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.

Build a shareable module

Building a shareable module is nothing more that the previous section. to achieve that you can start using the plugin-module starter.

Use custom configuration inside service

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.

Integration in an existing medusa project

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

Decorators API

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> })

Monitoring

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

Demo: Monitoring

Video demo: scoped products per store

Contribute πŸ—³οΈ

Contributions are welcome! You can look at the contribution guidelines