package longdistance import ( "bytes" "cmp" "log/slog" "maps" "slices" "strings" "sourcery.dny.nu/longdistance/internal/json" "sourcery.dny.nu/longdistance/internal/url" ) type expandOptions struct { frameExpansion bool ordered bool fromMap bool } func (e expandOptions) clone() expandOptions { return expandOptions{ frameExpansion: e.frameExpansion, ordered: e.ordered, fromMap: e.fromMap, } } func newExpandOptions() expandOptions { return expandOptions{} } // Expand transforms a JSON document into JSON-LD expanded document form. // // If the document was retrieved from a URL, pass it as the second argument. // Otherwise an empty string. func (p *Processor) Expand(document json.RawMessage, url string) ([]Node, error) { xopts := newExpandOptions() xopts.ordered = p.ordered baseIRI := cmp.Or(p.baseIRI, url) ctx := newContext(baseIRI) if p.expandContext != nil { var obj json.Object if err := json.Unmarshal(p.expandContext, &obj); err != nil { return nil, ErrInvalidLocalContext } var rawctx json.RawMessage if v, ok := obj[KeywordContext]; ok { rawctx = v } else { rawctx = p.expandContext } nctx, err := p.context(ctx, rawctx, ctx.originalBaseIRI, newCtxProcessingOpts()) if err != nil { return nil, err } ctx = nctx } res, err := p.expand(ctx, "", document, url, xopts) if err != nil { return res, err } if res == nil { return []Node{}, nil } // 19) if len(res) == 1 { r := res[0] if r.IsSimpleGraph() { res = r.Graph } } result := make([]Node, 0, len(res)) for _, obj := range res { if obj.IsZero() { continue } if obj.IsValue() { continue } if obj.Has(KeywordID) && len(obj.PropertySet()) == 1 { continue } result = append(result, obj) } return result, nil } func (p *Processor) expand( activeContext *Context, activeProperty string, element json.RawMessage, baseURL string, opts expandOptions, ) ([]Node, error) { // 1) if len(element) == 0 || json.IsNull(element) { return nil, nil } // 2) if activeProperty == KeywordDefault { opts.frameExpansion = false } // bail out on frame expansion since we don't do that if opts.frameExpansion { return nil, ErrFrameExpansionUnsupported } termDef, hasDef := activeContext.defs[activeProperty] // 3) var propContext json.RawMessage if hasDef { if termDef.Context != nil { propContext = termDef.Context } } switch element[0] { // null was handled at the function start case '[': // 5) var elems json.Array if err := json.Unmarshal(element, &elems); err != nil { return nil, err } if len(elems) == 0 { if slices.Contains(termDef.Container, KeywordList) { return []Node{{List: []Node{}}}, nil } return make([]Node, 0), nil } // 5.1) result := make([]Node, 0, len(elems)) // 5.2) for _, elem := range elems { // 5.2.1) res, err := p.expand( activeContext, activeProperty, elem, baseURL, opts.clone(), ) if err != nil { return nil, err } // 5.2.2) if slices.Contains(termDef.Container, KeywordList) { if len(elems) > 1 { if len(result) == 0 { result = append(result, Node{List: res}) } else { result[0].List = append(result[0].List, res...) } } else { if json.IsMap(elem) && len(res[0].List) != 0 { result = append(result, res...) } else { result = append(result, Node{List: res}) } } } else { // 5.2.3) result = append(result, res...) } } // 5.3) return result, nil case '{': // happens after the switch default: // 4) // 4.1) if activeProperty == "" || activeProperty == KeywordGraph { return nil, nil } // 4.2) if propContext != nil { def := activeContext.defs[activeProperty] nctx, err := p.context( activeContext, propContext, def.BaseIRI, newCtxProcessingOpts(), ) if err != nil { return nil, err } activeContext = nctx } // 4.3) res, err := p.expandValue( activeContext, activeProperty, element, ) if err != nil { return nil, err } return []Node{res}, nil } // 6) var elemObj json.Object if err := json.Unmarshal(element, &elemObj); err != nil { return nil, err } elemKeys := slices.Collect(maps.Keys(elemObj)) // 7) if activeContext.previousContext != nil && !opts.fromMap { hasValue := p.expandsToKeyword( activeContext, KeywordValue, elemKeys, ) hasID := p.expandsToKeyword( activeContext, KeywordID, elemKeys, ) if !hasValue && !(len(elemObj) == 1 && hasID) { activeContext = activeContext.previousContext } } // 8) if propContext != nil { ropts := newCtxProcessingOpts() ropts.override = true nctx, err := p.context( activeContext, propContext, termDef.BaseIRI, ropts, ) if err != nil { return nil, err } activeContext = nctx } // 9) if ctx, ok := elemObj[KeywordContext]; ok { nctx, err := p.context( activeContext, ctx, baseURL, newCtxProcessingOpts(), ) if err != nil { return nil, err } activeContext = nctx } // 10) typContext := activeContext // 11) objKeys := slices.Collect(maps.Keys(elemObj)) slices.Sort(objKeys) for _, k := range objKeys { u, err := p.expandIRI(activeContext, k, false, true, nil, nil) if err != nil { return nil, err } if u != KeywordType { continue } val := json.MakeArray(elemObj[k]) var values []json.RawMessage if err := json.Unmarshal(val, &values); err != nil { return nil, err } stringTerms := make([]string, 0, len(values)) for _, term := range values { var s string if err := json.Unmarshal(term, &s); err != nil { return nil, ErrInvalidTypeValue } stringTerms = append(stringTerms, s) } slices.Sort(stringTerms) for _, term := range stringTerms { if tscopeDef, ok := typContext.defs[term]; ok && tscopeDef.Context != nil { adef := activeContext.defs[term] ropts := newCtxProcessingOpts() ropts.propagate = false nctx, err := p.context( activeContext, tscopeDef.Context, adef.BaseIRI, ropts, ) if err != nil { return nil, err } activeContext = nctx } } } // 12) result := &Node{ Properties: make(Properties, len(objKeys)), } nests := Properties{} inputType := "" entry := "" for _, k := range objKeys { u, err := p.expandIRI(activeContext, k, false, true, nil, nil) if err != nil { return nil, err } if u == KeywordType { entry = k break } } if entry != "" { vals := json.MakeArray(elemObj[entry]) var valElems json.Array if err := json.Unmarshal(vals, &valElems); err != nil { return nil, err } last := valElems[len(valElems)-1] var s string if err := json.Unmarshal(last, &s); err != nil { return nil, err } u, err := p.expandIRI(activeContext, s, false, true, nil, nil) if err != nil { return nil, err } inputType = u } // 13) and 14) if err := p.expandElement( result, nests, activeContext, typContext, activeProperty, inputType, baseURL, elemObj, opts.clone(), ); err != nil { return nil, err } // 15) if result.Has(KeywordValue) { // 15.1) if !result.IsValue() { return nil, ErrInvalidValueObject } if result.Has(KeywordType) && (result.Has(KeywordLanguage) || result.Has(KeywordDirection)) { return nil, ErrInvalidValueObject } if slices.Equal(result.Type, []string{KeywordJSON}) { // 15.2) } else if json.IsNull(result.Value) { // 15.3) return nil, nil } else if result.Has(KeywordLanguage) && !json.IsString(result.Value) { // 15.4) return nil, ErrInvalidLanguageTaggedValue } else if len(result.Type) > 1 { // 15.5) return nil, ErrInvalidTypedValue } else if len(result.Type) == 1 { // 15.5) if !url.IsIRI(result.Type[0]) { return nil, ErrInvalidTypedValue } } } // 16) implicit since [Object.Type] is always an array // 17) if result.Has(KeywordSet) || result.Has(KeywordList) { // 17.1) if len(result.propsWithout( KeywordIndex, KeywordList, KeywordSet, )) != 0 { return nil, ErrInvalidSetOrListObject } // 17.2) if result.Has(KeywordSet) { return result.Set, nil } return []Node{*result}, nil } // 18) if result.Has(KeywordLanguage) && len(result.PropertySet()) == 1 { return nil, nil } // 19) if activeProperty == "" || activeProperty == KeywordGraph { props := result.PropertySet() if len(props) == 0 || result.Has(KeywordList) || result.Has(KeywordValue) { return nil, nil } else if len(props) == 1 && result.Has(KeywordID) { return nil, nil } } return []Node{*result}, nil } func (p *Processor) expandNestedElement( result *Node, nests Properties, activeContext *Context, typContext *Context, activeProperty string, inputType string, baseURL string, element json.Object, opts expandOptions, ) error { termDef, hasDef := activeContext.defs[activeProperty] // 3) var propContext json.RawMessage if hasDef { if termDef.Context != nil { propContext = termDef.Context } } // 8) if propContext != nil { ropts := newCtxProcessingOpts() ropts.override = true nctx, err := p.context( activeContext, propContext, termDef.BaseIRI, ropts, ) if err != nil { return err } activeContext = nctx } return p.expandElement(result, nests, activeContext, typContext, activeProperty, inputType, baseURL, element, opts) } func (p *Processor) expandElement( result *Node, nests Properties, activeContext *Context, typContext *Context, activeProperty string, inputType string, baseURL string, element json.Object, opts expandOptions, ) error { // 13) objKeys := slices.Collect(maps.Keys(element)) if opts.ordered { slices.Sort(objKeys) } mainLoop: for _, key := range objKeys { // 13.1) if key == KeywordContext { continue } // 13.2) expProp, err := p.expandIRI(activeContext, key, false, true, nil, nil) if err != nil { return err } // 13.3) if expProp == "" { // "null" continue } if !(isKeyword(expProp) || strings.Contains(expProp, ":")) { continue } value := element[key] // 13.4) if isKeyword(expProp) { // 13.4.1) if activeProperty == KeywordReverse { return ErrInvalidReversePropertyMap } // 13.4.2) if result.Has(expProp) { switch expProp { case KeywordIncluded, KeywordType: if p.modeLD10 { return ErrCollidingKeywords } default: return ErrCollidingKeywords } } switch expProp { case KeywordID: // 13.4.3) if json.IsNull(value) { return ErrInvalidIDValue } var s string if err := json.Unmarshal(value, &s); err != nil { // 13.4.3.1) return ErrInvalidIDValue } if s == "" { return ErrInvalidIDValue } iri, err := p.expandIRI(activeContext, s, true, false, nil, nil) if err != nil { return err } if iri == "" { // This is theoretically against spec, as empty string is // moonlighting for null and in theory we should output an // expanded form document with `id: null`. However, that's // invalid JSON-LD, so instead we error out here because // if someone does that it's BS or shenanigans. return ErrInvalidIDValue } // 13.4.3.2) result.ID = iri case KeywordType: // 13.4.4) if !json.IsString(value) && !json.IsArray(value) { // 13.4.4.1) return ErrInvalidTypeValue } // 13.4.4.2) 13.4.4.3) skipped because frame expansion // 13.4.4.4) value = json.MakeArray(value) var vals []string if err := json.Unmarshal(value, &vals); err != nil { return err } iris := make([]string, 0, len(vals)) for _, v := range vals { u, err := p.expandIRI(typContext, v, true, true, nil, nil) if err != nil { return err } iris = append(iris, u) } // 13.4.4.5) result.Type = append(result.Type, iris...) case KeywordGraph: // 13.4.5) xopts := newExpandOptions() xopts.frameExpansion = opts.frameExpansion xopts.ordered = opts.ordered res, err := p.expand(activeContext, KeywordGraph, value, baseURL, xopts) if err != nil { return err } result.Graph = res case KeywordIncluded: // 13.4.6) if p.modeLD10 { // 13.4.6.1) continue mainLoop } if !json.IsMap(value) && !json.IsArray(value) { return ErrInvalidIncludedValue } xopts := newExpandOptions() xopts.frameExpansion = opts.frameExpansion xopts.ordered = opts.ordered // 13.4.6.2) res, err := p.expand( activeContext, "", value, baseURL, xopts, ) if err != nil { return err } // 13.4.6.3) if res == nil { return ErrInvalidIncludedValue } for _, elem := range res { if !elem.isNode() { return ErrInvalidIncludedValue } } result.Included = append(result.Included, res...) case KeywordValue: // 13.4.7) if inputType == KeywordJSON { // 13.4.7.1) if p.modeLD10 { return ErrInvalidValueObjectValue } result.Value = value continue mainLoop } // 13.4.7.2) if !json.IsScalar(value) && !json.IsNull(value) { return ErrInvalidValueObjectValue } // 13.4.7.3) // 13.4.7.4) result.Value = value case KeywordLanguage: // 13.4.8) var l string if err := json.Unmarshal(value, &l); err != nil { // 13.4.8.1) return ErrInvalidLanguageTaggedString } // 13.4.8.2) result.Language = strings.ToLower(l) case KeywordDirection: // 13.4.9) if p.modeLD10 { // 13.4.9.1) continue mainLoop } var d string if err := json.Unmarshal(value, &d); err != nil { return ErrInvalidBaseDirection } // 13.4.9.2) switch d { case DirectionLTR, DirectionRTL: default: return ErrInvalidBaseDirection } // 13.4.9.3) result.Direction = d case KeywordIndex: // 13.4.10) var i string if err := json.Unmarshal(value, &i); err != nil { // 13.4.10.1) return ErrInvalidIndexValue } // 13.4.10.2) result.Index = i case KeywordList: // 13.4.11) if activeProperty == "" || activeProperty == KeywordGraph { // 13.4.11.1) continue mainLoop } if json.IsArray(value) && bytes.Equal(value, []byte(`[]`)) { result.List = make([]Node, 0) } else { // 13.4.11.2) xopts := newExpandOptions() xopts.frameExpansion = opts.frameExpansion xopts.ordered = opts.ordered res, err := p.expand( activeContext, activeProperty, value, baseURL, xopts, ) if err != nil { return err } result.List = res } case KeywordSet: // 13.4.12) xopts := newExpandOptions() xopts.frameExpansion = opts.frameExpansion xopts.ordered = opts.ordered res, err := p.expand( activeContext, activeProperty, value, baseURL, xopts, ) if err != nil { return err } result.Set = res case KeywordReverse: // 13.4.13) if !json.IsMap(value) { // 13.4.13.1) return ErrInvalidReverseValue } // 13.4.13.2) xopts := newExpandOptions() xopts.frameExpansion = opts.frameExpansion xopts.ordered = opts.ordered res, err := p.expand( activeContext, KeywordReverse, value, baseURL, xopts, ) if err != nil { return err } for _, obj := range res { // 13.4.13.3) for k, v := range obj.Reverse { result.Properties[k] = append(result.Properties[k], v...) } // 13.4.13.4), 13.4.13.4.2) for k, v := range obj.Properties { // 13.4.13.4.2.1 for _, item := range v { // 13.4.13.4.2.1.1) if item.IsValue() || item.IsList() { return ErrInvalidReversePropertyValue } if !result.Has(KeywordReverse) { result.Reverse = make(Properties, 8) } // 13.4.13.4.2.1.2) result.Reverse[k] = append(result.Reverse[k], item) } } } // 13.4.13.5) continue mainLoop case KeywordNest: // 13.4.14) if _, ok := nests[key]; !ok { nests[key] = []Node{} } continue mainLoop default: p.logger.Warn("unhandled property", slog.String("proprety", expProp)) } // 13.4.15) skip because frame expansion // 13.4.16) 13.4.17) we've already been doing this implicitly at each step continue mainLoop } // 13.5) termDef := activeContext.defs[key] cnt := termDef.Container expVal := []Node{} if termDef.Type == KeywordJSON { // 13.6) expVal = append(expVal, Node{Value: value, Type: []string{KeywordJSON}}) } else if slices.Contains(cnt, KeywordLanguage) && json.IsMap(value) { // 13.7) var langMap json.Object if err := json.Unmarshal(value, &langMap); err != nil { return err } // 13.7.1) langPairs := make([]Node, 0, len(langMap)) // 13.7.2) dir := activeContext.defaultDirection // 13.7.3) if termDef.Direction != "" { dir = termDef.Direction } langKeys := slices.Collect(maps.Keys(langMap)) if opts.ordered { slices.Sort(langKeys) } // 13.7.4) for _, langKey := range langKeys { langValue := langMap[langKey] // 13.7.4.1) langValue = json.MakeArray(langValue) var langValues json.Array if err := json.Unmarshal(langValue, &langValues); err != nil { return err } // 13.7.4.2) for _, item := range langValues { // 13.7.4.2.1) if json.IsNull(item) { continue } // 13.7.4.2.2) if !json.IsString(item) { return ErrInvalidLanguageMapValue } obj := Node{ Value: item, } // 13.7.4.2.3) if langKey != KeywordNone { if ldef := activeContext.defs[langKey]; ldef.IRI != KeywordNone { // 13.7.4.2.4) obj.Language = langKey } } // 13.7.4.2.5) if dir != "" && dir != KeywordNull { obj.Direction = dir } langPairs = append(langPairs, obj) } } // 13.7.4.2.6) expVal = langPairs } else if (slices.Contains(cnt, KeywordIndex) || slices.Contains(cnt, KeywordType) || slices.Contains(cnt, KeywordID)) && json.IsMap(value) { // 13.8) var objVal json.Object if err := json.Unmarshal(value, &objVal); err != nil { return err } // 13.8.1) implicit, we've already initialised expVal // 13.8.2) idxKey := cmp.Or(termDef.Index, KeywordIndex) // 13.8.3) idxKeys := slices.Collect(maps.Keys(objVal)) if opts.ordered { slices.Sort(idxKeys) } for _, idx := range idxKeys { idxVal := objVal[idx] var mapCtx *Context // 13.8.3.1) if slices.Contains(cnt, KeywordID) || slices.Contains(cnt, KeywordType) { if activeContext.previousContext != nil { mapCtx = activeContext.previousContext } else { mapCtx = activeContext } // 13.8.3.2) if slices.Contains(cnt, KeywordType) { if mapCtx == nil { mapCtx = newContext("") } if def, ok := mapCtx.defs[idx]; ok && def.Context != nil { nctx, err := p.context( mapCtx, def.Context, def.BaseIRI, newCtxProcessingOpts(), ) if err != nil { return err } mapCtx = nctx } } } else { // 13.8.3.3) mapCtx = activeContext } // 13.8.3.4) expIdx, err := p.expandIRI(activeContext, idx, false, true, nil, nil) if err != nil { return err } // 13.8.3.5) idxVal = json.MakeArray(idxVal) // 13.8.3.6) xopts := newExpandOptions() xopts.fromMap = true xopts.frameExpansion = opts.frameExpansion xopts.ordered = opts.ordered expIdxVals, err := p.expand( mapCtx, key, idxVal, baseURL, xopts, ) if err != nil { return err } // 13.8.3.7) for _, item := range expIdxVals { // 13.8.3.7.1) if slices.Contains(cnt, KeywordGraph) && item.Graph == nil { item = Node{Graph: []Node{item}} } if slices.Contains(cnt, KeywordIndex) && idxKey != KeywordIndex && expIdx != KeywordNone { // 13.8.3.7.2) // 13.8.3.7.2.1) rexpIdx, err := p.expandValue( activeContext, idxKey, []byte(`"`+idx+`"`), // we know idx is a string so we cheat a little ) if err != nil { return err } // 13.8.3.7.2.2) expIdxKey, err := p.expandIRI(activeContext, idxKey, false, true, nil, nil) if err != nil { return err } // 13.8.3.7.2.3) rexpPropVals := []Node{rexpIdx} rexpPropVals = append(rexpPropVals, item.Properties[expIdxKey]...) // 13.8.3.7.2.4) if item.Properties == nil { item.Properties = make(Properties, 4) } item.Properties[expIdxKey] = rexpPropVals // 13.8.3.7.2.5) if item.Has(KeywordValue) && !item.IsValue() { return ErrInvalidValueObject } } else if slices.Contains(cnt, KeywordIndex) && !item.Has(KeywordIndex) && expIdx != KeywordNone { // 13.8.3.7.3) item.Index = idx } else if slices.Contains(cnt, KeywordID) && !item.Has(KeywordID) && expIdx != KeywordNone { // 13.8.3.7.4) idx, err := p.expandIRI(activeContext, idx, true, false, nil, nil) if err != nil { return err } item.ID = idx } else if slices.Contains(cnt, KeywordType) && expIdx != KeywordNone { // 13.8.3.7.5) item.Type = append([]string{expIdx}, item.Type...) } // 13.8.3.7.6) expVal = append(expVal, item) } } } else { // 13.9) xopts := newExpandOptions() xopts.frameExpansion = opts.frameExpansion xopts.ordered = opts.ordered var expErr error expVal, expErr = p.expand( activeContext, key, value, baseURL, xopts, ) if expErr != nil { return expErr } } // 13.10) // check for nil and not len()>0 because a slice of 0 elements still // needs to be retained for sets. expand will return nil if the // element should be dropped. if expVal == nil { continue mainLoop } // 13.11) if slices.Contains(termDef.Container, KeywordList) { switch len(expVal) { case 1: if !expVal[0].IsList() { expVal = []Node{{List: expVal}} } default: expVal = []Node{{List: expVal}} } } // 13.12) if slices.Contains(cnt, KeywordGraph) && !slices.Contains(cnt, KeywordID) && !slices.Contains(cnt, KeywordIndex) { res := make([]Node, 0, len(expVal)) for _, obj := range expVal { res = append(res, Node{Graph: []Node{obj}}) } expVal = res } // 13.13) if termDef.Reverse { // 13.13.1) if !result.Has(KeywordReverse) { result.Reverse = make(Properties, len(expVal)) } // 13.13.2) can reference result.Reverse directly // 13.13.3) already is an array // 13.13.4) for _, obj := range expVal { // 13.13.4.1) if obj.IsValue() || obj.IsList() { return ErrInvalidReversePropertyValue } // 13.13.4.3) if result.Reverse[expProp] == nil { result.Reverse[expProp] = make([]Node, 0, len(obj.Properties)+2) } result.Reverse[expProp] = append(result.Reverse[expProp], obj) } } else { // 13.14) // explicitly initialise the expProp in case the first time // we encounter expProp expVal is an empty set because // appending with len(expVal)==0 does nothing but we need // to retain the fact that we got an empty array if !result.Has(expProp) { result.Properties[expProp] = expVal } else { result.Properties[expProp] = append(result.Properties[expProp], expVal...) } } } // 14) nestKeys := slices.Collect(maps.Keys(nests)) if opts.ordered { slices.Sort(nestKeys) } for _, k := range nestKeys { // 14.1) nestData := element[k] // 14.2) nestData = json.MakeArray(nestData) var nestValues []json.Object if err := json.Unmarshal(nestData, &nestValues); err != nil { return ErrInvalidNestValue } for _, nestValue := range nestValues { if p.expandsToKeyword( activeContext, KeywordValue, slices.Collect(maps.Keys(nestValue)), ) { // 14.2.1) return ErrInvalidNestValue } // 14.2.2) if err := p.expandNestedElement( result, nests, activeContext, typContext, k, inputType, baseURL, nestValue, opts.clone(), ); err != nil { return err } } } return nil } func (p *Processor) expandsToKeyword( activeContext *Context, keyword string, elems []string, ) bool { for _, k := range elems { res, err := p.expandIRI( activeContext, k, false, true, nil, nil, ) if err != nil { return false } if res == keyword { return true } } return false } func (p *Processor) expandValue( ctx *Context, property string, value json.RawMessage, ) (Node, error) { def := ctx.defs[property] result := Node{} switch def.Type { case KeywordID: // 1) if json.IsNull(value) { break } var val string if err := json.Unmarshal(value, &val); err != nil { break // don't coerce types of some other value } if val != "" { u, err := p.expandIRI(ctx, val, true, false, nil, nil) if err != nil { return result, err } result.ID = u return result, nil } case KeywordVocab: // 2) if json.IsNull(value) { break } var val string if err := json.Unmarshal(value, &val); err != nil { break // don't coerce types of some other value } if val != "" { u, err := p.expandIRI(ctx, val, true, true, nil, nil) if err != nil { return result, err } result.ID = u return result, nil } case KeywordNone, "": // 4) default: // 4) result.Type = []string{def.Type} } // 3) result.Value = value // 5) if json.IsString(value) { // 5.1) lang := ctx.defs[property].Language if lang == "" && ctx.defaultLang != "" { lang = ctx.defaultLang } // 5.2) dir := ctx.defs[property].Direction if dir == "" && ctx.defaultDirection != "" { dir = ctx.defaultDirection } // 5.3) if lang != KeywordNull { result.Language = lang } // 5.4) if dir != KeywordNull { result.Direction = dir } } return result, nil }