Naciśnij “Enter” aby skoczyć do treści

Proste Api, Swagger i OpenAPI

Jeżeli tworzymy system, który wystawia API, dobrym podejściem jest równoczesne tworzenie dokumentacji. Niestety zazwyczaj nie jest to zajęcie ciekawe i po kilku kolejnych zmianach w kodzie okazuje się, że nie jest już ona aktualna. Dodatkowo w przypadku REST API nie możemy pobrać kontraktu usługi, więc pojawia się problem podczas integracji z innymi systemami. Osoby, które będą miały zamiar skorzystać z naszego API, nie będą po prostu znały modelu, jaki należy przesłać do poszczególnych usług.

Założenia

  • Utworzymy prostą aplikację webową z interfejsem REST API do zarządzania listą książek. Aplikacja ma być prosta i służyć do prezentacji tworzenia dokumentacji.
  • Do wygenerowania i prezentacji dokumentacji użyjemy narzędzia Swaggera UI
  • Całość utworzymy w .NET Core 3.1

Proste API

Utworzymy aplikację o nazwie SimpleOpenAPI, która będzie składała się z dwóch projektów. Pierwszym projektem będzie nasza aplikacja webowa SimpleOpenAPI.Api. Drugim natomiast biblioteka domenowa SimpleOpenAPI.Domain, w której znajdzie się logika biznesowa naszej aplikacji. Oczywiście w tym prostym przykładzie będzie to tylko namiastka logiki biznesowej.

Przy pomocy naszego REST API będziemy w stanie

  • Dodać książkę
  • Zaktualizować książkę
  • Usunąć książkę
  • Pobrać książkę
  • Pobrać wszystkie książki

Tworzenie aplikacji webowej

Zaczynamy od utworzenia pierwszego projektu, czyli aplikacji webowej.

dotnet new webapi -f netcoreapp3.1 -n SimpleOpenAPI.Api

Następnie tworzymy bibliotekę domenową

dotnet new classlib -f netstandard2.1 -n SimpleOpenAPI.Domain

Na koniec tworzymy solucję i dołączamy utworzone projekty

dotnet new sln -n SimpleOpenAPI
dotnet sln SimpleOpenAPI.sln add SimpleOpenAPI.Api/SimpleOpenAPI.Api.csproj SimpleOpenAPI.Domain/SimpleOpenAPI.Domain.csproj

Biblioteka domenowa

W bibliotece domenowej umieścimy logikę biznesową naszej aplikacji. W naszym przypadku będzie to tylko proste repozytorium książek umieszczone w pamięci.

W projekcie SimpleOpenAPI.Domain tworzymy dwa katalogi Models i Repositories. Dodatkowo, usuwamy plik class1.cs.
W katalogu Models tworzymy klasę Books.

public class Book
{
  public long InternalId { get; set; }
  public string Id { get; set; }
  public string Title { get; set; }
  public int PageCount { get; set; }
  public string Isbn { get; set; }
  public DateTime PublicationDate { get; set; }
  public string AuthorName { get; set; }
  public string ShortDescription { get; set; }
  public string Publisher { get; set; }
  public string Url { get; set; }
}

InternalId to wewnętrzne Id, które reprezentuje klucz główny w tabeli. Aby nie zwracać wartości klucza głównego dodamy kolejne pole o nazwie Id. Będzie to po prostu GUID tworzony przy zapisie książki.
Dla uproszczenia nie będziemy używać bazy danych, a kolekcję książek zapiszemy w pamięci.

W katalogu Repositories tworzymy interfejs i klasę repozytorium.

public interface IBookRepository
{
  Book GetBook(string id);
  IEnumerable<Book> GetAllBooks();
  void SaveBook(Book book);
  void DeleteBook(string id);
  bool UpdateBook(Book book);
  bool BookExists(string id);
}

public class MemoryBookRepository : IBookRepository
{
  private readonly List<Book> _books = new List<Book>();

  public Book GetBook(string id)
    => _books.FirstOrDefault(c => c.Id == id);

  public IEnumerable<Book> GetAllBooks()
    => _books;

