使用 Spring Boot 构建 GraphQL API

GraphQL 是一种用于 API 的查询语言和运行时,它允许 API 消费者精确获取所需的信息,而不是服务器完全控制响应内容。某些 REST API 实现需要从多个 URL 加载资源的引用,而 GraphQL API 可以在单个响应中跟踪相关对象之间的引用并返回它们。

本教程逐步演示了如何使用 Spring Boot 和 Spring for GraphQL 构建一个 GraphQL API,用于查询 Neo4j 数据库中相关公司、人员和属性的示例数据集。它还演示了如何使用 Next.js 和 MUI Datagrid 构建一个 React 客户端来消费该 API。客户端和服务器都使用 Auth0 进行认证、授权,服务器使用 Okta Spring Boot Starter,客户端使用 Auth0 React SDK。

Spring、GraphQL 和 React

如果你想跳过所有步骤,直接运行程序,那么你可以以按照 GitHub Repository 中的 README 说明进行操作。

本文所使用的工具、服务如下:

使用 Spring for GraphQL 构建 GraphQL API

资源服务器(Resource Server)是一个 Spring Boot Web 应用,使用 Spring for GraphQL 暴露了一个 GraphQL API。该 API 允许使用 Spring Data Neo4j 查询 Neo4j 数据库,其中包含公司及其相关所有者和属性的信息。数据来自 Neo4j 用例示例

使用 Spring Initializr 和 HTTPie 创建应用:

https start.springboot.io/starter.zip \
  bootVersion==3.1.3 \
  language==java \
  packaging==jar \
  javaVersion==17 \
  type==gradle-project \
  dependencies==data-neo4j,graphql,web \
  groupId==com.okta.developer \
  artifactId==spring-graphql  \
  name=="Spring Boot API" \
  description=="Demo project of a Spring Boot GraphQL API" \
  packageName==com.okta.developer.demo > spring-graphql-api.zip

解压文件,编辑项目。在 src/main/resources/graphql 目录中使用名为 schema.graphqls 的 Schema 文件定义 GraphQL API:

# src/main/resources/graphql/schema.graphqls

type Query {
    companyList(page: Int): [Company!]!
    companyCount: Int
}

type Company {
    id: ID
    SIC: String
    category: String
    companyNumber: String
    countryOfOrigin: String
    incorporationDate: String
    mortgagesOutstanding: Int
    name: String
    status: String
    controlledBy: [Person!]!
    owns: [Property!]!
}

type Person {
    id: ID
    birthMonth: String
    birthYear: String
    nationality: String
    name: String
    countryOfResidence: String
}

type Property {
    id: ID
    address: String
    county: String
    district: String
    titleNumber: String
}

如上,Schema 定义了对象类型 CompanyPersonProperty 以及查询类型(query type)companyListcompanyCount

添加 domain 类。在 src/main/java 下创建包 com.okta.developer.demo.domain。添加 PersonPropertyCompany 类。

Person 类定义如下:

// Person.java
package com.okta.developer.demo.domain;

import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;

@Node
public class Person {

    @Id @GeneratedValue
    private Long id;

    private String birthMonth;
    private String birthYear;
    private String countryOfResidence;

    private String name;
    private String nationality;

    public Person(String birthMonth, String birthYear, String countryOfResidence, String name, String nationality) {
        this.id = null;
        this.birthMonth = birthMonth;
        this.birthYear = birthYear;
        this.countryOfResidence = countryOfResidence;
        this.name = name;
        this.nationality = nationality;
    }

    public Person withId(Long id) {
        if (this.id.equals(id)) {
            return this;
        } else {
            Person newObject = new Person(this.birthMonth, this.birthYear, this.countryOfResidence, this.name, this.nationality);
            newObject.id = id;
            return newObject;
        }
    }

    public String getBirthMonth() {
        return birthMonth;
    }

    public void setBirthMonth(String birthMonth) {
        this.birthMonth = birthMonth;
    }

    public String getBirthYear() {
        return birthYear;
    }

    public void setBirthYear(String birthYear) {
        this.birthYear = birthYear;
    }

    public String getCountryOfResidence() {
        return countryOfResidence;
    }

    public void setCountryOfResidence(String countryOfResidence) {
        this.countryOfResidence = countryOfResidence;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getNationality() {
        return nationality;
    }

    public void setNationality(String nationality) {
        this.nationality = nationality;
    }

    public Long getId() {
        return this.id;
    }
}

Property 类定义如下:

// Property.java
package com.okta.developer.demo.domain;

import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;

@Node
public class Property {

    @Id
    @GeneratedValue  private Long id;
    private String address;
    private String county;
    private String district;
    private String titleNumber;

    public Property(String address, String county, String district, String titleNumber) {
        this.id = null;
        this.address = address;
        this.county = county;
        this.district = district;
        this.titleNumber = titleNumber;
    }

    public Property withId(Long id) {
        if (this.id.equals(id)) {
            return this;
        } else {
            Property newObject = new Property(this.address, this.county, this.district, this.titleNumber);
            newObject.id = id;
            return newObject;
        }
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public String getCounty() {
        return county;
    }

    public void setCounty(String county) {
        this.county = county;
    }

    public String getDistrict() {
        return district;
    }

    public void setDistrict(String district) {
        this.district = district;
    }

    public String getTitleNumber() {
        return titleNumber;
    }

    public void setTitleNumber(String titleNumber) {
        this.titleNumber = titleNumber;
    }
}

Company 类如下:

// Company.java
package com.okta.developer.demo.domain;

import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

@Node
public class Company {
    @Id
    @GeneratedValue
    private Long id;
    private String SIC;
    private String category;
    private String companyNumber;
    private String countryOfOrigin;
    private LocalDate incorporationDate;
    private Integer mortgagesOutstanding;
    private String name;
    private String status;

    // Mapped automatically
    private List<Property> owns = new ArrayList<>();

    @Relationship(type = "HAS_CONTROL", direction = Relationship.Direction.INCOMING)
    private List<Person> controlledBy = new ArrayList<>();

    public Company(String SIC, String category, String companyNumber, String countryOfOrigin, LocalDate incorporationDate, Integer mortgagesOutstanding, String name, String status) {
        this.id = null;
        this.SIC = SIC;
        this.category = category;
        this.companyNumber = companyNumber;
        this.countryOfOrigin = countryOfOrigin;
        this.incorporationDate = incorporationDate;
        this.mortgagesOutstanding = mortgagesOutstanding;
        this.name = name;
        this.status = status;
    }

    public Company withId(Long id) {
        if (this.id.equals(id)) {
            return this;
        } else {
            Company newObject = new Company(this.SIC, this.category, this.companyNumber, this.countryOfOrigin, this.incorporationDate, this.mortgagesOutstanding, this.name, this.status);
            newObject.id = id;
            return newObject;
        }
    }

    public String getSIC() {
        return SIC;
    }

    public void setSIC(String SIC) {
        this.SIC = SIC;
    }

    public String getCategory() {
        return category;
    }

    public void setCategory(String category) {
        this.category = category;
    }

    public String getCompanyNumber() {
        return companyNumber;
    }

    public void setCompanyNumber(String companyNumber) {
        this.companyNumber = companyNumber;
    }

    public String getCountryOfOrigin() {
        return countryOfOrigin;
    }

    public void setCountryOfOrigin(String countryOfOrigin) {
        this.countryOfOrigin = countryOfOrigin;
    }

    public LocalDate getIncorporationDate() {
        return incorporationDate;
    }

    public void setIncorporationDate(LocalDate incorporationDate) {
        this.incorporationDate = incorporationDate;
    }

    public Integer getMortgagesOutstanding() {
        return mortgagesOutstanding;
    }

    public void setMortgagesOutstanding(Integer mortgagesOutstanding) {
        this.mortgagesOutstanding = mortgagesOutstanding;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }
}

创建 com.okta.developer.demo.repository 包和 CompanyRepository 类:

// CompanyRepository.java
package com.okta.developer.demo.repository;

import com.okta.developer.demo.domain.Company;
import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository;

public interface CompanyRepository extends ReactiveNeo4jRepository<Company, Long> {

}

在根包(root package)下创建配置类 GraphQLConfig

// GraphQLConfig.java
package com.okta.developer.demo;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
class GraphQLConfig {

    private static Logger logger = LoggerFactory.getLogger("graphql");

    @Bean
    public GraphQlSourceBuilderCustomizer sourceBuilderCustomizer() {
        return (builder) ->
                builder.inspectSchemaMappings(report -> {
                    logger.debug(report.toString());
                });
    }
}

在根包中也创建一个名为 SpringBootApiConfig 的配置类,定义响应式 Neo4j 所需的响应式事务管理器:

// SpringBootApiConfig.java
package com.okta.developer.demo;

import org.neo4j.driver.Driver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.neo4j.core.ReactiveDatabaseSelectionProvider;
import org.springframework.data.neo4j.core.transaction.ReactiveNeo4jTransactionManager;
import org.springframework.data.neo4j.repository.config.ReactiveNeo4jRepositoryConfigurationExtension;
import org.springframework.transaction.ReactiveTransactionManager;

@Configuration
public class SpringBootApiConfig {

    @Bean(ReactiveNeo4jRepositoryConfigurationExtension.DEFAULT_TRANSACTION_MANAGER_BEAN_NAME) //Required for neo4j
    public ReactiveTransactionManager reactiveTransactionManager(
            Driver driver,
            ReactiveDatabaseSelectionProvider databaseNameProvider) {
        return new ReactiveNeo4jTransactionManager(driver, databaseNameProvider);
    }
}

创建包 com.okta.developer.demo.controllerCompanyController 类,实现 GraphQL Schema 中定义的查询端点。

// CompanyController.java
package com.okta.developer.demo.controller;

import com.okta.developer.demo.domain.Company;
import com.okta.developer.demo.repository.CompanyRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Controller
public class CompanyController {

    @Autowired
    private CompanyRepository companyRepository;

    @QueryMapping
    public Flux<Company> companyList(@Argument Long page) {
        return companyRepository.findAll().skip(page * 10).take(10);
    }

    @QueryMapping
    public Mono<Long> companyCount() {
        return companyRepository.count();
    }
}

src/main/test/java 目录中的 com.okta.developer.demo.controller 包下创建 CompanyControllerTests 类(Web 层)。

// src/main/test/java/CompanyControllerTests.java
package com.okta.developer.demo.controller;

import com.okta.developer.demo.domain.Company;
import com.okta.developer.demo.repository.CompanyRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.graphql.GraphQlTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.graphql.test.tester.GraphQlTester;
import reactor.core.publisher.Flux;

import java.time.LocalDate;

import static org.mockito.Mockito.when;

@GraphQlTest(CompanyController.class)
public class CompanyControllerTests {

    @Autowired
    private GraphQlTester graphQlTester;

    @MockBean
    private CompanyRepository companyRepository;

