Kurs NestJS · Moduł 11: Swagger i OpenAPI

Wersjonowanie API - ery Imperium

5 min czytania
W tej lekcji6

Zmiana w API, z którego korzysta kilku klientów, ma jedną nieprzyjemną właściwość: nie da się jej wycofać z cudzych aplikacji. Usuwasz pole, którego nikt - jak sądzisz - nie używa, i tego samego popołudnia dowiadujesz się, że używał go integrator z drugiego końca kraju.

Rzym radził sobie z tym przez ery. Prawo Republiki nie znikało wraz z nastaniem Cesarstwa - obowiązywało dalej dla spraw wszczętych wcześniej, a nowe reguły szły obok. Wersjonowanie API jest tym samym: stara wersja żyje, dopóki ktoś z niej korzysta, a zmiany trafiają do nowej.

Trzy strategie

NestJS zna trzy sposoby wskazania wersji:

  • URI Versioning - wersja w ścieżce adresu, jak /v1/legiones. Najczęściej używana, bo widać ją w logach, w przeglądarce i w zakładkach.
  • Header Versioning - wersja w nagłówku HTTP, na przykład X-Version: 1. Adres pozostaje czysty, ale wersji nie widać bez podglądu żądania.
  • Media Type Versioning - wersja w nagłówku Content-Type. Najbardziej zgodna z duchem HTTP i najrzadziej spotykana w praktyce.

Przy URI Versioning z wersją pierwszą adres wygląda tak: /v1/legiones. Nie /legiones?version=1 - to byłby parametr zapytania, nie wersja. Nie /legiones/v1 - tu v1 wyglądałoby jak identyfikator zasobu. I nie /legiones z nagłówkiem X-Version - to już inna strategia.

Włączenie wersjonowania

Wersjonowanie włącza się raz, w main.ts:

1async function bootstrap() {
2  const app = await NestFactory.create(AppModule);
3
4  app.enableVersioning({
5    type: VersioningType.URI,
6    defaultVersion: '1',
7  });
8
9  await app.listen(3000);
10}

Zapis składa się z czterech części w stałej kolejności: app.enableVersioning({ otwiera konfigurację, type: VersioningType.URI, wybiera strategię, defaultVersion: '1' ustala wersję dla kontrolerów, które jej nie podają, a }); domyka.

defaultVersion warto ustawić od razu. Bez niego każdy kontroler bez jawnej wersji przestanie odpowiadać - a przy włączaniu wersjonowania w istniejącym projekcie to zwykle wszystkie kontrolery naraz.

Kontroler w wersji

Kontroler deklaruje swoją wersję w tym samym dekoratorze, w którym podaje ścieżkę:

1@Controller({
2  path: 'legiones', version: '1' })
3export class LegionV1Controller {}

Kolejność zapisu jest stała: @Controller({ otwiera obiekt konfiguracji, path: 'legiones', version: '1' }) podaje ścieżkę i wersję, a export class LegionV1Controller {} deklaruje samą klasę.

Zwróć uwagę, że @Controller('legiones') z napisem zamienia się tu w @Controller({ path, version }) z obiektem - to ta sama funkcja, tylko w wariancie przyjmującym więcej opcji. Pojedyncze metody można wersjonować osobno dekoratorem @Version('2'), gdy tylko jedna z nich się zmieniła.

Nazwa klasy - LegionV1Controller - nie ma znaczenia dla routingu; wersję ustala wyłącznie pole version. Warto ją jednak nazwać zgodnie z wersją, bo w projekcie z dwiema erami dwie klasy LegionController w różnych plikach szybko stają się źródłem pomyłek.

Osobna dokumentacja dla każdej wersji

Jeden dokument Swagger z pomieszanymi wersjami jest mniej użyteczny niż dwa osobne. Proces ma cztery kroki, w tej kolejności:

  1. app.enableVersioning() - najpierw aplikacja musi w ogóle rozróżniać wersje.
  2. DocumentBuilder dla każdej wersji - osobna konfiguracja z własnym tytułem i numerem.
  3. SwaggerModule.createDocument z opcją include - zbudowanie dokumentu wyłącznie z wybranych modułów.
  4. SwaggerModule.setup dla każdej wersji - wystawienie każdego dokumentu pod własnym adresem.
1const configV1 = new DocumentBuilder()
2  .setTitle('Legion API v1')
3  .setDescription('Pierwsza era Imperium')
4  .setVersion('1.0')
5  .setContact('Kancelaria', 'https://imperium.rome', 'kancelaria@imperium.rome')
6  .setLicense('MIT', 'https://opensource.org/licenses/MIT')
7  .addServer('https://api.imperium.rome')
8  .addTag('Legiony')
9  .addBearerAuth(undefined, 'JWT-auth')
10  .build();
11
12const documentV1 = SwaggerModule.createDocument(app, configV1, {
13  include: [LegionV1Module],
14});
15
16SwaggerModule.setup('api/v1', app, documentV1);

DocumentBuilder buduje się łańcuchem metod, z których każda dokłada jeden element opisu: setTitle, setDescription i setVersion to podstawa, setContact wskazuje, do kogo pisać, setLicense podaje licencję, addServer - adres, pod którym API naprawdę działa, addTag deklaruje grupę endpointów, a addBearerAuth włącza pole na token.

Opcja include uwzględnia w dokumentacji tylko wybrane moduły. Nie dodaje plików CSS do Swagger UI, nie importuje zewnętrznych specyfikacji OpenAPI i nie włącza dodatkowych dekoratorów. To ona sprawia, że dokument v1 zawiera wyłącznie endpointy pierwszej ery - bez niej oba dokumenty byłyby identyczne i pokazywałyby całe API.

