2023年6月14日 星期三

Java GraphQL 實作練習 - 不使用 SpringBoot,只使用一般 Spring MVC

這篇文要來紀錄一下 Java 的 GraphQL 純 Spring MVC 實作練習,
因為網路上的許多範例都是使用 SpringBoot 來實作,
所以在這裡紀錄一下使用純 Spring MVC 、不使用 SpringBoot 的方法。

在這裡想要實作的情境是:
我們有 Author、和 Book 兩種資料,
Author 和 Book 都有 id、name 屬性,
Author 還有 bookIdList 屬性,是一個 Array,紀錄著此 Author 撰寫的 Book 的 id。


我們要實作兩個 query 和一個 mutation,分別是:
Author  getAuthorById(id) :
用 Author id 查 Author。
回傳的 bookList 可以給一個 prefix 參數,它可以為回傳的 book name 前面加上 prefix
(這只是用來練習如何在 Java GraphQL server 端取得 Query 的參數)。

Book  getBookById(id) :
用 Book id 查 Book,

Author updateAuthorName(id, name):
修改特定 id 的 Author 的 name。

GraphQL 的 Schema 設計如下,其中 Date 是我們自訂的 type,之後會建立 GraphQLScalarType 類別來解析它:

scalar Date

type Book {
	id: ID!
	name: String
    publishDate: Date	
}

type Author {
	id: ID!
	name: String
	bookList(prefix: String): [Book]
}

type Query {
  getAuthorById(id: ID!): Author
  getBookById(id: ID!): Book
}

type Mutation {
    updateAuthorName(id: ID!, name: String!): Author
}

Query Request 的例子如下:

query {
  getAuthorById(id: 3) {
    id
    name
    bookList(prefix: "test") {
        id
        name
        publishDate
    }
  }
  
  getBookById(id: 33) {
    id
    name
    publishDate
  }
}

Mutatio Request 的例子如下:

mutation {
    updateAuthorName(id: 3, name: "Author 333") {
        id
        name
    }
}

以下開始實作,這裡使用 Opoen JDK 11,
先建立一個 archetype 為 maven-archetype-webapp 的 Java Dynamic Web Application Maven 專案,可以參考這裡
首先先來看一下專案的結構設計,如下圖所示:

接著開始說明,

因為我們要使用 Spring MVC 和 GraphQL 的 library,所以在 pom.xml 加上以下 dependency:

pom.xml (部份內容):

<!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->
	<dependency>
	    <groupId>org.springframework</groupId>
	    <artifactId>spring-webmvc</artifactId>
	    <version>5.1.3.RELEASE</version>
	</dependency>

	<!-- https://mvnrepository.com/artifact/org.springframework/spring-context-support -->
	<dependency>
	    <groupId>org.springframework</groupId>
	    <artifactId>spring-context-support</artifactId>
	    <version>5.1.3.RELEASE</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
	<dependency>
	    <groupId>javax.servlet</groupId>
	    <artifactId>javax.servlet-api</artifactId>
	    <version>3.0.1</version>
	    <scope>provided</scope>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjweaver -->
	<dependency>
	    <groupId>org.aspectj</groupId>
	    <artifactId>aspectjweaver</artifactId>
	    <version>1.9.6</version>
	</dependency>
    
    <!-- https://mvnrepository.com/artifact/com.graphql-java/graphql-java -->
	<dependency>
	    <groupId>com.graphql-java</groupId>
	    <artifactId>graphql-java</artifactId>
	    <version>20.2</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/com.graphql-java-kickstart/graphql-java-tools -->
	<dependency>
	    <groupId>com.graphql-java-kickstart</groupId>
	    <artifactId>graphql-java-tools</artifactId>
	    <version>13.0.3</version>
	</dependency>

接著在 web.xml 裡設定把流量導向 Spring MVC:

/src/main/webapp/WEB-INF/web.xml:

<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>
  
  <servlet>
    <servlet-name>dispatcher</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
			<param-name>contextConfigLocation</param-name>
			<param-value>/WEB-INF/mvc-config.xml</param-value>
		</init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>
  
  <servlet-mapping>
    <servlet-name>dispatcher</servlet-name> 
    <url-pattern>/*</url-pattern>
  </servlet-mapping>
</web-app>

設定 Spring MVC 的設定檔:

src/main/webapp/WEB-INF/mvc-config.xml:

<beans xmlns="http://www.springframework.org/schema/beans"  xmlns:security="http://www.springframework.org/schema/security" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:tx="http://www.springframework.org/schema/tx" xmlns:task="http://www.springframework.org/schema/task"
	xmlns:aop="http://www.springframework.org/schema/aop" xmlns:cache="http://www.springframework.org/schema/cache" xmlns:util="http://www.springframework.org/schema/util"
	xmlns:oxm="http://www.springframework.org/schema/oxm"
	xsi:schemaLocation="
        http://www.springframework.org/schema/beans     
        http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
        http://www.springframework.org/schema/context 
        http://www.springframework.org/schema/context/spring-context-3.0.xsd
        http://www.springframework.org/schema/tx
        http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd
        http://www.springframework.org/schema/task
        http://www.springframework.org/schema/task/spring-task-3.0.xsd
        http://www.springframework.org/schema/aop 
        http://www.springframework.org/schema/aop/spring-aop-4.0.xsd
        http://www.springframework.org/schema/cache 
        http://www.springframework.org/schema/cache/spring-cache.xsd
        http://www.springframework.org/schema/util
        http://www.springframework.org/schema/util/spring-util-3.0.xsd
        http://www.springframework.org/schema/oxm 
        http://www.springframework.org/schema/oxm/spring-oxm-3.0.xsd
        http://www.springframework.org/schema/security
        http://www.springframework.org/schema/security/spring-security-4.2.xsd"       
        >
                           	
	<context:component-scan base-package="com.*"/>
	<aop:aspectj-autoproxy/>
	<mvc:annotation-driven/>
</beans>

說明:把 GraphQL 的 Schema 設定在 schema.graphqls 中。

/src/main/resources/schema.graphqls:

scalar Date

type Book {
	id: ID!
	name: String
    publishDate: Date
}

type Author {
	id: ID!
	name: String
	bookList(prefix: String): [Book]
}

type Query {
  getAuthorById(id: ID!): Author
}

type Mutation {
    updateAuthorName(id: ID!, name: String!): Author
}

再來建好需要的 Bean 。

/src/java/com/bean/Author.java:

package com.bean;

import java.util.List;

public class Author {
	private int id;
	private String name;
	private List<Integer> bookIdList;

	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 List<Integer> getBookIdList() {
		return bookIdList;
	}

	public void setBookIdList(List<Integer> bookIdList) {
		this.bookIdList = bookIdList;
	}
	
	
}

/src/java/com/bean/Book.java:

package com.bean;

import java.util.Calendar;

public class Book {
	private int id;
	private String name;
    Calendar publishDate;

	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 Calendar getPublishDate() {
		return publishDate;
	}

	public void setPublishDate(Calendar publishDate) {
		this.publishDate = publishDate;
	}
}

建好查詢資料和修改資料的 DAO (這邊沒有使用真正的資料庫,只是建假的資料來模擬)。

/src/main/java/com/dao/AuthorDAO.java:

package com.dao;

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

import org.springframework.stereotype.Repository;

import com.bean.Author;

@Repository
public class AuthorDAO {
	private static List<Author> authorList;
	
	public AuthorDAO() {
		authorList  = new ArrayList<Author>();
		
		//Set mock data to simulate data in database.
		Author author = new Author();
		author.setId(1);
		author.setName("Author 1");
		author.setBookIdList(List.of(1, 11));
		authorList.add(author);
		
		author = new Author();
		author.setId(2);
		author.setName("Author 2");
		author.setBookIdList(List.of(2, 22));
		authorList.add(author);
		
		author = new Author();
		author.setId(3);
		author.setName("Author 3");
		author.setBookIdList(List.of(3, 33));
		authorList.add(author);
	}
	
	public Author getAuthorById(int id) {
		return authorList.stream().filter((Author author) -> {
			return author.getId() == id;
		}).findAny().orElse(null);
	}
	
	public Author updateAuthorName(int id, String name) {		
		Author author = getAuthorById(id);
		if (author != null) {
			author.setName(name);
		}
		return author;
	}
}

/src/main/java/com/dao/BookDAO.java:

package com.dao;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.stereotype.Repository;

import com.bean.Author;
import com.bean.Book;

@Repository
public class BookDAO {
	private static List<Book> bookList;
	
	public BookDAO() {
		bookList = new ArrayList<Book>();
		
		//Set mock data to simulate data in database.
		Book book = new Book();
		book.setId(1);
		book.setName("Book 1");
		book.setPublishDate(getCalendarByStr("2019-01-01T01:00:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(11);
		book.setName("Book 11");
		book.setPublishDate(getCalendarByStr("2019-01-01T01:01:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(2);
		book.setName("Book 2");
		book.setPublishDate(getCalendarByStr("2019-01-01T02:00:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(22);
		book.setName("Book 22");
		book.setPublishDate(getCalendarByStr("2019-01-01T02:02:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(3);
		book.setName("Book 3");
		book.setPublishDate(getCalendarByStr("2019-01-01T03:00:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(33);
		book.setName("Book 33");
		book.setPublishDate(getCalendarByStr("2019-01-01T03:03:00+0800"));
		bookList.add(book);
	}
	
	public Book getBookById(int id) {
		return bookList.stream().filter((Book book) -> {
			return book.getId() == id;
		}).findAny().orElse(null);
	}
	
	public List<Book> getBookListByAuthor(Author author) {
		if (author == null || author.getBookIdList() == null || author.getBookIdList().size() == 0) {
			return new ArrayList<Book>();
		}
		
		return author.getBookIdList().stream().map((Integer bookId) -> {
			return bookList.stream().filter((Book book) -> {
				return book.getId() == bookId;
			}).findAny().orElse(null);
		}).filter((Book book) -> {
			return book != null;
		})
		.collect(Collectors.toList());
	}
    
    Calendar getCalendarByStr(String dateStr) {
    	SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
    	Calendar date = Calendar.getInstance();
    	try {
			date.setTime(sdf.parse(dateStr));
		} catch (ParseException e) {
			e.printStackTrace();
		}
        return date;
	}
}

我們這邊建一個 GraphqlController.java 來對應至GraphQL server 會收到的 request body JSON,
等一下在 Spring MVC 的 controller 可以直接把 GraphQL request 的值用 GraphqlController.java 來接,
這邊說明一下,Client 端要向 GraphQL 發起請求的時候,可以使用 HTTP GET 及 HTTP POST 的方式:

  1. HTTP GET:
    將參數直接串在網址後面,例如以下例子,不過要注意有些限制,像是無法使用 variables、mutation (雖然官方這樣說,不過其實如果 GraphQL server 是像這篇例子一樣自己實做的話,還是可以自己去設計取得 variables, mutation 實作行為,例如 query=mutation {...}) 等,可參考 Making a GraphQL requests using the GET method
    https://xxx/xxx?query=...&operationName=...
    
  2. HTTP POST:
    將參數 (query、operationName, variables 等) 直接放在 HTTP Body 中 (相當於 Postman 的 raw 格式),例如:
    {
      "query": "...",
      "operationName": "...",
      "variables": { "myVariable": "someValue", ... }
    } 

詳細可以參考這裡的說明: HTTP Methods, Headers, and Body

而 GraphqlController.java 我們把它設計成可對應 HTTP POST Body  的 JSON (有 query, operationName, variables 等屬性)。

/src/main/java/com/query/GraphqlRequestQuery.java:

package com.query;

import java.util.Map;

public class GraphqlRequestQuery {
	String query;
	String operationName;	
	Map<String, Object> variables = new HashMap<>();

	public String getQuery() {
		return query;
	}

	public void setQuery(String query) {
		this.query = query;
	}

	public String getOperationName() {
		return operationName;
	}

	public void setOperationName(String operationName) {
		this.operationName = operationName;
	}

	public Map<String, Object> getVariables() {
		return variables;
	}

	public void setVariables(Map<String, Object> variables) {
		this.variables = variables;
	}
}


建立各個 Resolver 和 Mutation,這會分別對應到 GraphQL 的 query 和 mutation,
說明都寫在注釋裡。

QueryResolver.java 對應到 GraphQL query,裡面會實作 getAuthorById() 和 getBookById() 方法

/src/main/java/com/resolver/QueryResolver.java:

package com.resolver;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.bean.Author;
import com.bean.Book;
import com.dao.AuthorDAO;
import com.dao.BookDAO;

import graphql.kickstart.tools.GraphQLQueryResolver;

@Component //對應至 GraphQL request 的 query
public class QueryResolver implements GraphQLQueryResolver {
	
	@Autowired
	AuthorDAO authorDAO;
	
	@Autowired
	BookDAO bookDAO;
	
	//對應至 GraphQL request query 的 getAuthorById
	public Author getAuthorById(int id) {
        return authorDAO.getAuthorById(id);
    }
	
	//對應至 GraphQL request query 的 getBookById
	public Book getBookById(int id) {
        return bookDAO.getBookById(id);
    }
} 

AuthorResolver.java 對應到 GraphQL query 回傳 Author 的 bookList 屬性實作方法,
因為在 QueryResolver.java 中 getAuthorById() 回傳的 Author 裡面找不到 bookList 屬性 (Author.java 只有 bookIdList 和 getBookIdList() 的 getter ,並且 bookIdList 是 int[],也不是 Book[] ),
所以我們必須為 Author 的 bookList 屬性實作值的取得方法。

/src/main/java/com/resolver/AuthorResolver.java:

package com.resolver;

import java.util.List;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.bean.Author;
import com.bean.Book;
import com.dao.BookDAO;

import graphql.kickstart.tools.GraphQLResolver;

@Component //對應至 GraphQL request query 中,Author 屬性值的 Resolver 獲取方式 
public class AuthorResolver implements GraphQLResolver<Author> {
	
	@Autowired
	BookDAO bookDAO;
	
	//對應至 GraphQL request query 中,Author 屬性 bookList 值的 Resolver 獲取方式 
	List<Book> bookList(Author author, String prefix) { //可獲取 Parent (也就是 Author) 的資料和傳入參數 (此例為 prefix)
		List<Book> bookList = bookDAO.getBookListByAuthor(author);
		
		if (StringUtils.isNotBlank(prefix)) {			
			bookList = bookList.stream().map((Book book) -> {
				Book renamedBook = new Book();
				renamedBook.setId(book.getId());
				renamedBook.setName(prefix + "-" + book.getName());
				renamedBook.setPublishDate(book.getPublishDate());
				return renamedBook;
			}).collect(Collectors.toList());
		}
		
		return bookList;
	}
}

MutationResolver.java 對應至 GraphQL mutation,裡面會實作 updateAuthorName

/src/main/java/com/resolver/MutationResolver.java:

package com.resolver;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.bean.Author;
import com.dao.AuthorDAO;

import graphql.kickstart.tools.GraphQLMutationResolver;

@Component //對應至 GraphQL request 的 mutation
public class MutationResolver implements GraphQLMutationResolver{
	
	@Autowired
	AuthorDAO authorDAO;
	
	//對應至 GraphQL request mutation 的 updateAuthorName
	public Author updateAuthorName(int id, String name) {
		return authorDAO.updateAuthorName(id, name);
	}
}

接下來我們要來處理自訂的 Date Scalar , 我們這邊建立一個 DateScalarBuilder.java 來回傳自訂實作的 GraphQLScalarType,關於 parseValue() 和 parseLiteral() 的差異可以參考官方這篇 Scalars in GraphQL 和 Stackoverflow 這篇 what's the difference between parseValue and parseLiteral in  GraphQLScalarType 別人的回答
DateScalarBuilder.java:

package com.scalar;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;

import graphql.language.StringValue;
import graphql.schema.Coercing;
import graphql.schema.GraphQLScalarType;

public class DateScalarBuilder {
	
	private DateScalarBuilder() {
		
	}
	
	public static GraphQLScalarType getDateScalar() {
		String dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ";
		
		return GraphQLScalarType.newScalar()
				.name("Date")
				.description("A custom scalar that handles date.")
		        .coercing(new Coercing<Calendar, String>() {
		            @Override
		            public String serialize(Object dataFetcherResult) {
                    	//實作從 Java 物件 (此例是 Calendar) 轉成要回傳 graphql response 的 String
		            	Calendar date = (Calendar) dataFetcherResult;
		            	SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
		                return sdf.format(date.getTime()) ;
		            }

		            @Override
		            public Calendar parseValue(Object input) {
                    	//實作從 Graphql 得到的 String 轉成 Java 物件 (此例是 Calendar)
                        //如果 input 使用了 inline input 的方式,就會使用 parseValue() 來解析
		            	//例如:
		            	/*
		            	 * Request:
		            	 * query myQuery {		            	 * 
		            	 * 	getBookById(id: 1) {
		            	 *  	id
		            	 *  	name 
		            	 * 	}
		            	 * } 
		            	 */
		            	String dateStr = (String) input;
		            	SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
		            	Calendar date = Calendar.getInstance();
		            	try {
							date.setTime(sdf.parse(dateStr));
						} catch (ParseException e) {
							e.printStackTrace();
						}
		                return date;
		            }

		            @Override
		            public Calendar parseLiteral(Object input) {
                    	//實作從 Graphql 得到的 String 轉成 Java 物件 (此例是 Calendar)
                        //如果 input 使用了 variables 的方式,就會使用 parseLiteral() 來解析
		            	//例如:
		            	/*
		            	 * Request:
		            	 * query myQuery($id: ID) { 
		            	 * 
		            	 * 	getBookById(id: $id) {
		            	 *  	id
		            	 *  	name 
		            	 * 	}
		            	 * }
		            	 * 
		            	 * Variables:
		            	 * {
		            	 * 	"id": 1
		            	 * }
		            	 */
		            	String dateStr = ((StringValue) input).getValue();
		            	SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
		            	Calendar date = Calendar.getInstance();
		            	try {
							date.setTime(sdf.parse(dateStr));
						} catch (ParseException e) {
							e.printStackTrace();
						}
		                return date;
		            }
		        })
		        .build();
	}
}

