Add secure cached post API and remote import

This commit is contained in:
2026-08-19 21:20:22 +08:00
parent 053afab44d
commit 5fb98402c1
37 changed files with 715 additions and 1401 deletions

View File

@@ -1,2 +0,0 @@
# sample
Sample Project For Demostration of SpringBoot Only

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project-shared-configuration>
<!--
This file contains additional configuration written by modules in the NetBeans IDE.
The configuration is intended to be shared among all the users of project and
therefore it is assumed to be part of version control checkout.
Without this configuration present, some functionality in the IDE may be limited or fail altogether.
-->
<properties xmlns="http://www.netbeans.org/ns/maven-properties-data/1">
<!--
Properties that influence various parts of the IDE, especially code formatting and the like.
You can copy and paste the single properties, into the pom.xml file and the IDE will pick them up.
That way multiple projects can share the same settings (useful for formatting rules for example).
Any value defined here will override the pom.xml file value but is only applicable to the current project.
-->
<org-netbeans-modules-maven-j2ee.netbeans_2e_hint_2e_deploy_2e_server>Tomcat</org-netbeans-modules-maven-j2ee.netbeans_2e_hint_2e_deploy_2e_server>
<org-netbeans-modules-css-prep.less_2e_mappings>/less:/css</org-netbeans-modules-css-prep.less_2e_mappings>
<org-netbeans-modules-css-prep.less_2e_enabled>false</org-netbeans-modules-css-prep.less_2e_enabled>
<org-netbeans-modules-css-prep.sass_2e_enabled>false</org-netbeans-modules-css-prep.sass_2e_enabled>
<org-netbeans-modules-css-prep.sass_2e_compiler_2e_options/>
<org-netbeans-modules-css-prep.less_2e_compiler_2e_options/>
<org-netbeans-modules-css-prep.sass_2e_mappings>/scss:/css</org-netbeans-modules-css-prep.sass_2e_mappings>
<org-netbeans-modules-maven-j2ee.netbeans_2e_deploy_2e_on_2e_save>false</org-netbeans-modules-maven-j2ee.netbeans_2e_deploy_2e_on_2e_save>
<org-netbeans-modules-maven-j2ee.netbeans_2e_hint_2e_j2eeVersion>1.7-web</org-netbeans-modules-maven-j2ee.netbeans_2e_hint_2e_j2eeVersion>
</properties>
</project-shared-configuration>

View File

View File

@@ -1,79 +0,0 @@
package com.hoelee.demo.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.DefaultTemplateResolver;
import org.thymeleaf.templateresolver.ServletContextTemplateResolver;
/**
* <p>Class desc:</p>
*
*
* @version v2, 2020-09-30 12:01:49AM
* @author hoelee
*/
@SpringBootApplication
@EnableAutoConfiguration
@EnableConfigurationProperties
@ComponentScan({"com.hoelee.demo.demo.controller", "com.hoelee.demo.demo.helper"})
public class DemoApplication {
private static TemplateEngine templateEngine;
/** hoelee v2 2020-09-30 12:01:49AM
* <p>Method desc:</p>
*
*
* @param args
*/
public static void main(String[] args) {
SpringApplication springApplication = new SpringApplication();
ApplicationContext appCtx = springApplication.run(DemoApplication.class, args);
}
/** hoelee v2 2020-09-30 03:54:52AM
* <p>Method desc:</p>
*
@Bean
public TemplateResolver templateResolver() {
TemplateResolver templateResolver = new ClassLoaderTemplateResolver();
//templateResolver.setCacheable(false);
//templateResolver.setTemplateMode("XHTML"); // Default
templateResolver.setPrefix("/WEB-INF/thymeleaf/");
templateResolver.setCacheTTLMs(3600000L);
templateResolver.setSuffix(".html");
templateResolver.setTemplateMode("HTML5");
return templateResolver;
}
* @return
private static void initializeTemplateEngine() {
ServletContextTemplateResolver templateResolver = new ServletContextTemplateResolver();
// XHTML is the default mode, but we set it anyway for better understanding of code
templateResolver.setTemplateMode("XHTML");
// This will convert "home" to "/WEB-INF/templates/home.html"
templateResolver.setPrefix("/WEB-INF/thymeleaf/");
templateResolver.setSuffix(".html");
// Template cache TTL=1h. If not set, entries would be cached until expelled by LRU
templateResolver.setCacheTTLMs(3600000L);
//templateResolver.setCacheable(false);
templateEngine = new TemplateEngine();
templateEngine.setTemplateResolver(templateResolver);
}
*/
}
//~ v2, 2020-09-30 05:26:41AM - Last edited by hoelee

View File

@@ -1,67 +0,0 @@
package com.hoelee.demo.demo;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
import org.springframework.web.servlet.view.JstlView;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.spring5.ISpringTemplateEngine;
import org.thymeleaf.spring5.SpringTemplateEngine;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ITemplateResolver;
import org.thymeleaf.templateresolver.ServletContextTemplateResolver;
import javax.servlet.ServletContext;
/**
* <p>Class desc:</p>
*
*
* @version v2, 2020-09-30 05:59:17AM
* @author hoelee
*/
@Configuration
@EnableWebMvc
public class MvcConfig {
@Autowired
private TemplateEngine templateEngine;
@Autowired
private ServletContext servletContext;
@Bean
private void initializeTemplateEngine() {
ServletContextTemplateResolver templateResolver = new ServletContextTemplateResolver(servletContext);
// HTML is the default mode, but we set it anyway for better understanding of code
templateResolver.setTemplateMode(TemplateMode.HTML);
// This will convert "home" to "/WEB-INF/templates/home.html"
templateResolver.setPrefix("/WEB-INF/thymeleaf/");
templateResolver.setSuffix(".html");
// Template cache TTL=1h. If not set, entries would be cached until expelled
templateResolver.setCacheTTLMs(Long.valueOf(3600000L));
// Cache is set to true by default. Set to false if you want templates to
// be automatically updated when modified.
templateResolver.setCacheable(true);
this.templateEngine = new TemplateEngine();
this.templateEngine.setTemplateResolver(templateResolver);
}
}
//~ v2, 2020-09-30 05:59:17AM - Last edited by hoelee

