diff --git a/4Week_Mission/.gitignore b/4Week_Mission/.gitignore index 31945f1..1e52fe6 100644 --- a/4Week_Mission/.gitignore +++ b/4Week_Mission/.gitignore @@ -41,4 +41,5 @@ application.yml application-dev.yml application-prod.yml application-test.yml +application-base-addi.yml application-API-KEY.properties diff --git a/4Week_Mission/build.gradle b/4Week_Mission/build.gradle index 41bc0e4..2af987e 100644 --- a/4Week_Mission/build.gradle +++ b/4Week_Mission/build.gradle @@ -49,6 +49,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + testImplementation 'org.springframework.batch:spring-batch-test' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/api/controller/ApiController.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/api/controller/ApiController.java new file mode 100644 index 0000000..fd140e0 --- /dev/null +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/api/controller/ApiController.java @@ -0,0 +1,92 @@ +package com.ll.exam.ebooks.app.api.controller; + +import com.ll.exam.ebooks.app.base.dto.RsData; +import com.ll.exam.ebooks.app.base.rq.Rq; +import com.ll.exam.ebooks.app.member.entity.Member; +import com.ll.exam.ebooks.app.member.form.LoginForm; +import com.ll.exam.ebooks.app.member.service.MemberService; +import com.ll.exam.ebooks.app.myBook.entity.MyBook; +import com.ll.exam.ebooks.app.myBook.service.MyBookService; +import com.ll.exam.ebooks.app.security.dto.MemberContext; +import com.ll.exam.ebooks.util.Ut; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +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.util.List; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +@Slf4j +public class ApiController { + + private final MemberService memberService; + private final MyBookService myBookService; + + @PostMapping("/member/login") + public ResponseEntity login(@RequestBody LoginForm loginForm) { + Member member = memberService.findByUsername(loginForm.getUsername()); + + if (member == null) { + return Ut.spring.responseEntityOf(RsData.of("F-1", "일치하는 회원이 존재하지 않습니다.")); + } + + String accessToken = memberService.genAccessToken(member); + + return Ut.spring.responseEntityOf( + RsData.of( + "S-1", + "로그인 성공, Access Token을 발급합니다.", + Ut.mapOf( + "accessToken", accessToken + ) + ), + Ut.spring.httpHeadersOf("Authentication", accessToken) + ); + } + + @GetMapping("/member/me") + public ResponseEntity me(@AuthenticationPrincipal MemberContext memberContext) { + if (memberContext == null) { // 임시코드, 나중에는 시프링 시큐리티를 이용해서 로그인을 안했다면, 아예 여기로 못 들어오도록 + return Ut.spring.responseEntityOf(RsData.failOf(null)); + } + + return Ut.spring.responseEntityOf(RsData.successOf(memberContext.getMember())); + } + + @GetMapping("/myBooks") + public ResponseEntity myBooks(@AuthenticationPrincipal MemberContext memberContext) { + Member member = memberContext.getMember(); + + log.debug("member: " + member.getId()); + + if (member == null) { + return Ut.spring.responseEntityOf( + RsData.of( + "F-1", + "조회 권한이 없습니다." + ) + ); + } + + List myBooks = myBookService.findAllByOwnerId(member.getId()); + + log.debug("myBooks: " + myBooks); + + return Ut.spring.responseEntityOf( + RsData.successOf( + Ut.mapOf( + "myBooks", myBooks + ) + ) + ); + } + +} diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/base/dto/RsData.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/base/dto/RsData.java new file mode 100644 index 0000000..8bb2811 --- /dev/null +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/base/dto/RsData.java @@ -0,0 +1,36 @@ +package com.ll.exam.ebooks.app.base.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class RsData { + private String resultCode; + private String msg; + private T data; + + public static RsData of(String resultCode, String msg, T data) { + return new RsData<>(resultCode, msg, data); + } + + public static RsData of(String resultCode, String msg) { + return of(resultCode, msg, null); + } + + public static RsData successOf(T data) { + return of("S-1", "성공", data); + } + + public static RsData failOf(T data) { + return of("F-1", "실패", data); + } + + public boolean isSuccess() { + return resultCode.startsWith("S-1"); + } + + public boolean isFail() { + return isSuccess() == false; + } +} diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/entity/Member.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/entity/Member.java index 6df815a..563db74 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/entity/Member.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/entity/Member.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.ll.exam.ebooks.app.base.entity.BaseEntity; +import com.ll.exam.ebooks.util.Ut; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,6 +17,7 @@ import javax.persistence.Entity; import java.util.ArrayList; import java.util.List; +import java.util.Map; @Entity @Getter @@ -36,10 +38,13 @@ public class Member extends BaseEntity { private String nickname; + @JsonIgnore private int authLevel; + @JsonIgnore private long restCash; // 예치금 + @JsonIgnore public String getName() { if (nickname == null) { return username; @@ -51,6 +56,7 @@ public Member(long id) { super(id); } + @JsonIgnore public List getAuthorities() { List authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("MEMBER")); @@ -65,4 +71,17 @@ public List getAuthorities() { return authorities; } + + @JsonIgnore + public Map getAccessTokenClaims() { + return Ut.mapOf( + "id", getId(), + "createDate", getCreateDate(), + "modifyDate", getModifyDate(), + "username", getUsername(), + "email", getEmail(), + "authLevel", getAuthLevel(), + "authorities", getAuthorities() + ); + } } diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/form/LoginForm.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/form/LoginForm.java new file mode 100644 index 0000000..ce15926 --- /dev/null +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/form/LoginForm.java @@ -0,0 +1,20 @@ +package com.ll.exam.ebooks.app.member.form; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotEmpty; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginForm { + @NotEmpty + private String username; + + @NotEmpty + private String password; +} diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/service/MemberService.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/service/MemberService.java index 35e006d..545c050 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/service/MemberService.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/member/service/MemberService.java @@ -9,6 +9,7 @@ import com.ll.exam.ebooks.app.member.exception.MemberNotFoundException; import com.ll.exam.ebooks.app.member.repository.MemberRepository; import com.ll.exam.ebooks.app.security.dto.MemberContext; +import com.ll.exam.ebooks.app.security.jwt.JwtProvider; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContext; @@ -28,6 +29,7 @@ public class MemberService { private final PasswordEncoder passwordEncoder; private final MailService mailService; private final CashService cashService; + private final JwtProvider jwtProvider; @Transactional @@ -74,6 +76,10 @@ public Member adminJoin(JoinForm joinForm) { return member; } + public String genAccessToken(Member member) { + return jwtProvider.generatedAccessToken(member.getAccessTokenClaims(), 60 * 60 * 24 * 90); + } + public Member findByEmail(String email) { return memberRepository.findByEmail(email).orElse(null); } @@ -132,7 +138,7 @@ public boolean beAuthor(Member member, String nickname) { } private void forceAuthentication(Member member) { - MemberContext memberContext = new MemberContext(member, member.getAuthorities()); + MemberContext memberContext = new MemberContext(member); UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/myBook/repository/MyBookRepository.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/myBook/repository/MyBookRepository.java index 291ff09..496ff2d 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/myBook/repository/MyBookRepository.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/myBook/repository/MyBookRepository.java @@ -3,6 +3,10 @@ import com.ll.exam.ebooks.app.myBook.entity.MyBook; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface MyBookRepository extends JpaRepository { void deleteByProductIdAndOwnerId(Long productId, Long ownerId); + + List findAllByOwnerId(Long ownerId); } diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/myBook/service/MyBookService.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/myBook/service/MyBookService.java index 0f74488..3086440 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/myBook/service/MyBookService.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/myBook/service/MyBookService.java @@ -12,6 +12,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @Transactional(readOnly = true) @RequiredArgsConstructor @@ -35,4 +37,8 @@ public void remove(Order order) { .stream() .forEach(orderItem -> myBookRepository.deleteByProductIdAndOwnerId(orderItem.getProduct().getId(), order.getBuyer().getId())); } + + public List findAllByOwnerId(Long OwnerId) { + return myBookRepository.findAllByOwnerId(OwnerId); + } } diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/product/repository/ProductRepository.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/product/repository/ProductRepository.java index f4d11b8..c511e17 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/product/repository/ProductRepository.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/product/repository/ProductRepository.java @@ -7,5 +7,4 @@ public interface ProductRepository extends JpaRepository { List findAllByOrderByIdDesc(); - } diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/SecurityConfig.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/SecurityConfig.java index 35c7842..71ff2b9 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/SecurityConfig.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/SecurityConfig.java @@ -1,6 +1,6 @@ package com.ll.exam.ebooks.app.security; -import com.ll.exam.ebooks.app.security.service.CustomUserDetailsService; +import com.ll.exam.ebooks.app.security.filter.JwtAuthorizationFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,6 +12,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @@ -19,12 +20,11 @@ @RequiredArgsConstructor public class SecurityConfig { - private final CustomUserDetailsService customUserDetailsService; - @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf().disable() + .cors().disable() // 타 도메인에서 API 호출 가능 + .csrf().disable() // CSRF 토큰 끄기 .formLogin( formLogin -> formLogin .loginPage("/member/login") diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/dto/MemberContext.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/dto/MemberContext.java index feeadfc..906b947 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/dto/MemberContext.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/dto/MemberContext.java @@ -1,5 +1,6 @@ package com.ll.exam.ebooks.app.security.dto; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.ll.exam.ebooks.app.member.entity.Member; import lombok.Getter; import lombok.Setter; @@ -8,27 +9,28 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; @Getter +@JsonIgnoreProperties({"id", "createDate", "modifyDate", "username", "email", "nickname"}) public class MemberContext extends User { private final Long id; private final String username; - @Setter - private String email; - @Setter - private String nickname; + private final String email; + private final String nickname; private final LocalDateTime createDate; - @Setter - private LocalDateTime modifyDate; - - public MemberContext(Member member, List authorities) { - super(member.getUsername(), member.getPassword(), authorities); - this.id = member.getId(); - this.username = member.getUsername(); - this.email = member.getEmail(); - this.nickname = member.getNickname(); - this.createDate = member.getCreateDate(); - this.modifyDate = member.getModifyDate(); + private final LocalDateTime modifyDate; + + public MemberContext(Member member) { + super(member.getUsername(), "", member.getAuthorities()); + + id = member.getId(); + username = member.getUsername(); + email = member.getEmail(); + nickname = member.getNickname(); + createDate = member.getCreateDate(); + modifyDate = member.getModifyDate(); } public Member getMember() { diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/filter/JwtAuthorizationFilter.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/filter/JwtAuthorizationFilter.java new file mode 100644 index 0000000..7f0c5d9 --- /dev/null +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/filter/JwtAuthorizationFilter.java @@ -0,0 +1,62 @@ +package com.ll.exam.ebooks.app.security.filter; + +import com.ll.exam.ebooks.app.member.entity.Member; +import com.ll.exam.ebooks.app.member.service.MemberService; +import com.ll.exam.ebooks.app.security.dto.MemberContext; +import com.ll.exam.ebooks.app.security.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthorizationFilter extends OncePerRequestFilter { + private final JwtProvider jwtProvider; + private final MemberService memberService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String bearerToken = request.getHeader("Authorization"); + + if (bearerToken != null) { + String token = bearerToken.substring("Bearer ".length()); + + if (jwtProvider.verify(token)) { + Map claims = jwtProvider.getClaims(token); + String username = (String) claims.get("username"); + Member member = memberService.findByUsername(username); + + forceAuthentication(member); + } + } + + filterChain.doFilter(request, response); + } + + private void forceAuthentication(Member member) { + MemberContext memberContext = new MemberContext(member); + + UsernamePasswordAuthenticationToken authentication = + UsernamePasswordAuthenticationToken.authenticated( + memberContext, + null, + member.getAuthorities() + ); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } +} \ No newline at end of file diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/jwt/JwtConfig.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/jwt/JwtConfig.java new file mode 100644 index 0000000..f52bcc2 --- /dev/null +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/jwt/JwtConfig.java @@ -0,0 +1,21 @@ +package com.ll.exam.ebooks.app.security.jwt; + +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.crypto.SecretKey; +import java.util.Base64; + +@Configuration +public class JwtConfig { + @Value("${custom.jwt.secretKey}") + private String secretKeyPlain; + + @Bean + public SecretKey jwtSecretKey() { + String keyBase64Encoded = Base64.getEncoder().encodeToString(secretKeyPlain.getBytes()); + return Keys.hmacShaKeyFor(keyBase64Encoded.getBytes()); + } +} diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/jwt/JwtProvider.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/jwt/JwtProvider.java new file mode 100644 index 0000000..26913a5 --- /dev/null +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/jwt/JwtProvider.java @@ -0,0 +1,56 @@ +package com.ll.exam.ebooks.app.security.jwt; + +import com.ll.exam.ebooks.util.Ut; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + private final SecretKey jwtSecretKey; + + public SecretKey getSecretKey() { + return jwtSecretKey; + } + + public String generatedAccessToken(Map claims, int seconds) { + long now = new Date().getTime(); + Date accessTokenExpiresIn = new Date(now + 1000L * seconds); + + return Jwts.builder() + .claim("body", Ut.json.toStr(claims)) + .setExpiration(accessTokenExpiresIn) + .signWith(getSecretKey(), SignatureAlgorithm.HS512) + .compact(); + } + + public boolean verify(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(getSecretKey()) + .build() + .parseClaimsJws(token); + } catch (Exception e) { + return false; + } + + return true; + } + + public Map getClaims(String token) { + String body = Jwts.parserBuilder() + .setSigningKey(getSecretKey()) + .build() + .parseClaimsJws(token) + .getBody() + .get("body", String.class); + + return Ut.json.toMap(body); + } +} diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/service/CustomUserDetailsService.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/service/CustomUserDetailsService.java index 8ab393d..dd14d7d 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/service/CustomUserDetailsService.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/app/security/service/CustomUserDetailsService.java @@ -27,6 +27,6 @@ public class CustomUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Member member = memberRepository.findByUsername(username).get(); - return new MemberContext(member, member.getAuthorities()); + return new MemberContext(member); } } diff --git a/4Week_Mission/src/main/java/com/ll/exam/ebooks/util/Ut.java b/4Week_Mission/src/main/java/com/ll/exam/ebooks/util/Ut.java index d40ebc0..4e27fc0 100644 --- a/4Week_Mission/src/main/java/com/ll/exam/ebooks/util/Ut.java +++ b/4Week_Mission/src/main/java/com/ll/exam/ebooks/util/Ut.java @@ -1,13 +1,89 @@ package com.ll.exam.ebooks.util; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ll.exam.ebooks.app.AppConfig; +import com.ll.exam.ebooks.app.base.dto.RsData; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; public class Ut { + private static ObjectMapper getObjectMapper() { + return (ObjectMapper) AppConfig.getContext().getBean("objectMapper"); + } + + public static class json { + public static Object toStr(Map map) { + try { + return getObjectMapper().writeValueAsString(map); + } catch (JsonProcessingException e) { + e.printStackTrace(); + return null; + } + } + + public static Map toMap(String jsonStr) { + try { + return getObjectMapper().readValue(jsonStr, LinkedHashMap.class); + } catch (JsonProcessingException e) { + e.printStackTrace(); + return null; + } + } + } + + public static Map mapOf(Object... args) { + Map map = new LinkedHashMap<>(); + + int size = args.length / 2; + + for (int i = 0; i < size; i++) { + int keyIndex = i * 2; + int valueIndex = keyIndex + 1; + + K key = (K) args[keyIndex]; + V value = (V) args[valueIndex]; + + map.put(key, value); + } + + return map; + } + + public static class spring { + public static ResponseEntity responseEntityOf(RsData rsData) { + return responseEntityOf(rsData, null); + } + + public static ResponseEntity responseEntityOf(RsData rsData, HttpHeaders headers) { + return new ResponseEntity<>(rsData, headers, rsData.isSuccess() ? HttpStatus.OK : HttpStatus.BAD_REQUEST); + } + + public static HttpHeaders httpHeadersOf(String... args) { + HttpHeaders headers = new HttpHeaders(); + + Map map = Ut.mapOf(args); + + for (String key : map.keySet()) { + String value = map.get(key); + headers.set(key, value); + } + + return headers; + } + } + public static class date { public static int getEndDayOf(int year, int month) { String yearMonth = year + "-" + "%02d".formatted(month);