NormalBoardController.java

페이징을 위해 Pagable을 사용하였으나 swagger에서 default 수치가 적용되지 않아 swagger에서는 보이지 않도록 

@Parameter를 사용

    /*
    * @PageableDefault: 디폴트 페이지 설정(size= 한 페이지 당 보여줄 데이터의 수, sort=등록시간으로 정렬, directin= 내림차순 정렬
    * @Parameter: swagger 문서 작성을 위한 annotation(파라미터 설명 작성),  swagger에 디폴트 파라미터가 적용되지 않아서 추가
    * Pageable: 페이지 설정
    * */
    @PostMapping("/list")
    @Operation(summary = "게시물 목록 조회(")
    public ResponseEntity<ApiResultDto<Page<NormalBoardSearchDto>>> selectNormalBoardTitleList(
            @RequestBody NormalBoardSearchDto board
            , @PageableDefault(size = 10, direction = Sort.Direction.DESC, sort = "firstRegTime") @Parameter(hidden = true) Pageable page
    ) {
        ApiResultDto<Page<NormalBoardSearchDto>> result = new ApiResultDto<>();
        try {


            Page<NormalBoardSearchDto> t = normalBoardService.selectNormalBoardTitles(board, page);
            result.setData(t);
            result.setResultCode("200");
            result.setResultMessage("Success");
        } catch (Exception e) {
            log.error(e.getMessage());
            result.setResultCode("500");
            result.setResultMessage("Fail");
        }

        return new ResponseEntity<>(result, result.getResultCode().equals("200") ? org.springframework.http.HttpStatus.OK
                : org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR);
    }

 

NormalBaordSearchDto.java

Entity의 NormalBoard나 InsertDto를 사용하려 했으나 swagger에 불필요한 Content, LastUpdTime등 데이터가 표기 되어 별도의 Dto를 만들기로 함.

 

Repository에서 Entity가 아닌 Dto로 return 받을 수 있도록 생성자 추가

package com.portfolio.Dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;

/**
 * 일반 게시판(목록)
 date : 2024.09.03
 author : 김만기

 ====== Annotation description ======
 Schema: swagger 문서 작성을 위한 annotation

 ====== field description ======
 title: 게시판 제목
 content: 게시판 내용

 ====== method description ======


 ====== etc description ======

 */
@Getter
@Setter
public class NormalBoardSearchDto {

    @Schema(description = "게시판 아이디", example = "게시판 아이디")
    private String boardId;
    @Schema(description = "게시판 제목", example = "게시판 제목")
    private String title;
    @Schema(description = "게시판 작성자", example = "게시판 작성자")
    private String firstRegUser;
    @Schema(description = "작성 시간", example = "작성시간")
    private LocalDateTime firstRegTime;


    public NormalBoardSearchDto(String boardId, String title, String firstRegUser, LocalDateTime firstRegTime) {
        this.boardId = boardId;
        this.title = title;
        this.firstRegUser = firstRegUser;
        this.firstRegTime = firstRegTime;
    }

}

 

 

BoardService.java

BoardServiceImpl.java

Repo의 메소드 호출

package com.portfolio.serviceimpl;

import com.portfolio.Dto.NormalBoardSearchDto;
import com.portfolio.entity.NormalBoard;
import com.portfolio.repo.NormalBoardRepo;
import com.portfolio.service.NormalBoardService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;
/*
    * 일반 게시판 서비스 구현체
    * date: 2024-07-26
    * last_modified: 2024-10-28
    * author: 김만기
    *
    * ====== Annotation description ======
    * Service: 서비스 빈으로 등록
    *
    * ====== method description ======
    * insertNormalBoard: 일반 게시판 등록
    * selectAllBoardList: 모든 게시판 조회
    * selectNormalBoardTitles: 게시판 제목 조회
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class NormalBoardServiceImpl implements NormalBoardService {
    private final NormalBoardRepo boardRepo;

    @Override
    public void insertNormalBoard(NormalBoard board) {
        boardRepo.save(board);
    }

    @Override
    public List<NormalBoard> selectAllBoardList() {
        return boardRepo.findAll();
    }

    /**
     * 게시판 제목 조회
     * date 2024.10.28
     * last_modified 2024.10.28
     */
    @Override
    public Page<NormalBoardSearchDto> selectNormalBoardTitles(NormalBoardSearchDto board, Pageable pageable) {
        return boardRepo.findByTitle(board, pageable);
    }
}

 

 