View File

@@ -1,12 +0,0 @@
package com.hoelee.demo.demo;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
public class ServletInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(DemoApplication.class);
}
}

View File

@@ -1,434 +0,0 @@
package com.hoelee.demo.demo.controller;
import com.hoelee.demo.demo.entity.Comment;
import com.hoelee.demo.demo.entity.Post;
import com.hoelee.demo.demo.helper.InternetHelper;
import com.hoelee.demo.demo.helper.ListHelper;
import okhttp3.Authenticator;
import okhttp3.Call;
import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.Credentials;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.Route;
import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* <p>Class desc:</p>
*
* @version v2, 2020-09-30 03:02:39AM
* @author hoelee
*/
@Controller
@RequestMapping(value = "", method = {RequestMethod.GET, RequestMethod.POST})
public class HomeController {
private final Logger log = LoggerFactory.getLogger(this.getClass());
//
@Autowired
private InternetHelper internetHelper;
@ResponseBody
@GetMapping({"", "/"})
private String home() {
return "Hello World";
}
@ResponseBody
@GetMapping("/post/topComment")
private String question1TopCommentEndPoint() {
List<Post> postList = internetHelper.readAllPosts();
List<Comment> commentList = internetHelper.readAllComments();
// Distribute respective Comment into each Post
for(int a = 0; a < postList.size(); a++) {
Post post = postList.get(a);
List<Comment> commentListCurrentPost = new LinkedList<>();
for(int b = 0; b < commentList.size(); b++) {
Comment comment = commentList.get(b);
if (comment.getPostId() == post.getId())
commentListCurrentPost.add(comment);
}
post.setCommentList(commentListCurrentPost);
}
// Sort Post by highest number of comment
Collections.sort(postList,
new Comparator<Post>() {
@Override
public int compare(Post post1, Post post2) {
int post1CommentSize = post1.getCommentList().size();
int post2CommentSize = post2.getCommentList().size();
return post2CommentSize - post1CommentSize;
}
});
// Response JSONArray
JSONArray root = new JSONArray();
for(int a = 0; a < postList.size(); a++) {
root.put(postList.get(a).toJSONObjectCustomResponse1());
}
return root.toString();
}
/** hoelee v2 2020-10-02 12:40:33PM
* <p>Method desc:</p>
*
*
* @param postId
* @param commentId
* @param name
* @param email
* @param body
* @param operator - provide 'and', 'or'
* @return
*/
@ResponseBody
@GetMapping("/search")
private String question2SearchComment( //
@RequestParam(value = "postId", required = false) String postId, //
@RequestParam(value = "commentId", required = false) String commentId, //
@RequestParam(value = "name", required = false) String name, //
@RequestParam(value = "email", required = false) String email, //
@RequestParam(value = "body", required = false) String body, //
@RequestParam(value = "operator", required = false) String operator //
) {
List<Comment> commentList = internetHelper.readAllComments();
// Default no filter handler
if ((postId == null) && (commentId == null) && (name == null) && (email == null) && (body == null)) {
// Response JSONArray
JSONArray root = new JSONArray();
for(int a = 0; a < commentList.size(); a++) {
root.put(commentList.get(a).toJSONObject());
}
return root.toString();
}
// Check operator
boolean operatorAnd = false;
boolean operatorOr = false;
if (!((operator == null) || operator.isEmpty())) {
operator = operator.toLowerCase();
if (operator == "and")
operatorAnd = true;
if (operator == "or")
operatorOr = true;
} else {
// Default Operator
operatorOr = true;
}
// Start the search
if (operatorOr) {
// Do operator OR
List<Comment> commentListPostId = new LinkedList<>();
List<Comment> commnetListCommentId = new LinkedList<>();
List<Comment> commentListName = new LinkedList<>();
List<Comment> commentListEmail = new LinkedList<>();
List<Comment> commentListBody = new LinkedList<>();
List<Comment> commentListResult = new LinkedList<>();
if (!((postId == null) || postId.isEmpty())) {
// Remove unrelated post id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
int postIdInt = Integer.parseInt(postId);
if (comment.getPostId() == postIdInt)
commentListPostId.add(comment);
}
}
if (!((commentId == null) || commentId.isEmpty())) {
// Remove unrelated comment id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
int commentIdInt = Integer.parseInt(commentId);
if (comment.getId() == commentIdInt)
commnetListCommentId.add(comment);
}
}
if (!((name == null) || name.isEmpty())) {
// Remove unrelated comment id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
if (comment.getName().contains(name))
commentListName.add(comment);
}
}
if (!((email == null) || email.isEmpty())) {
// Remove unrelated comment id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
if (comment.getEmail().contains(email))
commentListEmail.add(comment);
}
}
if (!((body == null) || body.isEmpty())) {
// Remove unrelated comment id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
if (comment.getBody().contains(body))
commentListBody.add(comment);
}
}
// Remove duplicate Comment
Set<Comment> commentSetResult = new HashSet<>();
commentListResult.addAll(commentListPostId);
commentListResult.addAll(commnetListCommentId);
commentListResult.addAll(commentListName);
commentListResult.addAll(commentListEmail);
commentListResult.addAll(commentListBody);
for(int a = 0; a < commentListResult.size(); a++) {
commentSetResult.add(commentListResult.get(a));
}
// Response JSONArray
JSONArray root = new JSONArray();
for(Comment cmt : commentSetResult) {
root.put(cmt.toJSONObject());
}
return root.toString();
} else {
// Do operator AND
List<Comment> commentTotal = new LinkedList<>();
if (!((postId == null) || postId.isEmpty())) {
List<Comment> commentListResult = new LinkedList<>();
// Remove unrelated post id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
int postIdInt = Integer.parseInt(postId);
if (comment.getPostId() == postIdInt)
commentListResult.add(comment);
}
// Find out union result save to commentSetTotal
if (commentTotal.isEmpty()) {
commentTotal.addAll(commentListResult);
} else {
List<Comment> commentTotalNew = new LinkedList<>();
for(int a = 0; a < commentTotal.size(); a++) {
Comment cmt1 = commentTotal.get(a);
for(int b = 0; b < commentListResult.size(); b++) {
Comment cmt2 = commentListResult.get(b);
if (cmt1 == cmt2)
commentTotalNew.add(cmt2);
}
}
commentTotal = commentTotalNew;
}
}
if (!((commentId == null) || commentId.isEmpty())) {
List<Comment> commentListResult = new LinkedList<>();
// Remove unrelated post id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
int commentIdInt = Integer.parseInt(commentId);
if (comment.getId() == commentIdInt)
commentListResult.add(comment);
}
// Find out union result save to commentSetTotal
if (commentTotal.isEmpty()) {
commentTotal.addAll(commentListResult);
} else {
List<Comment> commentTotalNew = new LinkedList<>();
for(int a = 0; a < commentTotal.size(); a++) {
Comment cmt1 = commentTotal.get(a);
for(int b = 0; b < commentListResult.size(); b++) {
Comment cmt2 = commentListResult.get(b);
if (cmt1 == cmt2)
commentTotalNew.add(cmt2);
}
}
commentTotal = commentTotalNew;
}
}
if (!((name == null) || name.isEmpty())) {
List<Comment> commentListResult = new LinkedList<>();
// Remove unrelated post id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
if (comment.getName().contains(name))
commentListResult.add(comment);
}
// Find out union result save to commentSetTotal
if (commentTotal.isEmpty()) {
commentTotal.addAll(commentListResult);
} else {
List<Comment> commentTotalNew = new LinkedList<>();
for(int a = 0; a < commentTotal.size(); a++) {
Comment cmt1 = commentTotal.get(a);
for(int b = 0; b < commentListResult.size(); b++) {
Comment cmt2 = commentListResult.get(b);
if (cmt1 == cmt2)
commentTotalNew.add(cmt2);
}
}
commentTotal = commentTotalNew;
}
}
if (!((email == null) || email.isEmpty())) {
List<Comment> commentListResult = new LinkedList<>();
// Remove unrelated post id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
if (comment.getEmail().contains(email))
commentListResult.add(comment);
}
// Find out union result save to commentSetTotal
if (commentTotal.isEmpty()) {
commentTotal.addAll(commentListResult);
} else {
List<Comment> commentTotalNew = new LinkedList<>();
for(int a = 0; a < commentTotal.size(); a++) {
Comment cmt1 = commentTotal.get(a);
for(int b = 0; b < commentListResult.size(); b++) {
Comment cmt2 = commentListResult.get(b);
if (cmt1 == cmt2)
commentTotalNew.add(cmt2);
}
}
commentTotal = commentTotalNew;
}
}
if (!((body == null) || body.isEmpty())) {
List<Comment> commentListResult = new LinkedList<>();
// Remove unrelated post id
for(int a = 0; a < commentList.size(); a++) {
Comment comment = commentList.get(a);
if (comment.getBody().contains(body))
commentListResult.add(comment);
}
// Find out union result save to commentSetTotal
if (commentTotal.isEmpty()) {
commentTotal.addAll(commentListResult);
} else {
List<Comment> commentTotalNew = new LinkedList<>();
for(int a = 0; a < commentTotal.size(); a++) {
Comment cmt1 = commentTotal.get(a);
for(int b = 0; b < commentListResult.size(); b++) {
Comment cmt2 = commentListResult.get(b);
if (cmt1 == cmt2)
commentTotalNew.add(cmt2);
}
}
commentTotal = commentTotalNew;
}
}
// Response JSONArray
JSONArray root = new JSONArray();
for(Comment cmt : commentTotal) {
root.put(cmt.toJSONObject());
}
return root.toString();
}
}
}
//~ v2, 2020-10-02 03:53:15PM - Last edited by hoelee

