์‹ธํ”ผ์—์„œ ์ง„ํ–‰ํ•œ ๊ณตํ†ตํ”„๋กœ์ ํŠธ ๋•Œ API ๋ช…์„ธ์„œ๋กœ Swagger๊ฐ€ ์•„๋‹Œ Spring Rest Docs๋ฅผ ์ฑ„ํƒํ–ˆ๋‹ค. ๊ทธ ๋•Œ ์ ์šฉํ•˜๋Š” ๋ฒ•์„ ๋…ธ์…˜์— ๋Œ€์ถฉ ์ ์–ด๋†จ๋Š”๋ฐ ์ด๋ฒˆ์— ์ ์šฉํ•˜๋ฉด์„œ ๊น”๋”ํ•˜๊ฒŒ(?) ์ •๋ฆฌํ•ด๋ณด๋ ค๊ณ  ํ•œ๋‹ค.

 

๐Ÿ‘๐Ÿป ๋‚ด๊ฐ€ ์ƒ๊ฐํ–ˆ์„ ๋•Œ Spring Rest Docs์˜ ์žฅ์ 

- ํ…Œ์ŠคํŠธ๊ฐ€ ์„ฑ๊ณตํ•ด์•ผ ๋ฌธ์„œํ™”๊ฐ€ ๋˜๊ธฐ๋•Œ๋ฌธ์— ํ•ญ์ƒ ์ตœ์‹ ํ™” ๋˜์—‰์žˆ๊ณ  ํ’ˆ์งˆ์„ ๋ณด์žฅํ•  ์ˆ˜ ์žˆ๋‹ค.

- ๋ฌธ์„œํ™” ๋์„ ๋•Œ Swagger๋ณด๋‹ค๋Š” ํ”„๋ก ํŠธ๊ฐ€ ์‰ฝ๊ฒŒ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋‹ค๊ณ  ์ƒ๊ฐํ•œ๋‹ค.

- Request, Response์— ๊ด€ํ•œ ์„ค๋ช…์„ ์–ด๋…ธํ…Œ์ด์…˜์ด ์•„๋‹Œ ํ…Œ์ŠคํŠธ ์‹œ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์–ด spring ์„œ๋ฒ„์˜ ์ฝ”๋“œ๊ฐ€ ์ข€ ๋” ๊น”๋”ํ•ด๋ณด์ธ๋‹ค.

 

๐Ÿ‘Ž๐Ÿป Spring Rest Docs์˜ ๋‹จ์ 

- ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์งœ๋Š”๊ฒŒ ๋„ˆ๋ฌด๋„ˆ๋ฌด๋„ˆ๋ฌด ๊ท€์ฐฎ๋‹ค... ์ •๋ง๋กœ...

 

๋‹จ์ ์„ ์ž˜ ๊ทน๋ณตํ•˜๋ฉด ๋ฟŒ๋“ฏํ•œ ๊ฒฐ๊ณผ๋ฌผ์ด ๋‚˜์˜จ๋‹ค...ํ•˜ํ•˜...

 


๐Ÿ›  ๋‚˜์˜ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ

  • Spring Boot 2.7.3
  • Gradle 7.x
  • Java 8

โš ๏ธ Spring Rest Docs ์ ์šฉํ•˜๊ธฐ ์ „์— ๊ผญ ํ™•์ธํ•ด ๋ด์•ผ ํ•  ๊ฒƒ : Gradle ๋ฒ„์ „์ด๋‹ค.

gradle์˜ ๋ฒ„์ „์— ๋”ฐ๋ผ Spring Rest Docs ์ ์šฉ๋ฒ•์ด ๋‹ฌ๋ผ์ง„๋‹ค!!! ํ”Œ๋Ÿฌ๊ทธ์ธ๋ถ€ํ„ฐ snippet ์ƒ์„ฑ ๊ฒฝ๋กœ๊นŒ์ง€ ์–ด๋งˆ๋ฌด์‹œํ•˜๊ฒŒ ๋‹ค๋ฅด๋‹ˆ๊นŒ ๊ผญ ํ™•์ธํ•˜๋Š”๊ฒŒ ์ข‹๋‹ค...

 