  public void SaveBook(Book book)
    => _books.Add(book);

  public void DeleteBook(string id)
    => _books.RemoveAll(c => c.Id == id);

  public bool UpdateBook(Book book)
  {
    var bookIndex = _books.FindIndex(c => c.Id == book.Id);

    if (bookIndex >= 0)
    {
      _books[bookIndex] = book;
      return true;
    }

    return false;
  }

  public bool BookExists(string id) 
    => _books.FindIndex(c => c.Id == id) >= 0;

}

Aplikacja webowa

Zakładamy, że będzie to aplikacja z jednym kontrolerem REST, w którym umieścimy metody odwołujące się do repozytorium książek.

Zaczynamy od usunięcia przykładowych plików. Z projektu SimpleOpenAPI.Api usuwamy kontroler Controllers/WeatherForecastController.cs i model WeatherForecast.cs. Następnie tworzymy katalog Contracts i umieszczamy w nim kontrakty, które będą używane przez usługi REST. Znajdą się tu wszystkie klasy żądań i odpowiedzi. W przyszłości może pojawić się potrzeba utworzenia kolejnej wersji API, więc już teraz wszystkie klasy umieścimy w osobnym katalogu i przestrzeni nazw.

W katalogu SimpleOpenAPI.Api/Contracts/Common tworzymy klasę Response.cs. Będzie to bazowa klasa opisująca wszystkie odpowiedzi z usług REST. T oznaczać będzie typ klasy, która zostanie zwrócona w obiekcie Response. Dodatkowo nadpiszemy metodę ToString(), aby zwracała instancję klasy przekonwertowaną do postaci JSON.

public abstract class Response<T>
{
   public T Result { get; }

   public Response(T result)
   {
      Result = result;
   }

   public override string ToString()
      => JsonSerializer.Serialize(this, BaseJsonOptions.GetJsonSerializerOptions);
}

Klasę BaseJsonOptions tworzymy w katalogu SimpleOpenAPI.Api/Serializers. Będzie reprezentować konfigurację serializatora JSON. Użyjemy jej również później do konfiguracji w pliku Startup.cs.

public static class BaseJsonOptions
{
   public static bool IgnoreNullValues { get; } = true;
	
   public static JsonNamingPolicy PropertyNamingPolicy { get; } = JsonNamingPolicy.CamelCase;
	
   public static JsonSerializerOptions GetJsonSerializerOptions { get; } = new JsonSerializerOptions
   {
      IgnoreNullValues = IgnoreNullValues,
      PropertyNamingPolicy = PropertyNamingPolicy,
   };
}

W katalogu SimpleOpenAPI.Api/Contracts/V1/Resources tworzymy klasę BookResource.cs. Jest to klasa opisująca książkę. Między modelem domenowym, różni się brakiem pola InternalId, które używamy tylko do celów wewnętrznych. Katalog V1 oznacza, że wszystkie klasy dotyczą API w wersji V1.

public class BookResource
{
   public string Id { get; set; }   
   public string Title { get; set; }   
   public int PageCount { get; set; }   
   public string Isbn { get; set; }   
   public DateTime PublicationDate { get; set; }   
   public string AuthorName { get; set; }   
   public string ShortDescription { get; set; }   
   public string Publisher { get; set; }   
   public string Url { get; set; }
}

Żądania

W katalogu SimpleOpenAPI.Api/Contracts/V1/Request tworzymy klasy opisujące żądania do naszych usług.

Zaczynamy od klas opisujących dodawanie i aktualizację informacji o książce.

public abstract class BookBaseRequest
{
   [Required]
   public string Title { get; set; }
   [DefaultValue(0)]
   public int PageCount { get; set; }
   public string Isbn { get; set; }
   public DateTime PublicationDate { get; set; }
   public string AuthorName { get; set; }
   public string ShortDescription { get; set; }
   public string Publisher { get; set; }
   public string Url { get; set; }
}

public class UpdateBookIdRequest : BaseIdRequest
{

}

public class AddBookRequest : BookBaseRequest
{

}