    @Test
    void shouldGetCompanies() {

        when(this.companyRepository.findAll())
                .thenReturn(Flux.just(new Company(
                        "1234",
                        "private",
                        "12345678",
                        "UK",
                        LocalDate.of(2020, 1, 1),
                        0,
                        "Test Company",
                        "active")));

        this.graphQlTester
                .documentName("companyList")
                .variable("page", 0)
                .execute()
                .path("companyList")
                .matchesJson("""
                    [{
                        "id": null,
                        "SIC": "1234",
                        "name": "Test Company",
                        "status": "active",
                        "category": "private",
                        "companyNumber": "12345678",
                        "countryOfOrigin": "UK"
                    }]
                """);
    }
}

src/main/test/resources/graphql-test 目录下创建包含 “测试查询定义” 的文件 companyList.graphql

# src/main/test/resources/graphql-test/companyList.graphql
query companyList($page: Int) {
    companyList(page: $page) {
        id
        SIC
        name
        status
        category
        companyNumber
        countryOfOrigin
    }
}

更新 build.gradle 文件中的测试配置,以便记录通过的测试:

// build.gradle
tasks.named('test') {
    useJUnitPlatform()

    testLogging {
        // 设置日志级别生命周期的选项
        events "failed", "passed"
    }
}

运行测试,如下:

./gradlew test

你应该能看到测试成功日志:

...
SpringBootApiApplicationTests > contextLoads() PASSED

CompanyControllerTests > shouldGetCompanies() PASSED
...

添加 Neo4j 测试数据

添加 Neo4j migrations 依赖,插入测试数据。

编辑 build.gradle 文件并添加以下内容

// build.gradle
dependencies {
    ...
    implementation 'eu.michael-simons.neo4j:neo4j-migrations-spring-boot-starter:2.5.3'
    ...
}

创建 src/main/resources/neo4j/migrations 目录和以下迁移(migration)文件:

// src/main/resources/neo4j/migrations/V001_Constraint.cypher

CREATE CONSTRAINT FOR (c:Company) REQUIRE c.companyNumber IS UNIQUE;
//Constraint for a node key is a Neo4j Enterprise feature only - run on an instance with enterprise
//CREATE CONSTRAINT ON (p:Person) ASSERT (p.birthMonth, p.birthYear, p.name) IS NODE KEY
CREATE CONSTRAINT FOR (p:Person) REQUIRE (p.birthMonth, p.birthYear, p.name) IS UNIQUE;
CREATE CONSTRAINT FOR (p:Property) REQUIRE p.titleNumber IS UNIQUE;
// src/main/resources/neo4j/migrations/V002_Company.cypher

LOAD CSV WITH HEADERS FROM "file:///PSCAmericans.csv" AS row
MERGE (c:Company {companyNumber: row.company_number})
RETURN COUNT(*);
// src/main/resources/neo4j/migrations/V003_Person.cypher

LOAD CSV WITH HEADERS FROM "file:///PSCAmericans.csv" AS row
MERGE (p:Person {name: row.`data.name`, birthYear: row.`data.date_of_birth.year`, birthMonth: row.`data.date_of_birth.month`})
  ON CREATE SET p.nationality = row.`data.nationality`,
  p.countryOfResidence = row.`data.country_of_residence`
RETURN COUNT(*);
// src/main/resources/neo4j/migrations/V004_PersonCompany.cypher

LOAD CSV WITH HEADERS FROM "file:///PSCAmericans.csv" AS row
MATCH (c:Company {companyNumber: row.company_number})
MATCH (p:Person {name: row.`data.name`, birthYear: row.`data.date_of_birth.year`, birthMonth: row.`data.date_of_birth.month`})
MERGE (p)-[r:HAS_CONTROL]->(c)
SET r.nature = split(replace(replace(replace(row.`data.natures_of_control`, "[",""),"]",""),  '"', ""), ",")
RETURN COUNT(*);
// src/main/resources/neo4j/migrations/V005_CompanyData.cypher

LOAD CSV WITH HEADERS FROM "file:///CompanyDataAmericans.csv" AS row
MATCH (c:Company {companyNumber: row.` CompanyNumber`})
SET c.name = row.CompanyName,
c.mortgagesOutstanding = toInteger(row.`Mortgages.NumMortOutstanding`),
c.incorporationDate = Date(Datetime({epochSeconds: apoc.date.parse(row.IncorporationDate,'s','dd/MM/yyyy')})),
c.SIC = row.`SICCode.SicText_1`,
c.countryOfOrigin = row.CountryOfOrigin,
c.status = row.CompanyStatus,
c.category = row.CompanyCategory;
// src/main/resources/neo4j/migrations/V006_Land.cypher

LOAD CSV WITH HEADERS FROM "file:///LandOwnershipAmericans.csv" AS row
MATCH (c:Company {companyNumber: row.`Company Registration No. (1)`})
MERGE (p:Property {titleNumber: row.`Title Number`})
SET p.address = row.`Property Address`,
p.county  = row.County,
p.price   = toInteger(row.`Price Paid`),
p.district = row.District
MERGE (c)-[r:OWNS]->(p)
WITH row, c,r,p WHERE row.`Date Proprietor Added` IS NOT NULL
SET r.date = Date(Datetime({epochSeconds: apoc.date.parse(row.`Date Proprietor Added`,'s','dd-MM-yyyy')}));
CREATE INDEX FOR (c:Company) ON c.incorporationDate;

更新 application.properties,添加如下配置:

# application.properties
spring.graphql.graphiql.enabled=true
spring.graphql.schema.introspection.enabled=true
org.neo4j.migrations.transaction-mode=PER_STATEMENT
spring.neo4j.uri=bolt://localhost:7687
spring.neo4j.authentication.username=neo4j

spring.graphql.cors.allowed-origins=http://localhost:3000

spring.graphql.cors.allowed-origins 属性为客户端启用 CORS。

在项目根目录下创建一个 .env 文件,用于存储 Neo4j 凭证:

# .env
export SPRING_NEO4J_AUTHENTICATION_PASSWORD=verysecret

如果使用 git,别忘了在 .gitignore 中添加 .env 文件。

将以下测试数据文件下载到一个空目录中,他们将被挂载到 Neo4j 容器中:

创建 src/main/docker 目录,并在其中创建 neo4j.yml 文件,其内容如下:

# src/main/docker/neo4j.yml
name: companies
services:
  neo4j:
    image: neo4j:5
    volumes:
      - <csv-dir>:/var/lib/neo4j/import
    environment:
      - NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}
      - NEO4JLABS_PLUGINS=["apoc"]
    # 果你想在当前机器外暴露这些端口
    # 移除 127.0.0.1: 前缀
    ports:
      - '127.0.0.1:7474:7474'
      - '127.0.0.1:7687:7687'
    healthcheck:
      test: ['CMD', 'wget', 'http://localhost:7474/', '-O', '-']
      interval: 5s
      timeout: 5s
      retries: 10

创建文件 src/main/docker/.env,内容如下:

# src/main/docker/.env
NEO4J_PASSWORD=verysecret

如你所见,compose 文件会将 <csv-dir> 挂载到 /var/lib/neo4j/import Volume,使运行中的 Neo4j 容器可以访问内容。用之前下载的 CSV 文件的路径替换 <csv-dir>

在终端中进入 docker 目录并运行:

docker compose -f neo4j.yml up

运行 Spring Boot API 服务器

转到项目根目录,用以下命令启动应用:

source .env && ./gradlew bootRun

等待日志显示测试数据迁移已完成。

2023-09-13T11:52:08.041-03:00  ... Applied migration 001 ("Constraint").
2023-09-13T11:52:12.121-03:00  ... Applied migration 002 ("Company").
2023-09-13T11:52:16.508-03:00  ... Applied migration 003 ("Person").
2023-09-13T11:52:22.635-03:00  ... Applied migration 004 ("PersonCompany").
2023-09-13T11:52:25.979-03:00  ... Applied migration 005 ("CompanyData").
2023-09-13T11:52:27.703-03:00  ... Applied migration 006 ("Land").

http://localhost:8080/graphiql 使用 GraphiQL 测试 API。在左侧的查询框中,粘贴以下查询:

{
    companyList(page: 20) {
        id
        SIC
        name
        status
        category
        companyNumber
        countryOfOrigin
    }
}

你应该能在右侧框中看到查询输出结果:

GraphiQL 查询结果

注意:如果在服务器日志中看到 “The query used a deprecated function: id” 警告信息,可以忽略它。Spring Data Neo4j 仍能 正常运行

创建 React 客户端

现在,让我们使用 React 和 Next.js 创建一个单页应用 (SPA),以使用 GraphQL API。公司列表将显示在 MUI Data Grid 组件中。该应用将使用 Next.js 的 App Routersrc/app 目录只包含路由文件,UI 组件。应用代码将放在其他目录中。

安装 Node,并在终端中在 Spring Boot 应用的父目录中运行 create-next-app 命令。它将在与服务器应用目录相同级别的位置创建一个客户端应用的项目目录。

npx create-next-app

填下如下问题:

✔ What is your project named? ... react-graphql
✔ Would you like to use TypeScript? ... Yes
✔ Would you like to use ESLint? ... Yes
✔ Would you like to use Tailwind CSS? ... No
✔ Would you like to use `src/` directory? ... Yes
✔ Would you like to use App Router? (recommended) ... Yes
✔ Would you like to customize the default import alias? ... No

然后添加 MUI Datagrid 依赖、Vercel 的自定义 hook 和 Axios:

cd react-graphql && \
  npm install @mui/x-data-grid && \
  npm install @mui/material@5.14.5 @emotion/react @emotion/styled && \
  npm install react-use-custom-hooks && \
  npm install axios

运行项目,如下:

npm run dev

访问 http://localhost:3000,你会看到默认的 Next.js 页面:

Next.js 默认页面

创建 API 客户端

创建 src/services 目录,并添 base.tsx 文件,其内容如下:

// src/services/base.tsx
import axios from 'axios';

export const backendAPI = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_SERVER_URL
});

export default backendAPI;

添加 src/services/companies.tsx 文件,内容如下:

// src/services/companies.tsx
import { AxiosError } from 'axios';
import { backendAPI } from './base';

export type CompaniesQuery = {
  page: number;
};

export type CompanyDTO = {
  name: string;
  SIC: string;
  id: string;
  companyNumber: string;
  category: string;
};

export const CompanyApi = {

  getCompanyCount: async () => {
    try {
      const response = await backendAPI.post("/graphql", {
        query: `{
        companyCount
      }`,
      });
      return response.data.data.companyCount as number;
    } catch (error) {
      console.log("handle get company count error", error);
      if (error instanceof AxiosError) {
        let axiosError = error as AxiosError;
        if (axiosError.response?.data) {
          throw new Error(axiosError.response?.data as string);
        }
      }
      throw new Error("Unknown error, please contact the administrator");
    }
  },

  getCompanyList: async (params?: CompaniesQuery) => {
    try {
      const response = await backendAPI.post("/graphql", {
        query: `{
        companyList(page: ${params?.page || 0}) {
          name,
          SIC,
          id,
          companyNumber,
          category
        }}`,
      });
      return response.data.data.companyList as CompanyDTO[];
    } catch (error) {
      console.log("handle get companies error", error);
      if (error instanceof AxiosError) {
        let axiosError = error as AxiosError;
        if (axiosError.response?.data) {
          throw new Error(axiosError.response?.data as string);
        }
      }
      throw new Error("Unknown error, please contact the administrator");
    }
  },

};

在根目录下添加 .env.example.env.local 文件,内容如下:

NEXT_PUBLIC_API_SERVER_URL=http://localhost:8080

注意:.env.local 文件在 Repository 中被忽略,而 .env.example 被推送为 Reference,说明运行应用程序所需的环境变量。

创建公司主页

创建 src/components/company 目录,并添加 CompanyTable.tsx 文件,内容如下:

// src/components/company/CompanyTable.tsx
import { DataGrid, GridColDef, GridEventListener, GridPaginationModel } from '@mui/x-data-grid';

