Guide: Extend Create Product Flow

After linking the custom Brand data model and MedusaGo's Product Module in the previous chapter, you'll extend the create product workflow and API route to allow associating a brand with a product.

Some API routes, including the Create Product API route, accept an additional_data request body parameter. This parameter can hold custom data that's passed to the hooks of the workflow executed in the API route, allowing you to consume those hooks and perform actions with the custom data.

So, in this chapter, to extend the create product flow and associate a brand with a product, you will:

  • Consume the productsCreated hook of the CreateProductsWorkflow, which is executed within the workflow after the product is created. You'll link the product with the brand passed in the additional_data parameter.
  • Validate the additional_data of the Create Product API route, to allow passing a brand ID in it.

1. Consume the productsCreated Hook#

A workflow hook is a point in a workflow where you can inject a step to perform a custom functionality. Consuming a workflow hook allows you to extend the features of a workflow and, consequently, the API route that uses it.

The CreateProductsWorkflow used in the Create Product API route has a productsCreated hook that runs after the product is created. You'll consume this hook to link the created product with the brand specified in the request parameters.

The workflow is the CreateProductsWorkflow variable of the github.com/hitkey-io/medusago/packages/core/core-flows/src/product/workflows package. To consume its hook, create the file src/workflows/hooks/created-product.go with the following content:

src/workflows/hooks/created-product.go
// Register consumes the productsCreated hook of createProductsWorkflow.
// Call it once, before the application starts.
func Register() {
	workflows.CreateProductsWorkflow.RegisterHook("productsCreated", productsCreated, productsCreatedCompensation)
}

Every workflow has a RegisterHook method to consume its hooks. It accepts the name of the hook, a step function that is the hook handler, and an optional compensation function. You'll write the handler and the compensation function next.

Add the hook handler in the same file:

src/workflows/hooks/created-product.go
func productsCreated(ctx composer.StepExecutionContext, data any) (composer.StepResponse[any], error) {
	input, _ := data.(*dal.Record)
	additionalData, _ := input.Get("additional_data")
	additional, _ := additionalData.(*dal.Record)
	if additional == nil {
		return composer.NewStepResponse[any](nil), nil
	}
	brandID, _ := additional.Get("brand_id")
	if brandID == nil {
		return composer.NewStepResponse[any](nil), nil
	}

	// if the brand doesn't exist, an error is thrown
	service := ctx.Container.Resolve(brand.ModuleName).(*brand.BrandModuleService)
	if _, err := service.Call(ctx.Context, "retrieveBrand", []any{brandID}); err != nil {
		return composer.StepResponse[any]{}, err
	}

The step function accepts the following parameters:

  1. The step's context, which has a Container property whose value is the container to resolve Framework and commerce tools.
  2. The hook's input, a *dal.Record. It has the properties passed from the workflow to the hook, which in this case are the products property that holds the created products, and additional_data, which is the custom data passed in the additional_data request body parameter. It's nil if the request doesn't have it.

In the step, if a brand ID is passed in additional_data, you resolve the Brand Module's service and use its generated retrieveBrand method to retrieve the brand by its ID. The retrieveBrand method returns an error if the brand doesn't exist, which fails the workflow and rolls it back.

Next, you want to create a link between the created products and the brand. To do so, you use Link, which is a struct from the Modules SDK that provides methods to manage linked records.

At the end of the handler, add the following:

src/workflows/hooks/created-product.go
var links []modulessdk.LinkDefinition
createdProducts, _ := input.Get("products")
products, _ := createdProducts.([]*dal.Record)
for _, product := range products {
	productID, _ := product.Get("id")
	links = append(links, modulessdk.NewLinkDefinition(
		"product", "product_id", productID,
		brand.ModuleName, "brand_id", brandID,
	))
}

link := ctx.Container.Resolve(common.ContainerRegistrationKeys.LINK).(*modulessdk.Link)
if _, err := link.Create(ctx.Context, links); err != nil {
	return composer.StepResponse[any]{}, err
}

logger := ctx.Container.Resolve(common.ContainerRegistrationKeys.LOGGER).(*slog.Logger)
logger.Info("Linked brand to products")

// the links are the input of the compensation function
return composer.NewStepResponse[any](nil, links), nil

You resolve the Link from the container. Then, you loop over the created products to assemble a list of links to be created. After that, you pass the list of links to Link's Create method, which will link the product and brand records.

You build each link with modulessdk.NewLinkDefinition, which accepts the name of the first module, the name of the linked key and its value, followed by the same three values for the second module. The key is {model_name}_id, where {model_name} is the snake-case name of the module's data model. Its value is the ID of the record to be linked. You must pass the modules in the same order as the link configurations passed to DefineLink.

Finally, you return a StepResponse with the created links as its compensation input.

The second function you pass to RegisterHook is a compensation function that undoes what the step did. It receives the step's context as a first parameter, and the second parameter of the returned StepResponse as a second parameter.

To undo creating the links in the hook, add the following function to the file:

src/workflows/hooks/created-product.go
func productsCreatedCompensation(ctx composer.StepExecutionContext, data any) error {
	links, _ := data.([]modulessdk.LinkDefinition)
	if len(links) == 0 {
		return nil
	}
	link := ctx.Container.Resolve(common.ContainerRegistrationKeys.LINK).(*modulessdk.Link)
	_, err := link.Dismiss(ctx.Context, links)
	return err
}

In the compensation function, if the links list isn't empty, you resolve the Link and use its Dismiss method. This method removes a link between two records. It accepts the same parameter as the Create method.

Register the Hook#

Hooks of a workflow are consumed when you call RegisterHook, so call the Register function before the application starts. In main.go, call hooks.Register() after you define the links:

main.go
func main() {
	// define the module links and consume the workflow hooks before the
	// application starts
	if err := links.Define(); err != nil {
		log.Fatal(err)
	}
	hooks.Register()

	os.Exit(cli.Run(os.Args[1:], cli.Options{
		Config:      medusaConfig,
		Routes:      api.Routes,
		Middlewares: api.Middlewares,
	}))
}
Note: A hook can have only one handler. If you register another handler for the same hook, it replaces this one.

2. Validate Additional Data#

Now that you've consumed the productsCreated hook, you want to configure the /admin/products API route that creates a new product to only accept a valid brand ID in its additional_data parameter.

Differences from Medusa: In Medusa, you configure the properties of additional_data with the additionalDataValidator option of a middleware. MedusaGo doesn't have that option. The Create Product API route accepts any object in additional_data, so the brand ID already reaches your hook handler. To validate the object, you add a middleware that validates it, as you'll do in this section.

Create the file src/api/admin/products/additional-data.go with the following content:

src/api/admin/products/additional-data.go
// additionalData describes the custom properties accepted in the
// `additional_data` of POST /admin/products.
var additionalData = zod.Object(
	zod.F("brand_id", zod.String().Optional()),
)

// ValidateAdditionalData validates the `additional_data` of the request
// body. It runs after the validation middleware of the core route, so
// the body is already validated.
func ValidateAdditionalData(req *medusahttp.MedusaRequest, _ *medusahttp.MedusaResponse, next medusahttp.NextFunction) error {
	body, _ := req.ValidatedBody.(*dal.Record)
	if body == nil {
		next(nil)
		return nil
	}
	if data, _ := body.Get("additional_data"); data != nil {
		if _, err := zod.ZodValidator(additionalData, data); err != nil {
			return err
		}
	}
	next(nil)
	return nil
}

You create a validation schema for the custom properties you accept in additional_data: brand_id is an optional string. Then, you create a middleware that validates the additional_data of the request's validated body, and returns the validation error if it's invalid. The middleware runs after the validation middleware of the core API route, so the request's body is already validated when it runs.

Then, add the middleware to the middlewares of the POST /admin/products route in the same file:

src/api/admin/products/additional-data.go
// Middlewares are the middlewares added to the /admin/products routes.
var Middlewares = []medusahttp.MiddlewareRoute{
	{
		Matcher: "/admin/products",
		Methods: []string{"POST"},
		Middlewares: []medusahttp.MiddlewareFunction{
			ValidateAdditionalData,
		},
	},
}

Finally, add these middlewares to the middlewares of your project in src/api/middlewares.go:

src/api/middlewares.go
// Middlewares are the middlewares of the project.
var Middlewares = slices.Concat[[]medusahttp.MiddlewareRoute](
	brands.Middlewares,
	products.Middlewares,
)

POST requests sent to /admin/products can now pass the ID of a brand in the brand_id property of additional_data, and a request with another property in additional_data fails with a validation error.


Test it Out#

To test it out, first, retrieve the authentication token of your admin user by sending a POST request to /auth/user/emailpass:

Code
curl -X POST 'http://localhost:9000/auth/user/emailpass' \
-H 'Content-Type: application/json' \
--data-raw '{
    "email": "admin@medusa-test.com",
    "password": "supersecret"
}'

Make sure to replace the email and password in the request body with your user's credentials.

Then, send a POST request to /admin/products to create a product, and pass in the additional_data parameter a brand's ID:

Code
curl -X POST 'http://localhost:9000/admin/products' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {token}' \
--data '{
    "title": "Product 1",
    "options": [
      {
        "title": "Default option",
        "values": ["Default option value"]
      }
    ],
    "shipping_profile_id": "{shipping_profile_id}",
    "additional_data": {
        "brand_id": "{brand_id}"
    }
}'

Make sure to replace {token} with the token you received from the previous request, shipping_profile_id with the ID of a shipping profile in your application, and {brand_id} with the ID of a brand in your application. You can retrieve the ID of a shipping profile either from the Medusa Admin, or the List Shipping Profiles API route.

The request creates a product and returns it.

In the MedusaGo application's logs, you'll find the message Linked brand to products, indicating that the workflow hook handler ran and linked the brand to the products.


Next Steps: Query Linked Brands and Products#

Now that you've extended the create-product flow to link a brand to it, you want to retrieve the brand details of a product. You'll learn how to do so in the next chapter.

Found a problem with this page?Report Issue
Edited Nov 14·Edit this page