Fabio Maffioletti


19 Jan 2015

Document a Spring Boot RESTful API with JSONDoc

This blog post can be divided into two parts: in the first part I will write a Spring Boot RESTful API and in the second part I'll show how to use JSONDoc to document the created API. Doing the two parts should take a maximum of 15 minutes since creating an API with Spring Boot is very easy and fast, and the same goes with documenting it with the JSONDoc Spring Boot starter and UI webjar. I'll skip tests creation for this example, since the main goal how to document the API rather than writing and testing it.

Write the API

Let's begin with creating the Maven project based on the quickstart archetype

JSONDoc Maven coordinates

and declaring the needed dependencies for the API:

  • spring-boot-starter-web
  • spring-boot-starter-data-jpa
  • h2

I also added Lombok to let me keep my code cleaner. The resulting pom looks like this:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>org.example</groupId>
	<artifactId>jsondoc-shelf</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>jar</packaging>

	<name>jsondoc-shelf</name>
	<url>http://maven.apache.org</url>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	</properties>

	<dependencies>
	
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
			<version>1.2.0.RELEASE</version>
		</dependency>
		
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
			<version>1.2.0.RELEASE</version>
		</dependency>
		
		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<version>1.3.176</version>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.14.8</version>
		</dependency>

		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>4.11</version>
			<scope>test</scope>
		</dependency>
		
	</dependencies>
</project>

This application will be a collection of services to manage a simple shelf. There will be two entities:

  • Book
  • Author

Create Entities and Controllers

To do this I will create the usual components to manage the persistence and controller layers:

  • a new package named model that will contain Book and Author
  • a new package named repository that will contain BookRepository and AuthorRepository
  • a new package named controller that will contain BookController and AuthorController

For this example I will skip the service layer. I will also create a DatabasePopulator class, implementing CommandLineRunner so that at startup there will be some data in the in memory database. Let's see the entities, repositories and controllers code:

Entities
package org.example.shelf.model;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Entity
@Data
@EqualsAndHashCode(exclude = "id")
public class Book {

	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private Long id;

	@Column(name = "title")
	private String title;

	@ManyToOne
	@JoinColumn(name = "author_id")
	private Author author;

}
package org.example.shelf.model;

import java.util.ArrayList;
import java.util.List;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToMany;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;

import com.fasterxml.jackson.annotation.JsonIgnore;

@Entity
@Data
@NoArgsConstructor
@ToString(exclude = "books")
@EqualsAndHashCode(of = "name")
public class Author {

	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private Long id;

	@Column(name = "name")
	private String name;

	@JsonIgnore
	@OneToMany(mappedBy = "author", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
	private List<Book> books = new ArrayList<Book>();
	
}
Repositories
package org.example.shelf.repository;

import org.example.shelf.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {

}
package org.example.shelf.repository;

import org.example.shelf.model.Author;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

public interface AuthorRepository extends JpaRepository<Author, Long> {

}
Controllers
package org.example.shelf.controller;

import java.util.List;

import org.example.shelf.flow.ShelfFlowConstants;
import org.example.shelf.model.Book;
import org.example.shelf.repository.BookRepository;
import org.jsondoc.core.annotation.Api;
import org.jsondoc.core.annotation.ApiBodyObject;
import org.jsondoc.core.annotation.ApiMethod;
import org.jsondoc.core.annotation.ApiPathParam;
import org.jsondoc.core.annotation.ApiResponseObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

@RestController
@RequestMapping(value = "/books", produces = MediaType.APPLICATION_JSON_VALUE)
public class BookController {
	
	@Autowired
	private BookRepository bookRepository;
	
	@RequestMapping(value = "/{id}", method = RequestMethod.GET)
	public Book findOne(@PathVariable Long id) {
		return bookRepository.findOne(id);
	}
	
	@RequestMapping(method = RequestMethod.GET)
	public List<Book> findAll() {
		return bookRepository.findAll();
	}
	
	@RequestMapping(method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
	@ResponseStatus(value = HttpStatus.CREATED)
	public ResponseEntity<Void> save(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
		bookRepository.save(book);
		
		HttpHeaders headers = new HttpHeaders();
		headers.setLocation(uriComponentsBuilder.path("/books/{id}").buildAndExpand(book.getId()).toUri());
		return new ResponseEntity<Void>(headers, HttpStatus.CREATED);
	}
	
	@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
	@ResponseStatus(value = HttpStatus.OK)
	public void delete(@PathVariable Long id) {
		Book book = bookRepository.findOne(id);
		bookRepository.delete(book);
	}

}
package org.example.shelf.controller;

import java.util.List;

import org.example.shelf.flow.ShelfFlowConstants;
import org.example.shelf.model.Author;
import org.example.shelf.repository.AuthorRepository;
import org.jsondoc.core.annotation.Api;
import org.jsondoc.core.annotation.ApiBodyObject;
import org.jsondoc.core.annotation.ApiMethod;
import org.jsondoc.core.annotation.ApiPathParam;
import org.jsondoc.core.annotation.ApiResponseObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

@RestController
@RequestMapping(value = "/authors", produces = MediaType.APPLICATION_JSON_VALUE)
public class AuthorController {