NormalBoardRepo.java

package com.portfolio.repo;


import com.portfolio.Dto.NormalBoardSearchDto;
import com.portfolio.entity.NormalBoard;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

/*
    * date: 2024-07-26
    * author: 김만기
    * JpaRepository를 상속받아 NormalBoard 엔티티를 관리하는 레포지토리
    *
    *   ====== Annotation description ======
    * Query: JPQL을 사용하여 쿼리문 작성
    *  - select new com.portfolio.Dto.NormalBoardSearchDto: NormalBoardSearchDto객체에 매핑
    *  - where board.title like %:#{#board.title}%: searchBoard.title에 해당하는 값을 포함하는 데이터 조회
    *
 */
public interface NormalBoardRepo extends JpaRepository<NormalBoard, String> {

    /*
     * last_modified: 2024-10-28
     */
    @Query("select new com.portfolio.Dto.NormalBoardSearchDto( board.boardId, board.title, board.firstRegUser, board.firstRegTime)  " +
            "from NormalBoard board " +
            "where board.title like %:#{#searchBoard.title}% ")
    Page<NormalBoardSearchDto> findByTitle(NormalBoardSearchDto searchBoard, Pageable pageable);
}

 


SWAGGER Parameter 적용 전 

page의 데이터가 default로 설정한 값이 들어가지 않음.

 

조회 결과

 

requestBody

Response

{
  "resultCode": "200",
  "resultMessage": "Success",
  "data": {
    "content": [
      {
        "boardId": "8d6341c8-9e24-48a6-a3b6-2c657b7575fa",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:48.744752"
      },
      {
        "boardId": "08daea24-0be3-481f-a5f4-2eb56bb75a1d",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:48.600051"
      },
      {
        "boardId": "55416544-3a77-4418-ad97-d47b90d43cd8",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:48.451605"
      },
      {
        "boardId": "15dfeff3-2883-43b5-8b43-19b32c6d1ad2",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:48.311657"
      },
      {
        "boardId": "e49e5819-bded-4584-ab81-83982a34a8c0",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:48.156085"
      },
      {
        "boardId": "1c33ff14-d99f-4263-bc02-a3eb8c87a894",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:48.017085"
      },
      {
        "boardId": "766a45bb-7831-41c9-ae0c-d8d13a449c9c",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:47.88268"
      },
      {
        "boardId": "321ba6bb-70f1-4fa4-ad34-5772870fb8c4",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:47.740277"
      },
      {
        "boardId": "75530f37-230a-4703-8a69-b418f7b9dc96",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:47.599972"
      },
      {
        "boardId": "0d95876d-99f6-4266-93d4-c6381b699b6d",
        "title": "게시판 제목",
        "firstRegUser": "admin",
        "firstRegTime": "2024-10-28T13:11:47.4611"
      }
    ],
    "pageable": {
      "pageNumber": 0,
      "pageSize": 10,
      "sort": {
        "empty": false,
        "sorted": true,
        "unsorted": false
      },
      "offset": 0,
      "paged": true,
      "unpaged": false
    },
    "last": false,
    "totalElements": 28,
    "totalPages": 3,
    "first": true,
    "size": 10,
    "number": 0,
    "sort": {
      "empty": false,
      "sorted": true,
      "unsorted": false
    },
    "numberOfElements": 10,
    "empty": false
  }
}

https://github.com/Kmmanki/portfolio_was

 

GitHub - Kmmanki/portfolio_was: portfolio

portfolio. Contribute to Kmmanki/portfolio_was development by creating an account on GitHub.

github.com

 

build.gradle들이 정상적으로 동작하는지 확인하기 위해 boot jar를 만들어서 실행하기위함

 

intelij의 gradle에서 module-main > tasks > build > boot jar 더블클릭 

 

module-main > build > libs 위치에 jar파일 생성 확인 

 

파일 위치에서 탐색기에 cmd 입력 후 엔터

 

 

java -jar {파일명}

 

 

기동 완료

 

 

 