๋จผ์ € gradle/wrapper/gradle-wrapper.properties ๋ฅผ ๋“ค์–ด๊ฐ„๋‹ค.

์ด๊ฒŒ ๋ณด์ด๋Š” ๊ณณ์—์„œ gradle ๋ฒ„์ „์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. ๋‚˜๋Š” 7 ๋ฒ„์ „์„ ์‚ฌ์šฉ ์ค‘์ด๋‹ค!

 

โœ”๏ธ gradle์˜ ๋ฒ„์ „์ด 7 ์ด์ƒ์ด๋ฉด org.asciidoctor.jvm.convert ๋ผ๋Š” ๊ฒƒ์„ ์‚ฌ์šฉํ•œ๋‹ค.

gradle 6 ๋ฒ„์ „์€ ๊ตฌ๊ธ€๋งํ•˜๋ฉด ์—„์ฒญ๋‚˜์˜ค๊ธฐ ๋•Œ๋ฌธ์— ์•ˆ์“ฐ๋„๋ก ํ•˜๊ฒ ๋‹ค...(๊ท€์ฐฎ์•„์„œ๊ฐ€ ์ ˆ๋Œ€ ์•„๋‹˜...ใ…Ž)

 


๐Ÿ‘ฉ๐Ÿป‍๐Ÿ’ป build.gradle

ํ”Œ๋Ÿฌ๊ทธ์ธ ๋ฐ ์˜์กด์„ฑ ์ถ”๊ฐ€

plugins {
	...
	id 'org.asciidoctor.jvm.convert' version '3.3.2'
	...
}
dependencies {
	...
    
	// spring rest docs
	asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    
	...
}
configurations {
	asciidoctorExtensions
}

snippet ์ƒ์„ฑ ๋””๋ ‰ํ† ๋ฆฌ ์„ค์ •

ext {
	snippetsDir = file('build/generated-snippets')
}
test {
	outputs.dir snippetsDir
	finalizedBy 'asciidoctor'
}
tasks.named('test') {
	useJUnitPlatform()
}

asciidoctor ์„ค์ •

asciidoctor {
	inputs.dir snippetsDir
	configurations 'asciidoctorExtensions'
	dependsOn test
}

asciidoctor.doFirst {
	delete file('src/main/resources/static/docs')
}

bootJar ์„ค์ •

bootJar {
	dependsOn asciidoctor
	copy {
		from "build/docs/asciidoc"
		into "src/main/resources/static/docs"
	}
}

 

task copyDocument(type: Copy) {
	dependsOn asciidoctor
	from file("build/docs/asciidoc")
	into file("src/main/resources/static/docs")
}
build {
	dependsOn copyDocument
}

 

์ด๋ ‡๊ฒŒ ์ถ”๊ฐ€ํ•˜๋ฉด ์™„๋ฃŒ!!


๐Ÿ‘ฉ๐Ÿป‍๐Ÿ’ป ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ

๋‚˜๋Š” mockMvc ๊ธฐ๋ฐ˜์œผ๋กœ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜์˜€๋‹ค.

mockMvc๋กœ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ ์‹œ @AutoConfigureMockMvc์™€ @AutoConfigureRestDocs ์–ด๋…ธํ…Œ์ด์…˜์„ ๊ผญ(?) ๋‹ฌ์•„์•ผ ํ•œ๋‹ค.

ํ ... ๊ทผ๋ฐ ์ด๋ฒˆ ํ”„๋กœ์ ํŠธ์—์„œ @AutoConfigureMockMvc ์–ด๋…ธํ…Œ์ด์…˜ ์ถ”๊ฐ€๋ฅผ ์•ˆํ–ˆ๋Š”๋ฐ ์„ฑ๊ณตํ–ˆ๋‹ค..... ์™œ์ง€....(?) 

 

์•„๋ž˜๋Š” ์ด๋ฒˆ ํŠนํ™”ํ”„๋กœ์ ํŠธ์—์„œ ๋ฌธ์„œํ™”ํ•œ API ์ค‘ ํ•˜๋‚˜์ด๋‹ค.

Given-When-Then ํŒจํ„ด์œผ๋กœ ์ž‘์„ฑํ•ด๋ณด์•˜๋‹ค.

@WebMvcTest(YoutuberApiController.class)
@AutoConfigureRestDocs
public class YoutuberRestDocsTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private YoutuberService youtuberService;

    @Test
    public void youtuber_๊ฒ€์ƒ‰() throws Exception {
        // given
        String request = "์‚ผ์„ฑ์ฒญ๋…„";
        List<YoutuberResponseDto> response = new ArrayList<>();
        response.add(YoutuberResponseDto.builder()
                .channelId("UC_XI3ByFO1uZIIH-g-zJZiw")
                .channelName("์‚ผ์„ฑ์ฒญ๋…„SW์•„์นด๋ฐ๋ฏธ Youtube์ฑ„๋„ HELLOSSAFY")
                .thumbnail("https://yt3.ggpht.com/ytc/AMLnZu9qdR9T9_9OXz27_3lZVs4hfwECef2oUSylrcQv=s800-c-k-c0x00ffffff-no-rj").build());
        given(youtuberService.searchYoutuber(anyString())).willReturn(response);

        // when, then
        mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/youtubers/{keyword}", request))
                .andExpect(status().isOk())
                .andDo(document("youtuber-search",
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        pathParameters(
                                parameterWithName("keyword").description("๊ฒ€์ƒ‰ํ•  ๋‹จ์–ด")
                        ),
                        responseFields(
                                fieldWithPath("success").description("API ์š”์ฒญ ์„ฑ๊ณต ์—ฌ๋ถ€").type(JsonFieldType.BOOLEAN),
                                fieldWithPath("data").description("Response data").type(JsonFieldType.ARRAY)
                        ).andWithPrefix("data[].",
                                fieldWithPath("channelId").description("์œ ํŠœ๋ฒ„ ์ฑ„๋„ id").type(JsonFieldType.STRING),
                                fieldWithPath("channelName").description("์œ ํŠœ๋ฒ„ ์ฑ„๋„๋ช…").type(JsonFieldType.STRING),
                                fieldWithPath("thumbnail").description("์œ ํŠœ๋ฒ„ ์ธ๋„ค์ผ ์ฃผ์†Œ").type(JsonFieldType.STRING))
                        ));
    }
}

 

given

๋จผ์ € API request๋ฅผ ํ•˜๋‚˜ ๋งŒ๋“ค์–ด์ฃผ๊ณ  ๊ทธ request์— ๋งž๋Š” response๋„ ์ƒ์„ฑํ•ด์ค€๋‹ค.

๊ทธ๋ฆฌ๊ณ  Mockito์˜ given().willReturn()์„ ์ด์šฉํ•ด์„œ request์™€ response๋ฅผ ์ •ํ•ด๋†“๋Š”๋‹ค.

Service ๋กœ์ง ๊ฒ€์ฆ์ด ํ•„์š”ํ•œ๊ฒŒ ์•„๋‹ˆ๋ผ ๋ฌธ์„œํ™”๊ฐ€ ๋ชฉํ‘œ์ด๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ ‡๊ฒŒ ํ•˜๋Š” ๊ฒƒ ๊ฐ™๋‹ค...!

 

์ •๋ง ๊ฐ„๋‹จํžˆ mockito ์•Œ์•„๋ณด๊ธฐ