Na końcu SwaggerModule.setup('api/v1', ...) wystawia dokument pod adresem /api/v1. Drugą wersję budujesz analogicznie: configV2 z własnym tytułem i numerem, include: [LegionV2Module], a na końcu setup('api/v2', ...).

Wycofywanie endpointów

Wersja druga nie unieważnia pierwszej z dnia na dzień. Endpointy, które mają zniknąć, oznacza się najpierw jako wycofane:

1@ApiOperation({
2  summary: 'Lista legionów (stara wersja)',
3  deprecated: true,
4})
5@Get()
6findAllLegacy() {
7  return this.legionService.findAll();
8}

Endpoint oznaczasz jako wycofany właściwością deprecated: true w @ApiOperation. Nie removed, nie obsolete, nie disabled - te trzy nazwy brzmią sensownie, ale w OpenAPI nie istnieją; specyfikacja zna wyłącznie deprecated.

W Swagger UI taki endpoint zostaje przekreślony i opatrzony ostrzeżeniem, ale nadal działa. I o to chodzi: oznaczenie jest zapowiedzią, nie wyłączeniem. Daje integratorom czas na przejście, zanim endpoint zniknie naprawdę - a bez tego okresu przejściowego wersjonowanie nie ma sensu, bo zmiana i tak łamie cudze aplikacje.

Podsumowanie

Prawo Republiki obowiązuje, dopóki są sprawy wszczęte za Republiki:

  • trzy strategie: URI (wersja w ścieżce), Header (w nagłówku, np. X-Version), Media Type (w Content-Type),
  • przy URI Versioning adres wygląda tak: /v1/legiones - nie /legiones?version=1, nie /legiones/v1,
  • włączenie w main.ts: app.enableVersioning({ → type: VersioningType.URI, → defaultVersion: '1' → });,
  • defaultVersion chroni kontrolery, które wersji nie podają,
  • kontroler wersjonowany: @Controller({ → path: 'legiones', version: '1' }) → export class LegionV1Controller {},
  • osobna dokumentacja w czterech krokach: app.enableVersioning() → DocumentBuilder dla każdej wersji → createDocument z include → SwaggerModule.setup dla każdej wersji,
  • DocumentBuilder: setTitle, setDescription, setVersion, setContact, setLicense, addServer, addTag, addBearerAuth,
  • opcja include uwzględnia tylko wybrane moduły - nie dodaje CSS, nie importuje zewnętrznych specyfikacji, nie włącza dekoratorów,
  • deprecated: true w @ApiOperation oznacza endpoint jako wycofany - nie removed, nie obsolete, nie disabled; endpoint nadal działa.

W następnej lekcji zbierzemy cały moduł w jeden projekt - kompletną dokumentację Imperium API. A na razie zapamiętaj: wersjonowanie nie służy do wprowadzania zmian. Służy do tego, żeby zmiana nie zepsuła nikomu dnia.

Kod do tej lekcji: src/versioning.ts
1// Wersjonowanie API z dokumentacja Swagger
2import { VersioningType } from '@nestjs/common';
3import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
4
5// Konfiguracja wersjonowania w main.ts
6async function bootstrap() {
7  // const app = await NestFactory.create(AppModule);
8
9  // TODO: Wlacz URI versioning z defaultVersion '1'
10  // app.enableVersioning({
11  //   type: VersioningType.URI,
12  //   defaultVersion: '1',
13  // });
14
15  // TODO: Stworz DocumentBuilder dla wersji 1
16  const configV1 = new DocumentBuilder()
17    .setTitle('Imperium API v1')
18    .setDescription('Era Republiki')
19    .setVersion('1.0')
20    .build();
21
22  // TODO: Stworz DocumentBuilder dla wersji 2
23  // const configV2 = new DocumentBuilder()
24  //   .setTitle('Imperium API v2')
25  //   .setDescription('Era Cesarstwa')
26  //   .setVersion('2.0')
27  //   .build();
28
29  // TODO: Stworz osobne dokumenty Swagger dla kazdej wersji
30  // const docV1 = SwaggerModule.createDocument(app, configV1);
31  // SwaggerModule.setup('api/v1/docs', app, docV1);
32
33  // const docV2 = SwaggerModule.createDocument(app, configV2);
34  // SwaggerModule.setup('api/v2/docs', app, docV2);
35
36  console.log("Swagger v1: http://localhost:3000/api/v1/docs");
37  console.log("Swagger v2: http://localhost:3000/api/v2/docs");
38}
39
40bootstrap();
41
42console.log("=== Strategie wersjonowania ===");
43console.log("URI: /v1/legiones, /v2/legiones");
44console.log("Header: X-API-Version: 1");
45console.log("Media Type: Accept: application/json;v=1");
46console.log("");
47console.log("Kazda wersja moze miec osobny dokument Swagger");
48console.log("deprecated: true oznacza wycofany endpoint");
49

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jak wygląda URL endpointu przy URI Versioning z wersją 1?

  2. 2. Jak oznaczyć endpoint jako wycofany (deprecated) w @ApiOperation?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj kroki tworzenia wersjonowanego API z osobnymi dokumentami Swagger:

  • Edytor kodu

    Stwórz DocumentBuilder dla wersji 1 i wersji 2 API z odpowiednimi ustawieniami

  • Układanie w pionie

    Ułóż elementy deklaracji wersjonowanego kontrolera w prawidłowej kolejności:

  • Klikanie w kolejności

    Ułóż elementy włączania URI Versioning w main.ts:

  • Edytor kodu

    Stwórz konfigurację z title, description, version, contact, license, servers, tags i bearerAuth

Przydatne artykuły