Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat: JSON Deserializer/Denormalizer #506

Closed
wants to merge 7 commits into from
Closed

Conversation

lal12
Copy link

@lal12 lal12 commented Feb 23, 2023

Implements (half of) #86

Mostly done (I hope :D), but still have some issues with tests. Happy accepting advise.

if (spec === undefined) {
throw new UnsupportedFormatError(`Spec version ${data.specVersion} is not supported.`)
}
this.factory.spec = spec
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/CycloneDX/cyclonedx-javascript-library/pull/506/files#diff-facac0ec4128527fecf05b9814c75d81c08dfe23a5bc7c9f26611f6e224b3c51R112-R117

this should be part of the tool that does initial analysis.
reading which spec version is applied should be done before the factory is initialized.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might wonder how the factory should be injectable, while it is not fully initialized?
possible solution: Inject a factory method, or the class type, instead of an object o the class.

@jkowalleck
Copy link
Member

Thanks for your work,
I know there is still a lot to do, take your time.

@jkowalleck jkowalleck changed the title first version of json deserialization [WIP] feat: JSON deserialization Feb 23, 2023
@jkowalleck jkowalleck added the enhancement New feature or request label Feb 23, 2023
@jkowalleck jkowalleck changed the title [WIP] feat: JSON deserialization [WIP] feat: JSON Deserializer/Denormalizer Feb 23, 2023
@lal12
Copy link
Author

lal12 commented Mar 2, 2023

Worked on the denormalizer. Probably less flexible than wished, but silently ignoring known properties if their type isn't right, does not seem like good behavior to me either.

No tests yet, but want feedback (@jkowalleck) on the general approach.

Copy link
Member

@jkowalleck jkowalleck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed as requested