๋”๋ณด๊ธฐ
๐Ÿค” given(A).willReturn(B)?
A๋ผ๋Š” ์„œ๋น„์Šค๊ฐ€ ํ˜ธ์ถœ๋˜๋ฉด ๊ทธ ๊ฒฐ๊ณผ๋Š” ๋ฌด์กฐ๊ฑด B๋ผ๊ณ  ์„ค์ •(?)ํ•ด๋‘๋Š” ๊ฒƒ์ด๋‹ค.

๐Ÿค” given().willReturn()๊ณผ when().thenReturn()์˜ ์ฐจ์ด?
๋™์ž‘์€ ๋˜‘๊ฐ™๋‹ค. BDD๊ธฐ๋ฐ˜์œผ๋กœ ํ…Œ์ŠคํŠธํ•  ๊ฒฝ์šฐ //given ์ ˆ์—์„œ when()์„ ์‚ฌ์šฉํ•˜๋ฉด ํ๋ฆ„์ด ์ด์ƒํ•˜๋‹ค๊ณ  ์ƒ๊ฐํ• ๊นŒ๋ด given()์ด ์ƒ๊ฒผ๋‹จ๋‹ค.
๊ทธ๋ž˜์„œ ๊ทธ๋ƒฅ given()์„ ์ผ๋‹ค.

- import static org.mockito.**Mockito**.*when*
- import static org.mockito.**BDDMockito**.*given*;
 		// given
        String request = "์‚ผ์„ฑ์ฒญ๋…„";
        List<YoutuberResponseDto> response = new ArrayList<>();
        response.add(YoutuberResponseDto.builder()
                .channelId("UC_XI3ByFO1uZIIH-g-zJZiw")
                .channelName("์‚ผ์„ฑ์ฒญ๋…„SW์•„์นด๋ฐ๋ฏธ Youtube์ฑ„๋„ HELLOSSAFY")
                .thumbnail("https://yt3.ggpht.com/ytc/AMLnZu9qdR9T9_9OXz27_3lZVs4hfwECef2oUSylrcQv=s800-c-k-c0x00ffffff-no-rj").build());
        given(youtuberService.searchYoutuber(anyString())).willReturn(response);

 

when + then

๋ณ€์ˆ˜ ์ƒ์„ฑํ•˜๋Š”๊ฒŒ ๊ท€์ฐฎ์•„์„œ ํ•ฉ์ณ๋ฒ„๋ ธ๋‹ค...ใ…Žใ…Ž

mockMvc๋กœ perform์„ ์ˆ˜ํ–‰ํ•˜๋Š”๋ฐ ์ด ๋•Œ MockMvcRequestBuilders์ด ์•„๋‹ŒRestDocumentationRequestBuilders์˜ ๋ฉ”์†Œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์ž!!!

MockMvcRequestBuilders์˜ ๋ฉ”์†Œ๋“œ๋“ค์€ Path variables๋ฅผ ์‚ฌ์šฉํ•˜๋Š” api์— ๋Œ€ํ•ด ๋ฌธ์„œํ™”๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค....

๊ทธ๋ƒฅ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋ฌด์กฐ๊ฑด RestDocumentationRequestBuilders๋ฅผ ์‚ฌ์šฉํ•˜๊ธธ!!!!!!

// when, then
        mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/youtubers/{keyword}", request))
                .andExpect(status().isOk())
                
// -------------------------------------------------------์—ฌ๊ธฐ์„œ๋ถ€ํ„ฐ then
                .andDo(document("youtuber-search",
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        pathParameters(
                                parameterWithName("keyword").description("๊ฒ€์ƒ‰ํ•  ๋‹จ์–ด")
                        ),
                        responseFields(
                                fieldWithPath("success").description("API ์š”์ฒญ ์„ฑ๊ณต ์—ฌ๋ถ€").type(JsonFieldType.BOOLEAN),
                                fieldWithPath("data").description("Response data").type(JsonFieldType.ARRAY)
                        ).andWithPrefix("data[].",
                                fieldWithPath("channelId").description("์œ ํŠœ๋ฒ„ ์ฑ„๋„ id").type(JsonFieldType.STRING),
                                fieldWithPath("channelName").description("์œ ํŠœ๋ฒ„ ์ฑ„๋„๋ช…").type(JsonFieldType.STRING),
                                fieldWithPath("thumbnail").description("์œ ํŠœ๋ฒ„ ์ธ๋„ค์ผ ์ฃผ์†Œ").type(JsonFieldType.STRING))
                        ));

 