GraphqlController.java 是 SpringMVC 的 Controller 設定,
這裡我們設定了 /graphql 為處理 GraphQL request 的 url,
限制只接收 HTTP POST 且 Mime type 為 application/json 格式的 request。

接著讀取 GraphQL Schema 、設定要用的 Resolver 、 Scalar,並配合接收到的 GraphQL request
產生並回傳查詢結果。

src/java/com/controller/GraphqlController.java:

package com.controller;

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.kickstart.tools.SchemaParser;
import graphql.schema.GraphQLSchema;

import com.scalar.DateScalarBuilder;
import com.query.GraphqlRequestQuery;
import com.resolver.AuthorResolver;
import com.resolver.MutationResolver;
import com.resolver.QueryResolver;

@Controller
public class GraphqlController {
	
	@Autowired
	private QueryResolver queryResolver;
	
	@Autowired
	private AuthorResolver authorResolver;
	
	@Autowired
	private MutationResolver mutationResolver;
	
	@RequestMapping(value="/graphql", method = {RequestMethod.POST}, produces = "application/json")
	public @ResponseBody Map<String, Object> myGraphql(@RequestBody GraphqlRequestQuery requestQuery) {
		
		//讀取 GraphQL Schema、設定各 Resolver
		GraphQLSchema graphQLSchema = SchemaParser.newParser()
									  .file("schema.graphqls")
                                      .scalars(DateScalarBuilder.getDateScalar())
									  .resolvers(queryResolver
											     , authorResolver
											     , mutationResolver)
									  .build()
									  .makeExecutableSchema();

		GraphQL graphQL = GraphQL.newGraphQL(graphQLSchema).build();
		
		//將 GraphQL request 的 query (也可視需要取出 operationName, variables) 值取出,準備進行查詢
		ExecutionInput executionInput = ExecutionInput.newExecutionInput()
										.query(requestQuery.getQuery())
                                        .operationName(requestQuery.getOperationName())
                                        .variables(requestQuery.getVariables())
										.build();
		ExecutionResult executionResult = graphQL.execute(executionInput);	
		
		//executionResult.toSpecification() 會回傳一個 Map,
		//裡面包含了查詢結果 data 、 errors、 extensions 等資料
		return executionResult.toSpecification();
	}
}