https://github.com/Kmmanki/portfolio_was

 

GitHub - Kmmanki/portfolio_was: portfolio

portfolio. Contribute to Kmmanki/portfolio_was development by creating an account on GitHub.

github.com

 

잘못 기입된 값을 받으면 400(Bad Request)를 반환하도록 ValidationCheck 로직 추가

 

CreateNromalDto

@NotBlank, @Size 등 Validation을 확인하는 어노테이션 추가

ValidationCheck에서 실패 시 MehthodArgumentNotValidException 발생

package com.portfolio.Dto;

import com.portfolio.entity.NormalBoard;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;


/**
 * 일반 게시판 생성 DTO
 date : 2024.07.26
 author : 김만기

 ====== Annotation description ======
 Schema: swagger 문서 작성을 위한 annotation

 Validation 관련 Annotation(조건에 부합하지않느다면 MethodArgumentNotValidException 발생)
    NotBlank: null, "" 체크
    Size: 길이 체크

 ====== field description ======
 title: 게시판 제목
 content: 게시판 내용

 ====== method description ======
 toEntity: DTO를 DB에 적재하기 위한 Entity로 변환

 ====== etc description ======
 entity를 사용하고 싶었으나 entity를 사용하면 Swagger의 example Value가 모든 필드를 표기하는 문제가 있어서 DTO를 사용

 */
@Getter
@Setter
public class CreateNormalBoardDto {
    @Schema(description = "게시판 제목", example = "게시판 제목")
    @NotBlank(message = "제목은 필수 입니다.")
    @Size(max = 30, message = "제목은 최대 30자 입니다.")
    private String title;

    @Schema(description = "게시판 내용", example = "게시판 내용")
    @NotBlank(message = "내용은 필수 입니다.")
    private String content;

    public NormalBoard toEntity() {
        return NormalBoard.builder()
                .title(title)
                .content(content)
                .build();
    }
}

 

 


ControllerExceptionHandler

Controller의 Exceltion을 처리하는 Handler

MehthodArgumentNotValidException만 처리하는 메소드 handleValidationException 

package com.portfolio.exception.handler;

import com.portfolio.dto.ApiResultDto;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/*
 * 컨트롤러 예외처리 핸들러
 * date: 2024-07-29
 * author: 김만기
 *
 * ====== Annotation description ======
 * RestControllerAdvice: RestController에서 발생하는 예외를 처리하기 위한 annotation
 * ExceptionHandler: 특정 예외를 처리하기 위한 annotation
 * 
 * ====== method description ======
 * handleValidationExceptions: @size, @notnull 등에서 발생하는 예외를 처리
 *  400 Bad Request로 응답하고, data에는 @size 등에서 지정한 message=''를 담아서 전달
 */

@RestControllerAdvice
public class ControllerExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResultDto<String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        ApiResultDto<String> result = new ApiResultDto<>();
        result.setResultCode("400");
        result.setResultMessage("Validation Error");
        result.setData(ex.getBindingResult().getAllErrors().get(0).getDefaultMessage());
        return ResponseEntity.badRequest().body(result);
    }
}

 


프로젝트의 build.gradle

module-normal-board의 build.gralde

// 빌드 시 bootJar로 생성하지않음
bootJar { enabled = false }
// 다른 모듈의 라이브러리 형태가 될 것이기 때문에 jar 형태로 진행
jar { enabled = true }

dependencies {
    // common 모듈에 의존
    implementation project(":module-common")
    implementation group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '3.4.0'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '3.3.0'
}

 

 

module-common의 build.gradle

bootJar { enabled = false }
jar { enabled = true }

dependencies {
   /*
   * jpa: DB ORM
   * validation: Controller 등, validation Check
   * */
    implementation group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '3.4.0'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '3.3.0'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '2.5.2'
}

 

 


Sweager 조회 및 validation 테스트

request Body에 미리 적어둔 example 적용 완료 

 

 

shcema에는 validation 내용 및 description 확인

 

 

validation에 맞지 않는 데이터 테스트 및 결과

400 badRequest 및 @size에 함께 작성한 message 출력 확인

 

 

 


https://github.com/Kmmanki/portfolio_was

 

GitHub - Kmmanki/portfolio_was: portfolio

