-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi.go
More file actions
349 lines (329 loc) · 10.6 KB
/
api.go
File metadata and controls
349 lines (329 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
// Copyright 2025 Blink Labs Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
"embed"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
ouroboros "github.com/blinklabs-io/gouroboros"
"github.com/blinklabs-io/gouroboros/protocol/localtxsubmission"
_ "github.com/blinklabs-io/tx-submit-api/docs" // docs is generated by Swag CLI
"github.com/blinklabs-io/tx-submit-api/internal/config"
"github.com/blinklabs-io/tx-submit-api/internal/logging"
"github.com/blinklabs-io/tx-submit-api/submit"
"github.com/fxamacker/cbor/v2"
cors "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/penglongli/gin-metrics/ginmetrics"
swaggerFiles "github.com/swaggo/files" // swagger embed files
ginSwagger "github.com/swaggo/gin-swagger" // gin-swagger middleware
)
//go:embed static
var staticFS embed.FS
// @title tx-submit-api
// @version v0
// @description Cardano Transaction Submit API
// @BasePath /
// @contact.name Blink Labs Software
// @contact.url https://blinklabs.io
// @contact.email support@blinklabs.io
//
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
func Start(cfg *config.Config) error {
// Standard logging
logger := logging.GetLogger()
if cfg.Tls.CertFilePath != "" && cfg.Tls.KeyFilePath != "" {
logger.Info(
"starting API TLS listener",
"address", cfg.Api.ListenAddress,
"port", cfg.Api.ListenPort,
)
} else {
logger.Info(
"starting API listener",
"address", cfg.Api.ListenAddress,
"port", cfg.Api.ListenPort,
)
}
// Disable gin debug and color output
gin.SetMode(gin.ReleaseMode)
gin.DisableConsoleColor()
// Configure API router
router := gin.New()
// Catch panics and return a 500
router.Use(gin.Recovery())
// Configure CORS
corsConfig := cors.DefaultConfig()
corsConfig.AllowAllOrigins = true
corsConfig.AllowHeaders = []string{
"hx-current-url",
"hx-request",
"hx-target",
"hx-trigger",
}
router.Use(cors.New(corsConfig))
// Access logging
accessLogger := logging.GetAccessLogger()
skipPaths := []string{}
if cfg.Logging.Healthchecks {
skipPaths = append(skipPaths, "/healthcheck")
logger.Info("disabling access logs for /healthcheck")
}
router.Use(logging.GinLogger(accessLogger, skipPaths))
router.Use(logging.GinRecovery(accessLogger, true))
// Configure static route
fsys, err := fs.Sub(staticFS, "static")
if err != nil {
return err
}
router.StaticFS("/ui", http.FS(fsys))
// Redirect from root
router.GET("/", func(c *gin.Context) {
c.Request.URL.Path = "/ui"
router.HandleContext(c)
})
// Create a healthcheck (before metrics so it's not instrumented)
router.GET("/healthcheck", handleHealthcheck)
// Create a swagger endpoint (not instrumented)
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// Metrics
metricsRouter := gin.New()
// Configure CORS
metricsRouter.Use(cors.New(corsConfig))
metrics := ginmetrics.GetMonitor()
// Set metrics path
metrics.SetMetricPath("/")
// Set metrics router
metrics.Expose(metricsRouter)
// Use metrics middleware without exposing path in main app router
metrics.SetMetricPath("/metrics")
metrics.Use(router)
// Custom metrics
failureMetric := &ginmetrics.Metric{
// This is a Gauge because input-output-hk's is a gauge
Type: ginmetrics.Gauge,
Name: "tx_submit_fail_count",
Description: "transactions failed",
Labels: nil,
}
submittedMetric := &ginmetrics.Metric{
// This is a Gauge because input-output-hk's is a gauge
Type: ginmetrics.Gauge,
Name: "tx_submit_count",
Description: "transactions submitted",
Labels: nil,
}
// Add to global monitor object
_ = ginmetrics.GetMonitor().AddMetric(failureMetric)
_ = ginmetrics.GetMonitor().AddMetric(submittedMetric)
// Initialize metrics
_ = ginmetrics.GetMonitor().
GetMetric("tx_submit_fail_count").
SetGaugeValue(nil, 0.0)
_ = ginmetrics.GetMonitor().
GetMetric("tx_submit_count").
SetGaugeValue(nil, 0.0)
// Start metrics listener
go func() {
// TODO: return error if we cannot initialize metrics
logger.Info("starting metrics listener",
"address", cfg.Metrics.ListenAddress,
"port", cfg.Metrics.ListenPort)
_ = metricsRouter.Run(fmt.Sprintf("%s:%d",
cfg.Metrics.ListenAddress,
cfg.Metrics.ListenPort))
}()
// Configure API routes
router.POST("/api/submit/tx", handleSubmitTx)
router.GET("/api/hastx/:tx_hash", handleHasTx)
// Start API listener
if cfg.Tls.CertFilePath != "" && cfg.Tls.KeyFilePath != "" {
return router.RunTLS(
fmt.Sprintf("%s:%d", cfg.Api.ListenAddress, cfg.Api.ListenPort),
cfg.Tls.CertFilePath,
cfg.Tls.KeyFilePath,
)
} else {
return router.Run(fmt.Sprintf("%s:%d",
cfg.Api.ListenAddress,
cfg.Api.ListenPort))
}
}
func handleHealthcheck(c *gin.Context) {
// TODO: add some actual health checking here
c.JSON(200, gin.H{"failed": false})
}
// Path parameters for GET requests
type TxHashPathParams struct {
TxHash string `uri:"tx_hash" binding:"required"` // Transaction hash
}
// handleHasTx godoc
//
// @Summary HasTx
// @Description Determine if a given transaction ID exists in the node mempool.
// @Produce json
// @Param tx_hash path string true "Transaction Hash"
// @Success 200 {object} string "Ok"
// @Failure 400 {object} string "Bad Request"
// @Failure 404 {object} string "Not Found"
// @Failure 415 {object} string "Unsupported Media Type"
// @Failure 500 {object} string "Server Error"
// @Router /api/hastx/{tx_hash} [get]
func handleHasTx(c *gin.Context) {
// First, initialize our configuration and loggers
cfg := config.GetConfig()
logger := logging.GetLogger()
var uriParams TxHashPathParams
if err := c.ShouldBindUri(&uriParams); err != nil {
logger.Error("failed to bind transaction hash from path", "err", err)
c.JSON(400, fmt.Sprintf("invalid transaction hash: %s", err))
return
}
txHash := uriParams.TxHash
// convert to cbor bytes
cborData, err := cbor.Marshal(txHash)
if err != nil {
logger.Error("failed to encode transaction hash to CBOR", "err", err)
c.JSON(
400,
fmt.Sprintf("failed to encode transaction hash to CBOR: %s", err),
)
return
}
// Connect to cardano-node and check for transaction
oConn, err := ouroboros.NewConnection(
ouroboros.WithNetworkMagic(uint32(cfg.Node.NetworkMagic)),
ouroboros.WithNodeToNode(false),
)
if err != nil {
logger.Error("failure creating Ouroboros connection", "err", err)
c.JSON(500, "failure communicating with node")
return
}
if cfg.Node.Address != "" && cfg.Node.Port > 0 {
if err := oConn.Dial("tcp", fmt.Sprintf("%s:%d", cfg.Node.Address, cfg.Node.Port)); err != nil {
logger.Error("failure connecting to node via TCP", "err", err)
c.JSON(500, "failure communicating with node")
return
}
} else {
if err := oConn.Dial("unix", cfg.Node.SocketPath); err != nil {
logger.Error("failure connecting to node via UNIX socket", "err", err)
c.JSON(500, "failure communicating with node")
return
}
}
defer func() {
// Close Ouroboros connection
oConn.Close()
}()
hasTx, err := oConn.LocalTxMonitor().Client.HasTx(cborData)
if err != nil {
logger.Error("failure getting transaction", "err", err)
c.JSON(500, fmt.Sprintf("failure getting transaction: %s", err))
return
}
if !hasTx {
c.JSON(404, "transaction not found in mempool")
return
}
c.JSON(200, "transaction found in mempool")
}
// handleSubmitTx godoc
//
// @Summary Submit Tx
// @Description Submit an already serialized transaction to the network.
// @Produce json
// @Param Content-Type header string true "Content type" Enums(application/cbor)
// @Success 202 {object} string "Ok"
// @Failure 400 {object} string "Bad Request"
// @Failure 415 {object} string "Unsupported Media Type"
// @Failure 500 {object} string "Server Error"
// @Router /api/submit/tx [post]
func handleSubmitTx(c *gin.Context) {
// First, initialize our configuration and loggers
cfg := config.GetConfig()
logger := logging.GetLogger()
// Check our headers for content-type
if c.ContentType() != "application/cbor" {
// Log the error, return an error to the user, and increment failed count
logger.Error("invalid request body, should be application/cbor")
c.JSON(415, "invalid request body, should be application/cbor")
_ = ginmetrics.GetMonitor().GetMetric("tx_submit_fail_count").Inc(nil)
return
}
// Read raw transaction bytes from the request body and store in a byte array
txRawBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
// Log the error, return an error to the user, and increment failed count
logger.Error("failed to read request body", "err", err)
c.JSON(500, "failed to read request body")
_ = ginmetrics.GetMonitor().GetMetric("tx_submit_fail_count").Inc(nil)
return
}
// Close request body after read
if c.Request.Body != nil {
if err := c.Request.Body.Close(); err != nil {
logger.Error("failed to close request body", "err", err)
}
}
// Send TX
errorChan := make(chan error)
submitConfig := &submit.Config{
ErrorChan: errorChan,
NetworkMagic: cfg.Node.NetworkMagic,
NodeAddress: cfg.Node.Address,
NodePort: cfg.Node.Port,
SocketPath: cfg.Node.SocketPath,
Timeout: cfg.Node.Timeout,
}
txHash, err := submit.SubmitTx(submitConfig, txRawBytes)
if err != nil {
if c.GetHeader("Accept") == "application/cbor" {
var txRejectErr *localtxsubmission.TransactionRejectedError
if errors.As(err, &txRejectErr) {
c.Data(400, "application/cbor", txRejectErr.ReasonCbor)
} else {
c.Data(500, "application/cbor", []byte{})
}
} else {
if err.Error() != "" {
c.JSON(400, err.Error())
} else {
c.JSON(400, fmt.Sprintf("%s", err))
}
}
_ = ginmetrics.GetMonitor().GetMetric("tx_submit_fail_count").Inc(nil)
return
}
// Start async error handler
go func() {
err, ok := <-errorChan
if ok {
logger.Error("failure communicating with node", "err", err)
c.JSON(500, "failure communicating with node")
_ = ginmetrics.GetMonitor().
GetMetric("tx_submit_fail_count").
Inc(nil)
}
}()
// Return transaction ID
c.JSON(202, txHash)
// Increment custom metric
_ = ginmetrics.GetMonitor().GetMetric("tx_submit_count").Inc(nil)
}