最後展示一下查詢結果:

  1. 查詢:
    query {
      getAuthorById(id: 3) {
        id
        name
        bookList(prefix: "test") {
            id
            name
            publishDate
        }
      },
      getBookById(id: 33) {
        id
        name
        publishDate
      }
    }
    
    結果:
    {
        "data": {
            "getAuthorById": {
                "id": "3",
                "name": "Author 3",
                "bookList": [
                    {
                        "id": "3",
                        "name": "test-Book 3"
                        "publishDate": "2019-01-01T03:00:00+0800"
                    },
                    {
                        "id": "33",
                        "name": "test-Book 33"
                        "publishDate": "2019-01-01T03:03:00+0800"
                    }
                ]
            },
            "getBookById": {
                "id": "33",
                "name": "Book 33"
                "publishDate": "2019-01-01T03:03:00+0800"
            }
        }
    }
    
  2. 查詢:
    mutation {
        updateAuthorName(id: 3, name: "Author 3 - changed") {
            id
            name
            bookList(prefix: "test") {
                id
                name
                publishDate
            }
        }
    }
    
    結果:
    {
        "data": {
            "updateAuthorName": {
                "id": "3",
                "name": "Author 3 - changed",
                "bookList": [
                    {
                        "id": "3",
                        "name": "test-Book 3"
                        "publishDate": "2019-01-01T03:00:00+0800"
                    },
                    {
                        "id": "33",
                        "name": "test-Book 33"
                        "publishDate": "2019-01-01T03:03:00+0800"
                    }
                ]
            }
        }
    }
    