portfolio. Contribute to Kmmanki/portfolio_was development by creating an account on GitHub.

github.com

 

package com.portfolio.dto;


import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;

/*
    * API 요청 결과를 담는 DTO
    * date: 2024-07-26
    * author: 김만기
    ====== Annotation description ======
    Schema: swagger 문서 작성을 위한 annotation
 */
@Getter
@Setter
public class ApiResultDto<T> {

    @Schema(description = "결과 코드", example = "200")
    private String resultCode;
    @Schema(description = "결과 메시지", example = "성공")
    private String resultMessage;
    @Schema(description = "추가 데이터")
    private T data;
}

normal-board의 프로젝트 구조

NormalBoard 패키지구조


main 프로젝트의 application.porperites(JPA 및 DB 설정)

server.port = 8080


# JPA Configuration
spring.datasource.url=jdbc:mariadb://localhost:3306/portfolio
spring.datasource.username=adm
spring.datasource.password=123
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.jpa.hibernate.ddl-auto=create

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
logging.level.org.hibernate.type.descriptor.sql=trace

 

 


BaseEntity(테이블 공통) 및 NormalBoardEntity 

BaseEntity

package com.portfolio.entity;

import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;

import java.time.LocalDateTime;

/*
    * date : 2024.07.26
    * author : 김만기
    * Base Entity: 모든 엔티티의 공통 필드를 정의한 추상 클래스

    * ====== Annotation description ======
    MappedSuperclass: jpa에서 상속 시 상속 받은 값들을 컬럼으로 사용하기 위함
    SuperBuilder: extends한 클레스에 builder를 사용하기 위함
    Column: 컬럼명, 길이, notNull 등의 설정

    * ====== field description ======
    firstRegTime: 최초 등록 시간
    lastUpdTime: 마지막 수정 시간
    firstRegUser: 최초 등록 사용자
    lastUpdUser: 마지막 수정 사용자
 */

@MappedSuperclass
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public abstract class BaseEntity {
    @Column(nullable = false)
    LocalDateTime firstRegTime;
    @Column(nullable = false)
    LocalDateTime lastUpdTime;
    @Column(length = 15, nullable = false)
    String firstRegUser;
    @Column(length = 15, nullable = false)
    String lastUpdUser;
}

 NormalBoardEntity

package com.portfolio.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;


/**
 * 일반 게시판 Entity
 * date : 2024.07.26
 * author : 김만기
 ====== Annotation description ======
 Entity: JPA Entity로 선언
 SuperBuilder: 상속 받는 값을을 builder로 사용하기위해 필요
 Table: 테이블 명을 클래스명과 별도로 세팅, index 설정
 Id: pk로 사용할 변수 선언
 GeneratedValue: pk값 자동생성 전략을 선택
 Column: 컬럼명, 길이, notNull 등의 설정
 ====== field description ======
 boardId: 게시판 아이디
 title: 게시판 제목
 content: 게시판 내용
 */

@Entity
@Getter
@Setter
@SuperBuilder
@Table(name = "tbl_normal_board", indexes =
        @Index(name = "tbl_normal_board_idx_01", columnList = "first_reg_time")
)
@NoArgsConstructor
@AllArgsConstructor

public class NormalBoard extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String boardId;

    @Column(length = 30, nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;
}

 

실행후 JPA로 Table 생성 확인

 


NormalBoardRepo

package com.portfolio.repo;

import com.portfolio.entity.NormalBoard;
import org.springframework.data.jpa.repository.JpaRepository;

/*
    * date: 2024-07-26
    * author: 김만기
    * JpaRepository를 상속받아 NormalBoard 엔티티를 관리하는 레포지토리
 */
public interface NormalBoardRepo extends JpaRepository<NormalBoard, String> {
}

 


CreateNormalBoardDto

package com.portfolio.Dto;

import com.portfolio.entity.NormalBoard;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;


/**
 * 일반 게시판 DTO
 date : 2024.07.26
 author : 김만기

 ====== Annotation description ======
 Schema: swagger 문서 작성을 위한 annotation

 ====== field description ======
 title: 게시판 제목
 content: 게시판 내용

 ====== method description ======
 toEntity: DTO를 DB에 적재하기 위한 Entity로 변환

 ====== etc description ======
 entity를 사용하고 싶었으나 entity를 사용하면 Swagger의 example Value가 모든 필드를 표기하는 문제가 있어서 DTO를 사용

 */
