feat: add swagger-postman-importer plugin with JDK 8 support#812
feat: add swagger-postman-importer plugin with JDK 8 support#812bakthava wants to merge 3 commits intoundera:masterfrom
Conversation
- New JMeter plugin for importing Swagger/OpenAPI and Postman collections - Version 1.1 with full JDK 8 compatibility - Targets Java 8 with JMeter 5.1.1 dependencies - Includes JSON parsing and test plan generation - Registers as custom JMeter GUI action and menu creator Fixes: Integration with undera/jmeter-plugins ecosystem
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #812 +/- ##
============================================
+ Coverage 68.90% 69.07% +0.17%
- Complexity 2629 2654 +25
============================================
Files 230 233 +3
Lines 15965 16061 +96
Branches 1638 1650 +12
============================================
+ Hits 11000 11094 +94
+ Misses 4146 4138 -8
- Partials 819 829 +10 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds a new JMeter GUI plugin module intended to import Swagger/OpenAPI specs and Postman collections, preview extracted HTTP requests, and generate a runnable JMeter .jmx test plan.
Changes:
- Introduces a new Maven module (
swagger-postman-importer) with JMeter GUI action + Tools menu integration. - Implements Swagger/OpenAPI + Postman parsing into a unified
RequestModel. - Generates JMX test plans from parsed requests (including headers, query params, and bodies).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
plugins/swagger-postman-importer/pom.xml |
New module POM with JMeter deps and shading for JSON library. |
plugins/swagger-postman-importer/main/resources/META-INF/services/org.apache.jmeter.gui.plugin.MenuCreator |
Registers the menu creator via ServiceLoader. |
plugins/swagger-postman-importer/main/resources/META-INF/services/org.apache.jmeter.gui.action.Command |
Registers the GUI action via ServiceLoader. |
plugins/swagger-postman-importer/main/resources/extra_menus.xml |
Adds a Tools menu entry definition. |
plugins/swagger-postman-importer/main/java/com/example/jmeter/importer/YamlToJsonConverter.java |
Custom YAML→JSON conversion helper for OpenAPI YAML input. |
plugins/swagger-postman-importer/main/java/com/example/jmeter/importer/SwaggerPostmanImporterMenuCreator.java |
Adds Tools menu item that triggers the importer action. |
plugins/swagger-postman-importer/main/java/com/example/jmeter/importer/SwaggerPostmanImporterAction.java |
GUI dialog for selecting inputs/outputs, previewing parsed requests, and generating JMX. |
plugins/swagger-postman-importer/main/java/com/example/jmeter/importer/SwaggerImporter.java |
Parses Swagger/OpenAPI v2/v3 into RequestModel list. |
plugins/swagger-postman-importer/main/java/com/example/jmeter/importer/PostmanImporter.java |
Parses Postman v2/v2.1 collections into RequestModel list. |
plugins/swagger-postman-importer/main/java/com/example/jmeter/importer/model/RequestModel.java |
Shared request representation used by both importers and JMX generation. |
plugins/swagger-postman-importer/main/java/com/example/jmeter/importer/JMeterTestPlanBuilder.java |
Emits JMX XML for a Thread Group + HTTP samplers + per-sampler headers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <build> | ||
| <plugins> | ||
| <plugin> | ||
| <groupId>org.apache.maven.plugins</groupId> | ||
| <artifactId>maven-compiler-plugin</artifactId> | ||
| <version>3.11.0</version> | ||
| <configuration> | ||
| <release>8</release> | ||
| </configuration> | ||
| </plugin> |
There was a problem hiding this comment.
The module uses a non-standard Maven layout (main/java and main/resources), but the POM doesn’t configure sourceDirectory / resources accordingly. With the current POM, Maven will look in src/main/java and src/main/resources, so this module won’t compile and the META-INF/services files won’t be packaged. Either move sources/resources under src/main/... or configure the build to point at the existing directories.
| <groupId>com.example</groupId> | ||
| <artifactId>swagger-postman-importer</artifactId> | ||
| <version>1.1</version> |
There was a problem hiding this comment.
groupId/artifactId/packages are currently using placeholder com.example naming, which is inconsistent with the rest of this repository’s published coordinates (e.g., kg.apc:jmeter-plugins-*). This will make the artifact look unofficial and also leaks into the shaded relocation prefix (com.example.shaded...). Please align the Maven coordinates (and corresponding Java package / relocation prefix) with the project’s conventions.
| <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
| <modelVersion>4.0.0</modelVersion> | ||
| <groupId>com.example</groupId> | ||
| <artifactId>swagger-postman-importer</artifactId> | ||
| <version>1.1</version> | ||
| <packaging>jar</packaging> | ||
| <name>Swagger / Postman Importer for JMeter</name> | ||
| <description>JMeter plugin that imports Swagger/OpenAPI and Postman collections | ||
| and generates a JMeter Test Plan (.jmx).</description> | ||
|
|
There was a problem hiding this comment.
This module’s POM is missing the standard metadata that other plugin modules include (parent oss-parent, license, SCM, developers, encoding/source/target properties). If this is intended to be released alongside the other plugins, please mirror the structure used in existing plugin POMs so publishing/build tooling behaves consistently.
| if (inputPath.isEmpty()) throw new IllegalArgumentException("Please select an input file."); | ||
| return swaggerRadio.isSelected() | ||
| ? new SwaggerImporter().parse(inputPath) | ||
| : new PostmanImporter().parse(inputPath); | ||
| } | ||
|
|
||
| private void doPreview() { | ||
| statusLabel.setText("Parsing…"); | ||
| statusLabel.setForeground(Color.DARK_GRAY); | ||
| SwingUtilities.invokeLater(() -> { | ||
| try { | ||
| List<RequestModel> requests = parseInput(); | ||
| StringBuilder sb = new StringBuilder(); | ||
| sb.append(String.format("Found %d request(s):%n%n", requests.size())); | ||
| for (int i = 0; i < requests.size(); i++) { | ||
| RequestModel r = requests.get(i); | ||
| sb.append(String.format("#%d %s%n", i + 1, r.getName())); | ||
| sb.append(String.format(" %-8s %s://%s%s%s%n", | ||
| r.getMethod(), r.getProtocol(), r.getHost(), | ||
| r.getPort() > 0 ? ":" + r.getPort() : "", r.getPath())); | ||
| if (!r.getHeaders().isEmpty()) { | ||
| sb.append(" Headers:"); | ||
| for (RequestModel.HeaderEntry h : r.getHeaders()) | ||
| sb.append(String.format("%n %s: %s", h.getName(), h.getValue())); | ||
| sb.append("\n"); | ||
| } | ||
| if (!r.getQueryParams().isEmpty()) { | ||
| sb.append(" Query params:"); | ||
| for (RequestModel.ParamEntry qp : r.getQueryParams()) | ||
| sb.append(String.format("%n %s=%s", qp.getName(), qp.getValue())); | ||
| sb.append("\n"); | ||
| } | ||
| if (r.getBodyData() != null && !r.getBodyData().isEmpty()) { | ||
| String body = r.getBodyData().length() > 200 | ||
| ? r.getBodyData().substring(0, 197) + "…" | ||
| : r.getBodyData(); | ||
| sb.append(String.format(" Body: %s%n", body)); | ||
| } | ||
| sb.append("\n"); | ||
| } | ||
| previewArea.setText(sb.toString()); | ||
| previewArea.setCaretPosition(0); | ||
| statusLabel.setText("Parsed " + requests.size() + " request(s)."); | ||
| statusLabel.setForeground(new Color(0, 128, 0)); | ||
| } catch (Exception ex) { | ||
| previewArea.setText("Error: " + ex.getMessage()); | ||
| statusLabel.setText("Parse failed."); | ||
| statusLabel.setForeground(Color.RED); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Both parseInput() paths (Swagger/Postman) do file IO + JSON/YAML parsing, but doPreview() schedules that work with SwingUtilities.invokeLater, which still runs on the Swing EDT (and the action listener is already on the EDT). This will freeze the JMeter UI for larger specs/collections. Run parsing/generation on a background thread (e.g., SwingWorker) and update the UI on the EDT when done.
| if (inputPath.isEmpty()) throw new IllegalArgumentException("Please select an input file."); | |
| return swaggerRadio.isSelected() | |
| ? new SwaggerImporter().parse(inputPath) | |
| : new PostmanImporter().parse(inputPath); | |
| } | |
| private void doPreview() { | |
| statusLabel.setText("Parsing…"); | |
| statusLabel.setForeground(Color.DARK_GRAY); | |
| SwingUtilities.invokeLater(() -> { | |
| try { | |
| List<RequestModel> requests = parseInput(); | |
| StringBuilder sb = new StringBuilder(); | |
| sb.append(String.format("Found %d request(s):%n%n", requests.size())); | |
| for (int i = 0; i < requests.size(); i++) { | |
| RequestModel r = requests.get(i); | |
| sb.append(String.format("#%d %s%n", i + 1, r.getName())); | |
| sb.append(String.format(" %-8s %s://%s%s%s%n", | |
| r.getMethod(), r.getProtocol(), r.getHost(), | |
| r.getPort() > 0 ? ":" + r.getPort() : "", r.getPath())); | |
| if (!r.getHeaders().isEmpty()) { | |
| sb.append(" Headers:"); | |
| for (RequestModel.HeaderEntry h : r.getHeaders()) | |
| sb.append(String.format("%n %s: %s", h.getName(), h.getValue())); | |
| sb.append("\n"); | |
| } | |
| if (!r.getQueryParams().isEmpty()) { | |
| sb.append(" Query params:"); | |
| for (RequestModel.ParamEntry qp : r.getQueryParams()) | |
| sb.append(String.format("%n %s=%s", qp.getName(), qp.getValue())); | |
| sb.append("\n"); | |
| } | |
| if (r.getBodyData() != null && !r.getBodyData().isEmpty()) { | |
| String body = r.getBodyData().length() > 200 | |
| ? r.getBodyData().substring(0, 197) + "…" | |
| : r.getBodyData(); | |
| sb.append(String.format(" Body: %s%n", body)); | |
| } | |
| sb.append("\n"); | |
| } | |
| previewArea.setText(sb.toString()); | |
| previewArea.setCaretPosition(0); | |
| statusLabel.setText("Parsed " + requests.size() + " request(s)."); | |
| statusLabel.setForeground(new Color(0, 128, 0)); | |
| } catch (Exception ex) { | |
| previewArea.setText("Error: " + ex.getMessage()); | |
| statusLabel.setText("Parse failed."); | |
| statusLabel.setForeground(Color.RED); | |
| } | |
| }); | |
| return parseInput(inputPath, swaggerRadio.isSelected()); | |
| } | |
| private List<RequestModel> parseInput(String inputPath, boolean swaggerSelected) throws Exception { | |
| if (inputPath.isEmpty()) throw new IllegalArgumentException("Please select an input file."); | |
| return swaggerSelected | |
| ? new SwaggerImporter().parse(inputPath) | |
| : new PostmanImporter().parse(inputPath); | |
| } | |
| private String buildPreviewText(List<RequestModel> requests) { | |
| StringBuilder sb = new StringBuilder(); | |
| sb.append(String.format("Found %d request(s):%n%n", requests.size())); | |
| for (int i = 0; i < requests.size(); i++) { | |
| RequestModel r = requests.get(i); | |
| sb.append(String.format("#%d %s%n", i + 1, r.getName())); | |
| sb.append(String.format(" %-8s %s://%s%s%s%n", | |
| r.getMethod(), r.getProtocol(), r.getHost(), | |
| r.getPort() > 0 ? ":" + r.getPort() : "", r.getPath())); | |
| if (!r.getHeaders().isEmpty()) { | |
| sb.append(" Headers:"); | |
| for (RequestModel.HeaderEntry h : r.getHeaders()) | |
| sb.append(String.format("%n %s: %s", h.getName(), h.getValue())); | |
| sb.append("\n"); | |
| } | |
| if (!r.getQueryParams().isEmpty()) { | |
| sb.append(" Query params:"); | |
| for (RequestModel.ParamEntry qp : r.getQueryParams()) | |
| sb.append(String.format("%n %s=%s", qp.getName(), qp.getValue())); | |
| sb.append("\n"); | |
| } | |
| if (r.getBodyData() != null && !r.getBodyData().isEmpty()) { | |
| String body = r.getBodyData().length() > 200 | |
| ? r.getBodyData().substring(0, 197) + "…" | |
| : r.getBodyData(); | |
| sb.append(String.format(" Body: %s%n", body)); | |
| } | |
| sb.append("\n"); | |
| } | |
| return sb.toString(); | |
| } | |
| private void doPreview() { | |
| final String inputPath = inputFileField.getText().trim(); | |
| final boolean swaggerSelected = swaggerRadio.isSelected(); | |
| statusLabel.setText("Parsing…"); | |
| statusLabel.setForeground(Color.DARK_GRAY); | |
| new SwingWorker<String, Void>() { | |
| private int requestCount; | |
| @Override | |
| protected String doInBackground() throws Exception { | |
| List<RequestModel> requests = parseInput(inputPath, swaggerSelected); | |
| requestCount = requests.size(); | |
| return buildPreviewText(requests); | |
| } | |
| @Override | |
| protected void done() { | |
| try { | |
| previewArea.setText(get()); | |
| previewArea.setCaretPosition(0); | |
| statusLabel.setText("Parsed " + requestCount + " request(s)."); | |
| statusLabel.setForeground(new Color(0, 128, 0)); | |
| } catch (InterruptedException ex) { | |
| Thread.currentThread().interrupt(); | |
| previewArea.setText("Error: " + ex.getMessage()); | |
| statusLabel.setText("Parse failed."); | |
| statusLabel.setForeground(Color.RED); | |
| } catch (java.util.concurrent.ExecutionException ex) { | |
| Throwable cause = ex.getCause() != null ? ex.getCause() : ex; | |
| previewArea.setText("Error: " + cause.getMessage()); | |
| statusLabel.setText("Parse failed."); | |
| statusLabel.setForeground(Color.RED); | |
| } | |
| } | |
| }.execute(); |
| private void doGenerate() { | ||
| statusLabel.setText("Generating…"); | ||
| statusLabel.setForeground(Color.DARK_GRAY); | ||
| SwingUtilities.invokeLater(() -> { | ||
| try { | ||
| String outputPath = outputFileField.getText().trim(); | ||
| if (outputPath.isEmpty()) | ||
| throw new IllegalArgumentException("Please select an output JMX file."); | ||
|
|
||
| List<RequestModel> requests = parseInput(); | ||
| if (requests.isEmpty()) | ||
| throw new IllegalStateException("No requests found in the selected file."); | ||
|
|
||
| String planName = planNameField.getText().trim(); | ||
| if (planName.isEmpty()) planName = "Imported Test Plan"; | ||
|
|
||
| int threads = (Integer) threadSpinner.getValue(); | ||
| new JMeterTestPlanBuilder().writeJmx(requests, planName, threads, outputPath); | ||
|
|
There was a problem hiding this comment.
doGenerate() writes the JMX to disk inside SwingUtilities.invokeLater (EDT), so generating a plan from a large spec/collection will block the UI. Please move parsing + file write to a background task and only do UI updates (statusLabel, dialogs) on the EDT.
| public class JMeterTestPlanBuilder { | ||
|
|
||
| private final AtomicInteger idSeq = new AtomicInteger(100); | ||
|
|
||
| /** |
There was a problem hiding this comment.
idSeq is declared but never used. Please remove it, or use it to generate deterministic unique IDs if that was the intention (unused fields add noise and can confuse future maintenance).
| private String appendQueryToPath(String path, RequestModel req, boolean hasBody) { | ||
| if (!hasBody || req.getQueryParams().isEmpty()) return path; | ||
| StringBuilder sb = new StringBuilder(path).append('?'); | ||
| boolean first = true; | ||
| for (RequestModel.ParamEntry qp : req.getQueryParams()) { | ||
| if (!first) sb.append('&'); | ||
| first = false; | ||
| sb.append(qp.getName()).append('=').append(qp.getValue()); | ||
| } | ||
| return sb.toString(); |
There was a problem hiding this comment.
When hasBody is true, query params are appended to the path without URL-encoding and without handling an existing query string. This can generate invalid requests when names/values contain reserved characters (space, &, =, %, etc.) or when the path already contains ?. Consider URL-encoding params (and appending with ?/& appropriately) before writing them into HTTPSampler.path.
| skipEmptyLines(); | ||
| if (lineIdx >= lines.length) return null; | ||
| String line = lines[lineIdx]; | ||
| int indent = indentOf(line); |
There was a problem hiding this comment.
parseValue(int parentIndent) never uses parentIndent, so the parameter is effectively dead and makes the parsing logic harder to understand. Either remove the parameter or use it to enforce/validate indentation boundaries when descending (which would also make the parser more robust).
| int indent = indentOf(line); | |
| int indent = indentOf(line); | |
| if (parentIndent >= 0 && indent <= parentIndent) return null; |
| * Converts the subset of YAML used by standard OpenAPI specs to JSON without | ||
| * any external dependencies (uses only the bundled org.json via relocation). | ||
| * | ||
| * <p>Handles: block/flow mappings, sequences, scalars, quoted strings, | ||
| * inline JSON-style objects, multi-line values.</p> | ||
| */ | ||
| public class YamlToJsonConverter { | ||
|
|
||
| private String[] lines; | ||
| private int lineIdx; | ||
|
|
||
| public String convert(String yaml) { | ||
| lines = yaml.replace("\r\n", "\n").replace("\r", "\n").split("\n", -1); | ||
| lineIdx = 0; | ||
| StringBuilder sb = new StringBuilder(); | ||
| Object result = parseValue(-1); |
There was a problem hiding this comment.
This PR introduces a hand-rolled YAML parser/converter for OpenAPI YAML. Even “subset” YAML parsing is easy to get wrong (anchors/aliases, multiline scalars, escaping, indentation edge cases), which can lead to incorrect imports for valid OpenAPI specs. The repo already uses SnakeYAML in other modules (e.g. plugins/json), so it would be safer/less maintenance to reuse a real YAML parser instead of maintaining a custom one.
| * Converts the subset of YAML used by standard OpenAPI specs to JSON without | |
| * any external dependencies (uses only the bundled org.json via relocation). | |
| * | |
| * <p>Handles: block/flow mappings, sequences, scalars, quoted strings, | |
| * inline JSON-style objects, multi-line values.</p> | |
| */ | |
| public class YamlToJsonConverter { | |
| private String[] lines; | |
| private int lineIdx; | |
| public String convert(String yaml) { | |
| lines = yaml.replace("\r\n", "\n").replace("\r", "\n").split("\n", -1); | |
| lineIdx = 0; | |
| StringBuilder sb = new StringBuilder(); | |
| Object result = parseValue(-1); | |
| * Converts YAML used by standard OpenAPI specs to JSON. | |
| */ | |
| public class YamlToJsonConverter { | |
| public String convert(String yaml) { | |
| Object result = new org.yaml.snakeyaml.Yaml().load(yaml); | |
| StringBuilder sb = new StringBuilder(); |
| public List<RequestModel> parse(String filePath) throws IOException { | ||
| String content = new String(Files.readAllBytes(Paths.get(filePath))); | ||
|
|
||
| String lower = filePath.toLowerCase(); | ||
| if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { | ||
| content = stripYamlComments(content); | ||
| content = new YamlToJsonConverter().convert(content); | ||
| } | ||
|
|
||
| JSONObject root = new JSONObject(content); | ||
| List<RequestModel> requests = new ArrayList<>(); |
There was a problem hiding this comment.
There are existing JUnit tests throughout this repo for plugin logic, but this new importer module introduces substantial parsing and JMX-generation behavior without any automated tests. Adding unit tests for representative Swagger/OpenAPI (JSON+YAML) and Postman samples (including auth/header/query/body cases) would help prevent regressions and validate the JMX output.
- Fix Maven layout: move from main/ to src/main/ structure - Update Maven coordinates: com.example -> kg.apc (kg.apc prefix) - Update artifactId: swagger-postman-importer -> jmeter-plugin-swagger-postman-importer - Replace custom YAML parser with SnakeYAML (production-grade) - Fix unused idSeq field in JMeterTestPlanBuilder - Add URL encoding for query parameters - Update service files with new package names - Fix Maven shading configuration for kg.apc coordinates All Copilot AI review comments now addressed: ✓ Standard Maven layout with src/main/java ✓ Proper Maven coordinates (kg.apc groupId) ✓ Production YAML parser (SnakeYAML) ✓ Query parameter URL encoding ✓ Code quality improvements
|
We don't accept plugin source code here, we only accept descriptors. Look how other PRs are organized first. |
Fixes: Integration with undera/jmeter-plugins ecosystem