下載分享:

MyGraphQL.7z

參考資料:

  1. HTTP Methods, Headers, and Body
  2. Making a GraphQL requests using the GET method
  3. GraphQL x Spring WebMVC
  4. Defining a schema
  5. Think in GraphQL :: 2019 iT 邦幫忙鐵人賽

2023年6月7日 星期三

Java keytool 工具好用指令紀錄

Java keytool 工具好用指令紀錄 (keytool 通常放在 jdk 的 bin 資料夾裡)
想看指令參數說明,可以用 -h 指令,例如:
keytool -import -h

#匯入憑證到 keystore 檔
keytool –import –alias xxx –file certificationFile –keystore xxx

#將一個 .p12 keystore 檔併到另一個 .p12 keystore 檔
#如果用 -import 指令的話只能匯入憑證(trustedcertentry),沒有辦法匯入包含 private key 和憑證的一組資料 (PrivateKeyEntry)
keytool -importkeystore -srckeystore xxx -srcstoretype PKCS12 -destkeystore xxx

#查詢keystore的內容
keytool –list –v –keystore xxx

#刪除keystore內的其中一個憑證
keytool –delete –alias xxx –keystore xxx

#產生金錀對 (RSA為非對稱加密的演算法)
keytool -genkey -alias xxx -keyalg RSA -keystore xxx

#產生憑證申請檔
keytool -certreq -alias xxx -file xxx -keystore xxx

#修改 keystore 的密碼
keytool -storepasswd -new xxx -keystore xxx

2023年6月5日 星期一

Client Certificate 的 OCSP (Online Certificate Status Protocol) 設定

這篇是的
使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範
的 OCSP 設定補充。

如果要看 CRL 的設定可以看我的另一篇文章:

Client Certificate 的 CRL (Certificate Revocation List) 設定