@Getter
@Setter
public class CreateNormalBoardDto {
    @Schema(description = "게시판 제목", example = "게시판 제목")
    private String title;

    @Schema(description = "게시판 내용", example = "게시판 내용")
    private String content;

    public NormalBoard toEntity() {
        return NormalBoard.builder()
                .title(title)
                .content(content)
                .build();
    }
}

 

NormalBoardService

package com.portfolio.service;

import com.portfolio.entity.NormalBoard;

import java.util.List;
/*
    * 일반 게시판 서비스 인터페이스
    * date: 2024-07-26
    * author: 김만기
    ====== method description ======
    * insertNormalBoard: 일반 게시판 등록
 */
public interface NormalBoardService {
    public void insertNormalBoard(NormalBoard board);

    public List<NormalBoard> selectAllBoardList();
}

 

NormalBoardServiceImpl

package com.portfolio.serviceimpl;

import com.portfolio.entity.NormalBoard;
import com.portfolio.repo.NormalBoardRepo;
import com.portfolio.service.NormalBoardService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;
/*
    * 일반 게시판 서비스 구현체
    * date: 2024-07-26
    * author: 김만기
    *
    * ====== Annotation description ======
    * Service: 서비스 빈으로 등록
    *
    * ====== method description ======
    * insertNormalBoard: 일반 게시판 등록
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class NormalBoardServiceImpl implements NormalBoardService {
    private final NormalBoardRepo boardRepo;

    @Override
    public void insertNormalBoard(NormalBoard board) {
        try {
            boardRepo.save(board);
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    @Override
    public List<NormalBoard> selectAllBoardList() {
        return boardRepo.findAll();
    }
}

 

ApiResultDto

 

 

NormalBoardController

package com.portfolio.controller;

import com.portfolio.Dto.CreateNormalBoardDto;
import com.portfolio.dto.ApiResultDto;
import com.portfolio.entity.NormalBoard;
import com.portfolio.service.NormalBoardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;

/*
  * 일반 게시판 컨트롤러
  * date: 2024-07-26
  * author: 김만기

  *  ====== Annotation description ======
  * Tag: swagger 문서 작성을 위한 annotation(NormalBoardController를 한 그룹으로 세팅)
  * Operation: swagger 문서 작성을 위한 annotation(메소드별 설명 작성)
 */
@RestController
@RequestMapping("/api/normal-board")
@RequiredArgsConstructor
@Tag(name = "NormalBoard", description = "일반 게시판 관련 API")
public class NormalBoardController {

    private final NormalBoardService normalBoardService;

    @PostMapping("/insert")
    @Operation(summary = "게시물 등록")
    public ResponseEntity<ApiResultDto<NormalBoard>> insertNormalBoard(
            @RequestBody CreateNormalBoardDto board
    ) {
        ApiResultDto<NormalBoard> result = new ApiResultDto<>();
        try {
            NormalBoard normalBoard = board.toEntity();

            normalBoard.setFirstRegTime(LocalDateTime.now());
            normalBoard.setLastUpdTime(LocalDateTime.now());

            // 로그인 기능이 없기 때문이 임시세팅
            normalBoard.setFirstRegUser("admin");
            normalBoard.setLastUpdUser("admin");

            normalBoardService.insertNormalBoard(normalBoard);
            result.setData(normalBoard);
            result.setResultCode("200");
            result.setResultMessage("Success");
        } catch (Exception e) {
            result.setResultCode("500");
            result.setResultMessage("Fail");
        }

        return new ResponseEntity<>(result, result.getResultCode().equals("200") ? org.springframework.http.HttpStatus.OK
                : org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

 


TEST

Example Value default 값 확인

 

Insert 쿼리 확인

 

임시 Select 쿼리를 사용한 조회

 

 


https://github.com/Kmmanki/portfolio_was

 

GitHub - Kmmanki/portfolio_was: portfolio

portfolio. Contribute to Kmmanki/portfolio_was development by creating an account on GitHub.

github.com

 

+ Recent posts