remarks:

  • 👍 I like the implementation of type assertions.
    PS: they need improvement - see [WIP] feat: JSON Deserializer/Denormalizer #506 (comment)
  • ❌ there is an option in the code, to change the #spec of a denormalizer Factory at runtime while this factory is in use. This side effect must be removed to prevent inconsistency.
  • 👎 : I don't like the unforgiving behavior when data is not as expected. You could have an injectable optional warningFunc: (msg: string) => void that is called each time a value is unexpected or wrong, right before the value is ignored. It is important for the final result, to be able to generate a BOM model from partially invalid data. 💯 This feature could still be added later on.
    note possible implementation - example code:
    class Factory {
      readonly #spec: Spec
      readonly #warnFn: (msg: string) => void
      constructor(spec: Spec, warnFn?: (msg: string) => void) {
       this.#spec = spec
       this.#warnFn = warnFn ?? function () {/* noop */}
      }
     get warn () { return this.#warnFn } 
    } 
    myFactory = new Factory(...)
    myFactory.warn('foooo baaarrr')

if (spec === undefined) {
throw new UnsupportedFormatError(`Spec version ${data.specVersion} is not supported.`)
}
this.factory.spec = spec
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might wonder how the factory should be injectable, while it is not fully initialized?
possible solution: Inject a factory method, or the class type, instead of an object o the class.

@jkowalleck
Copy link
Member

jkowalleck commented Mar 3, 2023

re: #506 (review)

@lal12 i have it more thought and i thing the type checkers you were implementing are technically correct, but do not help TypeScript with the type assertions.
Could you implement them as proper type assertions or type predicates?

read https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
feature intro: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions

example:

function isString (v: any): v is string 
{
  return typeof v === 'string'
}

function assertIsString (v: any): assert v is string 
{
  if ( typeof v !== 'string' ) { 
    throw new AssertionError() 
  }
}

example code of a dynamic/templated type predicate:

export type NotUndefined<T> = T extends undefined ? never : T
export function isNotUndefined<T> (value: T | undefined): value is NotUndefined<T> {
return value !== undefined
}

@lal12
Copy link
Author

lal12 commented Mar 3, 2023

@jkowalleck
I already (partially) do that, e.g. function assertNonEmptyStr (value: any, path: PathType): asserts value is string { (here https://github.com/lal12/cyclonedx-javascript-library/blob/3e014d71c84d129e0384953f97493e8814771231/src/serialize/json/denormalize.ts#L45)

I still had it on my list to look into assertTypes (value: any, expected: VarType[], path: PathType): void, whether some kind of generic mapped assertion is possible (probably).

But I will refactor/change that anyway to get it working with warningFunc: (msg: string) => void.

Signed-off-by: Luca Lindhorst <[email protected]>
@lal12
Copy link
Author

lal12 commented Mar 3, 2023

@jkowalleck did some refactoring. Removed spec from factory and created a context object instead.

@jkowalleck
Copy link
Member

jkowalleck commented Mar 3, 2023

@lal12
General question: why do you compare values against null - like value != null?
As far as I know, none of the CycloneDX JSON schemas has any nullable value.
They have optional, which would be undefined, not null -- so you should compare value !== undefined. But best would be to simply test for the correct type - skip the null checks ...

Luca Lindhorst added 2 commits March 3, 2023 18:21
Signed-off-by: Luca Lindhorst <[email protected]>
@lal12
Copy link
Author

lal12 commented Mar 3, 2023

@jkowalleck

@lal12 General question: why do you compare values against null - like value != null? As far as I know, none of the CycloneDX JSON schemas has any nullable value. They have optional, which would be undefined, not null -- so you should compare value !== undefined. But best would be to simply test for the correct type - skip the null checks ...

Yeah doesn't really make sense anymore with the warning function. Corrected.

Fixed remaining type issues

@lal12
Copy link
Author

lal12 commented Mar 3, 2023

@jkowalleck something I've noticed while writing tests is that normalizer will look for BomRef by reference to the object in

Array.from(deps).filter(d => allRefs.has(d) && d !== ref),
instead of by it's string value. Is this intended?

Made tests working, not ideal, but comparing boms fails due to URLs and loadNormalizeResult drops undefined, so I have to renormalize and stringify.

web build complains that there is no URL polyfill, should I add one?

Waiting for your review

@lal12 lal12 marked this pull request as ready for review March 3, 2023 23:12
@lal12 lal12 requested a review from a team as a code owner March 3, 2023 23:12
@lal12 lal12 marked this pull request as draft March 3, 2023 23:13
@jkowalleck
Copy link
Member

jkowalleck commented Mar 3, 2023

@jkowalleck something I've noticed while writing tests is that normalizer will look for BomRef by reference to the object in

Array.from(deps).filter(d => allRefs.has(d) && d !== ref),

instead of by it's string value. Is this intended?

yes. is intended.
read https://github.com/CycloneDX/cyclonedx-php-library/blob/master/docs/dev/decisions/BomDependencyDataModel.md
Background: BomRef's must be unique. Therefore, the values are not final and will be discriminated - see https://github.com/CycloneDX/cyclonedx-php-library/blob/5f475ce38750745a45f920664ec1acaf0aa02b1a/src/Core/Serialization/BaseSerializer.php#L73-L74


web build complains that there is no URL polyfill, should I add one?

where? how? this is new.
do you have evidence/logs?


Waiting for your review

will review soon

@lal12
Copy link
Author

lal12 commented Mar 4, 2023

I import import {URL} from 'url'; which the web build doesn't like, without further config. Just gotta remove it.

@jkowalleck jkowalleck self-requested a review March 4, 2023 08:30
@jkowalleck
Copy link
Member

jkowalleck commented Mar 4, 2023

I import import {URL} from 'url'; which the web build doesn't like, without further config. Just gotta remove it.

just remove this import. URL is a predefined global class in JS.

Copy link
Member

@jkowalleck jkowalleck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did not review all of it, stopped at a point.

Generally I see a big wrong concept here, which i will try to explain

  • you are deserializing the JOSN to some data, and force the data to be denormalized. while the denormalization is already ongoing, it is decided to stop all of this, because something seams odd.
    THIS MUST NOT BE.
  • correct would be:
    1. Deserialize the JSON to some data
    2. check if this data is an object, that has the property "bomFormat" set to "CycloneDX" - if not STOP/THROW
    3. check if this data is an object, that has the property "specVersion" set to "one value that is known"
    4. fetch the Spec from SpecVersionDict based on the "specVersion" properrty. If it is not existing: Exit/Throw. If the Spec does not support JSON: Exit/Throw
    5. feed the data model into the denormalizer.

very basic (pseudo) example code to show the process:

Deserializer {
  readonly #df
  constructor(df: DenormalizerFactory) {
    this.df=df
  }
  deserialize(bom: string) {
    const data = JSON.parse(bom)
    if (typeof data !== 'object') { throw RangeError('no object') }
    if (data.bomFormat !== 'CycloneDX') { throw RangeError('wrong format') }
    const spec = SpecVersionDict[data.specVersion]
    if (! spec?.supportsFormat('JSON')) { throw SpecVersionDict('something') }
    this.#df.makeForBom().denormalize(data)
  }
}
// i dont see a need to inject the spec into the normalizer.

assertNonEmptyStr(url, path)
try {
return new URL(url)
} catch (e) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error not used? then just write try{ ...} catch { ... } without keeping the error e

version: warnStringOrUndef(data.version, ctx, [...path, 'version']),
tagVersion: warnNumberOrUndef(data.tagVersion, ctx, [...path, 'tagVersion']),
text: denormalizeRecord(data.text, ctx, [...path, 'text'], this._factory.makeForAttachment(ctx)),
url: url !== undefined
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better turned, so that no bool switch is needed:

 url === undefined
 ? url
 : this._factory.makeForUrl(ctx).denormalize(url, ctx, [...path, 'url'])

}

function callWarnFunc (ctx: JSONDenormalizerContext, warning: JSONDenormalizerWarning): void | never {
if (typeof ctx.options.warningFunc !== 'function') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turn arround. no bool inversin needed.

export interface Serializer {
/**
* @throws {@link Error}
*/
serialize: (bom: Bom, options?: SerializerOptions & NormalizerOptions) => string
}

export type VarType = 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | '_array' | '_record'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are no things we want to publish, they are internal.
need to find a place where to put this, so it is not exported to downstream

data: any,
options: JSONDenormalizerOptions = {}
): Bom {
return this.#normalizerFactory.makeForBom({ options })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options are injected into the class (constructor) and into the function? why???

}

function warnStringOrUndef (value: unknown, ctx: JSONDenormalizerContext, path: PathType): string | undefined {
if (value !== undefined && typeof value !== 'string') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turn bool compare around. and twist the code blocks.
value === undefined || typeof value === 'string'

import { Format, SpecVersionDict, UnsupportedFormatError } from '../../spec'
import type { JSONDenormalizerOptions, JSONDenormalizerWarning, PathType } from '../types'

interface JSONDenormalizerContext {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed? why are these three values encapsulated into a record?
i found rare usage - and none was justified. all usage i found was artificially complex.

denormalize (data: Record<string, unknown>, { options }: { options: JSONDenormalizerOptions }, path: PathType): Models.Bom {
assertEnum(data.bomFormat, ['CycloneDX'], [...path, 'bomFormat'])
assertEnum(data.specVersion, Object.keys(SpecVersionDict), [...path, 'specVersion'])
const spec = SpecVersionDict[data.specVersion as keyof typeof SpecVersionDict] as Protocol
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this as keyof typeof SpecVersionDict needed? the assertEnum should have done this already. if not, then it is done wrong.

i dont see a real need for this complex usage. please simplify

const spec = SpecVersionDict[data.specVersion as Version]
if (!spec) { throw Error('something') } 

assertEnum(data.bomFormat, ['CycloneDX'], [...path, 'bomFormat'])
assertEnum(data.specVersion, Object.keys(SpecVersionDict), [...path, 'specVersion'])
const spec = SpecVersionDict[data.specVersion as keyof typeof SpecVersionDict] as Protocol
if (!spec.supportsFormat(Format.JSON)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ NO!
the (de)normalizer does not decide whether to run or not - it just does the job.
the (de)serializer might decide to not run because of constraints.

@jkowalleck
Copy link
Member

FYI: i will be working on #620 soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants