与Go,Chi和inmemory商店进行休息API
#api #go #休息

什么是REST API?

API或应用程序编程接口是一组规则,该规则定义了应用程序或设备如何相互连接和通信。 REST API是符合其余的设计原理或代表性状态转移架构风格的API。因此,REST API有时被转移到Restful API中。

本教程的重点是用GO编写REST API。

电影资源

我们将使用当前项目管理Movie资源。这不是您将如何在Acutal系统中建模电影资源的准确表示,而只是少数基本类型的混合以及如何在REST API中处理。

字段 类型
id uuid
标题 字符串
导演 字符串
导演 字符串
发布 时间
ticketprice float64

项目设置

  • 创建一个用于项目的文件夹,我将其命名为restapi-with-go-chi-and-inmemory-store,但通常是GitHub repo的根或单声道中的子文件夹的根。
  • 执行以下命令以在终端上初始化go.mod
go mod init movies-api
  • 添加一个新的文件main.go,其中包含以下内容以开始
package main

func main() {
    println("Hello, World!")
}

配置

添加一个名为config的文件夹和一个名为config.go的文件。我喜欢将所有应用程序配置都放在一个地方,并且将使用出色的envconfig软件包来加载配置,还为选项设置了一些默认值。此软件包允许我们从环境变量加载应用程序配置,可以使用标准GO软件包来完成相同的操作,但是我认为此软件包提供了很好的抽象而不会失去可读性。

package config

import (
    "time"

    "github.com/kelseyhightower/envconfig"
)

const envPrefix = ""

type Configuration struct {
    HTTPServer
}

type HTTPServer struct {
    IdleTimeout  time.Duration `envconfig:"HTTP_SERVER_IDLE_TIMEOUT" default:"60s"`
    Port         int           `envconfig:"PORT" default:"8080"`
    ReadTimeout  time.Duration `envconfig:"HTTP_SERVER_READ_TIMEOUT" default:"1s"`
    WriteTimeout time.Duration `envconfig:"HTTP_SERVER_WRITE_TIMEOUT" default:"2s"`
}

func Load() (*Configuration, error) {
    cfg := Configuration{}
    err := envconfig.Process(envPrefix, &cfg)
    if err != nil {
        return nil, err
    }

    return &cfg, nil
}

这将导致一个错误可以通过在终端上执行。

go mod tidy

您可以通过将Configuration转换为interface,然后在每个子包装中添加配置来改进它,例如apistore

可以使用环境变量来更新配置,例如在我们更新main.go以启动服务器之后,在终端上执行以下将在端口5000启动服务器。

PORT=5000 go run main.go

电影商店界面

添加一个名为store的新文件夹和一个名为movie_store.go的文件。我们将为您的电影商店添加一个接口和支持结构。

package store

import (
    "context"
    "time"

    "github.com/google/uuid"
)

