211 lines
5.5 KiB
Go
211 lines
5.5 KiB
Go
// Package research serves the shared, public paper corpus. Account data and
|
|
// private collections do not belong to these anonymous read-only endpoints.
|
|
package research
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
type Source struct {
|
|
Label string `json:"label"`
|
|
URL string `json:"url"`
|
|
ExternalID string `json:"externalId"`
|
|
}
|
|
|
|
type Publication struct {
|
|
Venue string `json:"venue"`
|
|
Year int `json:"year"`
|
|
Track string `json:"track"`
|
|
Status string `json:"status"`
|
|
EvidenceURL string `json:"evidenceUrl,omitempty"`
|
|
}
|
|
|
|
type Paper struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Abstract *string `json:"abstract"`
|
|
Authors []string `json:"authors"`
|
|
PublishedAt *string `json:"publishedAt"`
|
|
Topics []string `json:"topics"`
|
|
Sources []Source `json:"sources"`
|
|
Publications []Publication `json:"publications,omitempty"`
|
|
DOI *string `json:"doi,omitempty"`
|
|
Journal *string `json:"journal,omitempty"`
|
|
Comments *string `json:"comments,omitempty"`
|
|
}
|
|
|
|
type Filters struct {
|
|
Query string
|
|
SearchIn string
|
|
Topic string
|
|
Sort string
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
type Page struct {
|
|
Items []Paper `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Topics []string `json:"topics"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pageSize"`
|
|
}
|
|
|
|
// Repository implementations must use a read-only transaction for each List,
|
|
// so its count, topics and page describe the same database snapshot.
|
|
type Repository interface {
|
|
List(context.Context, Filters) (Page, error)
|
|
Get(context.Context, int64) (Paper, error)
|
|
}
|
|
|
|
type service struct {
|
|
repository Repository
|
|
projects map[string]string
|
|
slots chan struct{}
|
|
timeout time.Duration
|
|
}
|
|
|
|
// Register adds only GET endpoints; no credentials or database errors are sent
|
|
// to anonymous callers. A nil repository leaves the existing server unchanged.
|
|
func Register(mux *http.ServeMux, repository Repository, projects map[string]string) {
|
|
if repository == nil {
|
|
return
|
|
}
|
|
s := &service{repository: repository, projects: projects, slots: make(chan struct{}, 2), timeout: 8 * time.Second}
|
|
mux.HandleFunc("GET /projects/{project}/research/papers", s.serve)
|
|
mux.HandleFunc("GET /projects/{project}/research/papers/{id}", s.serve)
|
|
}
|
|
|
|
func reply(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
func reject(w http.ResponseWriter, status int, code string) {
|
|
reply(w, status, map[string]string{"error": code})
|
|
}
|
|
|
|
var topicPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9.-]{0,79}$`)
|
|
var positiveInteger = regexp.MustCompile(`^[1-9][0-9]*$`)
|
|
|
|
func parseFilters(raw string) (Filters, error) {
|
|
f := Filters{SearchIn: "text", Sort: "recent", Page: 1, PageSize: 8}
|
|
values, err := url.ParseQuery(raw)
|
|
if err != nil {
|
|
return f, err
|
|
}
|
|
invalid := errors.New("invalid query")
|
|
for key, value := range values {
|
|
if len(value) != 1 {
|
|
return f, invalid
|
|
}
|
|
switch key {
|
|
case "q":
|
|
if !utf8.ValidString(value[0]) || utf8.RuneCountInString(value[0]) > 200 || len(strings.Fields(value[0])) > 20 || strings.ContainsRune(value[0], 0) {
|
|
return f, invalid
|
|
}
|
|
f.Query = strings.TrimSpace(value[0])
|
|
case "searchIn":
|
|
if value[0] != "text" && value[0] != "author" {
|
|
return f, invalid
|
|
}
|
|
f.SearchIn = value[0]
|
|
case "topic":
|
|
if value[0] != "" && !topicPattern.MatchString(value[0]) {
|
|
return f, invalid
|
|
}
|
|
f.Topic = value[0]
|
|
case "sort":
|
|
if value[0] != "recent" && value[0] != "title" {
|
|
return f, invalid
|
|
}
|
|
f.Sort = value[0]
|
|
case "page", "pageSize":
|
|
if !positiveInteger.MatchString(value[0]) {
|
|
return f, invalid
|
|
}
|
|
n, err := strconv.Atoi(value[0])
|
|
limit := 1000
|
|
if key == "pageSize" {
|
|
limit = 50
|
|
}
|
|
if err != nil || n > limit {
|
|
return f, invalid
|
|
}
|
|
if key == "page" {
|
|
f.Page = n
|
|
} else {
|
|
f.PageSize = n
|
|
}
|
|
default:
|
|
return f, invalid
|
|
}
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func (s *service) serve(w http.ResponseWriter, r *http.Request) {
|
|
if _, exists := s.projects[r.PathValue("project")]; !exists {
|
|
reject(w, 404, "not_found")
|
|
return
|
|
}
|
|
var filters Filters
|
|
var id int64
|
|
var err error
|
|
if value := r.PathValue("id"); value != "" {
|
|
if !positiveInteger.MatchString(value) || r.URL.RawQuery != "" {
|
|
reject(w, 400, "invalid_request")
|
|
return
|
|
}
|
|
id, err = strconv.ParseInt(value, 10, 64)
|
|
} else {
|
|
filters, err = parseFilters(r.URL.RawQuery)
|
|
}
|
|
if err != nil {
|
|
reject(w, 400, "invalid_request")
|
|
return
|
|
}
|
|
select {
|
|
case s.slots <- struct{}{}:
|
|
defer func() { <-s.slots }()
|
|
default:
|
|
w.Header().Set("Retry-After", "1")
|
|
reject(w, 429, "research_busy")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), s.timeout)
|
|
defer cancel()
|
|
var result any
|
|
if id != 0 {
|
|
result, err = s.repository.Get(ctx, id)
|
|
} else {
|
|
result, err = s.repository.List(ctx, filters)
|
|
}
|
|
if err == nil {
|
|
reply(w, 200, result)
|
|
return
|
|
}
|
|
var state interface{ SQLState() string }
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
reject(w, 404, "not_found")
|
|
case errors.Is(ctx.Err(), context.DeadlineExceeded), errors.Is(err, context.DeadlineExceeded), errors.As(err, &state) && state.SQLState() == "57014":
|
|
reject(w, 504, "research_timeout")
|
|
default:
|
|
reject(w, 503, "research_unavailable")
|
|
}
|
|
}
|