Klasy różnią się tylko nazwą, więc aby nie duplikować kodu tworzymy klasę bazową. W klasie bazowej używamy dodatkowo przestrzeni nazw System.ComponentModel i System.ComponentModel.DataAnnotations. Oznaczamy w ten sposób wymagalność pola Title i domyślną wartość PageCount. Zostanie to użyte podczas generowania dokumentacji.

Następnie tworzymy klasę bazową BaseIdRequest, która zostanie użyta w klasach przekazujących informację o identyfikatorze książki. Atrybut FromRoute oznacza, że pole id zostanie pobrane z URL żądania, np. api/v1/Books/{id}. Identyfikator w postaci GUID będzie tworzony podczas dodawania książki. Zamiast trzech klas dziedziczących można użyć jednej. My tworzymy trzy, aby jawnie określić, gdzie i do czego będą używane.

public abstract class BaseIdRequest
{
   /// <summary>
   /// Book id
   /// </summary>
   [FromRoute(Name = "id")]
   public Guid Id { get; set; }
}

public class DeleteBookRequest : BaseIdRequest
{

}

public class GetBookRequest : BaseIdRequest
{

}

public class UpdateBookIdRequest : BaseIdRequest
{

}

Odpowiedzi z usług

Przechodzimy do utworzenia klas opisujących odpowiedzi z usług. Tworzymy je w katalogu SimpleOpenAPI.Api/Contracts/V1/Responses.
Używamy wcześniej utworzonej klasy Response, która opakowuje odpowiedź.

public class IdResponse
{
   public Guid Id { get; }

   public IdResponse(Guid id)
   {
      Id = id;
   }
}

public abstract class BaseBookResponse : Response<IdResponse>
{
   protected BaseBookResponse(IdResponse result) : base(result)
   {
      
   }

   protected BaseBookResponse(Guid id) : base(new IdResponse(id))
   {
      
   }
}

public class AddBookResponse : BaseBookResponse
{
   public AddBookResponse(IdResponse result) : base(result)
   {
      
   }

   public AddBookResponse(Guid id) : base(id)
   {
      
   }
}

public class UpdateBookResponse : BaseBookResponse
{
   public UpdateBookResponse(IdResponse result) : base(result)
   {
      
   }

   public UpdateBookResponse(Guid id) : base(id)
   {
      
   }
}

public class GetBookResponse : Response<BookResource>
{
   public GetBookResponse(BookResource result) : base(result)
   {
      
   }
}

public class GetAllBooksResponse : Response<IEnumerable<BookResource>>
{
   public GetAllBooksResponse(IEnumerable<BookResource> result) : base(result)
   {
      
   }
}

Mapowanie obiektów

Do mapowania obiektów domenowych na obiekty REST użyjemy biblioteki AutoMapper. Dodajemy ją z poziomu menagera pakietów NuGet. Dodajemy również bibliotekę AutoMapper.Extensions.Microsoft.DependencyInjection, która zarejestruje (w Startup.cs) wszystkie klasy mapujące. Po dodaniu bibliotek, w pliku SimpleOpenAPI.Api.scproj powinna pojawić się mniej więcej taka sekcja. Wersje oczywiście mogą się różnić.

<ItemGroup>
   <PackageReference Include="AutoMapper" Version="10.1.1" />
   <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.0" />
</ItemGroup>

Następnie w projekcie SimpleOpenAPI.Api tworzymy katalog Mapping i dodajemy klasę MappingProfile.cs. Umieścimy w niej konfigurację mapowania. Użycie AutoMappera wyeliminuję potrzebę ręcznego przepisywania jednego obiektu na drugi.

public class MappingProfile : Profile
{
   public MappingProfile()
   {
      // Domain to Resource
      CreateMap<Book, BookResource>();
      
      // Resource to Domain
      CreateMap<BookResource, Book>();
      CreateMap<AddBookRequest, Book>();
      CreateMap<UpdateBookRequest, Book>();
   }
}

Konfiguracja