์ฝ”๋“œ์— ๋Œ€ํ•œ ์งง์€ ์„ค๋ช…์„ ์ถ”๊ฐ€ํ•ด๋ดค๋‹ค... ๋” ์ž์„ธํ•œ๊ฑด ์—ญ์‹œ ๊ตฌ๊ธ€๋ง๐Ÿ˜‡

๋”๋ณด๊ธฐ
document("youtuber-search", ...)

๋ฌธ์„œํ™”ํ•  document์˜ ํด๋” ์ด๋ฆ„์„ ๋„ฃ์–ด์ฃผ๋ฉด ๋œ๋‹ค.

API๊ฐ€ ์—ฌ๋Ÿฌ๊ฐœ ์กด์žฌํ•˜๋ฉด ๊ตฌ๋ถ„ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๊ธฐ๋Šฅ์— ๋Œ€ํ•œ ์„ค๋ช…์„ ์จ๋†“๋Š”๊ฒŒ ์ข‹์€ ๊ฒƒ ๊ฐ™๋‹ค.(๊ทธ๋ƒฅ ๋‚ด์ƒ๊ฐใ…Ž)

 

preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),

๊ฒฐ๊ณผ๋ฌผ์—์„œ request์™€ response๋ฅผ ๋ณด๊ธฐ ํŽธํ•œ ํ˜•์‹์œผ๋กœ ์ถœ๋ ฅํ•ด์ฃผ๋Š” ์šฉ๋„์ด๋‹ค.

์ด๊ฑธ ์•ˆํ•ด์ฃผ๋ฉด ํ•œ ์ค„๋กœ ์ซ˜๋ผ๋ฝ ๋‚˜์˜ค๊ธฐ ๋•Œ๋ฌธ์— ๋ฌด์กฐ๊ฑด ํ•˜๋Š” ๊ฑธ ์ถ”์ฒœ

 

pathParameters(
        parameterWithName("keyword").description("๊ฒ€์ƒ‰ํ•  ๋‹จ์–ด")
),

request์˜ path parameter๊ฐ€ ์กด์žฌํ•˜๋ฉด ์ถ”๊ฐ€ํ•˜๋ฉด๋œ๋‹ค.

์—ฌ๋Ÿฌ ๊ฐœ์ผ ๊ฒฝ์šฐ parameterWithName()์„ ์ถ”๊ฐ€ํ•˜์ž.

 

requestFields(
    fieldWithPath("stuffName").description("stuff ์˜๋ฌธ๋ช…"),
    fieldWithPath("stuffNameKor").description("stuff ํ•œ๊ธ€๋ช…")
),

์ด๊ฑด ๋‹ค๋ฅธ ํ”„๋กœ์ ํŠธ์—์„œ ๊ฐ€์ ธ์˜จ ์ฝ”๋“œ์ธ๋ฐ

request์— body๊ฐ€ ์กด์žฌํ•˜๋ฉด ์ด๋ ‡๊ฒŒ ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค!

 

responseFields(
        fieldWithPath("success").description("API ์š”์ฒญ ์„ฑ๊ณต ์—ฌ๋ถ€").type(JsonFieldType.BOOLEAN),
        fieldWithPath("data").description("Response data").type(JsonFieldType.ARRAY)
)

 response์— ๋“ค์–ด๊ฐ€๋Š” ๋ฐ์ดํ„ฐ์— ๋Œ€ํ•œ ์„ค๋ช…์„ ์“ฐ๋Š” ๊ณณ์ด๋‹ค.

responseFields๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด response ๋ฐ์ดํ„ฐ์— ๋Œ€ํ•œ ์„ค๋ช…์„ ๋ฌด์กฐ๊ฑด ๋‹ค ์ถ”๊ฐ€ํ•ด์•ผํ•˜๊ณ , relaxedResponseFields์˜€๋‚˜ ์•”ํŠผ ์ด๊ฑธ ์‚ฌ์šฉํ•˜๋ฉด ์›ํ•˜๋Š” ๋ฐ์ดํ„ฐ๋งŒ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

.andWithPrefix("data[].",
        fieldWithPath("channelId").description("์œ ํŠœ๋ฒ„ ์ฑ„๋„ id").type(JsonFieldType.STRING),
        fieldWithPath("channelName").description("์œ ํŠœ๋ฒ„ ์ฑ„๋„๋ช…").type(JsonFieldType.STRING),
        fieldWithPath("thumbnail").description("์œ ํŠœ๋ฒ„ ์ธ๋„ค์ผ ์ฃผ์†Œ").type(JsonFieldType.STRING))
)

์ € API ์‘๋‹ต์—์„œ data ๋ณ€์ˆ˜์— ์‹ค์งˆ์ ์ธ ๋ฐ์ดํ„ฐ๊ฐ€ ๋‹ด๊ฒจ์žˆ์–ด์„œ .andWithPrefix๋กœ data ์•ˆ์— ๋‹ด๊ฒจ์žˆ๋Š” ์ •๋ณด๋“ค์— ๋Œ€ํ•œ ์„ค๋ช…์„ ์ถ”๊ฐ€ํ•ด์ฃผ์—ˆ๋‹ค


๐Ÿ‘ฉ๐Ÿป‍๐Ÿ’ป HTML ๋ฌธ์„œ ๋งŒ๋“ค๊ธฐ

ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋๋‚œ๋‹ค๋ฉด, build/generated-snippets์— youtuber-search ํด๋”๊ฐ€ ์ƒ๊ธด๋‹ค.

์ด๊ฑธ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ชจ๋“  API ์„ค๋ช…์ด ๋“ค์–ด๊ฐ„ adoc ํŒŒ์ผ์„ ๋งŒ๋“ค ๊ฒƒ์ด๋‹ค.  

๋จผ์ € /src/docs/asciidoc ํด๋”์— index.adoc์„ ์ƒ์„ฑํ•œ๋‹ค.

 

์•„๋ž˜์™€ ๊ฐ™์ด ํด๋”๋ณ„๋กœ ์ถ”๊ฐ€ํ•ด์ฃผ๋ฉด ๋“œ๋””์–ด ๋์ด ๋ณด์ธ๋‹ค....

[[Youtuber-๊ฒ€์ƒ‰]]
== Youtuber ๊ฒ€์ƒ‰

=== Http Request
include::{snippets}/youtuber-search/http-request.adoc[]

=== Path parameters
include::{snippets}/youtuber-search/path-parameters.adoc[]

=== Http Response
include::{snippets}/youtuber-search/http-response.adoc[]

=== Response fields
include::{snippets}/youtuber-search/response-fields.adoc[]

๐Ÿฅน ์™„์„ฑ

./gradlew build๋ฅผ ํ•˜๊ณ  ์„œ๋ฒ„๋ฅผ ์‹คํ–‰ํ•˜๋ฉด localhost:8080/docs/index.html์— ์ ‘์†์ด ๊ฐ€๋Šฅํ•˜๊ณ  ๊ณ ์ƒํ•˜๋˜ ๋‚˜์˜ ๊ฒฐ๊ณผ๋ฌผ์ด ๋ณด์ธ๋‹ค...ํ•˜ํ•˜

๊ณตํ†ต ํ”„๋กœ์ ํŠธ ๋•Œ 15์‹œ๊ฐ„ ๊ฑธ๋ ค์„œ ๋งŒ๋“  ๋ช…์„ธ์„œ....ํ‘ํ‘....

728x90

BELATED ARTICLES

more