OCSP 的全名是 Online Certificate Status Protocol,
跟 CRL 相同的地方是,它一樣可以用一個 ./db/index 來紀錄憑證的撤銷狀態。
跟 CRL 不同的地方是,它不是提供一個憑證撤銷清單,而是自行啟動一個伺服器,我們把它叫做 OCSP Responder,當想要對憑證狀態進行確認時,可以對 OCSP Responder 發起詢問的請求 (OCSP Request),OCSP Responder 再把憑證的狀態回傳回去 (OCSP Response)。

跟 CRL 比較起來,OCSP 有幾個優點:
  1. 不像 CRL 一次回傳多個憑證狀態,一次只回傳被詢問的憑證狀態,對於網路成本及詢問者的解析成本都比較低。
  2. 因為是由 OCSP Responder Server 即時回應,相較 CRL 可能一段時間才發出更新或才會被人詢問來說,能獲取比較即時的憑證狀態。
缺點也是有:
  1. OCSP Responder 的流量負擔可能比較大,可能可以用多台 Server 做 Revers Proxy 分流解決。
OCSP Responder 可以用任何技術實作,只要它能處理 OCSP Request 並回傳正確格式的 OCSP Response 即可,如何儲存憑證的撤銷狀態也可自行用任何方法實現,其實 CRL 也是一樣,只是上次我們就直接使用 OpenSSL 的 index 檔功能來幫助我們實現。

在這篇文中,我一樣會只使用 OpenSSL 來實現 OCSP Responder,OpenSSL 本身就可以開啟一個 OCSP Responder,並配合 OpenSSL index 檔來處理 OCSP Request。

---------------------------------------------------------------------------------------------
現在要來示範要做 Client Certificate 時,建立 OCSP Responder 和 Nginx 設定的方式。

Nginx 的設定非常簡單,接續這一篇文章的 Nginx 設定 使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範,只要再加上 ssl_ocsp ssl_ocsp_responder 設定即可。
server {
   ......
   ssl_client_certificate "ca.crt";
   ssl_verify_client optional;
   ssl_ocsp leaf; #值可以是 on, leaf, off,leaf 是指只對 client certificate 做 ocsp
   ssl_ocsp_responder http://127.0.0.1:9000; # 自行指定 OCSP Responder 的 url
   ......
}

為了之後管理方便,在這裡我先建了幾個資料夾和檔案 (./crlnumber 和 ./crl 資料夾就不用了):
  1. ./certs:存放憑證的地方。
  2. ./csr:存放 CSR 的地方。
  3. ./db/index:index 為我手動放置的一個空白檔案,作為一個紀錄憑證狀態的純文字檔,其實 CRL 可以不需要,主要給 OCSP 使用,但 CRL 也可從其中產生出 CRL 檔,也方便之後管理憑證狀態。
  4. ./keys:存放 Private Key 的地方。
  5. ./serial/serial:serial 為我手動放置的一個純文字檔案,其內容為一個單純的 serial number 序號,供憑證產生時的累加 Serial Key 使用,serial number 可視為對每一個 client certificate 的辨識唯一序號。
  6. ca.conf:供 openssl ca 指令用的配置檔,用以簡化指令參數長度及方便管理。
檔案內容例如可用以下指定 (自己用編輯器輸入也可以):
touch ./db/index
openssl rand -hex 16 > serial/serial
因為會用到
openssl ca 指令,為了方便我們先來建立一個 config 配置檔供 openssl ca 指令參考,
這樣 openssl ca 指令就可以少打一些參數。

ca.conf:
[default]
name                    = root-ca
default_ca              = ca_config

[ca_config]
database                = db/index
serial                  = serial/serial
crlnumber               = crlnumber/crlnumber
default_crl_days        = 1
certificate             = certs/ca.crt
private_key             = keys/ca.key
new_certs_dir           = certs
default_md              = sha256
policy                  = ca_policy
unique_subject          = no

[ca_policy]
countryName             = match
stateOrProvinceName     = optional
organizationName        = match
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

[ocsp_ext]
basicConstraints        = critical,CA:false
extendedKeyUsage        = OCSPSigning
noCheck                 = yes
keyUsage                = critical,digitalSignature
subjectKeyIdentifier    = hash
接著各項指令如下,
#產生 CA private key
openssl genrsa -out ./keys/ca.key 4096

#產生 CA CSR
openssl req -new -key ./keys/ca.key -out ./csr/ca.csr

#產生 CA 憑證
openssl x509 -req -days 365 -in ./csr/ca.csr -signkey ./keys/ca.key -CAcreateserial -out ./certs/ca.crt

# OCSP Responder 也需要自己的憑證
#產生 OCSP Responder 的 private key
openssl genrsa -out ./keys/ocsp.key 4096

#產生 OCSP Responder 的 CSR
openssl req -new -key ./keys/ocsp.key -out ./csr/ocsp.csr