Aby z poziomu projektu SimpleOpenAPI.Api uzyskać dostęp do elementów projektu domenowego, musimy dodać go do referencji. Klikamy prawym przyciskiem myszy w Dependencies (projekt SimpleOpenAPI.Api) i wybieramy Add Project Reference. Z wyświetlonej listy wybieramy projekt SimpleOpenAPI.Domain.

Teraz przechodzimy do klasy Startup.cs i w metodzie ConfigureServices konfigurujemy serializator Json oraz rejestrujemy AutoMapper’a i repozytorium. Dobrym pomysłem byłoby używanie repozytorium tylko w projektach domenowych a w kontrolerze użycie np. serwisu BookService (utworzonego również w projekcie domenowym).

public void ConfigureServices(IServiceCollection services)
{
   services.AddControllers().AddJsonOptions(options =>
   {
         options.JsonSerializerOptions.IgnoreNullValues = BaseJsonOptions.IgnoreNullValues;
         options.JsonSerializerOptions.PropertyNamingPolicy = BaseJsonOptions.PropertyNamingPolicy;
   });

   services.AddAutoMapper(typeof(Startup));

   services.AddSingleton<IBookRepository, MemoryBookRepository>();
}

Automapper przeszukuje wszystkie klasy i rejestruje te, które implementują interfejsy i klasy abstrakcyjne przez niego używane, np. MappingProfile.
Repozytorium będzie trzymane w pamięci, więc rejestrujemy je jako singleton. W ten sposób dane w nim zapisane nie znikną między żądaniami.

Kontroler

W końcu doszliśmy do kontrolera. Dodajemy go w katalogu SimpleOpenAPI.Api/Controllers/V1 i nazywamy BooksController.cs

/// <summary>
/// Books
/// </summary>
[Route("api/v1/[controller]")]
[ApiController]
public class BooksController : ControllerBase
{
   private readonly IBookRepository _bookRepository;
   private readonly IMapper _mapper;

   public BooksController(IBookRepository bookRepository, IMapper mapper)
   {
      _bookRepository = bookRepository;
      _mapper = mapper;
   }

   /// <summary>
   /// Returns a list of all books
   /// </summary>
   /// <response code="200">Success - Returns a list of all books</response>
   /// <response code="204">No Content - The are no books</response>
   /// <returns>A list of all books</returns>
   [HttpGet]
   public ActionResult<GetAllBooksResponse> GetAllBooks()
   {
      var books = _bookRepository.GetAllBooks();
      
      if (books is null || !books.Any())
      {
            return NoContent();
      }

      var bookResources = _mapper.Map<IEnumerable<Book>, IEnumerable<BookResource>>(books);
      
      var response = new GetAllBooksResponse(bookResources);

      return Ok(response);
   }

   /// <summary>
   /// Returns the selected book
   /// </summary>
   /// <param name="request">Book Id</param>
   /// <response code="200">Success - Returns the selected book</response>
   /// <response code="404">Not Found - Book not found</response>
   /// <returns>Selected book</returns>
   [HttpGet("{id}")]
   public ActionResult<GetBookResponse> GetBook([FromRoute] GetBookRequest request)
   {
      var book = _bookRepository.GetBook(request.Id.ToString());

      if (book is null)
      {
            return NotFound("Book not found");
      }

      var bookResource = _mapper.Map<Book, BookResource>(book);
      
      var response = new GetBookResponse(bookResource);

      return Ok(response);
   }

   /// <summary>
   /// Add a book
   /// </summary>
   /// <param name="request">Book to be added</param>
   /// <response code="201">Success - The book has been added</response>
   /// <response code="400">BadRequest - If the book data are incorrect</response>
   /// <returns>Book Id</returns>
   [HttpPost]
   public ActionResult<AddBookResponse> AddBook(AddBookRequest request)
   {
      var book = _mapper.Map<AddBookRequest, Book>(request);

      book.Id = Guid.NewGuid().ToString();
      
      _bookRepository.SaveBook(book);

      return CreatedAtAction(nameof(GetBook), new GetBookRequest {Id = Guid.Parse(book.Id)},
            new AddBookResponse(Guid.Parse(book.Id)));
   }