export interface CompanyData {
  id: string,
  name: string,
  category: string,
  companyNumber: string,
  SIC: string
}

export interface CompanyTableProps {
  rowCount: number,
  rows: CompanyData[],
  columns: GridColDef[],
  pagination: GridPaginationModel,
  onRowClick?: GridEventListener<"rowClick">
  onPageChange?: (pagination: GridPaginationModel) => void,

}

const CompanyTable = (props: CompanyTableProps) => {

  return (
    <>
      <DataGrid
        rowCount={props.rowCount}
        rows={props.rows}
        columns={props.columns}
        pageSizeOptions={[props.pagination.pageSize ]}
        initialState={{
          pagination: {
            paginationModel: { page: props.pagination.page, pageSize: props.pagination.pageSize },
          },
        }}
        density="compact"
        disableColumnMenu={true}
        disableRowSelectionOnClick={true}
        disableColumnFilter={true}
        disableDensitySelector={true}
        paginationMode="server"
        onRowClick={props.onRowClick}
        onPaginationModelChange={props.onPageChange}
      />
    </>
  );
};

export default CompanyTable;

src/components/loader 目录中创建 Loader.tsx 组件,代码如下:

// src/components/loader/Loader.tsx
import { Box, CircularProgress, Skeleton } from '@mui/material';

const Loader = () => {
  return (
    <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 200 }}>
      <CircularProgress />
    </Box>
  );
}

export default Loader;

添加 src/components/company/CompanyTableContainer.tsx 文件,内容如下:

// src/components/company/CompanyTableContainer.tsx
import { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import CompanyTable from './CompanyTable';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { CompanyApi } from '@/services/companies';
import Loader from '../loader/Loader';
import { useAsync } from 'react-use-custom-hooks';

interface CompanyTableProperties {
  page?: number;
}

const columns: GridColDef[] = [
  { field: 'id', headerName: 'ID', width: 70 },
  {
    field: 'companyNumber',
    headerName: 'Company #',
    width: 100,
    sortable: false,
  },
  { field: 'name', headerName: 'Company Name', width: 350, sortable: false },
  { field: 'category', headerName: 'Category', width: 200, sortable: false },
  { field: 'SIC', headerName: 'SIC', width: 400, sortable: false },
];

const CompanyTableContainer = (props: CompanyTableProperties) => {
  const router = useRouter();
  const searchParams = useSearchParams()!;
  const pathName = usePathname();
  const page = props.page ? props.page : 1;

  const [dataList, loadingList, errorList] = useAsync(
    () => CompanyApi.getCompanyList({ page: page - 1 }),
    {},
    [page]
  );
  const [dataCount] = useAsync(() => CompanyApi.getCompanyCount(), {}, []);

  const onPageChange = (pagination: GridPaginationModel) => {
    const params = new URLSearchParams(searchParams.toString());
    const page = pagination.page + 1;
    params.set("page", page.toString());
    router.push(pathName + "?" + params.toString());
  };

  return (
    <>
      {loadingList && <Loader />}
      {errorList && <div>Error</div>}

      {!loadingList && dataList && (
        <CompanyTable
          pagination={{ page: page - 1, pageSize: 10 }}
          rowCount={dataCount}
          rows={dataList}
          columns={columns}
          onPageChange={onPageChange}
        ></CompanyTable>
      )}
    </>
  );
};

export default CompanyTableContainer;

添加以下 src/app/HomePage.tsx 文件,用于主页(homepage)。

// src/app/HomePage.tsx
'use client';

import CompanyTableContainer from '@/components/company/CompanyTableContainer';
import { Box, Typography } from '@mui/material';
import { useSearchParams } from 'next/navigation';

const HomePage = () => {
  const searchParams = useSearchParams();
  const page = searchParams.get("page")
    ? parseInt(searchParams.get("page") as string)
    : 1;

  return (
    <>
      <Box>
        <Typography variant="h4" component="h1">
          Companies
        </Typography>
      </Box>
      <Box mt={2}>
        <CompanyTableContainer page={page}></CompanyTableContainer>
      </Box>
    </>
  );
};

export default HomePage;

替换 src/app/page.tsx 的内容,将其改为渲染 HomePage 组件:

// src/app/page.tsx
import HomePage from './HomePage';

const Page = () => {
  return (
    <HomePage/>
  );
}

export default Page;

添加一个定义页面宽度的组件,以便在 Root Layout 中使用。创建 src/layout/WideLayout.tsx,内容如下:

// src/layout/WideLayout.tsx
'use client';

import { Container, ThemeProvider, createTheme } from '@mui/material';

const theme = createTheme({
  typography: {
    fontFamily: 'inherit',
  },
});

const WideLayout = (props: { children: React.ReactNode }) => {
  return (
    <ThemeProvider theme={theme}>
      <Container maxWidth="lg" sx={{ mt: 4 }}>
        {props.children}
      </Container>
    </ThemeProvider>
  );
};

export default WideLayout;

通过上述实现,页面内容将被包裹在一个 ThemeProvider 组件中,因此 MUI 子组件将从 Root Layout 继承字体。将 src/app/layout.tsx 的内容更新为:

// src/app/layout.tsx
import WideLayout from '@/layout/WideLayout';
import { Ubuntu} from 'next/font/google';

const font = Ubuntu({
  subsets: ['latin'],
  weight: ['300','400','500','700'],
});

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={font.className}>
        <WideLayout>{children}</WideLayout>
      </body>
    </html>
  );
}

此外,删除 src/app/globals.csssrc/app/page.module.css。然后用以下命令运行客户端程序:

npm run dev

访问 http://localhost:3000,你可以看到公司列表。

主页上的公司 datagrid

通过 Auth0 进行认证

为了确保服务器和客户端的安全,Auth0 平台提供了最佳的客户体验,只需几个简单的配置步骤,你就可以为你的应用添加身份认证功能。在 Auth0 注册 并安装 Auth0 CLI,它将帮助你创建租户和客户端应用。

为 GraphQL API 服务器添加资源服务器 Security

在命令行中,使用 CLI 登录 Auth0:

auth0 login

控制台会显示设备确认码,并打开浏览器会话以激活设备。

注意:如果浏览器不能自动打开,请手动打开 URL https://auth0.auth0.com/activate?user_code={deviceCode} 激活设备。

登录成功后,你将看到 租户,稍后把它用作令牌签发器(Token Issuer)。

下一步是创建客户端应用,只需一条命令即可:

auth0 apps create \
  --name "GraphQL Server" \
  --description "Spring Boot GraphQL Resource Server" \
  --type regular \
  --callbacks http://localhost:8080/login/oauth2/code/okta \
  --logout-urls http://localhost:8080 \
  --reveal-secrets

创建应用后,你将看到 OIDC 应用的配置:

=== dev-avup2laz.us.auth0.com application created

  CLIENT ID            ***
  NAME                 GraphQL Server
  DESCRIPTION          Spring Boot GraphQL Resource Server
  TYPE                 Regular Web Application
  CLIENT SECRET        ***
  CALLBACKS            http://localhost:8080/login/oauth2/code/okta
  ALLOWED LOGOUT URLS  http://localhost:8080
  ALLOWED ORIGINS
  ALLOWED WEB ORIGINS
  TOKEN ENDPOINT AUTH
  GRANTS               implicit, authorization_code, refresh_token, client_credentials

 ▸    Quickstarts: https://auth0.com/docs/quickstart/webapp
 ▸    Hint: Emulate this app's login flow by running `auth0 test login ***`
 ▸    Hint: Consider running `auth0 quickstarts download ***`

spring-graphql-api 项目的 build.gradle 文件中添加 okta-spring-boot-starter 依赖:

// build.gradle
dependencies {
    ...
    implementation 'com.okta.spring:okta-spring-boot-starter:3.0.5'
    ...
}

application.properties 文件中为 OAuth 2.0 设置客户端 ID、issuer 和 audience:

# application.properties
okta.oauth2.issuer=https://<your-auth0-domain>/
okta.oauth2.client-id=<client-id>
okta.oauth2.audience=${okta.oauth2.issuer}api/v2/

.env 文件中添加 client secret:

# .env
export OKTA_OAUTH2_CLIENT_SECRET=<client-secret>

SpringBootApiConfig 类中添加以下工厂方法,以便为所有请求提供 Bearer Token:

// SpringBootApiConfig.java
    ...
    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt(withDefaults()));
        return http.build();
    }
    ...

注意:Okta Spring Boot Starter 提供了开箱即用的 Security 自动配置,因此无需配置资源服务器。由于某些原因,如果不进行上述自定义,Spring for GraphQL CORS 允许的 origins 配置不会生效。

同样,在根目录下运行 API 服务器:

source .env && ./gradlew bootRun

使用 Auth0 CLI,通过 auth0 test token 命令获取 Access Token:

auth0 test token -a https://<your-auth0-domain>/api/v2/

通过 HTTPie,使用 Bearer Access Token 向 API 服务器发起请求:

ACCESS_TOKEN=<auth0-access-token>
echo -E '{"query":"{\n    companyList(page: 20) {\n        id\n        SIC\n        name\n        status\n        category\n        companyNumber\n        countryOfOrigin\n    }\n}"}' | \
  http -A bearer -a $ACCESS_TOKEN POST http://localhost:8080/graphql

注意:你也可以按照 这个说明 创建测试 Access Token。

在 React 客户端中添加 Auth0 登录

使用 Auth0 作为身份供应商(Identity Provider)时,可以配置通用 登录页面,以便快速集成,而无需创建登录表单。首先,使用 Auth0 CLI 注册 SPA 应用:

auth0 apps create \
  --name "React client for GraphQL" \
  --description "SPA React client for a Spring GraphQL API" \
  --type spa \
  --callbacks http://localhost:3000/callback \
  --logout-urls http://localhost:3000 \
  --origins http://localhost:3000 \
  --web-origins http://localhost:3000

复制 Auth0 域(domain)和客户端 ID,并更新 src/.env.local,添加以下属性:

# src/.env.local
NEXT_PUBLIC_AUTH0_DOMAIN=<your-auth0-domain>
NEXT_PUBLIC_AUTH0_CLIENT_ID=<client-id>
NEXT_PUBLIC_AUTH0_CALLBACK_URL=http://localhost:3000/callback
NEXT_PUBLIC_AUTH0_AUDIENCE=https://<your-auth0-domain>/api/v2/

将新变量也添加到 .env.example 文件中,但不添加值,以记录所需的配置。

要处理 Auth0 登录后行为,需要添加页面 src/app/callback/page.tsx,内容如下:

// src/app/callback/page.tsx
import Loader from '@/components/loader/Loader';

const Page = () => {
  return <Loader/>
};

export default Page;

在本例中,回调页面(callback page)将渲染为空。

在项目中添加 @auth0/auth0-react 依赖:

npm install @auth0/auth0-react

注意:你可能会问,为什么我使用的是 Auth0 React SDK,而不是 Auth0 Next.js SDK。我只使用了 Next.js 的前端功能。如果本示例使用的是 Next.js 后端,那么 Auth0 Next.js SDK 将更有意义。

src/components/authentication 目录中创建 Auth0ProviderWithNavigate 组件,内容如下:

// src/components/authentication/Auth0ProviderWithNavigate.tsx
import { AppState, Auth0Provider } from '@auth0/auth0-react';
import { useRouter } from 'next/navigation';
import React from 'react';

const Auth0ProviderWithNavigate = (props: { children: React.ReactNode }) => {
  const router = useRouter();

  const domain = process.env.NEXT_PUBLIC_AUTH0_DOMAIN || "";
  const clientId = process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID || "";
  const redirectUri = process.env.NEXT_PUBLIC_AUTH0_CALLBACK_URL || "";
  const audience = process.env.NEXT_PUBLIC_AUTH0_AUDIENCE || "";

  const onRedirectCallback = (appState?: AppState) => {
    router.push(appState?.returnTo || window.location.pathname);
  };

  if (!(domain && clientId && redirectUri)) {
    return null;
  }

  return (
    <Auth0Provider
      domain={domain}
      clientId={clientId}
      authorizationParams={{
        audience: audience,
        redirect_uri: redirectUri,
      }}
      useRefreshTokens={true}
      onRedirectCallback={onRedirectCallback}
    >
      <>{props.children}</>
    </Auth0Provider>
  );
};

export default Auth0ProviderWithNavigate;

Auth0ProviderWithNavigate 组件用 Auth0Provider(Auth0 Context 的提供者)包装子组件,并记住请求的 URL,以便登录后重定向。在 WideLayout 组件中使用该组件。最终代码必须如下所示:

// WideLayout.tsx
'use client';

import Auth0ProviderWithNavigate from '@/components/authentication/Auth0ProviderWithNavigate';
import { Container, ThemeProvider, createTheme } from '@mui/material';

const theme = createTheme({
  typography: {
    fontFamily: 'inherit',
  },
});

const WideLayout = (props: { children: React.ReactNode }) => {
  return (
    <ThemeProvider theme={theme}>
      <Auth0ProviderWithNavigate>
        <Container maxWidth="lg" sx={{ mt: 4 }}>
          {props.children}
        </Container>
      </Auth0ProviderWithNavigate>
    </ThemeProvider>
  );
};

export default WideLayout;

添加 src/components/authentication/AuthenticationGuard.tsx 文件,内容如下:

// src/components/authentication/AuthenticationGuard.tsx
'use client'

import { useAuth0 } from '@auth0/auth0-react';
import { useEffect } from 'react';
import Loader from '../loader/Loader';

const AuthenticationGuard = (props: { children: React.ReactNode }) => {
  const { isLoading, isAuthenticated, error, loginWithRedirect } = useAuth0();

  useEffect(() => {
    if (!isAuthenticated && !isLoading) {
      loginWithRedirect({
        appState: { returnTo: window.location.href },
      });
    }
  }, [isAuthenticated, isLoading, loginWithRedirect]);

  if (isLoading) {
    return <Loader />;
  }
  if (error) {
    return <div>Oops... {error.message}</div>;
  }
  return <>{isAuthenticated && props.children}</>;
};

export default AuthenticationGuard;

AuthenticationGuard 组件将用于保护需要认证的页面,并重定向至 Auth0 通用登录。用 AuthenticationGuard 组件封装 index 页面的内容,从而保护 index 页面:

// app/page.tsx
import AuthenticationGuard from '@/components/authentication/AuthenticationGuard';
import HomePage from './HomePage';

const Page = () => {
  return (
    <AuthenticationGuard>
      <HomePage/>
    </AuthenticationGuard>
  );
};

export default Page;

使用 Access Token 调用 API server

src/services/auth.tsx 文件中添加以下代码:

// src/services/auth.tsx
import backendAPI from './base';

let requestInterceptor: number;
let responseInterceptor: number;

export const clearInterceptors = () => {
  backendAPI.interceptors.request.eject(requestInterceptor);
  backendAPI.interceptors.response.eject(responseInterceptor);
};

export const setInterceptors = (accessToken: String) => {

  clearInterceptors();

  requestInterceptor = backendAPI.interceptors.request.use(
    // @ts-expect-error
    function (config) {
      return {
        ...config,
        headers: {
          ...config.headers,
          Authorization: `Bearer ${accessToken}`,
        },
      };
    },
    function (error) {
      console.log("request interceptor error", error);
      return Promise.reject(error);
    }
  );
};

添加文件 src/hooks/useAccessToken.tsx,内容如下:

// src/hooks/useAccessToken.tsx
import { setInterceptors } from '@/services/auth';
import { useAuth0 } from '@auth0/auth0-react';
import { useCallback, useState } from 'react';

export const useAccessToken = () => {
  const { isAuthenticated, getAccessTokenSilently } = useAuth0();
  const [accessToken, setAccessToken] = useState("");

  const saveAccessToken = useCallback(async () => {
    if (isAuthenticated) {
      try {
        const tokenValue = await getAccessTokenSilently();
        if (accessToken !== tokenValue) {
          setInterceptors(tokenValue);
          setAccessToken(tokenValue);
        }
      } catch (err) {
        // Inactivity timeout
        console.log("getAccessTokenSilently error", err);
      }
    }
  }, [getAccessTokenSilently, isAuthenticated, accessToken]);

  return {
    saveAccessToken,
  };
};

该 Hook 将调用 Auth0 的 getAccessTokenSilently(),并在 Access Token 过期时触发 Token 刷新。然后,它会更新 Axios 拦截器,在请求头中设置更新后的 Bearer Token 值。创建 useAsyncWithToken 钩子:

// useAsyncWithToken.tsx
import { useAccessToken } from './useAccessToken';
import { useAsync } from 'react-use-custom-hooks';

export const useAsyncWithToken = <T, P, E = string>(
  asyncOperation: () => Promise<T>, deps: any[]
) => {
  const { saveAccessToken } = useAccessToken();
  const [ data, loading, error ] = useAsync(async () => {
    await saveAccessToken();
    return asyncOperation();
  }, {},  deps);

  return {
    data,
    loading,
    error
  };
};

更新 CompanyTableContainer 组件中的调用,以使用 useAsyncWithToken Hook 而不是 useAsync

// CompanyTableContainer.tsx
- import { useAsync } from 'react-use-custom-hooks';
+ import { useAsyncWithToken } from '@/hooks/useAsyncWithToken';

...
- const [dataList, loadingList, errorList] = useAsync(
-   () => CompanyApi.getCompanyList({ page: page - 1 }),
-   {},
-   [page]
-  );
- const [dataCount] = useAsync(() => CompanyApi.getCompanyCount(), {}, []);
+ const {
+   data: dataList,
+   loading: loadingList,
+   error: errorList,
+ } = useAsyncWithToken(
+   () => CompanyApi.getCompanyList({ page: page - 1}),
+   [props.page]
+ );
+
+ const { data: dataCount } = useAsyncWithToken(
+   () => CompanyApi.getCompanyCount(),
+   []
+ );
...

运行应用,如下:

npm run dev

访问 http://localhost:3000,你将被重定向到 Auth0 通用登录页面。登录后,你将再次看到公司列表。

Auth0 通用登录表单

Auth0 授权申请表达

一旦公司数据加载完毕,就可以检查控制台 Web 请求,查看是否在请求头中发送了 Bearer Token。如下图所示:

Authorization: Bearer eyJhbGciOiJSU...

更新客户端中的 GraphQL 查询

React 客户端中的 GraphQL 查询可以轻松更新,以便从服务器请求更多数据。例如,添加 status 和有关谁控制公司的信息。首先,更新 API 客户端:

// companies.tsx
...

export type PersonDTO = {
  name: string;
}

export type CompanyDTO = {
  name: string;
  SIC: string;
  id: string;
  companyNumber: string;
  category: string;
  status: string;
  controlledBy: PersonDTO[]
};

...

  getCompanyList: async (params?: CompaniesQuery) => {

    try {
      const response = await backendAPI.post("/graphql", {
        query: `{
        companyList(page: ${params?.page || 0}) {
          name,
          SIC,
          id,
          companyNumber,
          category,
          status,
          controlledBy {
            name
          }
        }}`,
      });
      return response.data.data.companyList as CompanyDTO[];
    } catch (error) {
      console.log("handle get companies error", error);
      if (error instanceof AxiosError) {
        let axiosError = error as AxiosError;
        if (axiosError.response?.data) {
          throw new Error(axiosError.response?.data as string);
        }
      }
      throw new Error("Unknown error, please contact the administrator");
    }
  },
...

然后更新 CompanyTable.tsx 组件中的 CompanyData 接口:

// CompanyTable.tsx
export interface CompanyData {
  id: string,
  name: string,
  category: string,
  companyNumber: string,
  SIC: string
  status: string,
  owner: string
}

最后,更新 CompanyTableContainer 的列定义和数据格式。最终代码应如下所示:

// CompanyTableContainer.tsx
import { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import CompanyTable from './CompanyTable';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { CompanyApi, CompanyDTO } from '@/services/companies';
import Loader from '../loader/Loader';
import { useAsyncWithToken } from '@/app/hooks/useAsyncWithToken';

interface CompanyTableProperties {
  page?: number;
}

const columns: GridColDef[] = [
  { field: "id", headerName: "ID", width: 70 },
  {
    field: "companyNumber",
    headerName: "Company #",
    width: 100,
    sortable: false,
  },
  { field: "name", headerName: "Company Name", width: 250, sortable: false },
  { field: "category", headerName: "Category", width: 200, sortable: false },
  { field: "SIC", headerName: "SIC", width: 200, sortable: false },
  { field: "status", headerName: "Status", width: 100, sortable: false },
  { field: "owner", headerName: "Owner", width: 200, sortable: false },
];

const CompanyTableContainer = (props: CompanyTableProperties) => {
  const router = useRouter();
  const searchParams = useSearchParams()!;
  const pathName = usePathname();
  const page = props.page ? props.page : 1;

  const {
    data: dataList,
    loading: loadingList,
    error: errorList,
  } = useAsyncWithToken(
    () => CompanyApi.getCompanyList({ page: page - 1}),
    [props.page]
  );

  const { data: dataCount } = useAsyncWithToken(
    () => CompanyApi.getCompanyCount(),
    []
  );

  const onPageChange = (pagination: GridPaginationModel) => {
    const params = new URLSearchParams(searchParams.toString());
    const page = pagination.page + 1;
    params.set("page", page.toString());
    router.push(pathName + "?" + params.toString());
  };

  const companyData = dataList?.map((company: CompanyDTO) => {
    return {
      id: company.id,
      name: company.name,
      category: company.category,
      companyNumber: company.companyNumber,
      SIC: company.SIC,
      status: company.status,
      owner: company.controlledBy.map((person) => person.name).join(", "),
    }
  });

  return (
    <>
      {loadingList && <Loader />}
      {errorList && <div>Error</div>}

      {!loadingList && dataList && (
        <CompanyTable
          pagination={{ page: page - 1, pageSize: 10 }}
          rowCount={dataCount}
          rows={companyData}
          columns={columns}
          onPageChange={onPageChange}
        ></CompanyTable>
      )}
    </>
  );
};

export default CompanyTableContainer;

试试看吧!通过改变客户端,GraphQL可以让你轻松获取更多的数据,这真是非常棒!

总结

从 GraphQL 服务器中获取更多公司数据并不需要做太多工作,只需在客户端进行查询更新即可。此外,Auth0 通用登录和 Auth0 React SDK 提供了一种高效的方法,可确保 React 应用程序的安全,并遵循安全最佳实践。你可以在 GitHub 代码库 中找到本示例的所有代码。

查看 Auth0 文档,为你的 React 应用程序添加 注册注销 功能。


参考:https://auth0.com/blog/how-to-build-a-graphql-api-with-spring-boot/