Repository navigation
feat: add CSV, JSON and XML export to report list route - #471
Merged
Merged
Conversation
…all five ports Adds the standalone report-export catalog generator (importable and ejectable in every port), the ?format= branch on report list routes, the invalid_format, export_too_large and export_with_count envelopes, a maxRows cap that never truncates, a CSV formula-injection guard, and six shared api-contract scenarios. ADR-0031 Amendment 1 records that JSON and XML come from each port's standard serializer for now. Excel (xlsx) is reserved by name only.
… and generator counts. Export contract, ports, ADR-0031 Amendment 1, roadmap and changelog already match code at target. Edited four files: docs/CONFORMANCE.md, docs/features/api-contract.md, docs/ports/kotlin.md, README.md. No code or tests touched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Changed
?format=csv|json|xmlto the report list route in every port. A newreport-exportcatalog generator (TypeScript, C#, Java, Kotlin, Python) emits a per-report export module and a shared writer. Unknown formats return400 invalid_format, and exports overmaxRows(default 100,000) return400 export_too_largebefore any bytes are written.formatbranch only when an export generator is wired. The?format=parsing, the two error envelopes andContent-Dispositionlive in each port's existing runtime HTTP helper. The change adds no new dependency for adopters.api-contract.md,reporting.md, per-port docs, and the CHANGELOG. Thereport/api-contract corpus gains six export scenarios (CSV, JSON, XML, invalid format, too large, with count).Validation
Risk Assessment
✅ Low: The fix round (e7ace3f) cleanly resolves F1 through F5 across the TS, C#, Java, Kotlin and Python ports. It adds independent-oracle tests, and I found no defect I could substantiate with a concrete failing path.
Testing
Drove the shared export-* corpus scenarios (csv, json, xml, invalid-format, too-large, with-count) against each port's generated API over HTTP, plus the Java/Kotlin/C#/Python/TS codegen and compile-and-run tests. All passed. The only incomplete item is the
--quickmutation gate, which timed out under my own time limit; every step before it was green, and the configured baseline (ts-fast, ts-unit) had already passed. No UI surface, so no screenshots.Pipeline
Updates from git push no-mistakes
✅ **Intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
server/typescript/packages/runtime-ts/src/drizzle-fastify/report-export.ts:86- The intent says "withCountwith a non-JSON format returns 400", and it says "jsonis today's body plusContent-Disposition". The change refuseswithCountwith every format, JSON included:400 export_with_count, documented as "An export has no { rows, total } envelope, JSON included." Under the intent's wording,?format=json&withCount=1would return the{rows,total}envelope with an attachment header. The same rule appears in every port: TS Fastify mount-read-only.ts:211 and Hono mount-read-only.ts:180 (if (exportSpec && format !== undefined && isExportCountFlag(...)) throw exportWithCountError()), C# ReportExportHttp.Refuse, Python router_generator.py_report_list_with_export(if is_truthy_flag(with_count)), Java SpringControllerGeneratorappendReportExportHandler(ReportExport.isTruthy(withCount)), KotlinappendReportExportHandler. The corpus pins it in fixtures/api-contract-conformance/report/scenarios/export-with-count.yaml r2, and api-contract.md, reporting.md and ADR-0031 Amendment 1 describe it. Confirm that refusing JSON is the intended contract, or narrow the refusal to csv and xml.server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringReportExportGenerator.java:346- The intent requires CSV values "in wire form (currency as integer minor units, dates YYYY-MM-DD, decimals perwire...)", and api-contract.md and reporting.md say "a decimal as thewiresetting spells it". The Java and Kotlin writers always write a decimal in canonical form (stripTrailingZeros().toPlainString()), whatever thewiresetting. Under the defaultwire.decimals: native, the JVM list route and the JSON export write12.50, but the CSV and XML exports of the same row write12.5. docs/ports/java.md calls this deliberate ("under eitherwiresetting"), which contradicts the intent and the cross-port contract text. Same rule in Kotlin: KotlinReportExportGenerator.kt:296fun decimal. C# (DecimalsAsStringfromctx.Root.WireFormat), TS (wire-form rows) and Python (render_wire) do followwire. Decide whether JVM export should readloader.getRoot().getWireFormat()the way SpringDtoGenerator does, or whether the contract docs should carve out a JVM exception.server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java:158- The intent says theformatbranch is generated by the route generator "only when an export generator is wired". On Java and Kotlin the branch is controlled by a separateroutesarg,reportExport=true, and the presence ofreport-exportin the run has no effect. Two incorrect setups follow. (a) SetreportExport=truewithout wiringreport-export: the controller references<Report>Export.MAX_ROWS,.csv(rows)and.xml(rows), which nobody emitted, so the project does not compile. (b) Wirereport-exportwithout the arg: the build emits orphan<Report>Exportmodules, and?format=is silently ignored. TS (emitsReportExport), C# (GenContext.ReportExportMaxRows) and Python (REPORT_EXPORT_GENERATOR in ctx.generators) all detect the generator automatically. Same knob in Kotlin: KotlinSpringControllerGenerator.kt:130reportExport(). docs/ports/java.md justifies it: "the generator list is not visible to a sibling generator". Detecting the sibling may need new plumbing in the Maven plugin or SPI, and that remedy is what needs approval.server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs:190- Some temporal cells in CSV and XML are not in the list route's wire form, so the exported file and the JSON export disagree for the same row without any error. C#:DateTimeOffsetis formattedyyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z', but Iso8601TimestampConverter (the list route's wire form) writes whole seconds or exactly.fff. An instant at 10:00:00.120 exports as.12Zagainst wire.120Z, and a Postgres microsecond value exports as.123456Zagainst wire.123Z. Java: SpringReportExportGenerator.java:341raw()usesString.valueOf.LocalTime(field.time) andLocalDateTime(@localTimetimestamp)toString()drop zero seconds (10:00and2026-01-01T10:00), while Jackson's JSR-310 serializers write10:00:00and2026-01-01T10:00:00. Kotlin: KotlinReportExportGenerator.kt:293raw()usesvalue.toString(), same defect. Reachable by a report with a timestamp or time dimension, or by a min/max measure on one. Fix: use the converter's format in C#, and useDateTimeFormatter.ISO_LOCAL_TIME/ISO_LOCAL_DATE_TIME(or the ObjectMapper's value) for java.time types on JVM.server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportExportGeneratorTest.kt:66- Several new tests check substrings of generated source as a stand-in for behaviour, which the test-quality rule forbids. One cannot fail:"const val SEGMENT: String = \"store_totals\"" in module || "SEGMENT" in modulepasses as soon as the word SEGMENT appears anywhere. Others pin incidental text rather than behaviour: C# ReportExportGeneratorTests.cs:75 asserts the exactReportExportHttp.Csv(http, "line_totals", w => LineTotalsExport.WriteCsv(w, rows))call string, and C# ReportExportGeneratorTests.cs lines 64–66 and Python test_report_export.py:153 ("format" not in router.replace("information", "")) prove absence by grepping for a word. Kotlin test lines 341–345 have the same problem. Each of these breaks on a behaviour-preserving rename and passes on dead code. Better oracles already exist next to them: for the "no export wired" case, compare output byte-for-byte with and without the generator (as the Kotlin test does at line 347). For the wired case, rely on the compile-and-run tests and the api-contractexport-*corpus, which exercise the HTTP behaviour against independently written expected bodies.docs/features/reporting.md:805- Inline doc fix. The per-port table, and ADR-0031 Amendment 1 at spec/decisions/ADR-0031-serialization-vs-data-download-boundary.md:88, said the Python XML writer isxml.sax.saxutils.XMLGenerator. The generated Python writer (report_export_generator.py_WRITER_SOURCE) builds the XML itself withxml.sax.saxutils.escape/quoteattrand never uses XMLGenerator. Both lines now say it is a generated writer overxml.sax.saxutilsescaping.🔧 Fix applied.
1 info still open:
docs/features/reporting.md:805- Inline doc fix. The per-port table, and ADR-0031 Amendment 1 at spec/decisions/ADR-0031-serialization-vs-data-download-boundary.md:88, said the Python XML writer isxml.sax.saxutils.XMLGenerator. The generated Python writer (report_export_generator.py_WRITER_SOURCE) builds the XML itself withxml.sax.saxutils.escape/quoteattrand never uses XMLGenerator. Both lines now say it is a generated writer overxml.sax.saxutilsescaping.✅ **Test** - passed
✅ No issues found.
scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchainscd server/typescript/packages/integration-tests && bun test test/api-contract-report-corpus.test.ts test/api-contract-report-sqlite.test.ts test/api-contract-report.test.ts(130 pass; generated Fastify/Hono servers booted over HTTP)uv run --extra integration --extra dev pytest tests/integration/test_api_contract_report.py tests/codegen/test_report_export.py(134 pass; generated FastAPI app, Postgres/MySQL containers)mvn -pl codegen-spring -am test -Dtest=GeneratedReportExportCompileRunTest,ReportExportTest,ReportingInertTest(23 pass)mvn -f integration-tests/pom.xml test -Dtest=ReportOmdbSqliteApiContractConformanceTest(38 pass; Tomcat-hosted generated Spring controller)mvn -f integration-tests-kotlin/pom.xml test -Dtest=ReportGeneratedApiContractConformanceTest(113 pass)dotnet test MetaObjects.IntegrationTests --filter FullyQualifiedName~Report(112 pass; generated ASP.NET routes against Testcontainers)dotnet test MetaObjects.Codegen.Tests(1037 pass, 1 skipped)scripts/ci-local.sh --quick(hygiene, parity gates and TS unit suites green; the stryker mutation gate hit my 590s timeout, no failures before it)✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.