   /// <summary>
   /// Update a book
   /// </summary>
   /// <param name="idRequest">Book Id</param>
   /// <param name="request">Book to be updated</param>
   /// <response code="200">Success - The book has been updated</response>
   /// <response code="204">NoContent - There is no specific book</response>
   /// <returns></returns>
   [HttpPut("{id}")]
   public ActionResult<UpdateBookResponse> UpdateBook([FromRoute] UpdateBookIdRequest idRequest, UpdateBookRequest request)
   {
      var book = _mapper.Map<UpdateBookRequest, Book>(request);

      book.Id = idRequest.Id.ToString();

      if (_bookRepository.BookExists(book.Id))
      {
            _bookRepository.UpdateBook(book);
            return Ok(new UpdateBookResponse(idRequest.Id));
      }

      return NoContent();
   }

   /// <summary>
   /// Delete a book
   /// </summary>
   /// <param name="request">Book Id</param>
   /// <response code="204">Success - The book has been deleted</response>
   [HttpDelete("{id}")]
   public IActionResult DeleteBook([FromRoute] DeleteBookRequest request)
   {
      _bookRepository.DeleteBook(request.Id.ToString());
      return NoContent();
   }
}

W konstruktorze wstrzykujemy obiekty, z których korzystamy w metodach. Wcześniej zarejestrowaliśmy je w klasie Startup.cs. W poszczególnych metodach używamy automappera do przemapowania obiektów restowych na obiekty domenowe lub na odwrót. W metodzie AddBook tworzymy id książki oraz używamy metody CreatedAtAction, która tworzy odpowiedź ze statusem 201 oraz nagłówkiem Location, czyli adresem do dodanej książki, np. https://localhost:5001/api/v1/Books/338a491d-5e70-4e8c-9460-68eef525f82a.

Uruchamiamy i testujemy API

W konsoli przechodzimy do katalogu ./SimpleOpenAPI.Api/ i uruchamiamy REST Api.

dotnet run

Do testów API możemy użyć różnych aplikacji. Chyba najbardziej znany jest Postman ale my użyjemy darmową Insomnię. Insomnia a właściwie Insomnia Core jest wieloplatformowym klientem REST zbudowanym na bazie Electron.

Instalujemy i uruchamiamy aplikację.

W kolumnie po lewej stronie klikamy prawym przyciskiem myszy i tworzymy nowy folder o nazwie Simple API. Następnie klikamy prawym przyciskiem myszy na utworzony folder i wybieramy New Request. Wpisujemy nazwę żądania, np. Add book. Następnie zmieniamy metodę z GET na POST oraz No Body na JSON i klikamy Create.
Na górze obok metody POST podajemy adres do naszej usługi

https://localhost:5001/api/v1/books

Poniżej uzupełniamy treść żądania, czyli dane książki, którą chcemy dodać.

{
   "Title": "ASP.NET Core 3 and Angular 9 - Third Edition",
   "PageCount": 732,
   "Isbn": "9781789612165",
   "PublicationDate": "2020-08-22T19:08:20",
   "AuthorName": "Valerio De Sanctis",
   "ShortDescription": "The book will get you started with using the .NET Core framework and Web API Controllers to implement API calls and server-side routing in the backend.",
   "Publisher": "Packt",
   "Url": "https://www.packtpub.com/product/asp-net-core-3-and-angular-9-third-edition/9781789612165"
}

Klikamy Send. Jeżeli pojawi się błąd “Error: SSL peer certificate or SSH remote key was not OK” to możemy wyłączyć walidację certyfikatów (Application -> Preferences -> Request / Response -> Validate certificates) i jeszcze raz kliknąć Send.
Jeżeli wszystko przebiegło prawidłowo, powinniśmy dostać odpowiedź ze statusem 201 Created i id dodanej książki.
SimpleApi AddBook Insomnia
Teraz sprawdzamy, czy książka faktycznie została dodana.
Tworzymy nowe żądanie, tym razem GET i przekazujemy id dodanej książki

https://localhost:5001/api/v1/books/766d54af-01ce-44dc-8b93-55bd1025f235