type Movie struct {
    ID          uuid.UUID
    Title       string
    Director    string
    ReleaseDate time.Time
    TicketPrice float64
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

type CreateMovieParams struct {
    ID          uuid.UUID
    Title       string
    Director    string
    ReleaseDate time.Time
    TicketPrice float64
}

func NewCreateMovieParams(
    id uuid.UUID,
    title string,
    director string,
    releaseDate time.Time,
    ticketPrice float64,
) CreateMovieParams {
    return CreateMovieParams{
        ID:          id,
        Title:       title,
        Director:    director,
        ReleaseDate: releaseDate,
        TicketPrice: ticketPrice,
    }
}

type UpdateMovieParams struct {
    Title       string
    Director    string
    ReleaseDate time.Time
    TicketPrice float64
}

func NewUpdateMovieParams(
    title string,
    director string,
    releaseDate time.Time,
    ticketPrice float64,
) UpdateMovieParams {
    return UpdateMovieParams{
        Title:       title,
        Director:    director,
        ReleaseDate: releaseDate,
        TicketPrice: ticketPrice,
    }
}

type MoviesStore interface {
    GetAll(ctx context.Context) ([]*Movie, error)
    GetByID(ctx context.Context, id uuid.UUID) (*Movie, error)
    Create(ctx context.Context, createMovieParams CreateMovieParams) error
    Update(ctx context.Context, id uuid.UUID, updateMovieParams UpdateMovieParams) error
    Delete(ctx context.Context, id uuid.UUID) error
}

还添加了一个名为errors.go的自定义应用程序错误的文件,这些文件使我们的商店包的客户封装不可知,我们的存储实现将在返回客户端之前将任何本机错误转换为我们的业务错误。

package store

import (
    "fmt"

    "github.com/google/uuid"
)

type DuplicateIDError struct {
    ID uuid.UUID
}

func (e *DuplicateIDError) Error() string {
    return fmt.Sprintf("duplicate movie id: %v", e.ID)
}

type RecordNotFoundError struct{}

func (e *RecordNotFoundError) Error() string {
    return "record not found"
}

Inmemorymoviesstore

store下添加文件夹,名为in_memory和一个名为in_memory_movies_store.go的文件。添加带有地图字段的结构InMemoryMoviesStore将电影存储在内存中。还要添加一个RWMutex字段,以避免并发读/写入电影字段。

我们实现了为MovieStore接口定义的所有方法,以将/删除电影添加到InMemoryMoviesStore结构的映射字段中。对于阅读,我们锁定了收集以进行阅读,请阅读结果并使用defer释放锁。对于写作,我们获得了写锁而不是读取锁。

package in_memory

import (
    "context"
    "errors"
    "movies-api/store"
    "sync"
    "time"

    "github.com/google/uuid"
)

type InMemoryMoviesStore struct {
    movies map[uuid.UUID]*store.Movie
    mu     sync.RWMutex
}

func NewInMemoryMoviesStore() *InMemoryMoviesStore {
    return &InMemoryMoviesStore{
        movies: map[uuid.UUID]*store.Movie{},
    }
}

func (s *InMemoryMoviesStore) GetAll(ctx context.Context) ([]*store.Movie, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    movies := make([]*store.Movie, 0)
    for _, m := range s.movies {
        movies = append(movies, m)
    }
    return movies, nil
}

func (s *InMemoryMoviesStore) GetByID(ctx context.Context, id uuid.UUID) (*store.Movie, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    m, ok := s.movies[id]
    if !ok {
        return nil, &store.RecordNotFoundError{}
    }

    return m, nil
}

func (s *InMemoryMoviesStore) Create(ctx context.Context, createMovieParams store.CreateMovieParams) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.movies[createMovieParams.ID]; ok {
        return &store.DuplicateIDError{ID: createMovieParams.ID}
    }

    movie := &store.Movie{
        ID:          createMovieParams.ID,
        Title:       createMovieParams.Title,
        Director:    createMovieParams.Director,
        ReleaseDate: createMovieParams.ReleaseDate,
        TicketPrice: createMovieParams.TicketPrice,
        CreatedAt:   time.Now().UTC(),
        UpdatedAt:   time.Now().UTC(),
    }

    s.movies[movie.ID] = movie
    return nil
}

func (s *InMemoryMoviesStore) Update(ctx context.Context, id uuid.UUID, updateMovieParams store.UpdateMovieParams) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    m, ok := s.movies[id]
    if !ok {
        return &store.RecordNotFoundError{}
    }

    m.Title = updateMovieParams.Title
    m.Director = updateMovieParams.Director
    m.ReleaseDate = updateMovieParams.ReleaseDate
    m.TicketPrice = updateMovieParams.TicketPrice
    m.UpdatedAt = time.Now().UTC()

    s.movies[id] = m
    return nil
}

func (s *InMemoryMoviesStore) Delete(ctx context.Context, id uuid.UUID) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    delete(s.movies, id)
    return nil
}

REST服务器

添加一个新文件夹以添加所有REST API服务器相关的文件。让我们从添加server.go文件开始,然后添加一个结构来表示REST服务器。该结构将具有运行服务器,路由和所有依赖项所需的配置实例。还添加方法来启动服务器。
对于路线,我们将使用出色的chi路由器,这是一种构建HTTP服务的LigtWeight,IDOMANT和可组合路由器。

在开始方法中,我们将构造由标准net/http软件包提供的Server实例,提供我们在NewServer方法中设置的chi mux。然后,我们将设置一种优雅关闭的方法,并致电ListenAndServe启动我们的REST服务器。

package api

import (
    "context"
    "fmt"
    "log"
    "movies-api/config"
    "net/http"
    "os"
    "os/signal"
    "syscall"

    "movies-api/store"

    "github.com/go-chi/chi/v5"
)

type Server struct {
    cfg    config.HTTPServer
    store  store.MoviesStore
    router *chi.Mux
}

func NewServer(cfg config.HTTPServer, store store.MoviesStore) *Server {
    srv := &Server{
        cfg:    cfg,
        store:  store,
        router: chi.NewRouter(),
    }

    srv.routes()

    return srv
}

