Wprowadzenie
Cześć! Jeśli zastanawiasz się, czym jest Medusa JS, to opensourcowa platforma e-commerce. To baza, którą możesz dostosować w dowolnym kierunku. W moim przypadku to market dla wielu sprzedawców, co generuje specyficzne wymagania. Praca z tak dużym kodem może być przytłaczająca, ale na końcu zrozumiesz, jak projektować API, łączyć je z bazą danych, budować atrakcyjny frontend oraz utrzymywać kod w czystości. Zaczynajmy!
Co budujemy?
Wyobraź sobie marketplace, na którym różni sprzedawcy wystawiają swoje produkty, a każdy z nich potrzebuje własnych opcji wysyłki — np. darmowej wysyłki, stałej stawki, cokolwiek im odpowiada. W Medusa nazywamy to „profilem wysyłki”, a ja pokażę, jak umożliwić sprzedawcom ich tworzenie i zarządzanie nimi.
Omówimy:
- Backend: Tworzenie tras API, konfigurowanie struktur danych oraz obsługa logiki.
- Frontend: Pobieranie danych i prezentacja ich na ekranie.
- Dlaczego robimy to w ten sposób: Zrozumienie nietypowych wzorców Medusa, które na początku mieliły mi mózg.
Zaczynajmy od backendu!
Backend to miejsce, gdzie odbywa się cała magia w tle. To sposób, w jaki dane przepływają od kliknięcia przycisku do zapisania w bazie danych. Oto, jak to rozłożymy:
apps/backend/src/api/vendor/shipping-profiles/
├── route.ts # Main routes
├── [id]/ # ID-specific routes
│ └── route.ts # ID-specific endpoints
├── middlewares.ts # Authentication
├── validators.ts # Input validation
├── query-config.ts # Query parameters
└── schemas/ # Data schemas
├── shipping-profile.schema.ts
└── shipping-profile-response.schema.ts1. Ścieżki API i specyfikacja OpenAPI
Medusa używa czegoś, co nazywa się OpenAPI Specification (OAS) do definiowania API. To elegancki sposób dokumentowania działania Twojego API. Oto, jak to wygląda dla naszych profili wysyłki:
import { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework'
import { ContainerRegistrationKeys } from '@medusajs/framework/utils'
import { fetchSellerByAuthActorId } from '../../../../shared/infra/http/utils'
import { VendorUpdateShippingProfileType } from '../validators'
import {
deleteShippingProfileWorkflow,
updateShippingProfilesWorkflow
} from '@medusajs/medusa/core-flows'
/**
* @oas [get] /vendor/shipping-profiles/{id}
* operationId: "VendorGetShippingProfile"
* summary: "Get a Shipping Profile"
* description: "Retrieves a Shipping Profile."
* x-authenticated: true
* parameters:
* - in: path
* name: id
* required: true
* description: The ID of the Shipping Profile.
* schema:
* type: string
* responses:
* "200":
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* shipping_profile:
* $ref: "#/components/schemas/ShippingProfile"
* tags:
* - Shipping Profile
* security:
* - api_token: []
* - cookie_auth: []
*/
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
try {
const seller = await fetchSellerByAuthActorId(req.auth_context?.actor_id, req.scope);
if (!seller) {
return res.status(401).json({ message: "Unauthorized" });
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: [shipping_profile] } = await query.graph({
entity: "shipping_profile",
fields: req.queryConfig.fields,
filters: { id: req.params.id },
}, { throwIfKeyNotFound: true });
res.json({ shipping_profile });
} catch (error) {
res.status(500).json({ message: "Error fetching shipping profile" });
}
};Dlaczego OAS?
- Prosta dokumentacja: Ten obszerny blok komentarza przekształca się w czytelną dokumentację API, gdy używasz narzędzi takich jak Swagger.
- Przejrzystość: Każdy może zobaczyć „oh, to żądanie GET do
/vendor/shipping-profiles/{id}”. - Jednolitość: Twoi współpracownicy nie będą musieli zgadywać, co robi Twój kod – a jeśli pracujesz sam, łatwiej jest zapamiętać, co się dzieje.
Co się dzieje w kodzie?
AuthenticatedMedusaRequestsprawdza, czy użytkownik jest zalogowany.fetchSellerByAuthActorIdsprawdza, czy faktycznie jest sprzedawcą.query.graphpobiera profil wysyłki z bazy danych.
2. Schematy i walidacja
Schematy to zasady dotyczące danych. Dbają o to, aby nikt nie przesłał do Ciebie nieprawidłowych informacji. Oto prosty przykład (w moim przypadku chcę tylko ciągu znaków, bez zbyt wielu ograniczeń, ale możesz narzucić wcześniej określone opcje):
// validators.ts
import { z } from "zod";
export const VendorCreateShippingProfile = z.object({
name: z.string().min(1, "Name is required"),
type: z.string().optional().default("default"),
metadata: z.record(z.unknown()).optional(),
}).strict();
export type VendorCreateShippingProfileType = z.infer<typeof VendorCreateShippingProfile>;Dlaczego warto używać validatorów?
- Wykrywają błędne dane: Jeśli ktoś spróbuje stworzyć profil bez nazwy, walidacja natychmiast zawiedzie.
- Wsparcie dla TypeScript: Twoje IDE wyświetli błędy, jeśli coś pomylisz.
- Samo opisujące się: Łatwo widać, które pola są wymagane.
Co się dzieje?
z.string().min(1)oznacza, że wartość musi być ciągiem znaków zawierającym przynajmniej jeden znak.typejest opcjonalne i domyślnie przyjmuje wartość „default”, jeśli nie zostanie podane.metadatapozwala na dodanie dodatkowych informacji, jeśli są potrzebne.
3. Workflowy i logika biznesowa
Medusa korzysta z tzw. workflowów do obsługi złożonych operacji. Oto, jak tworzymy profil wysyłki:
export const POST = async (
req: AuthenticatedMedusaRequest<VendorCreateShippingProfileType>,
res: MedusaResponse
) => {
const seller = await fetchSellerByAuthActorId(req.auth_context?.actor_id, req.scope);
if (!seller) {
return res.status(401).json({ message: "Unauthorized" });
}
const { result } = await createShippingProfilesWorkflow(req.scope).run({
input: {
data: [{
name: req.validatedBody.name,
type: req.validatedBody.type,
}],
},
});
await req.scope.resolve(ContainerRegistrationKeys.REMOTE_LINK).create({
[SELLER_MODULE]: { seller_id: seller.id },
[Modules.FULFILLMENT]: { shipping_profile_id: result[0].id },
});
res.status(201).json({ shipping_profile: result[0] });
};Dlaczego workflowy?
- Jak klocki lego: Możesz je wielokrotnie wykorzystywać przy różnych funkcjach.
- Obsługa błędów: Mniej bloków try/catch w Twoim kodzie.
- Gotowe na przyszłość: Łatwo dodać kolejne kroki później.
Co tu się dzieje?
createShippingProfilesWorkflowzapisuje nasz profil w bazie danych.REMOTE_LINKłączy profil ze sprzedawcą, zasadniczo oznaczając, że „ten sprzedawca jest właścicielem tego profilu”.
4. Połączenia modułów
Medusa jest zbudowana z modułów – można je porównać do klocków Lego. Musimy je połączyć:
// seller-shipping-profile.ts
import { defineLink } from "@medusajs/framework/utils";
import FulfillmentModule from "@medusajs/medusa/fulfillment";
import SellerModule from "../modules/seller";
export default defineLink(
SellerModule.linkable.seller,
FulfillmentModule.linkable.shippingProfile
);Dlaczego "linki"?
- Definiują relacje: Mówią „sprzedawca może mieć profile wysyłki”.
- Niezależność modułów: Każdy moduł skupia się na jednym zadaniu, ale mogą ze sobą współpracować.
5. Konfiguracje zapytań – Strażnik Twoich danych
// query-config.ts
export const vendorShippingProfileFields = [
'id',
'name',
'type',
'created_at',
'updated_at',
'deleted_at',
'metadata'
]
export const vendorShippingProfileQueryConfig = {
list: {
defaults: vendorShippingProfileFields,
isList: true,
pagination: {
limit: 10
}
},
retrieve: {
defaults: vendorShippingProfileFields,
isList: false
}
}Co tu się dzieje?
- Domyślne pola:
vendorShippingProfileFieldszawiera wszystkie pola, które chcemy zwracać domyślnie. - Konfiguracja listy: Ustawienia
listobsługują pobieranie wielu profili, w tym paginację (10 elementów na stronę). - Konfiguracja pobierania: Funkcja
retrieve, gdy pobieramy pojedynczy profil.
Może się wydawać, że to nieistotne, ale te konfiguracje są zbawienne! Utrzymują spójność odpowiedzi API i poprawiają wydajność, nie zwracając zbędnych danych.
6. Middleware – Strażnicy bezpieczeństwa
Middleware działają jak ochroniarze przy wejściu do klubu – sprawdzają, czy masz dostęp, zanim dotrzesz do właściwego kontrolera trasy. Oto, jak wygląda middleware dla profili wysyłki:
// middlewares.ts
import {
validateAndTransformBody,
validateAndTransformQuery
} from '@medusajs/framework'
import { MiddlewareRoute } from '@medusajs/medusa'
import {
checkResourceOwnershipByResourceId,
filterBySellerId
} from '../../../shared/infra/http/middlewares'
import { vendorShippingProfileQueryConfig } from './query-config'
import {
VendorCreateShippingProfile,
VendorGetShippingProfileParams,
VendorUpdateShippingProfile
} from './validators'
const checkShippingProfileOwnership = () => {
return async (req, res, next) => {
const { id } = req.params
if (!id) return next()
const sellerId = req.context?.seller?.id
if (!sellerId) return next()
const manager = req.scope.resolve('manager')
const repo = manager.getRepository('seller_seller_shipping_profile')
const sellerProfile = await repo.findOne({
where: {
seller_id: sellerId,
shipping_profile_id: id,
deleted_at: null
}
})
if (!sellerProfile) {
return res.status(404).json({
message: `Shipping profile with id ${id} was not found`
})
}
next()
}
}
export const vendorShippingProfilesMiddlewares: MiddlewareRoute[] = [
{
method: ['GET'],
matcher: '/vendor/shipping-profiles',
middlewares: [
validateAndTransformQuery(
VendorGetShippingProfileParams,
vendorShippingProfileQueryConfig.list
)
// No filterBySellerId here as we'll handle that in the route handler
]
},
// ...other routes
]Co tu się dzieje?
- Dedykowany checker własności:
checkShippingProfileOwnershipupewnia się, że sprzedawca ma dostęp tylko do swoich profili. - Walidacja:
validateAndTransformBodyivalidateAndTransformQueryużywają naszych schematów do sprawdzania danych wejściowych. - Dopasowanie tras: Każdy middleware jest powiązany z określonymi metodami HTTP i URL-ami.
Nauczyłem się tego na własnej skórze – bez dobrych middleware’ów sprzedawcy mogliby potencjalnie zobaczyć dane innych. Te zabezpieczenia są kluczowe.
7. Rejestracja wszystkiego
Ostatni element układanki to rejestracja wszystkich middleware’ów w głównej aplikacji. Możesz pomyśleć: „Po co kolejny plik?”, ale uwierz mi – to utrzymuje porządek:
// Main middleware registration
export const vendorMiddlewares: MiddlewareRoute[] = [
{
matcher: '/vendor*',
middlewares: [vendorCors]
},
/**
* @desc Here we are authenticating the seller routes
* except for the route for creating a seller
* and the route for accepting a member invite
*/
{
matcher: '/vendor/sellers',
method: ['POST'],
middlewares: [
authenticate('seller', ['bearer', 'session'], {
allowUnregistered: true
})
]
},
{
matcher: '/vendor/invites/accept',
method: ['POST'],
middlewares: [authenticate('seller', ['bearer', 'session'])]
},
{
matcher: '/vendor/*',
middlewares: [
unlessBaseUrl(
/^\/vendor\/(sellers|invites\/accept)$/,
authenticate('seller', ['bearer', 'session'], {
allowUnregistered: false
})
)
]
},
// All the feature-specific middlewares
...vendorMeMiddlewares,
...vendorSellersMiddlewares,
// ... many other middlewares
...vendorShippingProfilesMiddlewares, // Our new addition!
]Czas na Frontend!
Teraz zbudujemy część widoczną dla użytkowników. Użyjemy Reacta, React Query i podzielimy wszystko na komponenty.
1. Hooki API
Używamy React Query do pobierania danych z backendu. To o wiele łatwiejsze niż zwykłe wywołania fetch:
// shipping-profiles.tsx
import { useQuery } from "@tanstack/react-query";
import { fetchQuery } from "../../lib/client";
import { shippingProfileQueryKeys } from "../../lib/query-key-factory";
export const useShippingProfiles = (query?: { limit?: number; offset?: number }) => {
const { data, ...rest } = useQuery({
queryFn: () => fetchQuery("/vendor/shipping-profiles", { query }),
queryKey: shippingProfileQueryKeys.list(query),
});
return { ...data, ...rest };
};Dlaczego hooki?
- Wykonują ciężką pracę: Obsługują stany ładowania, błędy – wszystko za Ciebie.
- Możesz ich używać wszędzie: Wystarczy wrzucić
useShippingProfilesdo dowolnego komponentu, który potrzebuje danych.
Co tu się dzieje?
fetchQuerywykonuje żądania HTTP do backendu.queryKeypomaga React Query w buforowaniu i odświeżaniu danych.
2. Fabryka kluczy zapytań
Na początku może to wydawać się dziwne, ale pomaga uporządkować dane:
// query-key-factory.ts
export const shippingProfileQueryKeys = {
list: (query?: object) => ["shipping_profile", "list", query],
detail: (id: string, query?: object) => ["shipping_profile", "detail", id, query],
};Dlaczego?
- Inteligentne buforowanie: React Query używa tych kluczy do efektywnego przechowywania danych.
- Łatwe aktualizacje: Gdy dane się zmieniają, możesz powiedzieć React Query, aby odświeżył tylko potrzebne elementy.
3. Struktura komponentów
Oto, jak możesz zorganizować kod frontend:
vendor-panel/src/
├── hooks/api/shipping-profiles.tsx # API hooks
├── routes/shipping-profiles/ # Route components
│ ├── shipping-profiles-list/ # List all profiles
│ │ ├── index.tsx
│ │ └── components/
│ ├── shipping-profile-create/ # Create a new profile
│ │ ├── index.tsx
│ │ └── components/
│ └── shipping-profile-detail/ # View/edit a profile
│ ├── index.tsx
│ └── components/
└── lib/query-key-factory.ts # Query keysA oto formularz do tworzenia profilu wysyłki (bez szczegółowego layoutu):
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useCreateShippingProfile } from "../../../../../hooks/api/shipping-profiles"
const CreateShippingOptionsSchema = zod.object({
name: zod.string().min(1),
type: zod.string().min(1),
})
export function CreateShippingProfileForm() {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof CreateShippingOptionsSchema>>({
defaultValues: {
name: "",
type: "",
},
resolver: zodResolver(CreateShippingOptionsSchema),
})
const { mutateAsync, isPending } = useCreateShippingProfile()
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
name: values.name,
type: values.type,
},
{
onSuccess: ({ shipping_profile }) => {
toast.success(
t("shippingProfile.create.successToast", {
name: shipping_profile.name,
})
)
handleSuccess(
`/settings/locations/shipping-profiles/${shipping_profile.id}`
)
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
return (
<form onSubmit={onSubmit}>
<Heading>{t("Create Shipping Profile")}</Heading>
<div>
<label>{t("Name")}</label>
<Input {...form.register("name")} />
</div>
<div>
<label>{t("Type")}</label>
<Input {...form.register("type")} />
</div>
<Button type="submit" isLoading={isPending}>
{t("Save")}
</Button>
</form>
)
}Dlaczego tak?
- Każdy ma swoje miejsce: Każdy folder odpowiada za jedną konkretną funkcję.
- Wielokrotne użycie komponentów: Elementy takie jak
<Input>mogą być używane w całej aplikacji.
Co się dzieje?
useFormzzodResolversprawdza, czy dane z formularza są poprawne.useCreateShippingProfilewysyła dane do naszego backendu po zatwierdzeniu formularza.
Jak to wszystko współgra?
- Użytkownik wypełnia formularz: Wprowadza nazwę i typ, a następnie naciska przycisk Zapisz.
- Walidacja formularza: Schemat z Zod upewnia się, że wszystko jest poprawne.
- Żądanie API: Dane są wysyłane do endpointu
/vendor/shipping-profiles. - Przetwarzanie w backendzie: Metoda POST ponownie waliduje dane, uruchamia workflow i zapisuje profil.
- Odpowiedź: Nowe dane profilu są buforowane przez React Query i wyświetlane na ekranie.
Kilka wskazówek, których nauczyłem się na własnej skórze
1. Używaj TypeScript wszędzie: Dzięki temu wychwytujesz wiele błędów zanim jeszcze się pojawią.
2. Zawsze obsługuj błędy: Nic nie jest gorsze niż niezauważone błędy.
if (!seller) {
return res.status(401).json({ message: "Unauthorized" });
}3. Myśl o wydajności:
- Używaj limitów i offsetów dla paginacji.
- Buforuj dane przy użyciu React Query, żeby nie wysyłać tych samych żądań wielokrotnie.
4.Nie pomijaj bezpieczeństwa:
x-authenticated: truew OAS zapewnia, że nieuprawnione osoby nie uzyskają dostępu do Twoich endpointów.- Zawsze waliduj dane wejściowe, żeby nikt nie mógł wysłać dziwnych danych do Twojego API.
Dlaczego tyle plików?
Wiem, że może się wydawać, że to zbyt wiele plików dla jednej funkcji, ale uwierz mi:
- Trasy: Oddzielne pliki utrzymują endpointy w porządku.
- Walidatory: Możesz wielokrotnie używać schematów w różnych trasach.
- Middleware: Obsługują autoryzację i walidację w jednym miejscu.
- Konfiguracje zapytań: Ustawiają domyślne pola i paginację.
Na początku może wydawać się to przesadne, ale gdy Twoja aplikacja rośnie, będziesz wdzięczny za dobrze zorganizowany kod.
Podsumowanie
I to by było na tyle! Dodawanie nowych funkcji do marketplace'u Medusa może być wyzwaniem, ale staje się jasne, gdy rozbijesz to na mniejsze części. Projektujesz API przy użyciu OAS, walidujesz dane za pomocą schematów, obsługujesz logikę przy pomocy workflowów, a frontend budujesz z React Query.
Każdy element współpracuje, tworząc rozwiązanie zarówno potężne dla użytkowników, jak i łatwe w utrzymaniu dla programistów.
Powodzenia w kodowaniu! 😊