Klikamy Send.
SimpleApi GetBook Insomnia
Otrzymaliśmy odpowiedź ze statusem 200 i z danymi książki, czyli API działa. Pozostałe metody spróbuj dodać we włąsnym zakresie.

Get all books GET: https://localhost:5001/api/v1/books
Update book PUT: https://localhost:5001/api/v1/books/766d54af-01ce-44dc-8b93-55bd1025f235
Delete book DELETE: https://localhost:5001/api/v1/books/766d54af-01ce-44dc-8b93-55bd1025f235

Swagger i OpenAPI

API jest już gotowe więc przechodzimy do generowania dokumentacji.

Zaczynamy od dodania dwóch bibliotek NuGet: Swashbuckle.AspNetCore i Swashbuckle.AspNetCore.Filters.
Pierwsza zawiera Swagger Tools do budowania dokumentacji, druga to dodatkowe filtry, których użyjemy do tworzenia przykładowych żądań i odpowiedzi z usług.

Plik SimpleOpenAPI.Api.scproj powinien wyglądać mniej więcej tak jak poniżej. Aby go otworzyć w Visual Studio, kliknij prawym przyciskiem myszy na SimpleOpenAPI.Api i wybierz Edit project file. Podobnie jest w Riderze, kliknij prawym przyciskiem myszy na SimpleOpenAPI.Api i następnie Edit -> Edit ‘SimpleOpenAPI.Api.csproj’.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AutoMapper" Version="10.1.1" />
    <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
    <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="6.0.1" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SimpleOpenAPI.Domain\SimpleOpenAPI.Domain.csproj" />
  </ItemGroup>

</Project>

Extensions

Aby nie umieszczać całej konfiguracji Swaggera w pliku Startup.cs, przenosimy ją do metod rozszerzających interfejsy IServiceCollection i IApplicationBuilder.
Tworzymy nowy katalog Extensions w SimpleOpenAPI.Api i dodajemy dwie klasy.

public static class ApplicationBuilderExtensions
{
   public static void UseCustomSwagger(this IApplicationBuilder app, string url = "/swagger/v1/swagger.json", string name = "API V1")
   {
      app.UseSwagger();
      app.UseSwaggerUI(c =>
      {
         c.SwaggerEndpoint(url: url, name: name);
      });
   }
}
public static class ServiceCollectionExtensions
{
	public static IServiceCollection AddSwagger<T>(this IServiceCollection services, bool includeXmlComments = false,
		string name = "v1", string title = "API", string version = "v1", string description = "", OpenApiContact contact = null)
		=> AddSwagger<T>(services, typeof(T).Assembly, includeXmlComments, name, title, version, description, contact);
	
	public static IServiceCollection AddSwagger<T>(this IServiceCollection services, Assembly assembly, bool includeXmlComments = false,
		string name = "v1", string title = "API", string version = "v1", string description = "", OpenApiContact contact = null)
	{
		services.AddSwaggerGen(c =>
		{
			c.SwaggerDoc(name: name, new OpenApiInfo
			{
				Title = title,
				Version = version,
				Description = description,
				Contact = contact
			});
			
			c.IgnoreObsoleteActions();

			if (includeXmlComments)
			{
				c.ExampleFilters();

				var xmlFile = $"{assembly.GetName().Name}.xml";
				var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
				if (File.Exists(xmlPath))
				{
					c.IncludeXmlComments(xmlPath);
				}
			}

		});

		if (includeXmlComments)
		{
			services.AddSwaggerExamplesFromAssemblyOf<T>();
		}

		return services;
	}
}

W ServiceCollectionExtensions używamy metody IncludeXmlComments, aby dołączyć plik z wygenerowanym opisem metod, parametrów i operacji. Zostanie on użyty podczas generowania dokumentacji. Aby utworzyć plik klikamy prawym przyciskiem myszy na SimpleOpenAPI.Api, wybieramy Properties -> Build i w sekcji Output zaznaczamy XML documentation file.
Uzupełniamy ścieżki do plików:

  • Debug: bin\Debug\SimpleOpenAPI.Api.xml
  • Release: bin\Release\SimpleOpenAPI.Api.xml

Po włączeniu generowania pliku z dokumentacją mogą pojawić się ostrzeżenia typu:

warning CS1591: Missing XML comment for publicly visible type or member

Aby się ich pozbyć możemy uzupełnić opisy lub wyłączyć ostrzeżenie. Dodatkowe opisy nie są nam potrzebne więc wyłączamy ostrzeżenie dodając w pliku impleOpenAPI.Api.csproj

<NoWarn>$(NoWarn);1591</NoWarn>

Po tych zmianach plik SimpleOpenAPI.Api.csproj powinien wyglądać mniej więcej tak

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <DocumentationFile>bin\Debug\SimpleOpenAPI.Api.xml</DocumentationFile>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
    <DocumentationFile>bin\Release\SimpleOpenAPI.Api.xml</DocumentationFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AutoMapper" Version="10.1.1" />
    <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
    <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="6.0.1" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SimpleOpenAPI.Domain\SimpleOpenAPI.Domain.csproj" />
  </ItemGroup>

</Project>

Następnie podpinamy konfigurację Swaggera w Startup.cs

public void ConfigureServices(IServiceCollection services)
{
   // ...
   services.AddSwagger<Startup>(includeXmlComments: true, name: "v1", title: "Book API", version: "v1",
      "API for book management", new OpenApiContact { Email = "apiowner@email.com", Name = "API Owner" });
   // ...
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
   // ...
   app.UseCustomSwagger("/swagger/v1/swagger.json", "Book API V1");
   // ...
}

Aby, po uruchomieniu aplikacji (z poziomu VS lub Ridera) zobaczyć stronę dokumentacji, modyfikujemy plik Properties/launchSettings.json i dla wszystkich wystąpień “launchUrl” ustawiamy wartość “swagger”.

"launchUrl": "swagger",

Przykładowe żądania i odpowiedzi

Zostaje jeszcze utworzenie przykładowych żądań i odpowiedzi z usług.
Tworzymy katalog SimpleOpenAPI.Api/SwaggerExamples i dodajemy następujące klasy

public class AddBookRequestExample : IExamplesProvider<AddBookRequest>
{
	public AddBookRequest GetExamples()
	{
		AddBookRequest request = new AddBookRequest
		{
			Title = "ASP.NET Core 3 and Angular 9 - Third Edition",
			PageCount = 732,
			Isbn = "9781789612165",
			PublicationDate = new DateTime(2020, 08, 22, 19, 08, 20),
			AuthorName = "Valerio De Sanctis",
			ShortDescription =
				"The book will get you started with using the .NET Core framework and Web API Controllers to implement API calls and server-side routing in the backend.",
			Publisher = "Packt",
			Url = "https://www.packtpub.com/product/asp-net-core-3-and-angular-9-third-edition/9781789612165"
		};

		return request;
	}
}

public class GetAllBooksResponseExample : IExamplesProvider<GetAllBooksResponse>
{
	public GetAllBooksResponse GetExamples()
	{
		IEnumerable<BookResource> bookResources = new List<BookResource>
		{
			GetBookResource(),
			GetBookResource()
		};
		
		var response = new GetAllBooksResponse(bookResources);
		return response;
	}

	private BookResource GetBookResource()
	{
		BookResource bookResource = new BookResource
		{
			Id = Guid.NewGuid().ToString(),
			Title = "ASP.NET Core 3 and Angular 9 - Third Edition",
			PageCount = 732,
			Isbn = "9781789612165",
			PublicationDate = new DateTime(2020, 08, 22, 19, 08, 20),
			AuthorName = "Valerio De Sanctis",
			ShortDescription =
				"The book will get you started with using the .NET Core framework and Web API Controllers to implement API calls and server-side routing in the backend.",
			Publisher = "Packt",
			Url = "https://www.packtpub.com/product/asp-net-core-3-and-angular-9-third-edition/9781789612165"
		};
		return bookResource;
	}
}