#產生 OCSP Responder Server 的 Certificate,這裡使用了 ocsp_ext 區塊
#不使用 config 檔裡的 ocsp_ext 區塊設定的話,之後向 OCSP Responder Server 驗證 Certificate 時會有 missing ocspsigning usage 錯誤訊息)
#此時 ./db/index 會被更新,可以看到有一筆 OCSP Certificate 的憑證資料被寫入
openssl ca -config ca.conf -in ./csr/ocsp.csr -out ./certs/ocsp.crt -extensions ocsp_ext -days 365

#啟動 OCSP Responder Server,這邊使用了 locahost 的 9000 port
#啟動成功後應該可以看到 console 顯示
#ocsp: waiting for OCSP client connections...
#的訊息
openssl ocsp -port 9000 -index ./db/index -rsigner ./certs/ocsp.crt -rkey ./keys/ocsp.key -CA ./certs/ca.crt -text

#產生 Client private key:
openssl genrsa -out ./keys/client.key 4096

#產生 Client CSR:
openssl req -new -key ./keys/client.key -out ./csr/client.csr

#產生 Client Certificate,
#此時 ./db/index 會被更新,可以看到有一筆 Client Certificate 的憑證資料被寫入
openssl ca -config ca.conf -in ./csr/client.csr -out ./certs/client.crt -days 365

#用 OpenSSL 對 OCSP Responder Server 發起 OCSP Request 進行憑證的驗證
#對 OCSP Certificate 做驗證
openssl ocsp -issuer ./certs/ca.crt -CAfile ./certs/ca.crt -cert ./certs/ocsp.crt -url http://127.0.0.1:9000
#對 Client Certificate 做驗證
openssl ocsp -issuer ./certs/ca.crt -CAfile ./certs/ca.crt -cert ./certs/client.crt -url http://127.0.0.1:9000
#如果驗證結果是合法 (Valid) 的話,應該會看到如下訊息:
Response verify OK
./certs/client.crt: good
        This Update: Jun
#被 Revoke 的憑證查詢結果會像這樣:
#(Reason 只會在 Revoke 時有加入 crl_reason 時才會出現,手動修改 ./db/index 內容也可以,就是在 Revoke date 欄位加上 Reason 並用逗號分隔)
Response verify OK
./certs/client.crt: revoked
        This Update: Jun  6 04:55:19 2023 GMT
		Reason: unspecified
        Revocation Time: Jun  6 04:53:37 2023 GMT

#撤銷 (Revoke) 一個 Certificate (可以看到跟 CRL 的 Revoke 指令相同,-crl_reason 參數可自選要不要加上)
openssl ca -config ca.conf -revoke ./certs/client.crt

#加進一個未撤銷 (Valid) 狀態的 Certificate 到 OCSP DB 中 (自己要先手動把 OCSP DB 中相同序號的 Certificate 紀錄刪掉),
#或是自己打開 OCSP DB 檔把 Certificate 的狀態從 R (Revoke 的意思) 改成 V (Valid 的意思) 也可以
openssl ca -valid ./certs/client.crt -config ca.conf

參考資料:

Client Certificate 的 CRL (Certificate Revocation List) 設定

 這篇是
使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範
這篇文章的 CRL 設定補充。

如果要看 OCSP 的設定可以看我的另一篇文章:

Client Certificate 的 OCSP (Online Certificate Status Protocol) 設定

CRL 的全名是 Certificate Revocation List,
顧名思意是一個憑證撤銷狀態的列表,
有時雖然憑證的日期沒有過期、簽名驗證也都正確,
但是因為一些其他因素,例如 Private Key 被駭客偷走的憑證安全性問題等,
憑證可能會被 CA 認為需要強制撤銷 (Revoke),
這時我們除了認證憑證合法以外,還需要有方法來確認憑證的 Revoke 狀態,
CRL 就是其中一種方法,其他還有像是 OCSP 的方法,會在我的另一篇文XXXXX 中做介紹。

在 CRL 方法中,CA 會定期更新憑證的撤銷列表到一個 CRL 檔案中,通常會以 pem 格式儲存,並且以 CA 來進行簽章 (Sign) 來證明其是由 CA 頒發的。

當我們要確認憑證的撤銷狀態時,通常會在憑證中找到 CRL 檔案的位置 (通常是一個網路 url 的位置),憑證中也有可能沒有寫上 CRL 資訊,這時有可能是 client, server 端已經彼些講好的位置,
拿到 CRL 檔案後,就可以在其中查找要確認的憑證撤銷狀態。

CRL 檔案或是憑證中可能還會有其他資訊,例如有效期限、下一次 CRL 更新日期等,我們可以在期限到後主動查找新發佈版本的 CRL。

CRL 有一些優點:
  1. 其中一個優點就是架構簡單、實現方便,CA 只要定時的更新 CRL 即可。
  2.  CRL 檔案容易取得,可能只是一個網路上的檔案,使用 HTTP Request 即可取得,取得後甚至可以把它在本地端,不用每次都去網路上抓取,等下次更新日或 CA 發表重大更新要求時再抓新版的 CRL。