func (s *Server) Start(ctx context.Context) {
    server := http.Server{
        Addr:         fmt.Sprintf(":%d", s.cfg.Port),
        Handler:      s.router,
        IdleTimeout:  s.cfg.IdleTimeout,
        ReadTimeout:  s.cfg.ReadTimeout,
        WriteTimeout: s.cfg.WriteTimeout,
    }

    shutdownComplete := handleShutdown(func() {
        if err := server.Shutdown(ctx); err != nil {
            log.Printf("server.Shutdown failed: %v\n", err)
        }
    })

    if err := server.ListenAndServe(); err == http.ErrServerClosed {
        <-shutdownComplete
    } else {
        log.Printf("http.ListenAndServe failed: %v\n", err)
    }

    log.Println("Shutdown gracefully")
}

func handleShutdown(onShutdownSignal func()) <-chan struct{} {
    shutdown := make(chan struct{})

    go func() {
        shutdownSignal := make(chan os.Signal, 1)
        signal.Notify(shutdownSignal, os.Interrupt, syscall.SIGTERM)

        <-shutdownSignal

        onShutdownSignal()
        close(shutdown)
    }()

    return shutdown
}

自定义错误

我们将在api文件夹下的errors.go文件中定义REST服务器返回的任何自定义错误。我已经继续前进,并添加了我在文件中此服务中返回的所有错误。但是实际上,我们将从最常见的开始,然后在需要时添加任何新的。

package api

import (
    "net/http"

    "github.com/go-chi/render"
)

type ErrResponse struct {
    Err            error `json:"-"` // low-level runtime error
    HTTPStatusCode int   `json:"-"` // http response status code

    StatusText string `json:"status"`          // user-level status message
    AppCode    int64  `json:"code,omitempty"`  // application-specific error code
    ErrorText  string `json:"error,omitempty"` // application-level error message, for debugging
}

func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
    render.Status(r, e.HTTPStatusCode)
    return nil
}

var (
    ErrNotFound            = &ErrResponse{HTTPStatusCode: 404, StatusText: "Resource not found."}
    ErrBadRequest          = &ErrResponse{HTTPStatusCode: 400, StatusText: "Bad request"}
    ErrInternalServerError = &ErrResponse{HTTPStatusCode: 500, StatusText: "Internal Server Error"}
)

func ErrConflict(err error) render.Renderer {
    return &ErrResponse{
        Err:            err,
        HTTPStatusCode: 409,
        StatusText:     "Duplicate ID",
        ErrorText:      err.Error(),
    }
}

路线

我喜欢保留服务在一个位置和名为routes.go的单个文件中提供的所有路线。它更容易记住并减轻认知超负荷。

routes方法悬挂在我们的Server结构上,定义了router字段上的所有端点。我定义了一个/health端点,该端点将返回此服务的当前健康状况。然后添加了一个用于电影的子路线组。这可以帮助我们仅适用于/api/movies路线,例如身份验证,请求记录。

package api

import "github.com/go-chi/chi/v5"

func (s *Server) routes() {
    s.router.Use(render.SetContentType(render.ContentTypeJSON))

    s.router.Get("/health", s.handleGetHealth())

    s.router.Route("/api/movies", func(r chi.Router) {
        r.Get("/", s.handleListMovies())
        r.Post("/", s.handleCreateMovie())
        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", s.handleGetMovie())
            r.Put("/", s.handleUpdateMovie())
            r.Delete("/", s.handleDeleteMovie())
        })
    })
}

请注意,所有处理程序都会悬挂在Server结构上,这有助于访问每个处理程序中所需的依赖项。如果服务中有多个资源,则在每个资源中添加单独的structs可能仅包含该资源所需的依赖项。

健康终点处理程序

我为health资源添加了一个单独的文件。它具有一个用于单个端点的处理程序,我们将作为响应结构的响应和实现发送一个结构。

package api

import (
    "net/http"

    "github.com/go-chi/render"
)

type healthResponse struct {
    OK bool `json:"ok"`
}

func (hr healthResponse) Render(w http.ResponseWriter, r *http.Request) error {
    return nil
}

func (s *Server) handleGetHealth() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        health := healthResponse{OK: true}
        render.Render(w, r, health)
    }
}

电影端点处理程序

通过ID获取电影

让我们开始添加一个结构,我们将使用一个构造将Movie返回到我们的休息服务的呼叫者,并实现Renderer接口,以便我们可以使用Render方法返回数据。

type movieResponse struct {
    ID          uuid.UUID `json:"id"`
    Title       string    `json:"title"`
    Director    string    `json:"director"`
    ReleaseDate time.Time `json:"release_date"`
    TicketPrice float64   `json:"ticket_price"`
}

func NewMovieResponse(m *store.Movie) movieResponse {
    return movieResponse{
        ID:          m.ID,
        Title:       m.Title,
        Director:    m.Director,
        ReleaseDate: m.ReleaseDate,
        TicketPrice: m.TicketPrice,
    }
}

func (hr movieResponse) Render(w http.ResponseWriter, r *http.Request) error {
    return nil
}

我喜欢使用这些使structs更靠近方法/软件包。它确实导致了一些代码重复,例如在这种情况下,movieResponsestore/movies_store.go文件中的Movie struct Defind非常相似,但这允许REST软件包不完全依赖store包,我们可以具有不同的标签,例如。 store struct中的db特定标签,但在movieResponse结构中没有。

现在是处理程序,我们收到一个ResponseWriterRequest,我们使用URLParam方法从路径中提取id参数,如果解析失败,我们rendera BadRequest

然后,如果在使用给定的id商店中找到记录,我们将继续从store获取movie,我们将渲染NotFound,如果返回的错误不是我们在商店包中定义的错误,那么我们可以添加InternalServerError,我们可以添加更多根据用例,定制/已知错误以存储并转换为Accland http状态代码。

如果一切都起作用,那么我们将store.Movie转换为movieResponse并呈现结果。结果将以json响应主体返回给呼叫者。

func (s *Server) handleGetMovie() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        idParam := chi.URLParam(r, "id")
        id, err := uuid.Parse(idParam)
        if err != nil {
            render.Render(w, r, ErrBadRequest)
            return
        }

        movie, err := s.store.GetByID(r.Context(), id)
        if err != nil {
            var rnfErr *store.RecordNotFoundError
            if errors.As(err, &rnfErr) {
                render.Render(w, r, ErrNotFound)
            } else {
                render.Render(w, r, ErrInternalServerError)
            }
            return
        }

        mr := NewMovieResponse(movie)
        render.Render(w, r, mr)
    }
}

获取所有/列表电影

对于响应,我们将使用我们为Get By ID定义的相同movieResponse结构

func NewMovieListResponse(movies []*store.Movie) []render.Renderer {
    list := []render.Renderer{}
    for _, movie := range movies {
        mr := NewMovieResponse(movie)
        list = append(list, mr)
    }
    return list
}

和处理程序方法非常简单,我们称为GetAll,如果错误返回InternalServerError else返回电影列表。

func (s *Server) handleListMovies() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        movies, err := s.store.GetAll(r.Context())
        if err != nil {
            render.Render(w, r, ErrInternalServerError)
            return
        }

        render.RenderList(w, r, NewMovieListResponse(movies))
    }
}

创建电影

与GET相同,我们将添加一个新结构以接收创建新电影所需的参数。但是,不用暗示Renderer,我们将实现Binder接口,在Bind中,如果需要,可以进行自定义映射,例如添加元数据或从JWT设置CreatedBy字段。

请注意,我们在此结构中没有CreatedAtUpdatedAt

type createMovieRequest struct {
    ID          string    `json:"id"`
    Title       string    `json:"title"`
    Director    string    `json:"director"`
    ReleaseDate time.Time `json:"release_date"`
    TicketPrice float64   `json:"ticket_price"`
}

func (mr *createMovieRequest) Bind(r *http.Request) error {
    return nil
}

在处理程序中,我们将请求主体绑定到我们的结构,如果Bind成功,则将其转换为store.Create方法期望的CreateMovieParams struct,并致电Create方法将电影添加到数据存储中。如果存在重复的密钥错误,我们将返回409 Conflict对于未知错误,我们返回500 InternalServerError,如果一切成功,我们都将返回200 OK

func (s *Server) handleCreateMovie() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        data := &createMovieRequest{}
        if err := render.Bind(r, data); err != nil {
            render.Render(w, r, ErrBadRequest)
            return
        }

        createMovieParams := store.NewCreateMovieParams(
            uuid.MustParse(data.ID),
            data.Title,
            data.Director,
            data.ReleaseDate,
            data.TicketPrice,
        )
        err := s.store.Create(r.Context(), createMovieParams)
        if err != nil {
            var dupIdErr *store.DuplicateIDError
            if errors.As(err, &dupIdErr) {
                render.Render(w, r, ErrConflict(err))
            } else {
                render.Render(w, r, ErrInternalServerError)
            }
            return
        }

        w.WriteHeader(200)
        w.Write(nil)
    }
}

更新电影

与上面的Create Movie相同,我们引入了一个新的struct updateMovieRequest,以接收更新电影和插入结构的Binder接口所需的参数。

type updateMovieRequest struct {
    Title       string    `json:"title"`
    Director    string    `json:"director"`
    ReleaseDate time.Time `json:"release_date"`
    TicketPrice float64   `json:"ticket_price"`
}