public class GetBookResponseExample : IExamplesProvider<GetBookResponse>
{
	public GetBookResponse GetExamples()
	{
		BookResource bookResource = new BookResource
		{
			Id = Guid.NewGuid().ToString(),
			Title = "ASP.NET Core 3 and Angular 9 - Third Edition",
			PageCount = 732,
			Isbn = "9781789612165",
			PublicationDate = new DateTime(2020, 08, 22, 19, 08, 20),
			AuthorName = "Valerio De Sanctis",
			ShortDescription =
				"The book will get you started with using the .NET Core framework and Web API Controllers to implement API calls and server-side routing in the backend.",
			Publisher = "Packt",
			Url = "https://www.packtpub.com/product/asp-net-core-3-and-angular-9-third-edition/9781789612165"
		};
		
		var response = new GetBookResponse(bookResource);
		return response;
	}
}

public class UpdateBookRequestExample : IExamplesProvider<UpdateBookRequest>
{
	public UpdateBookRequest GetExamples()
	{
		UpdateBookRequest request = new UpdateBookRequest
		{
			Title = "ASP.NET Core 3 and Angular 9 - Third Edition",
			PageCount = 732,
			Isbn = "9781789612165",
			PublicationDate = new DateTime(2020, 08, 22, 19, 08, 20),
			AuthorName = "Valerio De Sanctis",
			ShortDescription =
				"The book will get you started with using the .NET Core framework and Web API Controllers to implement API calls and server-side routing in the backend.",
			Publisher = "Packt",
			Url = "https://www.packtpub.com/product/asp-net-core-3-and-angular-9-third-edition/9781789612165"
		};

		return request;
	}
}

Do metod w kontrolerze dodajemy atrybut ProducesResponseType, który doprecyzuje zwracany typ danych i status. Zazwyczaj atrybut ProducesResponseType dodaje się tylko do metod, które zwracają IActionResult zamiast obiektów DTO lub kiedy dodajemy dodatkowe statusy odpowiedzi. Dla przejrzystości i spójności dodajmy jednak do wszystkich.

[HttpGet]
[ProducesResponseType(type: typeof(GetAllBooksResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<GetAllBooksResponse> GetAllBooks()

[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<GetBookResponse> GetBook([FromRoute] GetBookRequest request)

[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public ActionResult<AddBookResponse> AddBook(AddBookRequest request)

[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<UpdateBookResponse> UpdateBook([FromRoute] UpdateBookIdRequest idRequest, UpdateBookRequest request)

[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult DeleteBook([FromRoute] DeleteBookRequest request)

Podgląd dokumentacji

W końcu doszliśmy do końca, więc spróbujmy uruchomić aplikację i sprawdzić jak wygląda dokumentacja.
Po wciśnięciu F5, VisualStudio jak i Rider powinny otworzyć przeglądarkę z adresem dokumentacji. Jeżeli tak się nie stało lub uruchamiasz aplikację z konsoli to wystarczy, że w przeglądarce wpiszesz https://localhost:5001/swagger.
SpimpleApi Swagger 01
Dokumentacja jest w pełni interaktywna. Możemy dodawać książki, aktualizować, pobierać, usuwać. Dodaliśmy przykładowe żądania, więc nie musimy nawet podawać danych. Wystarczy, że np. klikniemy metodę odpowiedzialną za dodanie książki, następnie Try it out i Execute. Książka zostaje dodana i w odpowiedzi dostajemy jej id. Od teraz jesteśmy w stanie robić proste testy bez potrzeby używania zewnętrznych aplikacji.

Jeżeli chcemy uzyskać dostęp do pliku OpenAPI, klikamy w link na górze strony /swagger/v1/swagger.json. Bezpośredni adres https://localhost:5001/swagger/v1/swagger.json.

Podsumowanie

Myślałem, że będzie to krótki wpis a wyszło jak wyszło 🙂
Udało się jednak stworzyć proste REST API i rozszerzyć je o automatycznie generowaną dokumentację. W kolejnych postach zajmiemy się generowaniem klienta oraz rozwojem naszego API.

Dzięki i do następnego…

Kod aplikacji dostępny jest na GitHub