1// Package authoring builds native Atlas volumes from portable project manifests.2//3// A project is configuration only. Captured evidence lives in a shared cache and4// completed .atlas files live in the library; neither is copied beside the5// manifest. This keeps one small file as the reproducible statement of intent.6package authoring78import (9 "errors"10 "fmt"11 "io"12 "math"13 "os"14 "path/filepath"15 "strings"1617 "github.com/FelineStateMachine/atlas/format/vnext"18 "gopkg.in/yaml.v3"19)2021const (22 ProjectSchema = "atlas-project/v2"23 ProjectExtension = ".atlas-project"24)2526// Project is the complete durable input to one Atlas build.27type Project struct {28 Schema string `yaml:"schema"`29 SchemaNamespace string `yaml:"schema-namespace"`30 ID string `yaml:"id"`31 Title string `yaml:"title"`32 Target Target `yaml:"target"`33 FeatureSets []FeatureSetContract `yaml:"feature-sets,omitempty"`34 Sources []Source `yaml:"sources,omitempty"`35 Rasters []Raster `yaml:"rasters,omitempty"`36 Assets []Asset `yaml:"assets,omitempty"`37 Presentation Presentation `yaml:"presentation"`38 Release Release `yaml:"release,omitempty"`39 Budgets BuildBudgets `yaml:"budgets,omitempty"`4041 baseDir string42}4344// Target states the one world and coordinate space every configured source45// must speak before semantic assembly begins.46type Target struct {47 World string `yaml:"world"`48 Title string `yaml:"title"`49 CoordinateSpace CoordinateSpace `yaml:"coordinate-space"`50}5152type CoordinateSpace struct {53 ID string `yaml:"id"`54 Kind string `yaml:"kind"`55 Unit string `yaml:"unit"`56 Definition string `yaml:"definition"`57 Extent [4]float64 `yaml:"extent"`58 SourceZoom int64 `yaml:"source-zoom,omitempty"`59 OriginX int64 `yaml:"origin-x,omitempty"`60 OriginY int64 `yaml:"origin-y,omitempty"`61 // FirstTile is accepted only for legacy local projects while their62 // producers migrate to independent column and row origins.63 FirstTile int64 `yaml:"first-tile,omitempty"`64 TileSize int64 `yaml:"tile-size,omitempty"`65 Size int64 `yaml:"size,omitempty"`66}6768// Source is one configured instance of a reusable feature adapter.69type Source struct {70 ID string `yaml:"id"`71 Adapter string `yaml:"adapter"`72 Locator string `yaml:"locator"`73 MediaType string `yaml:"media-type,omitempty"`74 License string `yaml:"license,omitempty"`75 Attribution string `yaml:"attribution,omitempty"`76 EstimateBytes int64 `yaml:"estimate-bytes,omitempty"`77 Query Query `yaml:"query,omitempty"`78 Mapping Mapping `yaml:"mapping"`79}8081// Query carries source-selection knobs interpreted only by the selected82// adapter. Adapter-specific transport details never enter Atlas semantics.83type Query struct {84 Layer int `yaml:"layer,omitempty"`85 Collection string `yaml:"collection,omitempty"`86 Where string `yaml:"where,omitempty"`87 Limit int `yaml:"limit,omitempty"`88 ObjectID string `yaml:"object-id,omitempty"`89 SpatialReference int `yaml:"spatial-reference,omitempty"`90}9192// FeatureSetContract is the source-independent semantic and geometry contract93// populated by every mapping that targets its ID.94type FeatureSetContract struct {95 ID string `yaml:"id"`96 Title string `yaml:"title"`97 SemanticType string `yaml:"semantic-type"`98 Geometry string `yaml:"geometry"`99 Properties []PropertyContract `yaml:"properties,omitempty"`100 Relationships []RelationshipContract `yaml:"relationships,omitempty"`101}102103type PropertyContract struct {104 ID string `yaml:"id"`105 Name string `yaml:"name"`106 Type string `yaml:"type"`107 Optional bool `yaml:"optional,omitempty"`108}109110type RelationshipContract struct {111 ID string `yaml:"id"`112 Predicate string `yaml:"predicate"`113 FeatureSet string `yaml:"feature-set"`114 Optional bool `yaml:"optional,omitempty"`115}116117// Mapping binds source paths to one declared FeatureSet contract. Sources that118// name the same feature-set and canonical identity meet during assembly.119type Mapping struct {120 FeatureSet string `yaml:"feature-set"`121 Identity string `yaml:"identity"`122 FeatureTitle string `yaml:"feature-title"`123 Geometry GeometryMap `yaml:"geometry"`124 Properties []PropertyMap `yaml:"properties,omitempty"`125 Relations []RelationMap `yaml:"relationships,omitempty"`126}127128// GeometryMap is the source-to-world geometry contract for one FeatureSet.129// Atlas intentionally executes only explicit identity and affine transforms;130// a CRS label alone never implies executable projection behavior.131type GeometryMap struct {132 Family string `yaml:"-" json:"-"`133 SourceSpace string `yaml:"source-space"`134 Transform CoordinateTransform `yaml:"transform"`135}136137type CoordinateTransform struct {138 Kind string `yaml:"kind"`139 Matrix [6]float64 `yaml:"matrix,omitempty"`140}141142type PropertyMap struct {143 Field string `yaml:"field"`144 Source string `yaml:"source"`145}146147type RelationMap struct {148 Relationship string `yaml:"relationship"`149 Target string `yaml:"target"`150}151152// Raster is a configured raster adapter. A file is deterministically expanded153// through MaxZoom; an xyz source names explicit tile windows so planning never154// probes the network.155type Raster struct {156 ID string `yaml:"id"`157 Name string `yaml:"name"`158 Adapter string `yaml:"adapter"`159 Locator string `yaml:"locator"`160 MediaType string `yaml:"media-type,omitempty"`161 License string `yaml:"license,omitempty"`162 Attribution string `yaml:"attribution,omitempty"`163 EstimateBytes int64 `yaml:"estimate-bytes,omitempty"`164 TileSize int64 `yaml:"tile-size"`165 SourceZoom int64 `yaml:"source-zoom,omitempty"`166 MaxZoom int64 `yaml:"max-zoom,omitempty"`167 FullZoom int64 `yaml:"full-zoom,omitempty"`168 Interpolate bool `yaml:"interpolate,omitempty"`169 Background string `yaml:"background,omitempty"`170 Bounds *RasterRect `yaml:"bounds,omitempty"`171 Surface *RasterRect `yaml:"surface,omitempty"`172 Shard int64 `yaml:"shard,omitempty"`173 Levels []RasterLevel `yaml:"levels,omitempty"`174}175176type RasterRect struct {177 X float64 `yaml:"x"`178 Y float64 `yaml:"y"`179 Width float64 `yaml:"width"`180 Height float64 `yaml:"height"`181}182183type RasterLevel struct {184 Zoom int64 `yaml:"zoom"`185 MinX int64 `yaml:"min-x"`186 MinY int64 `yaml:"min-y"`187 MaxX int64 `yaml:"max-x"`188 MaxY int64 `yaml:"max-y"`189}190191type Presentation struct {192 ID string `yaml:"id"`193 Title string `yaml:"title"`194 Styles []Style `yaml:"styles,omitempty"`195 Layers []Layer `yaml:"layers,omitempty"`196}197198// Asset is authored supporting content packed into the immutable Atlas file.199type Asset struct {200 ID string `yaml:"id"`201 Locator string `yaml:"locator"`202 MediaType string `yaml:"media-type"`203 Provenance string `yaml:"provenance,omitempty"`204 License string `yaml:"license,omitempty"`205 Attribution string `yaml:"attribution,omitempty"`206 EstimateBytes int64 `yaml:"estimate-bytes,omitempty"`207}208209type Style struct {210 ID string `yaml:"id"`211 Symbol string `yaml:"symbol,omitempty"`212 Icon string `yaml:"icon,omitempty"`213 IconAsset string `yaml:"icon-asset,omitempty"`214 IconPicture bool `yaml:"icon-picture,omitempty"`215 RenderAs string `yaml:"render-as,omitempty"`216 Stroke string `yaml:"stroke,omitempty"`217 Fill string `yaml:"fill,omitempty"`218}219220type Layer struct {221 ID string `yaml:"id"`222 FeatureSet string `yaml:"feature-set"`223 Style string `yaml:"style"`224 Label string `yaml:"label"`225 Group string `yaml:"group,omitempty"`226 LabelPolicy string `yaml:"label-policy,omitempty"`227 Visible bool `yaml:"visible"`228 Order int64 `yaml:"order"`229 MinZoom int64 `yaml:"min-zoom,omitempty"`230 MaxZoom int64 `yaml:"max-zoom,omitempty"`231}232233type Release struct {234 Revision int `yaml:"revision,omitempty"`235}236237// BuildBudgets are reproducible refusal limits. Zero selects the stable238// compiler default; a manifest can choose a smaller or deliberately larger239// positive value, but evidence is never silently truncated.240type BuildBudgets struct {241 RequestBytes int64 `yaml:"request-bytes,omitempty" json:"requestBytes"`242 TotalBytes int64 `yaml:"total-bytes,omitempty" json:"totalBytes"`243 Requests int `yaml:"requests,omitempty" json:"requests"`244 RasterTiles int64 `yaml:"raster-tiles,omitempty" json:"rasterTiles"`245 RasterPixels int64 `yaml:"raster-pixels,omitempty" json:"rasterPixels"`246 Features int64 `yaml:"features,omitempty" json:"features"`247 GeometryPositions int64 `yaml:"geometry-positions,omitempty" json:"geometryPositions"`248}249250var defaultBuildBudgets = BuildBudgets{251 RequestBytes: 512 << 20,252 TotalBytes: 8 << 30,253 Requests: 100_000,254 RasterTiles: 90_000,255 RasterPixels: 16_000_000_000,256 Features: 2_000_000,257 GeometryPositions: 50_000_000,258}259260func (budgets BuildBudgets) resolved() (BuildBudgets, error) {261 if budgets.RequestBytes < 0 || budgets.TotalBytes < 0 || budgets.Requests < 0 || budgets.RasterTiles < 0 || budgets.RasterPixels < 0 || budgets.Features < 0 || budgets.GeometryPositions < 0 {262 return BuildBudgets{}, fmt.Errorf("build budgets must be positive")263 }264 resolved := budgets265 if resolved.RequestBytes == 0 {266 resolved.RequestBytes = defaultBuildBudgets.RequestBytes267 }268 if resolved.TotalBytes == 0 {269 resolved.TotalBytes = defaultBuildBudgets.TotalBytes270 }271 if resolved.Requests == 0 {272 resolved.Requests = defaultBuildBudgets.Requests273 }274 if resolved.RasterTiles == 0 {275 resolved.RasterTiles = defaultBuildBudgets.RasterTiles276 }277 if resolved.RasterPixels == 0 {278 resolved.RasterPixels = defaultBuildBudgets.RasterPixels279 }280 if resolved.Features == 0 {281 resolved.Features = defaultBuildBudgets.Features282 }283 if resolved.GeometryPositions == 0 {284 resolved.GeometryPositions = defaultBuildBudgets.GeometryPositions285 }286 if resolved.RequestBytes > resolved.TotalBytes {287 return BuildBudgets{}, fmt.Errorf("request byte budget %d exceeds total byte budget %d", resolved.RequestBytes, resolved.TotalBytes)288 }289 return resolved, nil290}291292// LoadProject strictly decodes one manifest. Unknown fields fail so a typo293// cannot silently remove a source, mapping, or layer from a build.294func LoadProject(path string) (Project, error) {295 if filepath.Ext(path) != ProjectExtension {296 return Project{}, fmt.Errorf("project %s must use %s", filepath.Base(path), ProjectExtension)297 }298 file, err := os.Open(path)299 if err != nil {300 return Project{}, fmt.Errorf("open project: %w", err)301 }302 defer file.Close()303 decoder := yaml.NewDecoder(file)304 decoder.KnownFields(true)305 var project Project306 if err := decoder.Decode(&project); err != nil {307 return Project{}, fmt.Errorf("decode project: %w", err)308 }309 var extra any310 if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {311 if err == nil {312 return Project{}, fmt.Errorf("project carries more than one YAML document")313 }314 return Project{}, fmt.Errorf("decode project trailer: %w", err)315 }316 project.baseDir = filepath.Dir(path)317 if err := project.Validate(); err != nil {318 return Project{}, err319 }320 return project, nil321}322323// ResolveLocator resolves only local locators. Remote locators remain exactly324// as authored so their request identity is portable across machines.325func (project Project) ResolveLocator(locator string) string {326 if isRemote(locator) || filepath.IsAbs(locator) {327 return locator328 }329 return filepath.Clean(filepath.Join(project.baseDir, filepath.FromSlash(locator)))330}331332func (project Project) Validate() error {333 if project.Schema != ProjectSchema {334 return fmt.Errorf("project schema %q, want %q", project.Schema, ProjectSchema)335 }336 if err := vnext.ValidSlug(project.ID); err != nil {337 return fmt.Errorf("project identity: %w", err)338 }339 if project.Title == "" {340 return fmt.Errorf("project %s has no title", project.ID)341 }342 if strings.TrimSpace(project.SchemaNamespace) == "" {343 return fmt.Errorf("project %s has no schema namespace", project.ID)344 }345 if err := validateTarget(project.Target); err != nil {346 return err347 }348 if _, err := project.Budgets.resolved(); err != nil {349 return fmt.Errorf("project %s: %w", project.ID, err)350 }351 if len(project.Sources) == 0 && len(project.Rasters) == 0 {352 return fmt.Errorf("project %s configures no sources or rasters", project.ID)353 }354 sets, err := validateFeatureSetContracts(project.FeatureSets)355 if err != nil {356 return err357 }358 sourceIDs := make(map[string]bool)359 for _, source := range project.Sources {360 if err := validateSource(source, project.Target.CoordinateSpace, sets); err != nil {361 return err362 }363 if sourceIDs[source.ID] {364 return fmt.Errorf("source %s is configured twice", source.ID)365 }366 sourceIDs[source.ID] = true367 }368 rasterIDs := make(map[string]bool)369 for _, raster := range project.Rasters {370 if err := validateRaster(raster); err != nil {371 return err372 }373 if rasterIDs[raster.ID] {374 return fmt.Errorf("raster %s is configured twice", raster.ID)375 }376 rasterIDs[raster.ID] = true377 }378 assetIDs := make(map[string]bool)379 for _, asset := range project.Assets {380 if err := validateAsset(asset); err != nil {381 return err382 }383 if assetIDs[asset.ID] {384 return fmt.Errorf("asset %s is configured twice", asset.ID)385 }386 assetIDs[asset.ID] = true387 }388 return validatePresentation(project.Presentation, sets, assetIDs)389}390391func validateTarget(target Target) error {392 if err := vnext.ValidSlug(target.World); err != nil {393 return fmt.Errorf("target world: %w", err)394 }395 space := target.CoordinateSpace396 if target.Title == "" || space.ID == "" || space.Kind == "" || space.Unit == "" || space.Definition == "" {397 return fmt.Errorf("target requires a world title and complete coordinate space")398 }399 if space.Extent[2] <= space.Extent[0] || space.Extent[3] <= space.Extent[1] {400 return fmt.Errorf("target coordinate extent is empty")401 }402 if space.SourceZoom < 0 || space.OriginX < 0 || space.OriginY < 0 || space.FirstTile < 0 || space.TileSize < 0 || space.Size < 0 {403 return fmt.Errorf("target coordinate grid metadata is invalid")404 }405 return nil406}407408func validateFeatureSetContracts(values []FeatureSetContract) (map[string]FeatureSetContract, error) {409 sets := make(map[string]FeatureSetContract, len(values))410 for _, set := range values {411 if err := validateFeatureSetContract(set); err != nil {412 return nil, err413 }414 if _, exists := sets[set.ID]; exists {415 return nil, fmt.Errorf("feature set %s is configured twice", set.ID)416 }417 sets[set.ID] = set418 }419 for _, set := range values {420 for _, relation := range set.Relationships {421 if _, exists := sets[relation.FeatureSet]; !exists {422 return nil, fmt.Errorf("feature set %s relationship %s targets unknown feature set %s", set.ID, relation.ID, relation.FeatureSet)423 }424 }425 }426 return sets, nil427}428429func validateFeatureSetContract(set FeatureSetContract) error {430 if err := vnext.ValidSlug(set.ID); err != nil {431 return fmt.Errorf("feature set identity: %w", err)432 }433 if set.Title == "" || set.SemanticType == "" {434 return fmt.Errorf("feature set %s requires a title and semantic type", set.ID)435 }436 if err := validateGeometryFamily(set.Geometry); err != nil {437 return fmt.Errorf("feature set %s: %w", set.ID, err)438 }439 properties := make(map[string]bool, len(set.Properties))440 for _, property := range set.Properties {441 if property.ID == "" || property.Name == "" || property.Type == "" {442 return fmt.Errorf("feature set %s has an incomplete property contract", set.ID)443 }444 if _, err := kindOf(property.Type); err != nil {445 return fmt.Errorf("feature set %s property %s: %w", set.ID, property.ID, err)446 }447 if properties[property.ID] {448 return fmt.Errorf("feature set %s declares property %s twice", set.ID, property.ID)449 }450 properties[property.ID] = true451 }452 return validateRelationshipContracts(set)453}454455func validateRelationshipContracts(set FeatureSetContract) error {456 identities := make(map[string]bool, len(set.Relationships))457 semantics := make(map[string]bool, len(set.Relationships))458 for _, relation := range set.Relationships {459 if err := vnext.ValidSlug(relation.ID); err != nil {460 return fmt.Errorf("feature set %s relationship identity: %w", set.ID, err)461 }462 if relation.Predicate == "" || relation.FeatureSet == "" {463 return fmt.Errorf("feature set %s has an incomplete relationship contract", set.ID)464 }465 semantic := relation.Predicate + "\x00" + relation.FeatureSet466 if identities[relation.ID] || semantics[semantic] {467 return fmt.Errorf("feature set %s declares relationship %s twice", set.ID, relation.ID)468 }469 identities[relation.ID], semantics[semantic] = true, true470 }471 return nil472}473474func validateGeometryFamily(family string) error {475 switch family {476 case "point", "path", "area":477 return nil478 default:479 return fmt.Errorf("unknown geometry family %q", family)480 }481}482483func validateSource(source Source, target CoordinateSpace, sets map[string]FeatureSetContract) error {484 if err := vnext.ValidSlug(source.ID); err != nil {485 return fmt.Errorf("source identity: %w", err)486 }487 if source.Adapter == "" || source.Locator == "" {488 return fmt.Errorf("source %s requires an adapter and locator", source.ID)489 }490 if strings.TrimSpace(source.License) == "" || strings.TrimSpace(source.Attribution) == "" {491 return fmt.Errorf("source %s must state a license and attribution", source.ID)492 }493 if source.EstimateBytes < 0 {494 return fmt.Errorf("source %s has a negative size estimate", source.ID)495 }496 if source.Query.Layer < 0 || source.Query.Limit < 0 || source.Query.SpatialReference < 0 {497 return fmt.Errorf("source %s has invalid query settings", source.ID)498 }499 mapping := source.Mapping500 set, exists := sets[mapping.FeatureSet]501 if !exists {502 return fmt.Errorf("source %s targets unknown feature set %s", source.ID, mapping.FeatureSet)503 }504 if mapping.Identity == "" || mapping.FeatureTitle == "" {505 return fmt.Errorf("source %s has an incomplete mapping", source.ID)506 }507 if err := validateGeometryMap(mapping.Geometry, target); err != nil {508 return fmt.Errorf("source %s geometry: %w", source.ID, err)509 }510 properties := make(map[string]bool, len(mapping.Properties))511 for _, property := range mapping.Properties {512 if property.Field == "" || property.Source == "" {513 return fmt.Errorf("source %s has an incomplete property mapping", source.ID)514 }515 if _, exists := propertyContract(set, property.Field); !exists {516 return fmt.Errorf("source %s maps unknown property %s", source.ID, property.Field)517 }518 if properties[property.Field] {519 return fmt.Errorf("source %s maps property %s twice", source.ID, property.Field)520 }521 properties[property.Field] = true522 }523 relations := make(map[string]bool, len(mapping.Relations))524 for _, relation := range mapping.Relations {525 if relation.Relationship == "" || relation.Target == "" {526 return fmt.Errorf("source %s has an incomplete relationship mapping", source.ID)527 }528 if _, exists := relationshipContract(set, relation.Relationship); !exists {529 return fmt.Errorf("source %s maps unknown relationship %s", source.ID, relation.Relationship)530 }531 if relations[relation.Relationship] {532 return fmt.Errorf("source %s maps relationship %s twice", source.ID, relation.Relationship)533 }534 relations[relation.Relationship] = true535 }536 return nil537}538539func validateGeometryMap(mapping GeometryMap, target CoordinateSpace) error {540 if strings.TrimSpace(mapping.SourceSpace) == "" {541 return fmt.Errorf("geometry has no source space")542 }543 switch mapping.Transform.Kind {544 case "identity":545 if mapping.SourceSpace != target.ID && mapping.SourceSpace != target.Definition {546 return fmt.Errorf("identity transform source space %q is not target %q", mapping.SourceSpace, target.ID)547 }548 case "affine":549 for _, value := range mapping.Transform.Matrix {550 if math.IsNaN(value) || math.IsInf(value, 0) {551 return fmt.Errorf("affine transform has a non-finite coefficient")552 }553 }554 matrix := mapping.Transform.Matrix555 if matrix[0]*matrix[4]-matrix[1]*matrix[3] == 0 {556 return fmt.Errorf("affine transform is singular")557 }558 default:559 return fmt.Errorf("unknown transform %q", mapping.Transform.Kind)560 }561 return nil562}563564func validateRaster(raster Raster) error {565 if err := vnext.ValidSlug(raster.ID); err != nil {566 return fmt.Errorf("raster identity: %w", err)567 }568 if raster.Name == "" || raster.Adapter == "" || raster.Locator == "" || raster.TileSize <= 0 || raster.EstimateBytes < 0 || raster.MaxZoom < 0 || raster.FullZoom < 0 || raster.Shard < 0 {569 return fmt.Errorf("raster %s is incomplete", raster.ID)570 }571 if strings.TrimSpace(raster.License) == "" || strings.TrimSpace(raster.Attribution) == "" {572 return fmt.Errorf("raster %s must state a license and attribution", raster.ID)573 }574 if raster.Bounds != nil && !validRasterRect(*raster.Bounds) {575 return fmt.Errorf("raster %s has invalid bounds", raster.ID)576 }577 if raster.Surface != nil && !validRasterRect(*raster.Surface) {578 return fmt.Errorf("raster %s has invalid surface", raster.ID)579 }580 switch raster.Adapter {581 case "raster-file":582 if len(raster.Levels) != 0 || raster.MaxZoom > 6 || (raster.FullZoom != 0 && raster.FullZoom > raster.MaxZoom) {583 return fmt.Errorf("raster-file %s must not declare xyz levels", raster.ID)584 }585 case "xyz":586 if len(raster.Levels) == 0 || !strings.Contains(raster.Locator, "{z}") || !strings.Contains(raster.Locator, "{x}") || !strings.Contains(raster.Locator, "{y}") {587 return fmt.Errorf("xyz raster %s requires a template and explicit levels", raster.ID)588 }589 if err := validateRasterLevels(raster); err != nil {590 return err591 }592 case "wmts":593 if len(raster.Levels) == 0 || !strings.Contains(raster.Locator, "{TileMatrix}") || !strings.Contains(raster.Locator, "{TileCol}") || !strings.Contains(raster.Locator, "{TileRow}") {594 return fmt.Errorf("wmts raster %s requires a REST template and explicit levels", raster.ID)595 }596 if err := validateRasterLevels(raster); err != nil {597 return err598 }599 default:600 return fmt.Errorf("raster %s uses unknown adapter %q", raster.ID, raster.Adapter)601 }602 return nil603}604605func validateRasterLevels(raster Raster) error {606 previous := int64(-1)607 for _, level := range raster.Levels {608 if level.Zoom != previous+1 || level.Zoom < 0 || level.Zoom > 30 {609 return fmt.Errorf("%s raster %s has an invalid or unordered level", raster.Adapter, raster.ID)610 }611 maximum := int64(1)<<level.Zoom - 1612 if level.MinX < 0 || level.MinY < 0 || level.MaxX < level.MinX || level.MaxY < level.MinY || level.MaxX > maximum || level.MaxY > maximum {613 return fmt.Errorf("%s raster %s has an invalid or unordered level", raster.Adapter, raster.ID)614 }615 previous = level.Zoom616 }617 return nil618}619620func validRasterRect(rect RasterRect) bool {621 return finite(rect.X) && finite(rect.Y) && finite(rect.Width) && finite(rect.Height) && rect.Width > 0 && rect.Height > 0622}623624func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }625626func validateAsset(asset Asset) error {627 if err := vnext.ValidSlug(asset.ID); err != nil {628 return fmt.Errorf("asset identity: %w", err)629 }630 if asset.ID == "build-receipt" {631 return fmt.Errorf("asset build-receipt is reserved")632 }633 if asset.Locator == "" || asset.MediaType == "" || asset.EstimateBytes < 0 {634 return fmt.Errorf("asset %s is incomplete", asset.ID)635 }636 if strings.TrimSpace(asset.License) == "" || strings.TrimSpace(asset.Attribution) == "" {637 return fmt.Errorf("asset %s must state a license and attribution", asset.ID)638 }639 return nil640}641642func validatePresentation(presentation Presentation, sets map[string]FeatureSetContract, assets map[string]bool) error {643 if presentation.ID == "" || presentation.Title == "" {644 return fmt.Errorf("presentation requires an ID and title")645 }646 styles := make(map[string]bool)647 for _, style := range presentation.Styles {648 if style.ID == "" || styles[style.ID] {649 return fmt.Errorf("presentation has an invalid or duplicate style")650 }651 if style.IconAsset != "" && !assets[style.IconAsset] {652 return fmt.Errorf("presentation style %s refers to unknown asset %s", style.ID, style.IconAsset)653 }654 styles[style.ID] = true655 }656 layers := make(map[string]bool)657 for _, layer := range presentation.Layers {658 _, knownSet := sets[layer.FeatureSet]659 if layer.ID == "" || layers[layer.ID] || !knownSet || !styles[layer.Style] || layer.Label == "" || layer.MaxZoom < layer.MinZoom {660 return fmt.Errorf("presentation layer %s is invalid", layer.ID)661 }662 layers[layer.ID] = true663 }664 return nil665}666667func propertyContract(set FeatureSetContract, id string) (PropertyContract, bool) {668 for _, property := range set.Properties {669 if property.ID == id {670 return property, true671 }672 }673 return PropertyContract{}, false674}675676func featureSetContract(sets []FeatureSetContract, id string) (FeatureSetContract, bool) {677 for _, set := range sets {678 if set.ID == id {679 return set, true680 }681 }682 return FeatureSetContract{}, false683}684685func relationshipContract(set FeatureSetContract, id string) (RelationshipContract, bool) {686 for _, relation := range set.Relationships {687 if relation.ID == id {688 return relation, true689 }690 }691 return RelationshipContract{}, false692}693694func kindOf(name string) (vnext.Kind, error) {695 switch name {696 case "bool":697 return vnext.KindBool, nil698 case "int64":699 return vnext.KindInt64, nil700 case "float64":701 return vnext.KindFloat64, nil702 case "string":703 return vnext.KindString, nil704 case "bytes":705 return vnext.KindBytes, nil706 case "id":707 return vnext.KindID, nil708 default:709 return vnext.KindInvalid, fmt.Errorf("unknown type %q", name)710 }711}712713func isRemote(locator string) bool {714 return strings.HasPrefix(locator, "https://") || strings.HasPrefix(locator, "http://")715}