func (mr *updateMovieRequest) Bind(r *http.Request) error {
    return nil
}

在柄中,我们从路径中读取id,然后从请求主体绑定结构。如果没有错误,则我们将请求转换为store.UpdateMovieParams,并致电Update商店的方法更新电影。如果Upate成功,我们返回200 OK

func (s *Server) handleUpdateMovie() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        idParam := chi.URLParam(r, "id")
        id, err := uuid.Parse(idParam)
        if err != nil {
            render.Render(w, r, ErrBadRequest)
            return
        }

        data := &updateMovieRequest{}
        if err := render.Bind(r, data); err != nil {
            render.Render(w, r, ErrBadRequest)
            return
        }

        updateMovieParams := store.NewUpdateMovieParams(
            data.Title,
            data.Director,
            data.ReleaseDate,
            data.TicketPrice,
        )
        err = s.store.Update(r.Context(), id, updateMovieParams)
        if err != nil {
            var rnfErr *store.RecordNotFoundError
            if errors.As(err, &rnfErr) {
                render.Render(w, r, ErrNotFound)
            } else {
                render.Render(w, r, ErrInternalServerError)
            }
            return
        }

        w.WriteHeader(200)
        w.Write(nil)
    }
}

删除电影

这可能是最简单的处理程序,因为它不需要任何RendererBinder,我们只是从路径中获取id,然后调用Delete商店的方法来删除资源。如果删除成功,我们返回200 OK

func (s *Server) handleDeleteMovie() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        idParam := chi.URLParam(r, "id")
        id, err := uuid.Parse(idParam)
        if err != nil {
            render.Render(w, r, ErrBadRequest)
            return
        }

        err = s.store.Delete(r.Context(), id)
        if err != nil {
            var rnfErr *store.RecordNotFoundError
            if errors.As(err, &rnfErr) {
                render.Render(w, r, ErrNotFound)
            } else {
                render.Render(w, r, ErrInternalServerError)
            }
            return
        }

        w.WriteHeader(200)
        w.Write(nil)
    }
}

启动服务器

现在一切都设置了,是时候更新main方法了。首先要洗涤配置,然后创建一个InMemoryMoviesStore的实例,在这里我们还可以实例化服务器依赖的任何其他依赖关系。下一步是创建一个api.Server struct的实例,并调用Start方法来启动服务器。服务器将开始在配置的端口上收听,您可以使用curlPostman调用端点。

package main

import (
    "context"
    "log"
    "movies-api/api"
    "movies-api/config"
    "movies-api/store/in_memory"
)

func main() {
    ctx := context.Background()
    cfg, err := config.Load()
    if err != nil {
        log.Fatal(err)
    }

    store := in_memory.NewInMemoryMoviesStore()
    server := api.NewServer(cfg.HTTPServer, store)
    server.Start(ctx)
}

测试

我将列出手动测试API端点的步骤,因为我们没有Swagger UI或任何其他UI与此交互,Postman也可以用于测试端点。

  • 启动服务器执行以下
go run main.go

执行以下测试,请记住如果您在与8080不同的端口上运行的端口。

测试

获取所有返回空名单

请求

curl --request GET --url "http://localhost:8080/api/movies"

预期响应

[]

使用无效ID获得ID

请求

curl --request GET --url "http://localhost:8080/api/movies/1"

预期响应

{"status":"Bad request"}

通过不存在的记录获得ID

请求

curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"

预期响应

{"status":"Resource not found."}

创建电影

请求

curl --request POST --data '{ "id": "98268a96-a6ac-444f-852a-c6472129aa22", "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 10.70 }' --url "http://localhost:8080/api/movies"

预期响应

使用现有ID创建电影

请求

curl --request POST --data '{ "id": "98268a96-a6ac-444f-852a-c6472129aa22", "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 10.70 }' --url "http://localhost:8080/api/movies"

预期响应

{"status":"Duplicate ID","error":"duplicate movie id: 98268a96-a6ac-444f-852a-c6472129aa22"}

获取所有电影

请求

curl --request GET --url "http://localhost:8080/api/movies"

预期响应

[{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":10.7}]

通过ID获取电影

请求

curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"

预期响应

{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":10.7}

更新电影

请求

curl --request PUT --data '{ "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 20.70 }' --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"

预期响应

通过ID获取电影 - 获取更新记录

请求

curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"

预期响应

{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":20.7}

删除电影

请求

curl --request DELETE --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"

预期响应

通过ID获取电影 - 删除记录

请求

curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"

预期响应

{"status":"Resource not found."}

来源

演示应用程序的源代码托管在blog-code-samples存储库中的GitHub上。

参考

没有特定顺序