View File

@@ -1,84 +0,0 @@
package com.hoelee.demo.demo.controller;
import com.hoelee.demo.demo.entity.Comment;
import com.hoelee.demo.demo.entity.Post;
import com.hoelee.demo.demo.helper.InternetHelper;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
import org.json.JSONArray;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@RequestMapping(value = "/test", method = {RequestMethod.GET, RequestMethod.POST})
public class TestController {
private final Logger log = LoggerFactory.getLogger(this.getClass());
//
@Autowired
private InternetHelper internetHelper;
@ResponseBody
@GetMapping({"/1"})
private String testSorting() {
List<Comment> commentList = internetHelper.readAllComments();
List<Post> postList = new LinkedList<>();
Post post1 = new Post();
List<Comment> commentList1 = new LinkedList<>();
commentList1.add(new Comment());
commentList1.add(new Comment());
commentList1.add(new Comment());
post1.setCommentList(commentList1);
Post post2 = new Post();
List<Comment> commentList2 = new LinkedList<>();
commentList2.add(new Comment());
commentList2.add(new Comment());
post2.setCommentList(commentList2);
Post post3 = new Post();
List<Comment> commentList3 = new LinkedList<>();
commentList3.add(new Comment());
post3.setCommentList(commentList3);
postList.add(post1);
postList.add(post2);
postList.add(post3);
Collections.sort(postList,
new Comparator<Post>() {
@Override
public int compare(Post post1, Post post2) {
int post1CommentSize = post1.getCommentList().size();
int post2CommentSize = post2.getCommentList().size();
return post2CommentSize - post1CommentSize;
}
});
JSONArray root = new JSONArray();
for(int a = 0; a < postList.size(); a++) {
root.put(postList.get(a).toJSONObjectCustomResponse1());
}
return root.toString();
}
@GetMapping("/2")
private String testThymeleaf() {
return "testing.html";
}
}

View File

@@ -1,105 +0,0 @@
package com.hoelee.demo.demo.entity;
import com.hoelee.demo.demo.exception.ExceptionJSONConversion;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* <p>Class desc:</p>
*
*
* @version v2, 2020-10-01 06:30:52PM
* @author hoelee
*/
public class Comment {
private final Logger log = LoggerFactory.getLogger(this.getClass());
private int postId;
private int id;
private String name;
private String email;
private String body;
public Comment(){
}
public Comment(int postId, int id, String name, String email, String body) {
this.postId = postId;
this.id = id;
this.name = name;
this.email = email;
this.body = body;
}
public int getPostId() {
return postId;
}
public void setPostId(int postId) {
this.postId = postId;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public void fromJSONObject(JSONObject jo) throws Exception{
try{
if(jo.has("postId"))
postId = jo.getInt("postId");
if (jo.has("id"))
id = jo.getInt("id");
if (jo.has("name"))
name = jo.getString("name");
if (jo.has("email"))
email = jo.getString("email");
if (jo.has("body"))
body = jo.getString("body");
}catch(Exception ex){
log.error(ex.getLocalizedMessage());
throw new ExceptionJSONConversion(ex.getLocalizedMessage());
}
}
public JSONObject toJSONObject(){
JSONObject jo = new JSONObject();
jo.put("postId", postId);
jo.put("id", id);
jo.put("name", name);
jo.put("email", email);
jo.put("body", body);
return jo;
}
}
//~ v2, 2020-10-01 06:30:52PM - Last edited by hoelee

View File

@@ -1,199 +0,0 @@
package com.hoelee.demo.demo.entity;
import com.hoelee.demo.demo.exception.ExceptionJSONConversion;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.LinkedList;
import java.util.List;
/**
* <p>Class desc:</p>
*
*
* @version v2, 2020-10-02 11:47:11AM
* @author hoelee
*/
public class Post {
private final Logger log = LoggerFactory.getLogger(this.getClass());
// All the comments for this post
private List<Comment> commentList = new LinkedList<>();
private int userId;
private int id;
private String title;
private String body;
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Constructor desc:</p>
*
*/
public Post() {}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Constructor desc:</p>
*
*
* @param userId
* @param id
* @param title
* @param body
*/
public Post(int userId, int id, String title, String body) {
this.userId = userId;
this.id = id;
this.title = title;
this.body = body;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
* @return
*/
public int getUserId() {
return userId;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
*
* @param userId
*/
public void setUserId(int userId) {
this.userId = userId;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
* @return
*/
public int getId() {
return id;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
*
* @param id
*/
public void setId(int id) {
this.id = id;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
* @return
*/
public String getTitle() {
return title;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
*
* @param title
*/
public void setTitle(String title) {
this.title = title;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
* @return
*/
public String getBody() {
return body;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
*
* @param body
*/
public void setBody(String body) {
this.body = body;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
* All the comments for this post
*
* @return
*/
public List<Comment> getCommentList() {
return commentList;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
* All the comments for this post
*
* @param commentList
*/
public void setCommentList(List<Comment> commentList) {
this.commentList = commentList;
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
*
* @param jo
*
* @throws Exception
*/
public void fromJSONObject(JSONObject jo) throws Exception {
try {
if (jo.has("userId"))
userId = jo.getInt("userId");
if (jo.has("id"))
id = jo.getInt("id");
if (jo.has("title"))
title = jo.getString("title");
if (jo.has("body"))
body = jo.getString("body");
} catch (Exception ex) {
log.error(ex.getLocalizedMessage());
throw new ExceptionJSONConversion(ex.getLocalizedMessage());
}
}
/** hoelee v2 2020-10-02 11:47:11AM
* <p>Method desc:</p>
*
* @return
*/
public JSONObject toJSONObject() {
JSONObject jo = new JSONObject();
jo.put("userId", userId);
jo.put("id", id);
jo.put("title", title);
jo.put("body", body);
return jo;
}
public JSONObject toJSONObjectCustomResponse1(){
JSONObject jo = new JSONObject();
jo.put("post_id", id);
jo.put("post_title", title);
jo.put("post_body", body);
jo.put("total_number_of_comments", commentList.size());
return jo;
}
}
//~ v2, 2020-10-02 11:47:11AM - Last edited by hoelee

View File

@@ -1,36 +0,0 @@
package com.hoelee.demo.demo.exception;
/**
* <p>Class desc:</p>
* Handle own JSONObject & JSONArray convert to object
*
* @version v2, 2020-10-01 06:39:32PM
* @author hoelee
*/
public class ExceptionJSONConversion extends Exception {
private Class modelClass;
/** hoelee v2 2020-10-01 06:39:32PM
* <p>Constructor desc:</p>
*
*
* @param message
*/
public ExceptionJSONConversion(String message) {
super(message);
this.modelClass = modelClass;
}
/** hoelee v2 2020-10-01 06:39:32PM
* <p>Method desc:</p>
*
* @return
*/
public Class getModelClass() {
return modelClass;
}
}
//~ v2, 2020-10-01 06:39:32PM - Last edited by hoelee

View File

@@ -1,222 +0,0 @@
package com.hoelee.demo.demo.helper;
import com.hoelee.demo.demo.entity.Comment;
import com.hoelee.demo.demo.entity.Post;
import okhttp3.Call;
import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpServletRequest;
/**
* <p>Class desc:</p>
*
*
* @version v2, 2020-09-30 03:14:36AM
* @author hoelee
*/
@Component
public class InternetHelper {
private final Logger log = LoggerFactory.getLogger(this.getClass());
private HttpServletRequest request;
/** hoelee v2 2020-10-01 06:10:46PM
* <p>Method desc:</p>
*
*
* @param request
*/
@Autowired
public void setRequest(HttpServletRequest request) {
this.request = request;
}
/** hoelee v2 2020-10-01 06:10:46PM
* <p>Method desc:</p>
*
* @return
*/
public List<Comment> readAllComments() {
String epReadComments = "https://jsonplaceholder.typicode.com/comments";
String response = synchronizeRequestGetMethod(epReadComments);
try {
JSONArray ja = new JSONArray(response);
List<Comment> commentList = new LinkedList<>();
for(int a = 0; a < ja.length(); a++) {
JSONObject jo = ja.getJSONObject(a);
Comment comment = new Comment();
comment.fromJSONObject(jo);
commentList.add(comment);
}
return commentList;
} catch (Exception ex) {
log.error(ex.getLocalizedMessage());
return null;
}
}
/** hoelee v2 2020-10-01 06:10:46PM
* <p>Method desc:</p>
*
* @return
*/
public List<Post> readAllPosts() {
String epReadPosts = "https://jsonplaceholder.typicode.com/posts";
String response = synchronizeRequestGetMethod(epReadPosts);
try {
JSONArray ja = new JSONArray(response);
List<Post> postList = new LinkedList<>();
for(int a = 0; a < ja.length(); a++) {
JSONObject jo = ja.getJSONObject(a);
Post post = new Post();
post.fromJSONObject(jo);
postList.add(post);
}
return postList;
} catch (Exception ex) {
log.error(ex.getLocalizedMessage());
return null;
}
}
/** hoelee v2 2020-10-01 06:10:46PM
* <p>Method desc:</p>
*
*
* @param postId
* @return
*/
public Post readPostById(int postId) {
String epReadPost = "https://jsonplaceholder.typicode.com/posts/" + postId;
String response = synchronizeRequestGetMethod(epReadPost);
try {
JSONObject jo = null;
Post post = new Post();
jo = new JSONObject(response);
post.fromJSONObject(jo);
return post;
} catch (Exception ex) {
log.error(ex.getLocalizedMessage());
return null;
}
}
/** hoelee v2 2020-10-01 06:10:46PM
* <p>Method desc:</p>
*
*
* @param commentId
* @return
*/
public Comment readCommentById(int commentId) {
String epReadComment = "https://jsonplaceholder.typicode.com/comments/" + commentId;
String response = synchronizeRequestGetMethod(epReadComment);
try {
JSONObject jo = new JSONObject(response);
Comment comment = new Comment();
comment.fromJSONObject(jo);
return comment;
} catch (Exception ex) {
log.error(ex.getLocalizedMessage());
return null;
}
}
/** hoelee v2 2020-09-30 03:14:36AM
* <p>Method desc:</p>
*
*
* @param url
* @return
*/
private String synchronizeRequestGetMethod(String url) {
CookieJar cookieJar = new CookieJar() {
@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
// Save Cookies
String urlString = url.toString();
for(Cookie cookie : cookies) {
String cookieString = cookie.toString();
}
}
@Override
public List<Cookie> loadForRequest(HttpUrl url) {
// Load new cookies
ArrayList<Cookie> cookies = new ArrayList<>();
Cookie cookie = new Cookie.Builder().hostOnlyDomain(url.host()).name("key").value("value").build();
cookies.add(cookie);
return cookies;
}
};
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
// Add Parameters
//urlBuilder.addQueryParameter("key", "value");
String finalUrl = urlBuilder.build().toString();
OkHttpClient mOkHttpClient = new OkHttpClient.Builder() //
.connectTimeout(60, TimeUnit.SECONDS).cookieJar(cookieJar).build();
Request request = new Request.Builder().url(finalUrl).build();
Call call = mOkHttpClient.newCall(request);
Response response = null;
String body = null;
try {
response = call.execute();
if (response.isSuccessful())
body = response.body().string();
else
log.warn("Http Get Request not success with URL: " + url);
} catch (IOException e) {
log.error("Http GET Request failed with URL: " + url + " because of " + e.getLocalizedMessage());
} finally {
try {
response.close();
} catch (Exception ex) {}
}
return body;
}
}
//~ v2, 2020-10-02 11:37:44AM - Last edited by hoelee

View File

@@ -1,56 +0,0 @@
package com.hoelee.demo.demo.helper;
import com.hoelee.demo.demo.entity.Comment;
import java.util.LinkedList;
import java.util.List;
/**
* <p>
* Class desc:</p>
*
* @version v2, 2020-10-02 02:48:54PM
* @author hoelee
*/
public class ListHelper {
/**
* hoelee v2 2020-10-02 02:48:54PM
* <p>
* Method desc:</p>
* Search same Comment in give 2 Comment LinkedList
*
* @param l1
* @param l2
* @return
*/
public static List findSameCommentInList(List l1, List l2) {
// If l2 empty no need find
if (l2.isEmpty())
return l1;
List result = new LinkedList<>();
for (int a = 0; a < l1.size(); a++) {
Comment l1Obj = (Comment) l1.get(a);
boolean gotSame = false;
for (int b = 0; b < l2.size(); b++) {
Comment l2Obj = (Comment) l2.get(b);
if (l1Obj == l2Obj) {
gotSame = true;
break;
}
}
if (gotSame) {
result.add(l1Obj);
}
}
return result;
}
}
//~ v2, 2020-10-02 02:48:54PM - Last edited by hoelee

View File

@@ -0,0 +1,30 @@
package com.hoelee.jsonplaceholder.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @version v1, 2024-10-22 08:10:00PM
* @author hoelee
* Learning note: a bounded local cache improves hot read latency without making cached data permanent.
*/
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("posts");
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(10)));
return cacheManager;
}
}
//~ v1, 2024-10-22 08:10:00PM - Last edited by hoelee

View File

@@ -0,0 +1,32 @@
package com.hoelee.jsonplaceholder.config;
import java.net.http.HttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
/**
* @version v1, 2024-10-22 09:10:00PM
* @author hoelee
* Learning note: an explicitly named HTTP client isolates a remote integration from the application API.
*/
@Configuration
public class RestClientConfig {
@Bean
RestClient jsonPlaceholderRestClient(RestClient.Builder builder, ApiProperties properties) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(properties.requestTimeout())
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(properties.requestTimeout());
return builder
.baseUrl(properties.jsonPlaceholderBaseUrl())
.requestFactory(requestFactory)
.build();
}
}
//~ v1, 2024-10-22 09:10:00PM - Last edited by hoelee

View File

@@ -0,0 +1,52 @@
package com.hoelee.jsonplaceholder.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/**
* @version v1, 2024-10-22 10:10:00PM
* @author hoelee
* Learning note: read operations stay public while state-changing API operations require a role.
*/
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/**").hasRole("EDITOR")
.anyRequest().denyAll())
.httpBasic(Customizer.withDefaults())
.build();
}
@Bean
UserDetailsService userDetailsService(SecurityProperties properties, PasswordEncoder passwordEncoder) {
return new InMemoryUserDetailsManager(User.withUsername(properties.editorUsername())
.password(passwordEncoder.encode(properties.editorPassword()))
.roles("EDITOR")
.build());
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
//~ v1, 2024-10-22 10:10:00PM - Last edited by hoelee

View File

@@ -0,0 +1,37 @@
package com.hoelee.jsonplaceholder.integration;
import com.hoelee.jsonplaceholder.support.UpstreamServiceException;
import java.util.List;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
/**
* @version v1, 2024-10-23 09:15:00PM
* @author hoelee
* Learning note: RestClient provides a typed boundary where timeouts and remote failures can be handled consistently.
*/
@Component
public class JsonPlaceholderClient {
private final RestClient restClient;
public JsonPlaceholderClient(RestClient jsonPlaceholderRestClient) {
this.restClient = jsonPlaceholderRestClient;
}
public List<JsonPlaceholderPost> fetchPosts(int limit) {
try {
List<JsonPlaceholderPost> posts = restClient.get()
.uri("/posts")
.retrieve()
.body(new ParameterizedTypeReference<>() { });
return posts == null ? List.of() : posts.stream().limit(limit).toList();
} catch (RestClientException exception) {
throw new UpstreamServiceException("JSONPlaceholder could not be reached", exception);
}
}
}
//~ v1, 2024-10-23 09:15:00PM - Last edited by hoelee

View File

@@ -0,0 +1,11 @@
package com.hoelee.jsonplaceholder.integration;
/**
* @version v1, 2024-10-23 08:15:00PM
* @author hoelee
* Learning note: a remote DTO is kept separate from the persisted domain model to prevent API coupling.
*/
public record JsonPlaceholderPost(Long id, Long userId, String title, String body) {
}
//~ v1, 2024-10-23 08:15:00PM - Last edited by hoelee

View File

@@ -0,0 +1,19 @@
package com.hoelee.jsonplaceholder.post;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
/**
* @version v1, 2024-10-24 09:20:00PM
* @author hoelee
* Learning note: immutable request records make the public contract concise and validation rules visible.
*/
public record CreatePostRequest(
@NotNull @Positive Long authorId,
@NotBlank @Size(max = 160) String title,
@NotBlank @Size(max = 10_000) String body) {
}
//~ v1, 2024-10-24 09:20:00PM - Last edited by hoelee

View File

@@ -0,0 +1,11 @@
package com.hoelee.jsonplaceholder.post;
/**
* @version v1, 2024-10-25 10:25:00PM
* @author hoelee
* Learning note: import results report what happened without returning an unnecessary full data set.
*/
public record ImportResult(int fetched, int imported, int skipped) {
}
//~ v1, 2024-10-25 10:25:00PM - Last edited by hoelee

View File

@@ -0,0 +1,29 @@
package com.hoelee.jsonplaceholder.post;
import java.util.List;
import java.util.function.Function;
import org.springframework.data.domain.Page;
/**
* @version v1, 2024-10-25 09:25:00PM
* @author hoelee
* Learning note: a stable page envelope avoids exposing Spring Data's serialized implementation details.
*/
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages) {
public static <S, T> PageResponse<T> from(Page<S> page, Function<S, T> mapper) {
return new PageResponse<>(
page.map(mapper).getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages());
}
}
//~ v1, 2024-10-25 09:25:00PM - Last edited by hoelee

View File

@@ -0,0 +1,112 @@
package com.hoelee.jsonplaceholder.post;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
/**
* @version v1, 2024-10-23 10:15:00PM
* @author hoelee
* Learning note: the entity owns persistence concerns, while request validation remains at the API boundary.
*/
@Entity
@Table(name = "posts", indexes = @Index(name = "idx_posts_title", columnList = "title"))
@EntityListeners(AuditingEntityListener.class)
public class Post {
@Id
@GeneratedValue
@UuidGenerator
private UUID id;
@Column(name = "source_post_id", unique = true)
private Long sourcePostId;
@Column(nullable = false)
private long authorId;
@Column(nullable = false, length = 160)
private String title;
@Column(nullable = false, length = 10_000)
private String body;
@Version
private long version;
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
protected Post() {
}
private Post(Long sourcePostId, long authorId, String title, String body) {
this.sourcePostId = sourcePostId;
this.authorId = authorId;
this.title = title;
this.body = body;
}
public static Post create(long authorId, String title, String body) {
return new Post(null, authorId, title, body);
}
public static Post imported(long sourcePostId, long authorId, String title, String body) {
return new Post(sourcePostId, authorId, title, body);
}
public void update(String title, String body) {
this.title = title;
this.body = body;
}
public UUID getId() {
return id;
}
public long getAuthorId() {
return authorId;
}
public String getTitle() {
return title;
}
public String getBody() {
return body;
}
public long getVersion() {
return version;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public Long getSourcePostId() {
return sourcePostId;
}
}
//~ v1, 2024-10-23 10:15:00PM - Last edited by hoelee

View File

@@ -0,0 +1,75 @@
package com.hoelee.jsonplaceholder.post;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
/**
* @version v1, 2024-10-27 09:35:00PM
* @author hoelee
* Learning note: the controller is deliberately thin: it translates HTTP, delegates, and returns standard status codes.
*/
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@GetMapping
public PageResponse<PostResponse> findPosts(
@RequestParam(required = false) String title,
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
return postService.findPosts(title, pageable);
}
@GetMapping("/{postId}")
public PostResponse findPost(@PathVariable UUID postId) {
return postService.findPost(postId);
}
@PostMapping
public ResponseEntity<PostResponse> createPost(@Valid @RequestBody CreatePostRequest request) {
PostResponse response = postService.createPost(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{postId}")
.buildAndExpand(response.id())
.toUri();
return ResponseEntity.created(location).body(response);
}
@PutMapping("/{postId}")
public PostResponse updatePost(@PathVariable UUID postId, @Valid @RequestBody UpdatePostRequest request) {
return postService.updatePost(postId, request);
}
@DeleteMapping("/{postId}")
public ResponseEntity<Void> deletePost(@PathVariable UUID postId) {
postService.deletePost(postId);
return ResponseEntity.noContent().build();
}
@PostMapping("/import/jsonplaceholder")
public ResponseEntity<ImportResult> importFromJsonPlaceholder() {
return ResponseEntity.status(HttpStatus.CREATED).body(postService.importFromJsonPlaceholder());
}
}
//~ v1, 2024-10-27 09:35:00PM - Last edited by hoelee

View File

@@ -0,0 +1,22 @@
package com.hoelee.jsonplaceholder.post;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* @version v1, 2024-10-24 08:20:00PM
* @author hoelee
* Learning note: Spring Data derives focused queries from method names and keeps data access out of controllers.
*/
public interface PostRepository extends JpaRepository<Post, UUID> {
Page<Post> findByTitleContainingIgnoreCase(String title, Pageable pageable);
List<Post> findAllBySourcePostIdIn(Collection<Long> sourcePostIds);
}
//~ v1, 2024-10-24 08:20:00PM - Last edited by hoelee

View File

@@ -0,0 +1,32 @@
package com.hoelee.jsonplaceholder.post;
import java.time.Instant;
import java.util.UUID;
/**
* @version v1, 2024-10-25 08:25:00PM
* @author hoelee
* Learning note: response DTOs stop database implementation details from becoming a permanent API contract.
*/
public record PostResponse(
UUID id,
long authorId,
String title,
String body,
long version,
Instant createdAt,
Instant updatedAt) {
public static PostResponse from(Post post) {
return new PostResponse(
post.getId(),
post.getAuthorId(),
post.getTitle(),
post.getBody(),
post.getVersion(),
post.getCreatedAt(),
post.getUpdatedAt());
}
}
//~ v1, 2024-10-25 08:25:00PM - Last edited by hoelee

View File

@@ -0,0 +1,121 @@
package com.hoelee.jsonplaceholder.post;
import com.hoelee.jsonplaceholder.config.ApiProperties;
import com.hoelee.jsonplaceholder.integration.JsonPlaceholderClient;
import com.hoelee.jsonplaceholder.integration.JsonPlaceholderPost;
import com.hoelee.jsonplaceholder.support.PostNotFoundException;
import com.hoelee.jsonplaceholder.support.PostVersionConflictException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* @version v1, 2024-10-27 08:35:00PM
* @author hoelee
* Learning note: this service owns business transactions, cache invalidation, and integration orchestration.
*/
@Service
public class PostService {
private final PostRepository postRepository;
private final JsonPlaceholderClient jsonPlaceholderClient;
private final ApiProperties apiProperties;
public PostService(
PostRepository postRepository,
JsonPlaceholderClient jsonPlaceholderClient,
ApiProperties apiProperties) {
this.postRepository = postRepository;
this.jsonPlaceholderClient = jsonPlaceholderClient;
this.apiProperties = apiProperties;
}
@Transactional(readOnly = true)
public PageResponse<PostResponse> findPosts(String title, Pageable pageable) {
Page<Post> posts = StringUtils.hasText(title)
? postRepository.findByTitleContainingIgnoreCase(title.trim(), pageable)
: postRepository.findAll(pageable);
return PageResponse.from(posts, PostResponse::from);
}
@Cacheable(cacheNames = "posts", key = "#postId")
@Transactional(readOnly = true)
public PostResponse findPost(UUID postId) {
return postRepository.findById(postId)
.map(PostResponse::from)
.orElseThrow(() -> new PostNotFoundException(postId));
}
@Transactional
public PostResponse createPost(CreatePostRequest request) {
Post post = Post.create(request.authorId(), request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
@CachePut(cacheNames = "posts", key = "#postId")
@Transactional
public PostResponse updatePost(UUID postId, UpdatePostRequest request) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
if (post.getVersion() != request.version()) {
throw new PostVersionConflictException(postId);
}
post.update(request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
@CacheEvict(cacheNames = "posts", key = "#postId")
@Transactional
public void deletePost(UUID postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
postRepository.delete(post);
}
@CacheEvict(cacheNames = "posts", allEntries = true)
@Transactional
public ImportResult importFromJsonPlaceholder() {
List<JsonPlaceholderPost> received = jsonPlaceholderClient.fetchPosts(apiProperties.importLimit());
List<JsonPlaceholderPost> candidates = received.stream()
.filter(this::isImportable)
.toList();
Set<Long> knownSourceIds = postRepository.findAllBySourcePostIdIn(
candidates.stream().map(JsonPlaceholderPost::id).toList())
.stream()
.map(Post::getSourcePostId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> seenSourceIds = new HashSet<>();
List<Post> newPosts = candidates.stream()
.filter(remotePost -> !knownSourceIds.contains(remotePost.id()))
.filter(remotePost -> seenSourceIds.add(remotePost.id()))
.map(remotePost -> Post.imported(
remotePost.id(),
remotePost.userId(),
remotePost.title().trim(),
remotePost.body().trim()))
.toList();
postRepository.saveAllAndFlush(newPosts);
return new ImportResult(received.size(), newPosts.size(), received.size() - newPosts.size());
}
private boolean isImportable(JsonPlaceholderPost post) {
return post.id() != null
&& post.userId() != null
&& post.userId() > 0
&& StringUtils.hasText(post.title())
&& post.title().length() <= 160
&& StringUtils.hasText(post.body())
&& post.body().length() <= 10_000;
}
}
//~ v1, 2024-10-27 08:35:00PM - Last edited by hoelee

View File

@@ -0,0 +1,19 @@
package com.hoelee.jsonplaceholder.post;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* @version v1, 2024-10-24 10:20:00PM
* @author hoelee
* Learning note: accepting a version makes concurrent edits explicit instead of silently overwriting data.
*/
public record UpdatePostRequest(
@NotBlank @Size(max = 160) String title,
@NotBlank @Size(max = 10_000) String body,
@NotNull @Min(0) Long version) {
}
//~ v1, 2024-10-24 10:20:00PM - Last edited by hoelee

View File

@@ -0,0 +1,64 @@
package com.hoelee.jsonplaceholder.support;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.RestClientException;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
/**
* @version v1, 2024-10-27 10:35:00PM
* @author hoelee
* Learning note: one error boundary makes RFC 9457-style problem responses consistent across every endpoint.
*/
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(PostNotFoundException.class)
ProblemDetail handleNotFound(PostNotFoundException exception) {
return problem(HttpStatus.NOT_FOUND, "POST_NOT_FOUND", exception.getMessage());
}
@ExceptionHandler(PostVersionConflictException.class)
ProblemDetail handleConflict(PostVersionConflictException exception) {
return problem(HttpStatus.CONFLICT, "POST_VERSION_CONFLICT", exception.getMessage());
}
@ExceptionHandler({UpstreamServiceException.class, RestClientException.class})
ProblemDetail handleUpstreamFailure(RuntimeException exception) {
return problem(HttpStatus.SERVICE_UNAVAILABLE, "UPSTREAM_UNAVAILABLE",
"The post import service is temporarily unavailable");
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException exception,
org.springframework.http.HttpHeaders headers,
org.springframework.http.HttpStatusCode status,
WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) {
errors.putIfAbsent(fieldError.getField(), fieldError.getDefaultMessage());
}
ProblemDetail problem = problem(HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "One or more fields are invalid");
problem.setProperty("errors", errors);
return ResponseEntity.badRequest().body(problem);
}
private ProblemDetail problem(HttpStatus status, String code, String detail) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setType(URI.create("https://github.com/hoelee/springboot-jsonplaceholder-demo/problems/" + code));
problem.setProperty("code", code);
return problem;
}
}
//~ v1, 2024-10-27 10:35:00PM - Last edited by hoelee

View File

@@ -0,0 +1,17 @@
package com.hoelee.jsonplaceholder.support;
import java.util.UUID;
/**
* @version v1, 2024-10-26 08:30:00PM
* @author hoelee
* Learning note: domain-specific exceptions let the HTTP adapter provide precise client feedback.
*/
public class PostNotFoundException extends RuntimeException {
public PostNotFoundException(UUID postId) {
super("Post %s was not found".formatted(postId));
}
}
//~ v1, 2024-10-26 08:30:00PM - Last edited by hoelee

View File

@@ -0,0 +1,17 @@
package com.hoelee.jsonplaceholder.support;
import java.util.UUID;
/**
* @version v1, 2024-10-26 09:30:00PM
* @author hoelee
* Learning note: optimistic locking gives API clients a clear retry signal when data changed concurrently.
*/
public class PostVersionConflictException extends RuntimeException {
public PostVersionConflictException(UUID postId) {
super("Post %s has changed; fetch it again before retrying".formatted(postId));
}
}
//~ v1, 2024-10-26 09:30:00PM - Last edited by hoelee

View File

@@ -0,0 +1,15 @@
package com.hoelee.jsonplaceholder.support;
/**
* @version v1, 2024-10-26 10:30:00PM
* @author hoelee
* Learning note: wrapping remote client errors prevents third-party exception details leaking through the API.
*/
public class UpstreamServiceException extends RuntimeException {
public UpstreamServiceException(String message, Throwable cause) {
super(message, cause);
}
}
//~ v1, 2024-10-26 10:30:00PM - Last edited by hoelee

View File

@@ -1,10 +0,0 @@
spring.server.port=10080

View File

@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the JRebel configuration file. It maps the running application to your IDE workspace, enabling JRebel reloading for this project.
Refer to https://manuals.jrebel.com/jrebel/standalone/config.html for more information.
-->
<application generated-by="netbeans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.zeroturnaround.com" xsi:schemaLocation="http://www.zeroturnaround.com http://update.zeroturnaround.com/jrebel/rebel-2_1.xsd">
<classpath>
<dir name="C:\Users\hoelee\Documents\NetBeansProjects\demo\target\classes">
</dir>
</classpath>
<web>
<link target="/">
<dir name="C:\Users\hoelee\Documents\NetBeansProjects\demo\src\main\webapp">
</dir>
</link>
</web>
</application>

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<!--
To change this license header, choose License Headers in Project Properties.
To change this template file, choose Tools | Templates
and open the template in the editor.
-->
<html>
<head>
<title>TODO supply a title</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div>TODO write content</div>
</body>
</html>

View File

@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Context path=""/>

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<!--
To change this license header, choose License Headers in Project Properties.
To change this template file, choose Tools | Templates
and open the template in the editor.
-->
<html>
<head>
<title>TODO supply a title</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div>TODO write content</div>
</body>
</html>

View File

@@ -1,14 +0,0 @@
package com.hoelee.demo.demo;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class DemoApplicationTests {
@Test
void contextLoads() {}
}
//~ v2, 2020-09-30 12:23:32AM - Last edited by hoelee