この記事はmacで動かす前提で書いています。
goのバージョンは1.8です。
jqとMakeがインストールされている前提です
こんな感じのurlでwebAPIを作りたいかなと漠然と考えました
methods | エンドポイント | 目的 |
---|---|---|
get,post | http://chat/api/rooms | チャットルームの作成と一覧の取得 |
get,post | http://chat/api/rooms/3/messages | room3からのメッセージ取得と送信 |
またchatとして自動更新するようにwebsocketも使います。
エンドポイント | 目的 |
---|---|
ws://chat/api/rooms/3/watch | room 3の変更通知受取 |
これを念頭にDSLを書きます
あとデータベースはmysqlを使います。xoでdbからコード生成を行う方針で行きます。
初期設定
goaのインストール
go get -u -v github.com/goadesign/goa/..
GOPATH
上にプロジェクトを作ります
mkdir -p $GOPATH/src/github.com/m0a-mystudy/goa-chat
cd $GOPATH/src/github.com/m0a-mystudy/goa-chat
goa-cellerを参考にMakefileを作ります。
# ! /usr/bin/make
#
# Makefile for goa chat
#
# Targets:
# - clean delete all generated files
# - generate (re)generate all goagen-generated files.
# - build compile executable
#
# Meta targets:
# - all is the default target, it runs all the targets in the order above.
#
all: clean generate build
clean:
@rm -rf app
@rm -rf client
@rm -rf tool
@rm -rf public/swagger
@rm -rf public/schema
@rm -rf public/js
@rm -f todo
bootstrap:
@goagen main -d github.com/m0a-mystudy/goa-chat/design -o controllers
generate:
@goagen app -d github.com/m0a-mystudy/goa-chat/design
@goagen swagger -d github.com/m0a-mystudy/goa-chat/design -o public
@goagen schema -d github.com/m0a-mystudy/goa-chat/design -o public
@goagen client -d github.com/m0a-mystudy/goa-chat/design
@goagen js -d github.com/m0a-mystudy/goa-chat/design -o public
build:
@go build -o chat
DSLの記述
package design
import (
. "github.com/goadesign/goa/design"
. "github.com/goadesign/goa/design/apidsl"
)
var _ = API("Chat API", func() {
Title("goa study chat") // Documentation title
Description("goa study chat api")
Host("localhost:8080")
Scheme("http")
BasePath("/api")
Origin("http://localhost:3000", func() {
Methods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
Headers("Origin", "X-Requested-With", "Content-Type", "Accept")
})
ResponseTemplate(Created, func(pattern string) {
Description("Resource created")
Status(201)
Headers(func() {
Header("Location", String, "href to created resource", func() {
Pattern(pattern)
})
})
})
})
var _ = Resource("room", func() {
DefaultMedia(Room)
BasePath("/rooms")
Action("list", func() {
Routing(GET(""))
Description("Retrieve all rooms.")
Response(OK, CollectionOf(Room))
Response(NotFound)
})
Action("show", func() {
Routing(
GET("/:roomID"),
)
Description("Retrieve room with given id")
Params(func() {
Param("roomID", Integer)
})
Response(OK)
Response(NotFound)
Response(BadRequest, ErrorMedia)
})
Action("post", func() {
Routing(POST(""))
Description("Create new Room")
Payload(RoomPayload)
Response(Created, "/rooms/[0-9]+")
Response(BadRequest)
})
Action("watch", func() {
Routing(
GET("/:roomID/watch"),
)
Scheme("ws")
Description("Retrieve room with given id")
Params(func() {
Param("roomID", Integer)
})
Response(SwitchingProtocols)
Response(BadRequest, ErrorMedia)
})
})
var _ = Resource("message", func() {
DefaultMedia(Message)
BasePath("messages")
Parent("room")
Action("list", func() {
Routing(GET(""))
Description("Retrieve all messages.")
Response(OK, CollectionOf(Message))
Response(NotFound)
})
Action("post", func() {
Routing(POST(""))
Description("Create new message")
Payload(MessagePayload)
Response(Created, "^/rooms/[0-9]+/messages/[0-9]+$")
Response(BadRequest)
})
Action("show", func() {
Routing(
GET("/:messageID"),
)
Description("Retrieve message with given id")
Params(func() {
Param("messageID", Integer)
})
Response(OK)
Response(NotFound)
Response(BadRequest, ErrorMedia)
})
})
var Message = MediaType("application/vnd.message+json", func() {
Description("A Message")
Reference(MessagePayload)
Attributes(func() {
Attribute("accountID")
Attribute("body")
Attribute("postDate")
Required("accountID", "body", "postDate")
})
View("default", func() {
Attribute("accountID")
Attribute("body")
Attribute("postDate")
})
})
var MessagePayload = Type("MessagePayload", func() {
Attribute("accountID", Integer, func() {
Example(1)
})
Attribute("body", func() {
MinLength(1)
MaxLength(400)
Example("this is chat message")
})
Attribute("postDate", DateTime, func() {
Default("1978-06-30T10:00:00+09:00")
})
Required("accountID", "body", "postDate")
})
var Room = MediaType("application/vnd.room+json", func() {
Description("A room")
Reference(RoomPayload)
Attributes(func() {
Attribute("id")
Attribute("name")
Attribute("description")
Attribute("created")
Required("name", "description")
})
View("default", func() {
Attribute("id")
Attribute("name")
Attribute("description")
Attribute("created")
})
})
var RoomPayload = Type("RoomPayload", func() {
Attribute("id", Integer, "ID of room")
Attribute("name", String, "Name of room", func() {
Example("room001")
})
Attribute("description", String, "description of room", func() {
Example("room description")
MaxLength(400)
})
Attribute("created", DateTime, "Date of creation")
Required("name", "description")
})
上記はまだpayloadの作りとかよくわかってないのでちょっと仮な感じです。
ちょっとずつこちらを修正していきます。
詰まったところ
CORS設定
まずクライアントはcreate-react-app
を使って作ろうとしてまして、何も考えないと別ドメイン扱いになります。
それでもアクセス可能にするためにCORSの設定が必要でしたがどこにも情報がなくて苦労しました。
(Content-Type is not allowed by Access-Control-Allow-Headers
とか出る)
以下のように設定したら上手く動きました。
Origin("http://localhost:3000", func() {
Methods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
Headers("Origin", "X-Requested-With", "Content-Type", "Accept")
})
親子関係の設定
methods | エンドポイント | 目的 |
---|---|---|
get,post | http://chat/api/rooms/3/messages | room 3へのチャットメッセージの作成と一覧の取得 |
上記のように2つのリソース roomとmessageには親子関係になっているので
それを素直に設定するとこんな感じになります
var _ = Resource("room", func() {
DefaultMedia(Room)
BasePath("/rooms")
Action("list", func() {
Routing(GET(""))
//省略
})
Action("post", func() {
Routing(POST(""))
//省略
})
})
var _ = Resource("message", func() {
DefaultMedia(Message)
BasePath("messages")
Parent("room")
Action("list", func() {
Routing(GET(""))
//省略
})
Action("post", func() {
Routing(POST(""))
//省略
})
})
ところがこのままdslからコード生成を行うと以下のようなエラーが出ます
exit status 1
resource "message": Parent resource "room" has no canonical action
link "room" of type "Message": Link name must match one of the parent media type attribute names
make: *** [bootstrap] Error 1
このエラーメッセージに悩みました。
対応方法はshow
actionを親側に定義します。(canonical action)
Action("show", func() {
Routing(
GET("/:roomID"),
)
Description("Retrieve room with given id")
Params(func() {
Param("roomID", Integer)
})
Response(OK)
Response(NotFound)
Response(BadRequest, ErrorMedia)
})
参考:https://goa.design/reference/goa/design/apidsl/#func-resource-a-name-apidsl-resource-a
(ちなみにshow
という名前が嫌ならCanonicalActionNameを使って変更できる)
実装
make bootstrap
を実行してコード生成を行います。
修正が必要なファイルはcontrollers
に作られますが、main.goだけは手動で直下に移動しておきます。
database側を作る
goaをつかってdbのスキーマを作るのもいいのですが今回は
mysql側でsqlを書いてxoでgoの構造体を作るとう言う方針で行きます。
xoのインストール
$ go get -u -v github.com/knq/xo...
mysql側でスキーマを作りました。ダンプは以下となります。
--
-- Table structure for table `messages`
--
DROP TABLE IF EXISTS `messages`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `messages` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`room_id` int(11) NOT NULL,
`account_id` int(11) NOT NULL,
`body` varchar(400) NOT NULL,
`postDate` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `room_id_idx` (`room_id`),
CONSTRAINT `room_id` FOREIGN KEY (`room_id`) REFERENCES `rooms` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `rooms`
--
DROP TABLE IF EXISTS `rooms`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `rooms` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(256) NOT NULL,
`description` varchar(400) NOT NULL,
`created` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name_UNIQUE` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
mysqlへ流し込んでおきます。(方法は省略)
db_nameはgoa_chat
とします。
以下のコマンドでdbスキーマからgo codeを生成します。
$ mkdir -p models
$ xo mysql://<usrname>:<pass>@localhost/goa_chat -o models
$ ls -l models
total 64
-rw-r--r-- 1 m0a staff 4199 5 17 13:25 message.xo.go
-rw-r--r-- 1 m0a staff 3581 5 17 13:25 room.xo.go
-rw-r--r-- 1 m0a staff 2128 5 17 10:56 xo_db.xo.go
生成されたコードはこんな感じです
// Package models contains the types for schema 'goa_chat'.
package models
// GENERATED BY XO. DO NOT EDIT.
import (
"errors"
"time"
)
// Room represents a row from 'goa_chat.rooms'.
type Room struct {
ID int `json:"id"` // id
Name string `json:"name"` // name
Description string `json:"description"` // description
Created time.Time `json:"created"` // created
// xo fields
_exists, _deleted bool
}
// Exists determines if the Room exists in the database.
func (r *Room) Exists() bool {
return r._exists
}
// Deleted provides information if the Room has been deleted from the database.
func (r *Room) Deleted() bool {
return r._deleted
}
// Insert inserts the Room to the database.
func (r *Room) Insert(db XODB) error {
var err error
// if already exist, bail
if r._exists {
return errors.New("insert failed: already exists")
}
// sql insert query, primary key provided by autoincrement
const sqlstr = `INSERT INTO goa_chat.rooms (` +
`name, description, created` +
`) VALUES (` +
`?, ?, ?` +
`)`
// run query
XOLog(sqlstr, r.Name, r.Description, r.Created)
res, err := db.Exec(sqlstr, r.Name, r.Description, r.Created)
if err != nil {
return err
}
// retrieve id
id, err := res.LastInsertId()
if err != nil {
return err
}
// set primary key and existence
r.ID = int(id)
r._exists = true
return nil
}
//
// コード省略
//
// RoomByID retrieves a row from 'goa_chat.rooms' as a Room.
//
// Generated from index 'rooms_id_pkey'.
func RoomByID(db XODB, id int) (*Room, error) {
var err error
// sql query
const sqlstr = `SELECT ` +
`id, name, description, created ` +
`FROM goa_chat.rooms ` +
`WHERE id = ?`
// run query
XOLog(sqlstr, id)
r := Room{
_exists: true,
}
err = db.QueryRow(sqlstr, id).Scan(&r.ID, &r.Name, &r.Description, &r.Created)
if err != nil {
return nil, err
}
return &r, nil
}
中身を見ると全体を取得する関数が定義されてないのでroomの分だけ作ります
package models
func AllRooms(db XODB, limit int) ([]*Room, error) {
// sql query
const sqlstr = `SELECT ` +
`id, name, description, created ` +
`FROM goa_chat.rooms LIMIT ?`
// run query
XOLog(sqlstr, limit)
q, err := db.Query(sqlstr, limit)
if err != nil {
return nil, err
}
defer q.Close()
var res []*Room
for q.Next() {
r := Room{}
err = q.Scan(&r.ID, &r.Name, &r.Description, &r.Created)
if err != nil {
return nil, err
}
res = append(res, &r)
}
return res, nil
}
goaのコードとdbの紐付け
goa-cellerを真似てcontrollerにdbをつけておきます。
controllers
のroom.go
,message.go
を修正します
// RoomController implements the room resource.
type RoomController struct {
*goa.Controller
+ db *sql.DB
}
// NewRoomController creates a room controller.
- func NewRoomController(service *goa.Service) *RoomController {
+ func NewRoomController(service *goa.Service, db *sql.DB) *RoomController {
return &RoomController{
Controller: service.NewController("RoomController"),
+ db: db,
}
}
message.go
は省略します
main.go
にて実際にdbを接続するコードを追加します
package main
import (
+ "database/sql"
"github.com/goadesign/goa"
"github.com/goadesign/goa/middleware"
"github.com/m0a-mystudy/goa-chat/app"
"github.com/m0a-mystudy/goa-chat/controllers"
+ _ "github.com/go-sql-driver/mysql"
)
func main() {
// Create service
service := goa.New("Chat API")
// Mount middleware
service.Use(middleware.RequestID())
service.Use(middleware.LogRequest(true))
service.Use(middleware.ErrorHandler(service, true))
service.Use(middleware.Recover())
+ db, err := sql.Open("mysql", "user:password@/goa_chat?parseTime=true")
+ if err != nil {
+ service.LogError("startup", "err", err)
+ }
// Mount "message" controller
- c := controllers.NewMessageController(service)
+ c := controllers.NewMessageController(service, db)
app.MountMessageController(service, c)
// Mount "room" controller
- c2 := controllers.NewRoomController(service)
+ c2 := controllers.NewRoomController(service, db)
app.MountRoomController(service, c2)
// Start service
if err := service.ListenAndServe(":8080"); err != nil {
service.LogError("startup", "err", err)
}
}
controllers/room.go実装
先ずはmodel側の構造体とgoa側の構造体の変換用の処理関数を作っておきます
func ToRoomMedia(room *models.Room) *app.Room {
ret := app.Room{
ID: &room.ID,
Description: room.Description,
Name: room.Name,
Created: &room.Created,
}
return &ret
}
DSLでActionを定義した分だけメソッドができているので中味を実装していきます。
package controllers
import (
"database/sql"
"time"
"github.com/goadesign/goa"
"github.com/m0a-mystudy/goa-chat/app"
"github.com/m0a-mystudy/goa-chat/models"
)
//
// コード省略
//
// List runs the list action.
func (c *RoomController) List(ctx *app.ListRoomContext) error {
res := app.RoomCollection{}
rooms, err := models.AllRooms(c.db, 100) //とりあえず100件固定で
if err != nil {
return err
}
for _, room := range rooms {
res = append(res, ToRoomMedia(room))
}
return ctx.OK(res)
}
// Post runs the post action.
func (c *RoomController) Post(ctx *app.PostRoomContext) error {
room := models.Room{
Name: ctx.Payload.Name,
Description: ctx.Payload.Description,
Created: time.Now(),
}
err := room.Insert(c.db)
if err != nil {
return err
}
return ctx.Created(ToRoomMedia(&room))
}
// Show runs the show action.
func (c *RoomController) Show(ctx *app.ShowRoomContext) error {
room, err := models.RoomByID(c.db, ctx.RoomID)
if err != nil {
return err
}
if room == nil {
return ctx.NotFound()
}
res := ToRoomMedia(room)
return ctx.OK(res)
}
ctxからroomIDが取得できたり、Actionのルーティング設定から必要なパラメータをctxから取得できます。
便利。フレームワークによってはinterface{}型だったりするんですがコード生成なのでちゃんとroomIDはint型になっているのが素敵です。
controllers/message.go実装
こちらも基本的に同じです。
package controllers
import (
"database/sql"
"time"
"github.com/goadesign/goa"
"github.com/m0a-mystudy/goa-chat/app"
"github.com/m0a-mystudy/goa-chat/models"
)
//
// コード省略
//
// List runs the list action.
func (c *MessageController) List(ctx *app.ListMessageContext) error {
res := app.MessageCollection{}
messages, err := models.MessagesByRoomID(c.db, ctx.RoomID)
if err != nil {
return err
}
for _, m := range messages {
res = append(res, ToMessageMedia(m))
}
return ctx.OK(res)
}
// Post runs the post action.
func (c *MessageController) Post(ctx *app.PostMessageContext) error {
m := models.Message{
RoomID: ctx.RoomID,
AccountID: ctx.Payload.AccountID,
Body: ctx.Payload.Body,
Postdate: time.Now(),
}
err := m.Insert(c.db)
if err != nil {
return ctx.BadRequest()
}
return ctx.Created(ToMessageMedia(&m))
}
xoもスキーマから読み取ってMessagesByRoomID
を作ってくれるのが素敵です。
WebScoket処理の実装
チャットなのでRoom毎に変更通知を受け取るようにします。
websocketのコネクションごとに作るチャネルをプールする実装を作ります
package controllers
import (
"context"
"github.com/goadesign/goa"
)
var (
loginfo = goa.LogInfo
logError = goa.LogError
)
type Comm chan struct{}
// WsConnections is connection pool websocket conn
type WsConnections struct {
connections map[int][]Comm
ctx context.Context
}
// NewConnections create WsConnections
func NewConnections(ctx context.Context) *WsConnections {
return &WsConnections{
connections: map[int][]Comm{},
ctx: ctx,
}
}
func (l *WsConnections) apendConn(roomID int, comm Comm) {
list := l.connections[roomID]
if list == nil {
list = []Comm{}
}
list = append(list, comm)
l.connections[roomID] = list
loginfo(l.ctx, "apendConn", "list", list)
}
func (l *WsConnections) removeConn(roomID int, comm Comm) {
list := l.connections[roomID]
if list == nil {
list = []Comm{}
}
newList := []Comm{}
for _, c := range list {
if c == comm {
continue
}
newList = append(newList, c)
}
l.connections[roomID] = newList
loginfo(l.ctx, "removeConn", "list", newList)
}
func (l *WsConnections) updateRoom(roomID int) {
loginfo(l.ctx, "updateRoom", "roomID", roomID)
comms, ok := l.connections[roomID]
if !ok {
return
}
loginfo(l.ctx, "updateRoom", "comms", comms)
for _, comm := range comms {
comm <- struct{}{}
}
}
dbと同じようcontrolerから管理します
// MessageController implements the message resource.
type MessageController struct {
*goa.Controller
db *sql.DB
+ connections *WsConnections
}
-func NewMessageController(service *goa.Service, db *sql.DB) *MessageController {
+func NewMessageController(service *goa.Service, db *sql.DB, wsc *WsConnections) *MessageController {
return &MessageController{
Controller: service.NewController("MessageController"),
db: db,
+ connections: wsc,
}
}
controllers/message.goとmain.goの修正は省略します。dbの時と同じなので
websocketによるコネクションが張られたら上記プールにチャネルを登録する処理を実装します
// Watch watches the message with the given id.
func (c *RoomController) Watch(ctx *app.WatchRoomContext) error {
Watcher(ctx.RoomID, c, ctx).ServeHTTP(ctx.ResponseWriter, ctx.Request)
return nil
}
// Roomの変更通知の送信
func Watcher(roomID int, c *RoomController, ctx *app.WatchRoomContext) websocket.Handler {
return func(ws *websocket.Conn) {
ch := make(chan struct{})
c.connections.apendConn(roomID, ch)
for {
<-ch
_, err := ws.Write([]byte(fmt.Sprintf("Room: %d", roomID)))
if err != nil {
break
}
}
c.connections.removeConn(roomID, ch)
}
}
メッセージがポストされるたびに通知を飛ばすように実装を追加します
// Post runs the post action.
func (c *MessageController) Post(ctx *app.PostMessageContext) error {
m := models.Message{
RoomID: ctx.RoomID,
AccountID: ctx.Payload.AccountID,
Body: ctx.Payload.Body,
Postdate: time.Now(),
}
err := m.Insert(c.db)
if err != nil {
//return err
return ctx.BadRequest()
}
+ c.connections.updateRoom(ctx.RoomID)
ctx.ResponseData.Header().Set("Location", app.MessageHref(ctx.RoomID, m.ID))
return ctx.Created()
}
クライアント側の実装
自分はtypeScriptが好きなのでreact+typescriptな環境を作ります。
swaggerからclient apiを作るための環境をインストールします
$ brew install swagger-codegen
Makefile等を変更します
all: depend clean generate generete-client build
clean:
@rm -rf app
@rm -rf client
@rm -rf tool
@rm -rf public/swagger
@rm -rf public/schema
@rm -rf public/js
@rm -f todo
bootstrap:
@goagen main -d github.com/m0a-mystudy/goa-chat/design -o controllers
generate:
@goagen app -d github.com/m0a-mystudy/goa-chat/design
@goagen swagger -d github.com/m0a-mystudy/goa-chat/design -o public
@goagen schema -d github.com/m0a-mystudy/goa-chat/design -o public
@goagen client -d github.com/m0a-mystudy/goa-chat/design
@goagen js -d github.com/m0a-mystudy/goa-chat/design -o public
generate-client:
@swagger-codegen generate -l typescript-fetch -i ./public/swagger/swagger.json -o ./chat-client-api
@jq -s '.[0] * .[1]' chat-client-api/package.json chat-client-api/package_replace.json > replaced_package.json
@rm chat-client-api/package.json
@mv replaced_package.json chat-client-api/package.json
build:
@go build -o chat
build-client:
@cd chat-client-api && yarn
@cd chat-client-api && npm link
run:
@chat
また Makefileと合わせて以下も追加します
{
"name": "chat-client-api",
"version": "0.0.1",
"license": "MIT"
}
上記修正後に
make generate-client
と make build-client
を実行してapiクライアントを自動生成し npm linkしておきます。
合わせて以下のコマンドでReact環境も作ります
$ create-react-app --scripts-version=react-scripts-ts goa-chat-client
$ cd goa-chat-client
$ npm link chat-client-api
$ yarn
$ yarn start
localhost:3000
でクライアントが立ち上がります。
以下実装内容の抜粋になります
import * as React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import * as comm from 'chat-client-api';
type ChatProps = RouteComponentProps<{ roomID: number }>;
interface ChatState {
messages: comm.MessageCollection;
text: string;
}
export default class Chat
extends React.Component<ChatProps, ChatState> {
private messageAPI: comm.MessageApi;
constructor(props: ChatProps) {
super(props);
this.state = {
messages: [] as comm.MessageCollection,
text: ''
};
this.messageAPI = new comm.MessageApi();
this.fetchMessages.bind(this);
this.onChangeText.bind(this);
this.postMessage.bind(this);
}
async fetchMessages() {
const roomID = this.props.match.params.roomID;
const messages = await this.messageAPI.messageList({ roomID });
this.setState({
messages
});
}
async postMessage() {
const accountID = 10;
const body = this.state.text;
const roomID = this.props.match.params.roomID;
const options = {
mode: 'cors',
// credentials: 'include',
headers: {
'content-Type': 'application/json',
'accept' : 'application/vnd.message+json'
}
} as {};
const payload = {
accountID,
body
} as comm.MessagePayload;
await this.messageAPI.messagePost({
roomID,
payload
}, options);
await this.fetchMessages();
}
async componentDidMount() {
await this.fetchMessages();
const roomID = this.props.match.params.roomID;
const wsURL = `ws://localhost:8080/api/rooms/${roomID}/watch`;
const ws = new WebSocket(wsURL);
ws.onmessage = async (ev) => {
await this.fetchMessages();
};
}
onChangeText(e: {target: { value: string}}) {
this.setState({ text: e.target.value });
}
render() {
const { messages, text } = this.state;
return (
<div>
{messages.map(message => {
return (
<div key={`postDate=${message.postDate}`} >
<p>id:{message.accountID}</p>
<p>{message.body}</p>
<p>postDate:{message.postDate}</p>
</div>
);
})}
<textarea value={text} onChange={e => (this.onChangeText(e))} />
<button onClick={() => (this.postMessage())}> submit </button>
</div>);
}
}
最後に
swagger.yamlが作られているので
https://editor.swagger.io/ から対応するcurlコマンドを作ってくれたり
typescriptのクライアントapiを自動生成してくれるのが素敵です。
とりあえずここまでのコードは以下においておきます。
https://github.com/m0a-mystudy/goa-chat