但相對地其反面就是它的缺點:
  1. CA 如果不及時更新 CRL 檔 或是 詢問者不即時取得最新的 CRL 的話,即意味著 CRL 可能不是最新的,如果有憑證有安全問題即時的被 CA 公告需要 Revoke,沒更新 CRL 可能會無法即时知道。
  2. 另一個缺點是,因為 CRL 可能包含許多其他可能用不到的憑證狀態資訊,如果 CRL 檔很大,網路的傳送成本就會提高,也會讓我們在其中查找要確認的憑證時花較多的時間。

---------------------------------------------------------------------------------------------
現在要來示範要做 Client Certificate 時,產生 CRL 檔案和 Nginx 設定的方式。

Nginx 的設定非常簡單,接續這一篇文章的 Nginx 設定 使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範,只要再加上 ssl_crl 設定即可。
server {
   ......
   ssl_client_certificate "ca.crt";
   ssl_verify_client optional;
   ssl_crl "D:\xxx\xxx\ca.crl"; # CRL 檔案的位置,也可以是網路上的 url
   ......
}

接下來要來產生 CRL 檔案,

為了之後管理方便,在這裡我先建了幾個資料夾和檔案:
  1. ./certs:存放憑證的地方。
  2. ./csr:存放 CSR 的地方。
  3. ./crlnumber:存放 CRL 檔版本序號的地方。
  4. ./crl:存放 CRL 檔的地方。
  5. ./db/index:index 為我手動放置的一個空白檔案,作為一個紀錄憑證狀態的純文字檔,其實 CRL 可以不需要,主要給 OCSP 使用,但 CRL 也可從其中產生出 CRL 檔,也方便之後管理憑證狀態。
  6. ./keys:存放 Private Key 的地方。
  7. ./serial/serial:serial 為我手動放置的一個純文字檔案,其內容為一個單純的 serial number 序號,供憑證產生時的累加 Serial Key 使用,serial number 可視為對每一個 client certificate 的辨識唯一序號。
  8. ca.conf:供 openssl ca 指令用的配置檔,用以簡化指令參數長度及方便管理。
檔案內容例如可用以下指定 (自己用編輯器輸入也可以):
touch ./db/index
openssl rand -hex 16 > serial/serial
echo 1001 > crlnumber/crlnumber
因為會用到
openssl ca 指令,為了方便我們先來建立一個 config 配置檔供 openssl ca 指令參考,
這樣 openssl ca 指令就可以少打一些參數。

ca.conf:
[default]
name                    = root-ca
default_ca              = ca_config

[ca_config]
database                = db/index
serial                  = serial/serial
crlnumber               = crlnumber/crlnumber
default_crl_days        = 1
certificate             = certs/ca.crt
private_key             = keys/ca.key
new_certs_dir           = certs
default_md              = sha256
policy                  = ca_policy
unique_subject          = no

[ca_policy]
countryName             = match
stateOrProvinceName     = optional
organizationName        = match
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional
接著各項指令如下,
#產生 CA private key
openssl genrsa -out ./keys/ca.key 4096

#產生 CA CSR
openssl req -new -key ./keys/ca.key -out ./csr/ca.csr

#產生 CA 憑證
openssl x509 -req -days 365 -in ./csr/ca.csr -signkey ./keys/ca.key -CAcreateserial -out ./certs/ca.crt

#產生 CRL
openssl ca -gencrl -config ./ca.conf -out ./crl/ca.crl

#觀看 CRL 檔中的資訊
openssl crl -in ./crl/ca.crl -text -noout

#產生 Client private key
openssl genrsa -out ./keys/client.key 4096

#產生 Client CSR
openssl req -new -key ./keys/client.key -out ./csr/client.csr

#使用 ca.conf 產生 CA 憑證,這時 client 憑證可得到一個可識別的唯一序號, serial 裡的 serial number 也會改變,
#此時也會發現 db/index 被加進了一條此 client 憑證的資料,db/index 有變動時可以手動下指令再製作一次新版本的 CRL
openssl ca -config ./ca.conf -in ./csr/client.csr -out ./certs/client.crt -days 365

#向 CRL 檢查憑證是否 Valid or Revoke
openssl verify -crl_check -CRLfile ./crl/ca.crl -CAfile ./certs/ca.crt ./certs/client.crt

#撤銷 (Revoke) Client 憑證 -crl_reason 參數是 Revoke reason
#撤銷後 db/index 會更新,憑證資訊欄位的 V (Valid 的意思) 會變成 R (Revoke) 的意思,並且會被標上 Revoke Date 訊息
#記得再下指令產生一下新版本的 CRL
openssl ca -config ca.conf -revoke ./certs/client.crt -crl_reason unspecified
# Revoke reason 可選列表如下:
unspecified
keyCompromise
CACompromise
affiliationChanged
superseded
cessationOfOperation
certificateHold
removeFromCRL

參考資料: