clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / internal / app / data.go
data.go 12.3K
1package app23import (4 "encoding/base64"5 "encoding/json"6 "fmt"7 "log/slog"8 "math"9 "net/http"10 "sort"11 "strconv"12 "strings"1314 "github.com/FelineStateMachine/atlas/format/vnext"15 "github.com/FelineStateMachine/atlas/internal/app/hostenv"16 "github.com/FelineStateMachine/atlas/internal/logging"17)1819// The native vNext data plane. app_test.go holds schema, typed tables,20// content-addressed assets and the absence of the retired projections to it.2122// BasePath is the URL prefix volume content is served under. It appears in the23// composed catalog so nothing has to assemble a content URL from parts it had24// to guess.25const BasePath = "/data/v"2627// contentTypes names the content types the bundle layout can hold, and is the28// gate on what may be served at all: an entry whose extension is not named29// here is not served, whatever it is. Responses carry nosniff, so a response30// must say what it is or a browser drops it.31var contentTypes = map[string]string{32 ".json": "application/json",33 ".text": "application/json",34 ".bin": "application/octet-stream",35 ".jpg": "image/jpeg",36 ".png": "image/png",37 ".pack": "application/vnd.atlas.table",38 ".webp": "image/webp",39 ".svg": "image/svg+xml",40}4142// catalogVolume is one volume as the composed catalog lists it. The base URL43// carries the version stamp, so everything beneath it may be cached as44// immutable and a new build of the volume arrives at new URLs.45//46// The field order and the tags are the wire, and the wire is pinned by47// TestCatalogComposition and TestCatalogCarriesTheBuildAndItsGrid.48type catalogVolume struct {49 Slug string `json:"slug"`50 Title string `json:"title"`51 Stamp string `json:"stamp"`52 Base string `json:"base"`53 TileGrid hostenv.TileGrid `json:"tileGrid"`54 Worlds []hostenv.WorldInfo `json:"worlds"`55}5657// catalogDoc is the whole catalog. BundlesDir rides along so an empty library58// can tell the reader where a bundle goes, in the words of their own machine;59// it is whatever the store calls its location and the handler never reads it.60type catalogDoc struct {61 Volumes []catalogVolume `json:"volumes"`62 BundlesDir string `json:"bundlesDir,omitempty"`63}6465// handleCatalog answers with the library as it stands. It is composed per66// request and never cached: it is the one response whose whole job is to be67// current.68func (a *App) handleCatalog(w http.ResponseWriter, r *http.Request) {69 body, err := composeCatalog(a.library().order, a.env.Volumes().Location())70 if err != nil {71 // Nothing in a validated manifest can fail to marshal. If something72 // does, an empty catalog is a saner face than a panic.73 slog.Error("composing the catalog", logging.Op("catalog"), slog.Any("error", err))74 body = []byte(`{"volumes":[]}`)75 }76 w.Header().Set("Content-Type", "application/json")77 w.Header().Set("Cache-Control", "no-store")78 _, _ = w.Write(body)79}8081// composeCatalog folds the serving volumes into the catalog document.82// Volumes are listed by title, which is the order a reader meets them in.83func composeCatalog(volumes []hostenv.Volume, location string) ([]byte, error) {84 listed := make([]catalogVolume, 0, len(volumes))85 for _, volume := range volumes {86 info := volume.Info()87 listed = append(listed, catalogVolume{88 Slug: info.Slug, Title: info.Title, Stamp: info.Stamp,89 Base: volumeBase(info), TileGrid: info.TileGrid, Worlds: info.Worlds,90 })91 }92 sort.Slice(listed, func(i, j int) bool { return listed[i].Title < listed[j].Title })93 return json.Marshal(catalogDoc{Volumes: listed, BundlesDir: location})94}9596// volumeBase is where one build's content is served from.97func volumeBase(info hostenv.VolumeInfo) string {98 return BasePath + "/" + info.Slug + "/" + vnext.ShortStamp(info.Stamp)99}100101// handleContent serves one entry out of one build.102//103// The stamp in the URL is the whole cache story: it names exactly one build of104// one volume, so the answer may be cached forever, and the moment a newer105// build takes the slug over the old URL is gone -- 404, which is the client's106// cue to refetch the catalog.107//108// Byte ranges are deliberately not served. Tiles are stored uncompressed109// precisely so that they could be, and issue #5 ยง2 describes them that way,110// but the data plane's published behavior is plainer: a Range request is111// answered 200 with the whole body and no Accept-Ranges, and112// TestContentDoesNotServeRanges asserts exactly that exchange. Answering 206113// would be a better data plane and a different one; it is a change to make114// deliberately, in the open, once something wants it.115func (a *App) handleContent(w http.ResponseWriter, r *http.Request) {116 held, ok := a.library().bySlug[r.PathValue("slug")]117 if !ok {118 http.NotFound(w, r)119 return120 }121 info := held.Info()122 if r.PathValue("stamp") != vnext.ShortStamp(info.Stamp) {123 http.NotFound(w, r)124 return125 }126127 rest := r.PathValue("rest")128 if rest == "outline.json" {129 w.Header().Set("X-Atlas-Feature-Partitions", "0")130 serveJSON(w, outlineResponse(held.Outline()))131 return132 }133 if strings.HasPrefix(rest, "features/") && strings.HasSuffix(rest, ".json") {134 a.handleFeaturePage(w, r, held, strings.TrimSuffix(strings.TrimPrefix(rest, "features/"), ".json"))135 return136 }137 if rest == "schema.json" {138 data, err := held.Schema()139 if err != nil {140 http.NotFound(w, r)141 return142 }143 serveContent(w, data, "application/schema+json")144 return145 }146 if strings.HasPrefix(rest, "data/") && strings.HasSuffix(rest, ".pack") {147 data, err := held.TableBlock(rest)148 if err != nil {149 http.NotFound(w, r)150 return151 }152 serveContent(w, data, contentTypes[".pack"])153 return154 }155 if strings.HasPrefix(rest, "worlds/") || strings.HasPrefix(rest, "icons/") {156 http.NotFound(w, r)157 return158 }159 if tile, err := held.RasterTile(rest); err == nil {160 w.Header().Set("X-Atlas-Raster-Ranges", "1")161 w.Header().Set("X-Atlas-Raster-Bytes", strconv.Itoa(len(tile.Data)))162 serveContent(w, tile.Data, tile.MediaType)163 return164 }165166 data, err := held.Blob(rest)167 if err != nil {168 http.NotFound(w, r)169 return170 }171 kind := ""172 if dot := strings.LastIndexByte(rest, '.'); dot >= 0 {173 kind = contentTypes[rest[dot:]]174 }175 if kind == "" {176 for _, asset := range held.Outline().Assets {177 if asset.Path == rest {178 kind = asset.MediaType179 break180 }181 }182 }183 if kind == "" {184 kind = http.DetectContentType(data)185 }186 w.Header().Set("Content-Type", kind)187 w.Header().Set("Content-Length", strconv.Itoa(len(data)))188 w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")189 if _, err := w.Write(data); err != nil {190 // The response is already on the wire; there is nothing to say to the191 // client, and a reader who navigated away is the usual cause.192 slog.Debug("content cut short", logging.Op("serve"),193 logging.Volume(info.Slug), logging.Path(rest), slog.Any("error", err))194 }195}196197func (a *App) handleFeaturePage(w http.ResponseWriter, r *http.Request, volume hostenv.Volume, set string) {198 limit := 1_000199 if raw := r.URL.Query().Get("limit"); raw != "" {200 parsed, err := strconv.Atoi(raw)201 if err != nil {202 http.Error(w, "feature page limit is invalid", http.StatusBadRequest)203 return204 }205 limit = parsed206 }207 bounds, err := featureBounds(r)208 if err != nil {209 http.Error(w, err.Error(), http.StatusBadRequest)210 return211 }212 page, err := volume.FeaturePage(vnext.FeaturePageRequest{213 FeatureSet: set, Bounds: bounds, After: r.URL.Query().Get("after"), Limit: limit,214 })215 if err != nil {216 http.Error(w, "feature page could not be read", http.StatusBadRequest)217 return218 }219 w.Header().Set("X-Atlas-Feature-Partitions", strconv.Itoa(page.PartitionsRead))220 w.Header().Set("X-Atlas-Feature-Bytes", strconv.FormatInt(page.BytesRead, 10))221 serveJSON(w, featurePageResponse(page))222}223224type featurePageWire struct {225 Features []featureWire `json:"features"`226 Next string `json:"next,omitempty"`227}228229type featureWire struct {230 ID string `json:"id"`231 Title string `json:"title"`232 Subtitle string `json:"subtitle,omitempty"`233 Description string `json:"description,omitempty"`234 Center *vnext.Position `json:"center,omitempty"`235 Shard string `json:"shard"`236 Geometry geometryWire `json:"geometry"`237 Properties []propertyWire `json:"properties,omitempty"`238 Relationships []relationshipWire `json:"relationships,omitempty"`239 Provenance []provenanceWire `json:"provenance,omitempty"`240}241242type geometryWire struct {243 Kind vnext.GeometryKind `json:"kind"`244 Parts []geometryPartWire `json:"parts"`245}246247type geometryPartWire struct {248 Rings [][]vnext.Position `json:"rings"`249}250251type propertyWire struct {252 ID string `json:"id"`253 Name string `json:"name"`254 Kind vnext.Kind `json:"kind"`255 Value interface{} `json:"value"`256}257258type relationshipWire struct {259 Predicate string `json:"predicate"`260 Target string `json:"target"`261}262263type provenanceWire struct {264 Source string `json:"source"`265 NativeID string `json:"nativeId"`266 CapturedAt string `json:"capturedAt"`267}268269func featurePageResponse(page vnext.FeaturePageResult) featurePageWire {270 out := featurePageWire{Next: page.Next, Features: make([]featureWire, 0, len(page.Features))}271 for _, feature := range page.Features {272 out.Features = append(out.Features, featureResponse(feature))273 }274 return out275}276277func featureResponse(feature vnext.Feature) featureWire {278 out := featureWire{279 ID: feature.ID, Title: feature.Title, Subtitle: feature.Subtitle, Description: feature.Description,280 Center: feature.Center, Shard: strconv.FormatInt(feature.Shard, 10),281 Geometry: geometryWire{Kind: feature.Geometry.Kind, Parts: make([]geometryPartWire, 0, len(feature.Geometry.Parts))},282 Properties: make([]propertyWire, 0, len(feature.Properties)),283 Relationships: make([]relationshipWire, 0, len(feature.Relationships)),284 Provenance: make([]provenanceWire, 0, len(feature.Provenance)),285 }286 for _, part := range feature.Geometry.Parts {287 out.Geometry.Parts = append(out.Geometry.Parts, geometryPartWire{Rings: part.Rings})288 }289 for _, property := range feature.Properties {290 out.Properties = append(out.Properties, propertyResponse(property))291 }292 for _, edge := range feature.Relationships {293 out.Relationships = append(out.Relationships, relationshipWire{Predicate: edge.Predicate, Target: edge.Target})294 }295 for _, evidence := range feature.Provenance {296 out.Provenance = append(out.Provenance, provenanceWire{297 Source: evidence.Source, NativeID: evidence.NativeID, CapturedAt: evidence.CapturedAt,298 })299 }300 return out301}302303func propertyResponse(property vnext.Property) propertyWire {304 id := property.FieldID305 if id == (vnext.ID{}) {306 id = property.Field.ID307 }308 out := propertyWire{ID: id.String(), Name: property.Field.Name, Kind: property.Value.Kind}309 switch property.Value.Kind {310 case vnext.KindBool:311 out.Value = property.Value.Bool312 case vnext.KindInt64:313 out.Value = strconv.FormatInt(property.Value.Int64, 10)314 case vnext.KindFloat64:315 out.Value = property.Value.Float64316 case vnext.KindString:317 out.Value = property.Value.String318 case vnext.KindBytes:319 out.Value = base64.StdEncoding.EncodeToString(property.Value.Bytes)320 case vnext.KindID:321 out.Value = property.Value.ID.String()322 default:323 out.Value = nil324 }325 return out326}327328func featureBounds(r *http.Request) (*vnext.SpatialBounds, error) {329 names := []string{"minX", "minY", "maxX", "maxY"}330 values := make([]float64, len(names))331 present := 0332 for index, name := range names {333 raw := r.URL.Query().Get(name)334 if raw == "" {335 continue336 }337 value, err := strconv.ParseFloat(raw, 64)338 if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {339 return nil, fmt.Errorf("feature bounds are invalid")340 }341 values[index] = value342 present++343 }344 if present == 0 {345 return nil, nil346 }347 if present != len(names) || values[0] > values[2] || values[1] > values[3] {348 return nil, fmt.Errorf("feature bounds are incomplete or inverted")349 }350 return &vnext.SpatialBounds{MinX: values[0], MinY: values[1], MaxX: values[2], MaxY: values[3]}, nil351}352353func serveJSON(w http.ResponseWriter, value any) {354 data, err := json.Marshal(value)355 if err != nil {356 http.Error(w, "native data could not be encoded", http.StatusInternalServerError)357 return358 }359 serveContent(w, data, "application/json")360}361362func serveContent(w http.ResponseWriter, data []byte, kind string) {363 w.Header().Set("Content-Type", kind)364 w.Header().Set("Content-Length", strconv.Itoa(len(data)))365 w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")366 _, _ = w.Write(data)367}