	@Autowired
	private AuthorRepository authorRepository;

	@RequestMapping(value = "/{id}", method = RequestMethod.GET)
	public Author findOne(@PathVariable Long id) {
		return authorRepository.findOne(id);
	}

	@RequestMapping(method = RequestMethod.GET)
	public List<Author> findAll() {
		return authorRepository.findAll();
	}

	@RequestMapping(method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
	@ResponseStatus(value = HttpStatus.CREATED)
	public ResponseEntity<Void> save(@RequestBody Author author, UriComponentsBuilder uriComponentsBuilder) {
		authorRepository.save(author);
		
		HttpHeaders headers = new HttpHeaders();
		headers.setLocation(uriComponentsBuilder.path("/authors/{id}").buildAndExpand(author.getId()).toUri());
		return new ResponseEntity<Void>(headers, HttpStatus.CREATED);
	}

	@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
	@ResponseStatus(value = HttpStatus.OK)
	public void delete(@PathVariable Long id) {
		Author author = authorRepository.findOne(id);
		authorRepository.delete(author);
	}

}
Database populator
package org.example.shelf;

import org.example.shelf.model.Author;
import org.example.shelf.model.Book;
import org.example.shelf.repository.AuthorRepository;
import org.example.shelf.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DatabasePopulator implements CommandLineRunner {
	
	@Autowired
	private AuthorRepository authorRepository;
	
	@Autowired
	private BookRepository bookRepository;
	
	public void run(String... arg0) throws Exception {
		Author horbny = new Author();
		horbny.setId(1L);
		horbny.setName("Nick Horby");
		
		Author smith = new Author();
		smith.setId(2L);
		smith.setName("Wilbur Smith");
		
		authorRepository.save(horbny);
		authorRepository.save(smith);
		
		Book highFidelty = new Book();
		highFidelty.setId(1L);
		highFidelty.setTitle("High fidelty");
		highFidelty.setAuthor(horbny);
		
		Book aLongWayDown = new Book();
		aLongWayDown.setId(2L);
		aLongWayDown.setTitle("A long way down");
		aLongWayDown.setAuthor(horbny);

		Book desertGod = new Book();
		desertGod.setId(3L);
		desertGod.setTitle("Desert god");
		desertGod.setAuthor(smith);
		
		bookRepository.save(highFidelty);
		bookRepository.save(aLongWayDown);
		bookRepository.save(desertGod);
	}

}

It's now time to write the main class to run the application. I'll call it Shelf and also in this case, thanks to Spring Boot, it's very simple:

package org.example.shelf;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@EnableAutoConfiguration
@EnableJpaRepositories
@ComponentScan
public class Shelf {
	
	public static void main(String[] args) {
		SpringApplication.run(Shelf.class, args);
	}

}

By running this class we can actually verify that the application is responding as expected to requests. You can easily test that the API work by using curl:

curl -i http://localhost:8080/books/1
curl -i http://localhost:8080/books

curl -i http://localhost:8080/authors/1
curl -i http://localhost:8080/authors

Document the API with JSONDoc

Here is the interesting and new part, i.e. using the JSONDoc library to annotate your code and automatically produce its documentation. To do that you have to declare the JSONDoc dependencies and insert a little code in your classes. Let's see how to do that:

Declare JSONDoc dependencies

Just add two more dependencies to the pom file:

<dependency>
	<groupId>org.jsondoc</groupId>
	<artifactId>spring-boot-starter-jsondoc</artifactId>
	<version>1.1.3</version>
</dependency>

<dependency>
	<groupId>org.jsondoc</groupId>
	<artifactId>jsondoc-ui-webjar</artifactId>
	<version>1.1.3</version>
</dependency>

Enable JSONDoc in main class

With the JSONDoc starter you can enable documentation generation just by adding @EnableJSONDoc to the Shelf class, which will be like:

package org.example.shelf;

import org.jsondoc.spring.boot.starter.EnableJSONDoc;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@EnableAutoConfiguration
@EnableJpaRepositories
@EnableJSONDoc
@ComponentScan
public class Shelf {
	
	public static void main(String[] args) {
		SpringApplication.run(Shelf.class, args);
	}

}

Configure JSONDoc

Next thing to do is configure JSONDoc to scan your controllers, objects and flow classes. To do that just add some entries to the application.properties file (create it under src/main/resources if you don't have it)

jsondoc.version=1.0
jsondoc.basePath=http://localhost:8080
jsondoc.packages[0]=org.example.shelf.model
jsondoc.packages[1]=org.example.shelf.controller

Document controllers

JSONDoc can grab several information from the Spring annotations to build the documentation. Anyway it is an opt-in process, meaning that JSONDoc will scan classes and methods only if annotated with its own annotations. For example, to properly document the BookController, here is how the JSONDoc annotations should be used:

package org.example.shelf.controller;

import java.util.List;

import org.example.shelf.flow.ShelfFlowConstants;
import org.example.shelf.model.Book;
import org.example.shelf.repository.BookRepository;
import org.jsondoc.core.annotation.Api;
import org.jsondoc.core.annotation.ApiBodyObject;
import org.jsondoc.core.annotation.ApiMethod;
import org.jsondoc.core.annotation.ApiPathParam;
import org.jsondoc.core.annotation.ApiResponseObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

@RestController
@RequestMapping(value = "/books", produces = MediaType.APPLICATION_JSON_VALUE)
@Api(description = "The books controller", name = "Books services")
public class BookController {
	
	@Autowired
	private BookRepository bookRepository;
	
	@ApiMethod
	@RequestMapping(value = "/{id}", method = RequestMethod.GET)
	public @ApiResponseObject Book findOne(@ApiPathParam(name = "id") @PathVariable Long id) {
		return bookRepository.findOne(id);
	}
	
	@ApiMethod
	@RequestMapping(method = RequestMethod.GET)
	public @ApiResponseObject List<Book> findAll() {
		return bookRepository.findAll();
	}
	
	@ApiMethod
	@RequestMapping(method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
	@ResponseStatus(value = HttpStatus.CREATED)
	public @ApiResponseObject ResponseEntity<Void> save(@ApiBodyObject @RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
		bookRepository.save(book);
		
		HttpHeaders headers = new HttpHeaders();
	    headers.setLocation(uriComponentsBuilder.path("/books/{id}").buildAndExpand(book.getId()).toUri());
		return new ResponseEntity<Void>(headers, HttpStatus.CREATED);
	}
	
	@ApiMethod
	@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
	@ResponseStatus(value = HttpStatus.OK)
	public void delete(@ApiPathParam(name = "id") @PathVariable Long id) {
		Book book = bookRepository.findOne(id);
		bookRepository.delete(book);
	}

}

The same goes for AuthorController.

Document objects

Next thing to do is to put some JSONDoc annotations also on objects that need to be documented, in this case Book and Author. Here is the resulting Book class:

package org.example.shelf.model;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;

import org.jsondoc.core.annotation.ApiObject;
import org.jsondoc.core.annotation.ApiObjectField;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Entity
@Data
@EqualsAndHashCode(exclude = "id")
@ApiObject
public class Book {

	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	@ApiObjectField(description = "The book's ID")
	private Long id;

	@Column(name = "title")
	@ApiObjectField(description = "The book's title")
	private String title;

	@ManyToOne
	@JoinColumn(name = "author_id")
	@ApiObjectField(description = "The book's author")
	private Author author;

}

And also in this case, the same goes for Author.

Checkpoint: start up the application

Before going ahead with documenting flows, let's startup the application and see what happens:

  • If you go to http://localhost:8080/jsondoc you will see a json, that is generated by JSONDoc and represents the documentation based on the annotation put on controllers methods and model objects
  • If you go to http://localhost:8080/jsondoc-ui.html you will see the JSONDoc UI. Just copy and paste http://localhost:8080/jsondoc in the input field and get the documentation in the clear user interface

This is a good moment to take some time to explore the interface and play with the API in the playground.

Document flows

By flow I mean a subsequential execution of a number of API methods aimed to achieve a goal, that could be purchase a book, or browse the catalog and get the book details. There are cases in which the flows could involve several methods, and API users could need to know which is the correct sequence of methods to call to achieve an objective. In this example I can't think of meaningful flows, but let's assume that I want to document the sequence of methods to browse the shelf and get an author's detail passing through a book I choose, so the resulting flow for this use case would be something like:

  • Get the list of books
  • Choose a book and get its details
  • Get the author of this book

To document this flow you just need to follow these steps:

  1. Create a class that will contain the flows of your application. This class is needed only for documentation purposes, it will not be actually used in your app. Annotate this class with the @ApiFlowSet annotation, which makes JSONDoc understand that this class should be taken into account when building the documentation.
  2. In this class create fake methods, annotated with @ApiFlow. The body of the method, as well as its return type and argument can be void, since the method signature server only as a hook for the @ApiFlow annotation
  3. Decide an ID that identifies each API method within the JSONDoc produced documentation, for example the findAll method of the BookController can have an ID like BOOK_FIND_ALL
  4. Put this ID inside the id property of the @ApiMethod annotation and inside the apimethodid property of the @ApiFlowStep annotation
  5. If you put the flow class in a separate package, remember to update the application.properties file with that value

Let's see how I did it. Here is the class holding the flows of the application:

package org.example.shelf.flow;

import org.jsondoc.core.annotation.ApiFlow;
import org.jsondoc.core.annotation.ApiFlowSet;
import org.jsondoc.core.annotation.ApiFlowStep;

@ApiFlowSet
public class ShelfFlows {

	@ApiFlow(
		name = "Author detail flow",
		description = "Gets an author's details starting from the book's list",
		steps = {
			@ApiFlowStep(apimethodid = ShelfFlowConstants.BOOK_FIND_ALL),	
			@ApiFlowStep(apimethodid = ShelfFlowConstants.BOOK_FIND_ONE),	
			@ApiFlowStep(apimethodid = ShelfFlowConstants.AUTHOR_FIND_ONE)	
		}
	)
	public void authorDetailFlow() {

	}

}

Here is the class containing the methods IDs to be referenced in the annotations:

package org.example.shelf.flow;

public class ShelfFlowConstants {
	
	// Book IDs
	public final static String BOOK_FIND_ALL = "BOOK_FIND_ALL";
	public final static String BOOK_FIND_ONE = "BOOK_FIND_ONE";
	public final static String BOOK_SAVE = "BOOK_SAVE";
	public final static String BOOK_DELETE = "BOOK_DELETE";

	// Author IDs
	public final static String AUTHOR_FIND_ALL = "AUTHOR_FIND_ALL";
	public final static String AUTHOR_FIND_ONE = "AUTHOR_FIND_ONE";
	public final static String AUTHOR_SAVE = "AUTHOR_SAVE";
	public final static String AUTHOR_DELETE = "AUTHOR_DELETE";

}

Here is the resulting BookController, after having specified the id property:

package org.example.shelf.controller;

import java.util.List;

import org.example.shelf.flow.ShelfFlowConstants;
import org.example.shelf.model.Book;
import org.example.shelf.repository.BookRepository;
import org.jsondoc.core.annotation.Api;
import org.jsondoc.core.annotation.ApiBodyObject;
import org.jsondoc.core.annotation.ApiMethod;
import org.jsondoc.core.annotation.ApiPathParam;
import org.jsondoc.core.annotation.ApiResponseObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

@RestController
@RequestMapping(value = "/books", produces = MediaType.APPLICATION_JSON_VALUE)
@Api(description = "The books controller", name = "Books services")
public class BookController {
	
	@Autowired
	private BookRepository bookRepository;
	
	@ApiMethod(id = ShelfFlowConstants.BOOK_FIND_ONE)
	@RequestMapping(value = "/{id}", method = RequestMethod.GET)
	public @ApiResponseObject Book findOne(@ApiPathParam(name = "id") @PathVariable Long id) {
		return bookRepository.findOne(id);
	}
	
	@ApiMethod(id = ShelfFlowConstants.BOOK_FIND_ALL)
	@RequestMapping(method = RequestMethod.GET)
	public @ApiResponseObject List<Book> findAll() {
		return bookRepository.findAll();
	}
	
	@ApiMethod(id = ShelfFlowConstants.BOOK_SAVE)
	@RequestMapping(method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
	@ResponseStatus(value = HttpStatus.CREATED)
	public @ApiResponseObject ResponseEntity<Void> save(@ApiBodyObject @RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
		bookRepository.save(book);
		
		HttpHeaders headers = new HttpHeaders();
	    headers.setLocation(uriComponentsBuilder.path("/books/{id}").buildAndExpand(book.getId()).toUri());
		return new ResponseEntity<Void>(headers, HttpStatus.CREATED);
	}
	
	@ApiMethod(id = ShelfFlowConstants.BOOK_DELETE)
	@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
	@ResponseStatus(value = HttpStatus.OK)
	public void delete(@ApiPathParam(name = "id") @PathVariable Long id) {
		Book book = bookRepository.findOne(id);
		bookRepository.delete(book);
	}

}

And finally the application.properties file, with the new package:

jsondoc.version=1.0
jsondoc.basePath=http://localhost:8080
jsondoc.packages[0]=org.example.shelf.model
jsondoc.packages[1]=org.example.shelf.controller
jsondoc.packages[2]=org.example.shelf.flow

It's time to startup the application again, go to http://localhost:8080/jsondoc-ui.html, insert http://localhost:8080/jsondoc in the input box and get the documentation. Enjoy!

Final JSONDoc documentation

Resources

Here is the resulting structure of the project:

Final shelf project structure

Links


comments powered by Disqus