From c4fb967e312a6b34a497ed817424417a5f9b87d3 Mon Sep 17 00:00:00 2001 From: aleembhd Date: Wed, 11 Mar 2026 12:00:28 +0530 Subject: [PATCH 1/3] Add converter implementation and related tests --- atom_tools/lib/converter.py | 358 +++++++++++++++- test/data/java-spring-annotations-usages.json | 384 ++++++++++++++++++ test/test_converter.py | 12 +- test/test_spring_annotations.py | 166 ++++++++ 4 files changed, 913 insertions(+), 7 deletions(-) create mode 100644 test/data/java-spring-annotations-usages.json create mode 100644 test/test_spring_annotations.py diff --git a/atom_tools/lib/converter.py b/atom_tools/lib/converter.py index 4efc701..1da2692 100644 --- a/atom_tools/lib/converter.py +++ b/atom_tools/lib/converter.py @@ -26,6 +26,66 @@ logger = logging.getLogger(__name__) regex = OpenAPIRegexCollection() +# Maps fully-qualified Java types to OpenAPI schema dicts. +_JAVA_TYPE_SCHEMA: Dict[str, Dict] = { + 'java.lang.String': {'type': 'string'}, + 'java.lang.CharSequence': {'type': 'string'}, + 'java.lang.Integer': {'type': 'integer'}, + 'int': {'type': 'integer'}, + 'java.lang.Long': {'type': 'integer', 'format': 'int64'}, + 'long': {'type': 'integer', 'format': 'int64'}, + 'java.lang.Short': {'type': 'integer'}, + 'short': {'type': 'integer'}, + 'java.lang.Double': {'type': 'number', 'format': 'double'}, + 'double': {'type': 'number', 'format': 'double'}, + 'java.lang.Float': {'type': 'number', 'format': 'float'}, + 'float': {'type': 'number', 'format': 'float'}, + 'java.lang.Boolean': {'type': 'boolean'}, + 'boolean': {'type': 'boolean'}, + 'java.util.UUID': {'type': 'string', 'format': 'uuid'}, + 'java.util.List': {'type': 'array'}, + 'java.util.ArrayList': {'type': 'array'}, + 'java.util.Set': {'type': 'array'}, + 'java.util.Collection': {'type': 'array'}, + 'java.util.Map': {'type': 'object'}, + 'java.util.HashMap': {'type': 'object'}, + 'java.time.Instant': {'type': 'string', 'format': 'date-time'}, + 'java.time.LocalDate': {'type': 'string', 'format': 'date'}, + 'java.time.LocalDateTime': {'type': 'string', 'format': 'date-time'}, + 'java.time.OffsetDateTime': {'type': 'string', 'format': 'date-time'}, + 'java.math.BigDecimal': {'type': 'number'}, + 'java.math.BigInteger': {'type': 'integer'}, +} + +# Annotations that indicate a Spring parameter annotation. +_PARAM_ANNOTATION_PREFIXES = ('@RequestBody', '@PathVariable', '@RequestParam', '@RequestHeader') + +# Spring HTTP-method mapping annotations and their OpenAPI verbs. +_SPRING_METHOD_ANNOTATIONS = [ + ('post', 'PostMapping'), + ('put', 'PutMapping'), + ('patch', 'PatchMapping'), + ('get', 'GetMapping'), + ('delete', 'DeleteMapping'), +] + + +def _java_type_to_schema(type_full_name: str) -> Dict: + """Return an OpenAPI schema dict for a Java type, defaulting to object.""" + return dict(_JAVA_TYPE_SCHEMA.get(type_full_name, {'type': 'object'})) + + +def _extract_annotation_string_value(resolved_method: str) -> str: + """ + Extract the quoted string value from an annotation, e.g. + '@RequestHeader("X-Key")' → 'X-Key' + '@RequestHeader(value = "X-Auth-Token")' → 'X-Auth-Token' + Returns empty string if no quoted value is present. + """ + m = re.search(r'["\']([^"\']+)["\']', resolved_method) + return m.group(1) if m else '' + + exclusions = ['/content-type', '/application/javascript', '/application/json', '/application/text', '/application/xml', '/*', '/*/*', '/allow', '/get', '/post', '/xml', '/cookie', '/usestrict', '/maxage', '/sessionid'] @@ -112,6 +172,14 @@ def convert_usages(self) -> Dict[str, Dict]: udt_methods = self._extract_methods_from_udt() if udt_methods: paths = merge_path_objects(paths, udt_methods) + param_annotation_paths = self._enrich_from_param_annotation() + if param_annotation_paths: + paths = merge_path_objects(paths, param_annotation_paths) + paths = self._backfill_from_annotation_slices(paths) + # Remove paths that are not valid OpenAPI path strings. + # Must contain only valid characters AND either be root '/' or contain at least one alphanumeric. + paths = {k: v for k, v in paths.items() + if re.match(r'^/[A-Za-z0-9_.~\-{}/]*$', k) and (k == '/' or re.search(r'[A-Za-z0-9]', k))} return paths def create_file_to_method_dict(self, method_map: Dict[str, Any]) -> Dict[str, List]: @@ -412,7 +480,7 @@ def _generic_params_helper(self, endpoint: str, orig_endpoint: str) -> List[Dict existing_path_params = {i['name'] for i in params} if matches := regex.processed_param.findall(endpoint): params.extend( - [{'name': m, 'in': 'path', 'required': True} for m in matches if + [{'name': m, 'in': 'path', 'required': True, 'schema': {'type': 'string'}} for m in matches if m not in existing_path_params] ) return params @@ -582,6 +650,283 @@ def _get_java_class_prefixes(self) -> Dict[str, str]: prefixes[file_name] = extracted[0] return prefixes + def _build_udt_schema_map(self) -> Dict[str, List[Dict]]: + """Return a map of {typeFullName: fields list} from userDefinedTypes.""" + result: Dict[str, List[Dict]] = {} + for udt in self.usages.content.get('userDefinedTypes', []): + name = udt.get('name', '') or '' + fields = udt.get('fields') or [] + if name and fields: + result[name] = fields + return result + + def _build_schema_from_type(self, type_full_name: str, udt_map: Dict[str, List[Dict]]) -> Dict: + """ + Build an OpenAPI schema for a Java type. + + If the type has a UDT entry, its fields become object properties. + Nested types with their own UDT entries are expanded one level deep. + Everything else falls back to _java_type_to_schema. + """ + fields = udt_map.get(type_full_name) + if not fields: + return _java_type_to_schema(type_full_name) + properties: Dict[str, Dict] = {} + for field in fields: + field_name = field.get('name', '') or '' + field_type = field.get('typeFullName', '') or '' + if not field_name: + continue + nested_fields = udt_map.get(field_type) + if nested_fields: + nested_props = { + nf['name']: _java_type_to_schema(nf.get('typeFullName', '') or '') + for nf in nested_fields + if nf.get('name') + } + properties[field_name] = {'type': 'object', 'properties': nested_props} + else: + properties[field_name] = _java_type_to_schema(field_type) + return {'type': 'object', 'properties': properties} + + def _enrich_from_param_annotation(self) -> Dict[str, Any]: + """ + Scan objectSlices for Spring parameter annotations emitted as separate + ANNOTATION usage entries (new atom format) and produce OpenAPI + requestBody / parameter objects. + + New atom format per usage entry: + - label == "PARAM" → parameter definition (name, typeFullName, position) + - label == "ANNOTATION" → annotation for the parameter whose name matches; + resolvedMethod starts with @RequestBody, + @PathVariable, @RequestParam, or @RequestHeader. + + HTTP-method annotations (PostMapping, GetMapping, …) appear as ANNOTATION + entries with name == the annotation short name (e.g. "PostMapping"). + """ + if self.usages.origin_type not in ('java', 'jar'): + return {} + udt_map = self._build_udt_schema_map() + class_prefixes = self._get_java_class_prefixes() + paths: Dict[str, Any] = {} + + for obj_slice in self.usages.content.get('objectSlices', []): + file_name = obj_slice.get('fileName', '') or '' + line_number = obj_slice.get('lineNumber') + usages = obj_slice.get('usages') or [] + + # ── collect PARAM and ANNOTATION entries from this objectSlice ── + params_by_name: Dict[str, Dict] = {} + annotations_by_name: Dict[str, str] = {} # param_name → resolvedMethod + + endpoint: str | None = None + http_method: str | None = None + + for usage in usages: + target = usage.get('targetObj') or {} + label = target.get('label', '') + name = target.get('name', '') or '' + resolved = target.get('resolvedMethod', '') or '' + + if label == 'PARAM' and name: + params_by_name[name] = target + elif label == 'ANNOTATION' and name: + if any(resolved.startswith(p) for p in _PARAM_ANNOTATION_PREFIXES): + annotations_by_name[name] = resolved + elif endpoint is None: + for verb, ann_name in _SPRING_METHOD_ANNOTATIONS: + if ann_name in resolved or ann_name in name: + extracted = self._extract_endpoints(resolved) + if extracted: + endpoint = extracted[0] + http_method = verb + break + + if not annotations_by_name or not endpoint or not http_method: + continue + + # Apply class-level URL prefix + prefix = class_prefixes.get(file_name, '') + if prefix: + endpoint = prefix.rstrip('/') + endpoint + + # ── build parameters and requestBody from annotation↔param pairs ── + path_params: List[Dict] = [] + query_params: List[Dict] = [] + header_params: List[Dict] = [] + request_body: Dict | None = None + + for param_name, ann_resolved in annotations_by_name.items(): + param_entry = params_by_name.get(param_name, {}) + type_full_name = param_entry.get('typeFullName', '') or '' + + if ann_resolved.startswith('@RequestBody'): + schema = self._build_schema_from_type(type_full_name, udt_map) + request_body = { + 'content': {'application/json': {'schema': schema}}, + 'required': True, + } + elif ann_resolved.startswith('@PathVariable'): + path_params.append({ + 'in': 'path', + 'name': param_name, + 'required': True, + 'schema': _java_type_to_schema(type_full_name), + }) + elif ann_resolved.startswith('@RequestParam'): + query_params.append({ + 'in': 'query', + 'name': param_name, + 'schema': _java_type_to_schema(type_full_name), + }) + elif ann_resolved.startswith('@RequestHeader'): + header_name = param_name + header_params.append({ + 'in': 'header', + 'name': header_name, + 'schema': _java_type_to_schema(type_full_name), + }) + + all_params = path_params + query_params + header_params + if not all_params and request_body is None: + continue + + operation: Dict = { + 'responses': self._infer_java_response_codes(file_name, line_number, http_method) + } + if all_params: + operation['parameters'] = all_params + if request_body: + operation['requestBody'] = request_body + + if endpoint not in paths: + paths[endpoint] = {} + existing_op = paths[endpoint].get(http_method) + if existing_op is None: + paths[endpoint][http_method] = operation + else: + if request_body and 'requestBody' not in existing_op: + existing_op['requestBody'] = request_body + if all_params: + existing_op.setdefault('parameters', []) + existing_op['parameters'] = merge_params(existing_op['parameters'], all_params) + + return paths + + def _backfill_from_annotation_slices(self, paths: Dict[str, Any]) -> Dict[str, Any]: + """ + Second-pass enrichment for Spring methods whose HTTP-mapping annotation + (@PostMapping, @GetMapping, etc.) was NOT emitted in their objectSlice. + + Atom sometimes omits the method-level mapping annotation while still + emitting @RequestBody / @PathVariable / @RequestParam / @RequestHeader + annotations. The existing call-detection code already found the correct + endpoint URL for these methods (stored in x-atom-usages). We link the + two by observing that: + + x-atom-usages "call" line == objectSlice.lineNumber + 1 + + For each POST/PUT/PATCH/DELETE/GET operation that already has a path but + is missing requestBody and/or parameters, we look up the objectSlice + whose lineNumber+1 matches the call line and whose file name matches, + then extract parameters and requestBody from its ANNOTATION entries. + """ + if self.usages.origin_type not in ('java', 'jar'): + return paths + + udt_map = self._build_udt_schema_map() + + # Build lookup: (posix_file_name, call_line) -> enrichment dict + # call_line = objectSlice.lineNumber + 1 + enrichment: Dict[tuple, Dict] = {} + + for obj_slice in self.usages.content.get('objectSlices', []): + file_name = obj_slice.get('fileName', '') or '' + line_number = obj_slice.get('lineNumber') + if not file_name or line_number is None: + continue + + usages = obj_slice.get('usages') or [] + + # Skip slices that already have a method annotation (handled in first pass) + if any( + any(ann_name in (u.get('targetObj', {}).get('resolvedMethod', '') or '') + for _, ann_name in _SPRING_METHOD_ANNOTATIONS) + for u in usages + if u.get('targetObj', {}).get('label') == 'ANNOTATION' + ): + continue + + # Collect PARAM entries and ANNOTATION entries for parameter annotations + params_by_name: Dict[str, Dict] = {} + path_params: List[Dict] = [] + query_params: List[Dict] = [] + header_params: List[Dict] = [] + request_body: Dict | None = None + + for u in usages: + t = u.get('targetObj') or {} + label = t.get('label', '') + name = t.get('name', '') or '' + if label == 'PARAM' and name: + params_by_name[name] = t + + for u in usages: + t = u.get('targetObj') or {} + label = t.get('label', '') + name = t.get('name', '') or '' + resolved = t.get('resolvedMethod', '') or '' + if label != 'ANNOTATION' or not name: + continue + if not any(resolved.startswith(p) for p in _PARAM_ANNOTATION_PREFIXES): + continue + param_entry = params_by_name.get(name, {}) + type_full_name = param_entry.get('typeFullName', '') or '' + if resolved.startswith('@RequestBody'): + schema = self._build_schema_from_type(type_full_name, udt_map) + request_body = {'content': {'application/json': {'schema': schema}}, 'required': True} + elif resolved.startswith('@PathVariable'): + path_params.append({'in': 'path', 'name': name, 'required': True, + 'schema': _java_type_to_schema(type_full_name)}) + elif resolved.startswith('@RequestParam'): + query_params.append({'in': 'query', 'name': name, + 'schema': _java_type_to_schema(type_full_name)}) + elif resolved.startswith('@RequestHeader'): + header_params.append({'in': 'header', 'name': name, + 'schema': _java_type_to_schema(type_full_name)}) + + all_params = path_params + query_params + header_params + if not all_params and request_body is None: + continue + + posix_file = Path(file_name).as_posix() + entry = {'requestBody': request_body, 'parameters': all_params} + # Register under both line_number and line_number+1 since atom uses either offset + enrichment[(posix_file, line_number)] = entry + enrichment[(posix_file, line_number + 1)] = entry + + # Apply enrichment to paths that are missing requestBody/parameters + for path_key, path_item in paths.items(): + call_data = path_item.get('x-atom-usages', {}).get('call', {}) + for call_file, line_nums in call_data.items(): + posix_call_file = Path(call_file).as_posix() + for ln in (line_nums or []): + info = enrichment.get((posix_call_file, ln)) + if not info: + continue + # Apply to all POST/PUT/PATCH operations (request bodies) or any HTTP method (params) + for method in ('post', 'put', 'patch', 'delete', 'get'): + op = path_item.get(method) + if not isinstance(op, dict): + continue + if info['requestBody'] and method in ('post', 'put', 'patch') and 'requestBody' not in op: + op['requestBody'] = info['requestBody'] + if info['parameters']: + op.setdefault('parameters', []) + op['parameters'] = merge_params(op['parameters'], info['parameters']) + + return paths + def _infer_java_response_codes(self, file_name: str, line_number: int | None, http_method: str) -> Dict: """ Infer HTTP response codes for a Java/Spring controller method from slice data. @@ -892,10 +1237,17 @@ def merge_params(p1: List, p2: List) -> List: Returns: list: The merged list of parameters. """ - names = [i.get('name') for i in p1] + p1_by_name = {i.get('name'): i for i in p1} for i in p2: - if i.get('name', '') not in names: + name = i.get('name', '') + if name not in p1_by_name: p1.append(i) + else: + # Enrich existing entry with any fields the incoming entry has that are missing + existing = p1_by_name[name] + for k, v in i.items(): + if k not in existing: + existing[k] = v return p1 diff --git a/test/data/java-spring-annotations-usages.json b/test/data/java-spring-annotations-usages.json new file mode 100644 index 0000000..8a14849 --- /dev/null +++ b/test/data/java-spring-annotations-usages.json @@ -0,0 +1,384 @@ +{ + "objectSlices": [ + { + "code": "", + "fullName": "com.example.ChargeController.charge:(2)", + "signature": "(2)", + "fileName": "src/main/java/com/example/ChargeController.java", + "lineNumber": 15, + "columnNumber": 3, + "usages": [ + { + "targetObj": { + "name": "PostMapping", + "typeFullName": "org.springframework.web.bind.annotation.PostMapping", + "resolvedMethod": "@PostMapping(\"/charge\")", + "isExternal": false, + "lineNumber": 15, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "PostMapping", + "typeFullName": "org.springframework.web.bind.annotation.PostMapping", + "resolvedMethod": "@PostMapping(\"/charge\")", + "isExternal": false, + "lineNumber": 15, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "body", + "typeFullName": "com.example.ChargeRequest", + "position": 1, + "lineNumber": 16, + "columnNumber": 40, + "label": "PARAM" + }, + "definedBy": { + "name": "body", + "typeFullName": "com.example.ChargeRequest", + "position": 1, + "lineNumber": 16, + "columnNumber": 40, + "label": "PARAM" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "body", + "typeFullName": "org.springframework.web.bind.annotation.RequestBody", + "resolvedMethod": "@RequestBody", + "isExternal": false, + "lineNumber": 16, + "columnNumber": 40, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "body", + "typeFullName": "org.springframework.web.bind.annotation.RequestBody", + "resolvedMethod": "@RequestBody", + "isExternal": false, + "lineNumber": 16, + "columnNumber": 40, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "apiKey", + "typeFullName": "org.springframework.web.bind.annotation.RequestHeader", + "resolvedMethod": "@RequestHeader(\"X-Api-Key\")", + "isExternal": false, + "lineNumber": 16, + "columnNumber": 70, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "apiKey", + "typeFullName": "org.springframework.web.bind.annotation.RequestHeader", + "resolvedMethod": "@RequestHeader(\"X-Api-Key\")", + "isExternal": false, + "lineNumber": 16, + "columnNumber": 70, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "apiKey", + "typeFullName": "java.lang.String", + "position": 2, + "lineNumber": 16, + "columnNumber": 70, + "label": "PARAM" + }, + "definedBy": { + "name": "apiKey", + "typeFullName": "java.lang.String", + "position": 2, + "lineNumber": 16, + "columnNumber": 70, + "label": "PARAM" + }, + "invokedCalls": [], + "argToCalls": [] + } + ] + }, + { + "code": "", + "fullName": "com.example.OrderController.getOrder:(2)", + "signature": "(2)", + "fileName": "src/main/java/com/example/OrderController.java", + "lineNumber": 10, + "columnNumber": 3, + "usages": [ + { + "targetObj": { + "name": "GetMapping", + "typeFullName": "org.springframework.web.bind.annotation.GetMapping", + "resolvedMethod": "@GetMapping(\"/orders/{orderId}\")", + "isExternal": false, + "lineNumber": 10, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "GetMapping", + "typeFullName": "org.springframework.web.bind.annotation.GetMapping", + "resolvedMethod": "@GetMapping(\"/orders/{orderId}\")", + "isExternal": false, + "lineNumber": 10, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "orderId", + "typeFullName": "java.lang.String", + "position": 1, + "lineNumber": 11, + "columnNumber": 45, + "label": "PARAM" + }, + "definedBy": { + "name": "orderId", + "typeFullName": "java.lang.String", + "position": 1, + "lineNumber": 11, + "columnNumber": 45, + "label": "PARAM" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "orderId", + "typeFullName": "org.springframework.web.bind.annotation.PathVariable", + "resolvedMethod": "@PathVariable", + "isExternal": false, + "lineNumber": 11, + "columnNumber": 45, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "orderId", + "typeFullName": "org.springframework.web.bind.annotation.PathVariable", + "resolvedMethod": "@PathVariable", + "isExternal": false, + "lineNumber": 11, + "columnNumber": 45, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "expand", + "typeFullName": "java.lang.Boolean", + "position": 2, + "lineNumber": 11, + "columnNumber": 80, + "label": "PARAM" + }, + "definedBy": { + "name": "expand", + "typeFullName": "java.lang.Boolean", + "position": 2, + "lineNumber": 11, + "columnNumber": 80, + "label": "PARAM" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "expand", + "typeFullName": "org.springframework.web.bind.annotation.RequestParam", + "resolvedMethod": "@RequestParam(required = false)", + "isExternal": false, + "lineNumber": 11, + "columnNumber": 80, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "expand", + "typeFullName": "org.springframework.web.bind.annotation.RequestParam", + "resolvedMethod": "@RequestParam(required = false)", + "isExternal": false, + "lineNumber": 11, + "columnNumber": 80, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + } + ] + }, + { + "code": "", + "fullName": "com.example.OrderController.deleteOrder:(2)", + "signature": "(2)", + "fileName": "src/main/java/com/example/OrderController.java", + "lineNumber": 20, + "columnNumber": 3, + "usages": [ + { + "targetObj": { + "name": "DeleteMapping", + "typeFullName": "org.springframework.web.bind.annotation.DeleteMapping", + "resolvedMethod": "@DeleteMapping(\"/orders/{orderId}\")", + "isExternal": false, + "lineNumber": 20, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "DeleteMapping", + "typeFullName": "org.springframework.web.bind.annotation.DeleteMapping", + "resolvedMethod": "@DeleteMapping(\"/orders/{orderId}\")", + "isExternal": false, + "lineNumber": 20, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "orderId", + "typeFullName": "java.lang.String", + "position": 1, + "lineNumber": 21, + "columnNumber": 45, + "label": "PARAM" + }, + "definedBy": { + "name": "orderId", + "typeFullName": "java.lang.String", + "position": 1, + "lineNumber": 21, + "columnNumber": 45, + "label": "PARAM" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "orderId", + "typeFullName": "org.springframework.web.bind.annotation.PathVariable", + "resolvedMethod": "@PathVariable", + "isExternal": false, + "lineNumber": 21, + "columnNumber": 45, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "orderId", + "typeFullName": "org.springframework.web.bind.annotation.PathVariable", + "resolvedMethod": "@PathVariable", + "isExternal": false, + "lineNumber": 21, + "columnNumber": 45, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "authToken", + "typeFullName": "java.lang.String", + "position": 2, + "lineNumber": 21, + "columnNumber": 80, + "label": "PARAM" + }, + "definedBy": { + "name": "authToken", + "typeFullName": "java.lang.String", + "position": 2, + "lineNumber": 21, + "columnNumber": 80, + "label": "PARAM" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "authToken", + "typeFullName": "org.springframework.web.bind.annotation.RequestHeader", + "resolvedMethod": "@RequestHeader(value = \"X-Auth-Token\")", + "isExternal": false, + "lineNumber": 21, + "columnNumber": 80, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "authToken", + "typeFullName": "org.springframework.web.bind.annotation.RequestHeader", + "resolvedMethod": "@RequestHeader(value = \"X-Auth-Token\")", + "isExternal": false, + "lineNumber": 21, + "columnNumber": 80, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + } + ] + } + ], + "userDefinedTypes": [ + { + "name": "com.example.ChargeRequest", + "fields": [ + { + "name": "amount", + "typeFullName": "java.lang.Long", + "lineNumber": 5, + "columnNumber": 18, + "label": "LOCAL" + }, + { + "name": "currency", + "typeFullName": "java.lang.String", + "lineNumber": 6, + "columnNumber": 18, + "label": "LOCAL" + }, + { + "name": "description", + "typeFullName": "java.lang.String", + "lineNumber": 7, + "columnNumber": 18, + "label": "LOCAL" + } + ], + "procedures": [], + "fileName": "src/main/java/com/example/ChargeRequest.java", + "lineNumber": 3, + "columnNumber": 1 + } + ] +} diff --git a/test/test_converter.py b/test/test_converter.py index a82ec88..fa85e9f 100644 --- a/test/test_converter.py +++ b/test/test_converter.py @@ -129,7 +129,8 @@ def test_endpoints_to_openapi(java_usages_1): '/accounts/{accountName}': {'get': {'responses': {'200': {'description': 'OK'}}}, 'parameters': [{'in': 'path', 'name': 'accountName', - 'required': True}], + 'required': True, + 'schema': {'type': 'string'}}], 'x-atom-usages': {'call': {'notification-service/src/main/java/com/piggymetrics/notification/client/AccountServiceClient.java': [12]}}}, '/current': {'get': {'responses': {'200': {'description': 'OK'}}}, 'put': {'responses': {'200': {'description': 'OK'}}}, @@ -146,7 +147,8 @@ def test_endpoints_to_openapi(java_usages_1): 'target': {'notification-service/src/main/java/com/piggymetrics/notification/controller/RecipientController.java': [26]}}}, '/statistics/{accountName}': {'parameters': [{'in': 'path', 'name': 'accountName', - 'required': True}], + 'required': True, + 'schema': {'type': 'string'}}], 'put': {'responses': {'200': {'description': 'OK'}}}, 'x-atom-usages': {'call': {'account-service/src/main/java/com/piggymetrics/account/client/StatisticsServiceClient.java': [13]}}}, '/uaa/users': {'post': {'responses': {'201': {'description': 'Created'}}}, @@ -157,7 +159,8 @@ def test_endpoints_to_openapi(java_usages_1): '/{accountName}': {'get': {'responses': {'200': {'description': 'OK'}}}, 'parameters': [{'in': 'path', 'name': 'accountName', - 'required': True}], + 'required': True, + 'schema': {'type': 'string'}}], 'put': {'responses': {'200': {'description': 'OK'}}}, 'x-atom-usages': {'call': {'statistics-service/src/main/java/com/piggymetrics/statistics/controller/StatisticsController.java': [26, 32]}, @@ -165,7 +168,8 @@ def test_endpoints_to_openapi(java_usages_1): '/{name}': {'get': {'responses': {'200': {'description': 'OK'}}}, 'parameters': [{'in': 'path', 'name': 'name', - 'required': True}], + 'required': True, + 'schema': {'type': 'string'}}], 'x-atom-usages': {'call': {'account-service/src/main/java/com/piggymetrics/account/controller/AccountController.java': [20]}}}}} diff --git a/test/test_spring_annotations.py b/test/test_spring_annotations.py new file mode 100644 index 0000000..1f09942 --- /dev/null +++ b/test/test_spring_annotations.py @@ -0,0 +1,166 @@ +""" +Tests for Spring parameter annotation support in the OpenAPI converter. + +Covers the new atom slice format where @RequestBody, @PathVariable, +@RequestParam, and @RequestHeader are emitted as separate ANNOTATION +usage entries (label == "ANNOTATION") rather than an annotations array +on PARAM entries. +""" +import pytest + +from atom_tools.lib.converter import OpenAPI, _java_type_to_schema, _extract_annotation_string_value + + +# ── unit helpers ────────────────────────────────────────────────────────────── + +def test_java_type_to_schema_primitives(): + assert _java_type_to_schema('java.lang.String') == {'type': 'string'} + assert _java_type_to_schema('java.lang.Integer') == {'type': 'integer'} + assert _java_type_to_schema('int') == {'type': 'integer'} + assert _java_type_to_schema('java.lang.Long') == {'type': 'integer', 'format': 'int64'} + assert _java_type_to_schema('long') == {'type': 'integer', 'format': 'int64'} + assert _java_type_to_schema('java.lang.Double') == {'type': 'number', 'format': 'double'} + assert _java_type_to_schema('java.lang.Boolean') == {'type': 'boolean'} + assert _java_type_to_schema('boolean') == {'type': 'boolean'} + + +def test_java_type_to_schema_collections(): + assert _java_type_to_schema('java.util.List') == {'type': 'array'} + assert _java_type_to_schema('java.util.Map') == {'type': 'object'} + assert _java_type_to_schema('java.util.UUID') == {'type': 'string', 'format': 'uuid'} + + +def test_java_type_to_schema_unknown_defaults_to_object(): + assert _java_type_to_schema('com.example.SomeDto') == {'type': 'object'} + assert _java_type_to_schema('ANY') == {'type': 'object'} + assert _java_type_to_schema('') == {'type': 'object'} + + +def test_extract_annotation_string_value_double_quotes(): + assert _extract_annotation_string_value('@RequestHeader("X-Api-Key")') == 'X-Api-Key' + + +def test_extract_annotation_string_value_named_attribute(): + assert _extract_annotation_string_value('@RequestHeader(value = "X-Auth-Token")') == 'X-Auth-Token' + + +def test_extract_annotation_string_value_no_value(): + assert _extract_annotation_string_value('@RequestHeader') == '' + assert _extract_annotation_string_value('@PathVariable') == '' + + +# ── fixture ─────────────────────────────────────────────────────────────────── + +@pytest.fixture +def spring_annotations(): + return OpenAPI( + 'openapi3.1.0', 'java', + 'test/data/java-spring-annotations-usages.json' + ) + + +# ── _build_udt_schema_map ───────────────────────────────────────────────────── + +def test_build_udt_schema_map(spring_annotations): + udt_map = spring_annotations._build_udt_schema_map() + assert 'com.example.ChargeRequest' in udt_map + fields = udt_map['com.example.ChargeRequest'] + field_names = [f['name'] for f in fields] + assert 'amount' in field_names + assert 'currency' in field_names + assert 'description' in field_names + + +# ── _build_schema_from_type ─────────────────────────────────────────────────── + +def test_build_schema_from_type_known_udt(spring_annotations): + udt_map = spring_annotations._build_udt_schema_map() + schema = spring_annotations._build_schema_from_type('com.example.ChargeRequest', udt_map) + assert schema['type'] == 'object' + props = schema['properties'] + assert props['amount'] == {'type': 'integer', 'format': 'int64'} + assert props['currency'] == {'type': 'string'} + assert props['description'] == {'type': 'string'} + + +def test_build_schema_from_type_unknown_falls_back(spring_annotations): + udt_map = spring_annotations._build_udt_schema_map() + schema = spring_annotations._build_schema_from_type('com.example.Unknown', udt_map) + assert schema == {'type': 'object'} + schema2 = spring_annotations._build_schema_from_type('java.lang.String', udt_map) + assert schema2 == {'type': 'string'} + + +# ── _enrich_from_param_annotation ──────────────────────────────────────────── + +def test_enrich_post_requestbody(spring_annotations): + paths = spring_annotations._enrich_from_param_annotation() + assert '/charge' in paths + post_op = paths['/charge']['post'] + assert 'requestBody' in post_op + rb = post_op['requestBody'] + assert rb['required'] is True + schema = rb['content']['application/json']['schema'] + assert schema['type'] == 'object' + assert 'amount' in schema['properties'] + assert 'currency' in schema['properties'] + assert 'description' in schema['properties'] + + +def test_enrich_post_requestheader(spring_annotations): + paths = spring_annotations._enrich_from_param_annotation() + post_op = paths['/charge']['post'] + assert 'parameters' in post_op + params_by_name = {p['name']: p for p in post_op['parameters']} + # @RequestHeader uses the Java parameter name, not the annotation string value + assert 'apiKey' in params_by_name + assert params_by_name['apiKey']['in'] == 'header' + assert params_by_name['apiKey']['schema'] == {'type': 'string'} + + +def test_enrich_get_pathvariable_and_queryparam(spring_annotations): + paths = spring_annotations._enrich_from_param_annotation() + assert '/orders/{orderId}' in paths + get_op = paths['/orders/{orderId}']['get'] + assert 'parameters' in get_op + params_by_name = {p['name']: p for p in get_op['parameters']} + # @PathVariable orderId + assert 'orderId' in params_by_name + assert params_by_name['orderId']['in'] == 'path' + assert params_by_name['orderId']['required'] is True + assert params_by_name['orderId']['schema'] == {'type': 'string'} + # @RequestParam expand + assert 'expand' in params_by_name + assert params_by_name['expand']['in'] == 'query' + assert params_by_name['expand']['schema'] == {'type': 'boolean'} + + +def test_enrich_delete_pathvariable_and_header(spring_annotations): + paths = spring_annotations._enrich_from_param_annotation() + del_op = paths['/orders/{orderId}']['delete'] + assert 'parameters' in del_op + params_by_name = {p['name']: p for p in del_op['parameters']} + assert params_by_name['orderId']['in'] == 'path' + # @RequestHeader uses the Java parameter name (authToken), not the annotation string value + assert params_by_name['authToken']['in'] == 'header' + + +# ── full convert_usages integration ────────────────────────────────────────── + +def test_convert_usages_includes_requestbody(spring_annotations): + paths = spring_annotations.convert_usages() + assert '/charge' in paths + post_op = paths['/charge'].get('post', {}) + assert 'requestBody' in post_op, "POST /charge must have requestBody" + + +def test_convert_usages_no_requestbody_on_get(spring_annotations): + paths = spring_annotations.convert_usages() + get_op = paths.get('/orders/{orderId}', {}).get('get', {}) + assert 'requestBody' not in get_op + + +def test_non_java_origin_skipped(): + """_enrich_from_param_annotation returns {} for non-Java slice types.""" + api = OpenAPI('openapi3.1.0', 'python', 'test/data/py-breakable-flask-usages.json') + assert api._enrich_from_param_annotation() == {} From fc490f85458cbca23e8e8c67718c2e282e10ed7e Mon Sep 17 00:00:00 2001 From: aleembhd Date: Thu, 12 Mar 2026 03:03:22 +0530 Subject: [PATCH 2/3] feat(java): infer @RequestBody schema from getters; use DTO name as response key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _properties_from_getters() to build request body schema from argToCalls getter methods when the DTO is absent from userDefinedTypes - Add _extract_response_dto_key() to derive the response object key from the actual response DTO class name instead of hard-coding 'description' - Add _is_custom_dto() to distinguish application DTOs from stdlib types - Add ±1 line tolerance in _infer_java_response_codes() to handle atom recording slices at the annotation line vs the method signature line - Fix OpenAPI title format: '{name} OpenAPI Specification' --- atom_tools/lib/converter.py | 118 +++++++++- test/data/java-spring-annotations-usages.json | 212 +++++++++++++++++- test/test_converter.py | 4 +- test/test_response_codes.py | 8 +- test/test_spring_annotations.py | 206 ++++++++++++++++- 5 files changed, 535 insertions(+), 13 deletions(-) diff --git a/atom_tools/lib/converter.py b/atom_tools/lib/converter.py index 1da2692..ff077f6 100644 --- a/atom_tools/lib/converter.py +++ b/atom_tools/lib/converter.py @@ -75,6 +75,44 @@ def _java_type_to_schema(type_full_name: str) -> Dict: return dict(_JAVA_TYPE_SCHEMA.get(type_full_name, {'type': 'object'})) +def _properties_from_getters(arg_to_calls: List[Dict]) -> Dict: + """ + Build an OpenAPI object schema from getter methods in argToCalls. + + For each entry like getEmail → property 'email', using returnType for + the schema. Falls back to {'type': 'string'} when returnType is 'ANY' + or unresolved, since DTO properties are usually primitives/strings. + Deduplicates properties; non-getter callNames are ignored. + + Returns {'type': 'object', 'properties': {...}} when at least one getter + is found, otherwise {'type': 'object'} (no properties key). + """ + properties: Dict[str, Dict] = {} + for call in arg_to_calls: + call_name = call.get('callName') or '' + if len(call_name) <= 3 or not call_name.startswith('get'): + continue + prop_name = call_name[3].lower() + call_name[4:] + if prop_name in properties: + continue + return_type = call.get('returnType') or '' + if return_type and return_type not in ('ANY', 'void'): + schema = _java_type_to_schema(return_type) + else: + # Try to extract return type from 'Class.method:ReturnType(n)' signature + resolved = call.get('resolvedMethod') or '' + m = re.match(r'.+:([^<(][^(]*)\(\d+\)$', resolved) + if m: + extracted = m.group(1).strip() + schema = _java_type_to_schema(extracted) if extracted else {'type': 'string'} + else: + schema = {'type': 'string'} + properties[prop_name] = schema + if not properties: + return {'type': 'object'} + return {'type': 'object', 'properties': properties} + + def _extract_annotation_string_value(resolved_method: str) -> str: """ Extract the quoted string value from an annotation, e.g. @@ -85,6 +123,62 @@ def _extract_annotation_string_value(resolved_method: str) -> str: m = re.search(r'["\']([^"\']+)["\']', resolved_method) return m.group(1) if m else '' +_PRIMITIVE_TYPES = { + 'int', 'long', 'boolean', 'double', 'float', 'char', 'byte', 'short', + 'void', 'ANY', 'null', '', +} +_STDLIB_PREFIXES = ( + 'java.', 'javax.', 'jakarta.', 'org.springframework.', 'sun.', 'com.sun.', +) + + +def _is_custom_dto(type_full_name: str) -> bool: + """Return True if type_full_name looks like an application-level DTO class.""" + if not type_full_name or type_full_name in _PRIMITIVE_TYPES: + return False + return not any(type_full_name.startswith(p) for p in _STDLIB_PREFIXES) + + +def _extract_response_dto_key(usages_list: list) -> str: + """ + Derive a camelCase key name from the response DTO type found in a slice. + + Priority: + 1. paramTypes[0] from ResponseEntity builder invokedCalls + (e.g. ResponseEntity.ok(productDTO) → 'productDTO'). + 2. The variable name (name field) of the first LOCAL-label targetObj + whose typeFullName is a custom DTO. + + Returns the camelCase key string, or '' if no custom DTO is found. + The returned value is used as the key inside the response object + instead of the hard-coded 'description'. + """ + for usage in usages_list: + for call in usage.get('invokedCalls', []): + # Builder pattern: ResponseEntity.ok(dto), ResponseEntity.created(), etc. + if ('ResponseEntity' in (call.get('resolvedMethod') or '') + and call.get('callName') in RESPONSE_ENTITY_STATUS_MAP): + param_types = call.get('paramTypes') or [] + if param_types and _is_custom_dto(param_types[0]): + simple = param_types[0].rsplit('.', 1)[-1] + return simple[0].lower() + simple[1:] if simple else '' + # Constructor pattern: new ResponseEntity<>(dto, HttpStatus.X) + # resolvedMethod is null for constructors; HttpStatus appears as "ANY" + if call.get('callName') == '': + param_types = call.get('paramTypes') or [] + if (len(param_types) >= 2 + and _is_custom_dto(param_types[0]) + and param_types[1] == 'ANY'): + simple = param_types[0].rsplit('.', 1)[-1] + return simple[0].lower() + simple[1:] if simple else '' + for usage in usages_list: + tgt = usage.get('targetObj', {}) + if tgt.get('label') == 'LOCAL' and _is_custom_dto(tgt.get('typeFullName', '')): + name = tgt.get('name', '') + if name: + return name + return '' + exclusions = ['/content-type', '/application/javascript', '/application/json', '/application/text', '/application/xml', '/*', '/*/*', '/allow', '/get', '/post', '/xml', '/cookie', @@ -148,7 +242,7 @@ def __init__( self.semantics: AtomSlice = AtomSlice(semantics, origin_type) if semantics and Path( semantics).exists() else None self.openapi_version = dest_format.replace('openapi', '') - self.title = f'OpenAPI Specification for {Path(usages).parent.stem}' if Path( + self.title = f'{Path(usages).parent.stem} OpenAPI Specification' if Path( usages).parent.stem else "OpenAPI Specification" self.file_endpoint_map: Dict = {} self.params: Dict[str, List[Dict]] = {} @@ -717,6 +811,7 @@ def _enrich_from_param_annotation(self) -> Dict[str, Any]: # ── collect PARAM and ANNOTATION entries from this objectSlice ── params_by_name: Dict[str, Dict] = {} + param_arg_calls: Dict[str, List] = {} annotations_by_name: Dict[str, str] = {} # param_name → resolvedMethod endpoint: str | None = None @@ -730,6 +825,7 @@ def _enrich_from_param_annotation(self) -> Dict[str, Any]: if label == 'PARAM' and name: params_by_name[name] = target + param_arg_calls[name] = usage.get('argToCalls') or [] elif label == 'ANNOTATION' and name: if any(resolved.startswith(p) for p in _PARAM_ANNOTATION_PREFIXES): annotations_by_name[name] = resolved @@ -762,6 +858,8 @@ def _enrich_from_param_annotation(self) -> Dict[str, Any]: if ann_resolved.startswith('@RequestBody'): schema = self._build_schema_from_type(type_full_name, udt_map) + if 'properties' not in schema: + schema = _properties_from_getters(param_arg_calls.get(param_name, [])) request_body = { 'content': {'application/json': {'schema': schema}}, 'required': True, @@ -859,6 +957,7 @@ def _backfill_from_annotation_slices(self, paths: Dict[str, Any]) -> Dict[str, A # Collect PARAM entries and ANNOTATION entries for parameter annotations params_by_name: Dict[str, Dict] = {} + param_arg_calls: Dict[str, List] = {} path_params: List[Dict] = [] query_params: List[Dict] = [] header_params: List[Dict] = [] @@ -870,6 +969,7 @@ def _backfill_from_annotation_slices(self, paths: Dict[str, Any]) -> Dict[str, A name = t.get('name', '') or '' if label == 'PARAM' and name: params_by_name[name] = t + param_arg_calls[name] = u.get('argToCalls') or [] for u in usages: t = u.get('targetObj') or {} @@ -884,6 +984,8 @@ def _backfill_from_annotation_slices(self, paths: Dict[str, Any]) -> Dict[str, A type_full_name = param_entry.get('typeFullName', '') or '' if resolved.startswith('@RequestBody'): schema = self._build_schema_from_type(type_full_name, udt_map) + if 'properties' not in schema: + schema = _properties_from_getters(param_arg_calls.get(name, [])) request_body = {'content': {'application/json': {'schema': schema}}, 'required': True} elif resolved.startswith('@PathVariable'): path_params.append({'in': 'path', 'name': name, 'required': True, @@ -947,17 +1049,23 @@ def _infer_java_response_codes(self, file_name: str, line_number: int | None, ht if self.usages.origin_type not in ('java', 'jar'): return {} for s in self.usages.content.get('objectSlices', []): - if s.get('fileName') == file_name and s.get('lineNumber') == line_number: + s_line = s.get('lineNumber') or 0 + # Allow ±1 line tolerance: atom records the slice at the @Mapping annotation line + # but call references use the method signature line (one line below). + if s.get('fileName') == file_name and abs(s_line - (line_number or 0)) <= 1: found: set = set() - for usage in s.get('usages', []): + slice_usages = s.get('usages', []) + for usage in slice_usages: for call in usage.get('invokedCalls', []): resolved = call.get('resolvedMethod') or '' call_name = call.get('callName') or '' if 'ResponseEntity' in resolved and call_name in RESPONSE_ENTITY_STATUS_MAP: found.add(RESPONSE_ENTITY_STATUS_MAP[call_name]) + dto_key = _extract_response_dto_key(slice_usages) or 'description' if found: - return {code: {'description': STATUS_DESCRIPTIONS.get(code, 'Success')} for code in sorted(found)} - break + return {code: {dto_key: STATUS_DESCRIPTIONS.get(code, 'Success')} for code in sorted(found)} + status = HTTP_METHOD_DEFAULT_STATUS.get(http_method, '200') + return {status: {dto_key: STATUS_DESCRIPTIONS.get(status, 'OK')}} status = HTTP_METHOD_DEFAULT_STATUS.get(http_method, '200') return {status: {'description': STATUS_DESCRIPTIONS.get(status, 'OK')}} diff --git a/test/data/java-spring-annotations-usages.json b/test/data/java-spring-annotations-usages.json index 8a14849..81a979c 100644 --- a/test/data/java-spring-annotations-usages.json +++ b/test/data/java-spring-annotations-usages.json @@ -47,7 +47,17 @@ "columnNumber": 40, "label": "PARAM" }, - "invokedCalls": [], + "invokedCalls": [ + { + "callName": "ok", + "resolvedMethod": "org.springframework.http.ResponseEntity.ok:(1)", + "paramTypes": ["com.example.ChargeResponse"], + "returnType": "ANY", + "isExternal": true, + "lineNumber": 18, + "columnNumber": 12 + } + ], "argToCalls": [] }, { @@ -347,6 +357,206 @@ "argToCalls": [] } ] + }, + { + "code": "", + "fullName": "com.example.ApproveController.approve:(1)", + "signature": "(1)", + "fileName": "src/main/java/com/example/ApproveController.java", + "lineNumber": 10, + "columnNumber": 3, + "usages": [ + { + "targetObj": { + "name": "PostMapping", + "typeFullName": "org.springframework.web.bind.annotation.PostMapping", + "resolvedMethod": "@PostMapping(\"/approve\")", + "isExternal": false, + "lineNumber": 10, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "PostMapping", + "typeFullName": "org.springframework.web.bind.annotation.PostMapping", + "resolvedMethod": "@PostMapping(\"/approve\")", + "isExternal": false, + "lineNumber": 10, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "body", + "typeFullName": "com.example.ApproveRequest", + "position": 1, + "lineNumber": 11, + "columnNumber": 40, + "label": "PARAM" + }, + "definedBy": { + "name": "body", + "typeFullName": "com.example.ApproveRequest", + "position": 1, + "lineNumber": 11, + "columnNumber": 40, + "label": "PARAM" + }, + "invokedCalls": [], + "argToCalls": [ + { + "callName": "getRequestId", + "resolvedMethod": "com.example.ApproveRequest.getRequestId:java.util.UUID(0)", + "paramTypes": [], + "returnType": "java.util.UUID", + "position": 0, + "isExternal": true, + "lineNumber": 14, + "columnNumber": 20 + }, + { + "callName": "getComment", + "resolvedMethod": "com.example.ApproveRequest.getComment:java.lang.String(0)", + "paramTypes": [], + "returnType": "java.lang.String", + "position": 0, + "isExternal": true, + "lineNumber": 15, + "columnNumber": 20 + }, + { + "callName": "getComment", + "resolvedMethod": "com.example.ApproveRequest.getComment:java.lang.String(0)", + "paramTypes": [], + "returnType": "java.lang.String", + "position": 0, + "isExternal": true, + "lineNumber": 18, + "columnNumber": 20 + } + ] + }, + { + "targetObj": { + "name": "body", + "typeFullName": "org.springframework.web.bind.annotation.RequestBody", + "resolvedMethod": "@RequestBody", + "isExternal": false, + "lineNumber": 11, + "columnNumber": 40, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "body", + "typeFullName": "org.springframework.web.bind.annotation.RequestBody", + "resolvedMethod": "@RequestBody", + "isExternal": false, + "lineNumber": 11, + "columnNumber": 40, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + } + ] + }, + { + "code": "", + "fullName": "com.example.MigrateController.migrate:(1)", + "signature": "(1)", + "fileName": "src/main/java/com/example/MigrateController.java", + "lineNumber": 5, + "columnNumber": 3, + "usages": [ + { + "targetObj": { + "name": "PostMapping", + "typeFullName": "org.springframework.web.bind.annotation.PostMapping", + "resolvedMethod": "@PostMapping(\"/migrate\")", + "isExternal": false, + "lineNumber": 5, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "PostMapping", + "typeFullName": "org.springframework.web.bind.annotation.PostMapping", + "resolvedMethod": "@PostMapping(\"/migrate\")", + "isExternal": false, + "lineNumber": 5, + "columnNumber": 3, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + }, + { + "targetObj": { + "name": "body", + "typeFullName": ".MigrateRequest", + "position": 1, + "lineNumber": 6, + "columnNumber": 30, + "label": "PARAM" + }, + "definedBy": { + "name": "body", + "typeFullName": ".MigrateRequest", + "position": 1, + "lineNumber": 6, + "columnNumber": 30, + "label": "PARAM" + }, + "invokedCalls": [], + "argToCalls": [ + { + "callName": "getOrganizationId", + "resolvedMethod": ".MigrateRequest.getOrganizationId:(0)", + "paramTypes": [], + "returnType": "ANY", + "position": 0, + "isExternal": true, + "lineNumber": 8, + "columnNumber": 20 + }, + { + "callName": "getOrganizationName", + "resolvedMethod": ".MigrateRequest.getOrganizationName:(0)", + "paramTypes": [], + "returnType": "ANY", + "position": 0, + "isExternal": true, + "lineNumber": 9, + "columnNumber": 20 + } + ] + }, + { + "targetObj": { + "name": "body", + "typeFullName": "org.springframework.web.bind.annotation.RequestBody", + "resolvedMethod": "@RequestBody", + "isExternal": false, + "lineNumber": 6, + "columnNumber": 30, + "label": "ANNOTATION" + }, + "definedBy": { + "name": "body", + "typeFullName": "org.springframework.web.bind.annotation.RequestBody", + "resolvedMethod": "@RequestBody", + "isExternal": false, + "lineNumber": 6, + "columnNumber": 30, + "label": "ANNOTATION" + }, + "invokedCalls": [], + "argToCalls": [] + } + ] } ], "userDefinedTypes": [ diff --git a/test/test_converter.py b/test/test_converter.py index fa85e9f..ea8c6fe 100644 --- a/test/test_converter.py +++ b/test/test_converter.py @@ -107,7 +107,7 @@ def test_populate_endpoints2(js_usages_3): def test_usages_class(java_usages_1): - assert java_usages_1.title == 'OpenAPI Specification for data' + assert java_usages_1.title == 'data OpenAPI Specification' def test_convert_usages(java_usages_1, java_usages_2, js_usages_1, js_usages_2, py_usages_1, py_usages_2): @@ -122,7 +122,7 @@ def test_convert_usages(java_usages_1, java_usages_2, js_usages_1, js_usages_2, def test_endpoints_to_openapi(java_usages_1): result = sort_openapi_result(java_usages_1.endpoints_to_openapi()) - assert result == {'info': {'title': 'OpenAPI Specification for data', 'version': '1.0.0'}, + assert result == {'info': {'title': 'data OpenAPI Specification', 'version': '1.0.0'}, 'openapi': '3.1.0', 'paths': {'/': {'post': {'responses': {'201': {'description': 'Created'}}}, 'x-atom-usages': {'call': {'account-service/src/main/java/com/piggymetrics/account/controller/AccountController.java': [35]}}}, diff --git a/test/test_response_codes.py b/test/test_response_codes.py index a7cffc7..37e7d84 100644 --- a/test/test_response_codes.py +++ b/test/test_response_codes.py @@ -101,21 +101,21 @@ def test_infer_non_java_returns_empty(js_converter): def test_infer_response_entity_ok(java_response_codes_converter): - """GET /users/{id} slice has ResponseEntity.ok -> should return 200.""" + """GET /users/{id} slice has ResponseEntity.ok(User) -> 200 keyed by DTO name 'user'.""" result = java_response_codes_converter._infer_java_response_codes( 'src/main/java/com/example/controller/UserController.java', 20, 'get' ) assert '200' in result - assert result['200']['description'] == 'OK' + assert result['200']['user'] == 'OK' def test_infer_response_entity_created(java_response_codes_converter): - """POST /users slice has ResponseEntity.created -> should return 201.""" + """POST /users slice has ResponseEntity.created + LOCAL 'saved' DTO -> 201 keyed by 'saved'.""" result = java_response_codes_converter._infer_java_response_codes( 'src/main/java/com/example/controller/UserController.java', 30, 'post' ) assert '201' in result - assert result['201']['description'] == 'Created' + assert result['201']['saved'] == 'Created' def test_infer_response_entity_no_content(java_response_codes_converter): diff --git a/test/test_spring_annotations.py b/test/test_spring_annotations.py index 1f09942..9cecf08 100644 --- a/test/test_spring_annotations.py +++ b/test/test_spring_annotations.py @@ -8,7 +8,14 @@ """ import pytest -from atom_tools.lib.converter import OpenAPI, _java_type_to_schema, _extract_annotation_string_value +from atom_tools.lib.converter import ( + OpenAPI, + _java_type_to_schema, + _extract_annotation_string_value, + _is_custom_dto, + _extract_response_dto_key, + _properties_from_getters, +) # ── unit helpers ────────────────────────────────────────────────────────────── @@ -145,6 +152,99 @@ def test_enrich_delete_pathvariable_and_header(spring_annotations): assert params_by_name['authToken']['in'] == 'header' +# ── _properties_from_getters ───────────────────────────────────────────────── + +def test_properties_from_getters_resolved_types(): + """Getter methods with resolved returnType produce typed properties.""" + arg_to_calls = [ + { + 'callName': 'getRequestId', + 'resolvedMethod': 'com.example.ApproveRequest.getRequestId:java.util.UUID(0)', + 'paramTypes': [], + 'returnType': 'java.util.UUID', + }, + { + 'callName': 'getComment', + 'resolvedMethod': 'com.example.ApproveRequest.getComment:java.lang.String(0)', + 'paramTypes': [], + 'returnType': 'java.lang.String', + }, + ] + schema = _properties_from_getters(arg_to_calls) + assert schema['type'] == 'object' + assert schema['properties']['requestId'] == {'type': 'string', 'format': 'uuid'} + assert schema['properties']['comment'] == {'type': 'string'} + + +def test_properties_from_getters_unresolved_any_fallback(): + """Getter methods with returnType=ANY fall back to {'type': 'string'}.""" + arg_to_calls = [ + { + 'callName': 'getOrganizationId', + 'resolvedMethod': '.Request.getOrganizationId:(0)', + 'paramTypes': [], + 'returnType': 'ANY', + }, + ] + schema = _properties_from_getters(arg_to_calls) + assert schema['properties']['organizationId'] == {'type': 'string'} + + +def test_properties_from_getters_deduplicates(): + """Duplicate getter calls produce only one property entry.""" + arg_to_calls = [ + {'callName': 'getEmail', 'resolvedMethod': 'X.getEmail:java.lang.String(0)', + 'paramTypes': [], 'returnType': 'java.lang.String'}, + {'callName': 'getEmail', 'resolvedMethod': 'X.getEmail:java.lang.String(0)', + 'paramTypes': [], 'returnType': 'java.lang.String'}, + ] + schema = _properties_from_getters(arg_to_calls) + assert list(schema['properties'].keys()) == ['email'] + + +def test_properties_from_getters_ignores_non_getters(): + """Non-getter callNames (set*, is*, etc.) are ignored.""" + arg_to_calls = [ + {'callName': 'setFoo', 'resolvedMethod': 'X.setFoo(1)', 'paramTypes': [], 'returnType': 'void'}, + {'callName': 'get', 'resolvedMethod': 'X.get(0)', 'paramTypes': [], 'returnType': 'ANY'}, + {'callName': 'getName', 'resolvedMethod': 'X.getName:java.lang.String(0)', + 'paramTypes': [], 'returnType': 'java.lang.String'}, + ] + schema = _properties_from_getters(arg_to_calls) + assert 'foo' not in schema.get('properties', {}) + assert 'name' in schema['properties'] + + +def test_properties_from_getters_empty_returns_object(): + """No getter methods → {'type': 'object'} with no properties key.""" + assert _properties_from_getters([]) == {'type': 'object'} + assert 'properties' not in _properties_from_getters([]) + + +def test_enrich_requestbody_properties_from_getters(spring_annotations): + """@RequestBody DTO not in userDefinedTypes uses argToCalls getter inference.""" + paths = spring_annotations._enrich_from_param_annotation() + assert '/approve' in paths + post_op = paths['/approve']['post'] + rb = post_op['requestBody'] + schema = rb['content']['application/json']['schema'] + assert schema['type'] == 'object' + assert 'properties' in schema + assert schema['properties']['requestId'] == {'type': 'string', 'format': 'uuid'} + assert schema['properties']['comment'] == {'type': 'string'} + + +def test_enrich_requestbody_properties_from_getters_unresolved(spring_annotations): + """@RequestBody DTO with unresolved returnType=ANY falls back to string properties.""" + paths = spring_annotations._enrich_from_param_annotation() + assert '/migrate' in paths + schema = paths['/migrate']['post']['requestBody']['content']['application/json']['schema'] + assert schema['type'] == 'object' + assert 'properties' in schema + assert schema['properties']['organizationId'] == {'type': 'string'} + assert schema['properties']['organizationName'] == {'type': 'string'} + + # ── full convert_usages integration ────────────────────────────────────────── def test_convert_usages_includes_requestbody(spring_annotations): @@ -164,3 +264,107 @@ def test_non_java_origin_skipped(): """_enrich_from_param_annotation returns {} for non-Java slice types.""" api = OpenAPI('openapi3.1.0', 'python', 'test/data/py-breakable-flask-usages.json') assert api._enrich_from_param_annotation() == {} + + +# ── _is_custom_dto ──────────────────────────────────────────────────────────── + +def test_is_custom_dto_application_class(): + assert _is_custom_dto('com.example.ChargeResponse') is True + assert _is_custom_dto('ai.levo.iam.dto.InviteValidationResponseDTO') is True + + +def test_is_custom_dto_rejects_stdlib(): + assert _is_custom_dto('java.lang.String') is False + assert _is_custom_dto('org.springframework.http.ResponseEntity') is False + assert _is_custom_dto('javax.servlet.http.HttpServletRequest') is False + + +def test_is_custom_dto_rejects_primitives(): + assert _is_custom_dto('ANY') is False + assert _is_custom_dto('void') is False + assert _is_custom_dto('') is False + + +# ── _extract_response_dto_key ───────────────────────────────────────────────── + +def test_extract_response_dto_key_from_builder(): + """ResponseEntity.ok(dto) invokedCall → camelCase simple class name.""" + usages = [ + { + "targetObj": {"name": "body", "typeFullName": "com.example.ChargeRequest", "label": "PARAM"}, + "invokedCalls": [ + { + "callName": "ok", + "resolvedMethod": "org.springframework.http.ResponseEntity.ok:(1)", + "paramTypes": ["com.example.ChargeResponse"], + "returnType": "ANY", + } + ], + "argToCalls": [], + } + ] + assert _extract_response_dto_key(usages) == 'chargeResponse' + + +def test_extract_response_dto_key_from_local(): + """LOCAL variable with custom DTO type → variable name used directly.""" + usages = [ + { + "targetObj": {"name": "resultDto", "typeFullName": "com.example.ResultDTO", "label": "LOCAL"}, + "invokedCalls": [], + "argToCalls": [], + } + ] + assert _extract_response_dto_key(usages) == 'resultDto' + + +def test_extract_response_dto_key_from_constructor(): + """new ResponseEntity<>(dto, HttpStatus.X) → callName '', paramTypes[1]='ANY' → camelCase.""" + usages = [ + { + "targetObj": {"name": "inviteValidationResponseDTO", "typeFullName": "ai.levo.iam.dto.InviteValidationResponseDTO", "label": "LOCAL"}, + "invokedCalls": [ + { + "callName": "", + "resolvedMethod": None, + "paramTypes": ["ai.levo.iam.dto.InviteValidationResponseDTO", "ANY"], + "returnType": "ANY", + } + ], + "argToCalls": [], + } + ] + assert _extract_response_dto_key(usages) == 'inviteValidationResponseDTO' + + +def test_extract_response_dto_key_no_dto(): + """No custom DTO in slice → returns empty string.""" + usages = [ + { + "targetObj": {"name": "orderId", "typeFullName": "java.lang.String", "label": "PARAM"}, + "invokedCalls": [], + "argToCalls": [], + } + ] + assert _extract_response_dto_key(usages) == '' + + +# ── _infer_java_response_codes uses DTO key ─────────────────────────────────── + +def test_infer_response_code_uses_dto_key(spring_annotations): + """ChargeController has ResponseEntity.ok(ChargeResponse) → key is 'chargeResponse'.""" + responses = spring_annotations._infer_java_response_codes( + 'src/main/java/com/example/ChargeController.java', 15, 'post' + ) + assert '200' in responses + assert 'chargeResponse' in responses['200'] + assert responses['200']['chargeResponse'] == 'OK' + + +def test_infer_response_code_fallback_to_description(spring_annotations): + """OrderController has no DTO in invokedCalls or LOCAL → key falls back to 'description'.""" + responses = spring_annotations._infer_java_response_codes( + 'src/main/java/com/example/OrderController.java', 10, 'get' + ) + assert '200' in responses + assert 'description' in responses['200'] From 6854fa631c3f5a646815ed1ec30242cd9e52d728 Mon Sep 17 00:00:00 2001 From: aleembhd Date: Fri, 13 Mar 2026 12:47:10 +0530 Subject: [PATCH 3/3] feat(converter): extract HttpStatus enum response codes from constructor pattern --- atom_tools/lib/converter.py | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/atom_tools/lib/converter.py b/atom_tools/lib/converter.py index ff077f6..e526137 100644 --- a/atom_tools/lib/converter.py +++ b/atom_tools/lib/converter.py @@ -197,6 +197,28 @@ def _extract_response_dto_key(usages_list: list) -> str: 'internalServerError': '500', } +# Maps Spring HttpStatus enum constant names to HTTP status codes. +# Used when code uses new ResponseEntity<>(body, HttpStatus.BAD_REQUEST) constructor pattern. +HTTPSTATUS_ENUM_MAP = { + 'OK': '200', + 'CREATED': '201', + 'ACCEPTED': '202', + 'NO_CONTENT': '204', + 'BAD_REQUEST': '400', + 'UNAUTHORIZED': '401', + 'FORBIDDEN': '403', + 'NOT_FOUND': '404', + 'METHOD_NOT_ALLOWED': '405', + 'CONFLICT': '409', + 'GONE': '410', + 'UNPROCESSABLE_ENTITY': '422', + 'TOO_MANY_REQUESTS': '429', + 'INTERNAL_SERVER_ERROR': '500', + 'NOT_IMPLEMENTED': '501', + 'BAD_GATEWAY': '502', + 'SERVICE_UNAVAILABLE': '503', +} + # Default HTTP status code per HTTP method (standard REST conventions). HTTP_METHOD_DEFAULT_STATUS = { 'get': '200', @@ -218,7 +240,15 @@ def _extract_response_dto_key(usages_list: list) -> str: '401': 'Unauthorized', '403': 'Forbidden', '404': 'Not Found', + '405': 'Method Not Allowed', + '409': 'Conflict', + '410': 'Gone', + '422': 'Unprocessable Entity', + '429': 'Too Many Requests', '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', } @@ -1056,11 +1086,20 @@ def _infer_java_response_codes(self, file_name: str, line_number: int | None, ht found: set = set() slice_usages = s.get('usages', []) for usage in slice_usages: + # Pattern 1: ResponseEntity builder — ResponseEntity.ok(body), ResponseEntity.badRequest()... for call in usage.get('invokedCalls', []): resolved = call.get('resolvedMethod') or '' call_name = call.get('callName') or '' if 'ResponseEntity' in resolved and call_name in RESPONSE_ENTITY_STATUS_MAP: found.add(RESPONSE_ENTITY_STATUS_MAP[call_name]) + # Pattern 2: ResponseEntity constructor — new ResponseEntity<>(body, HttpStatus.BAD_REQUEST) + # Atom records the HttpStatus enum arg as targetObj/definedBy with typeFullName org.springframework.http.HttpStatus + for field in ('targetObj', 'definedBy'): + obj = usage.get(field) or {} + if obj.get('typeFullName') == 'org.springframework.http.HttpStatus': + enum_name = obj.get('name') or '' + if enum_name in HTTPSTATUS_ENUM_MAP: + found.add(HTTPSTATUS_ENUM_MAP[enum_name]) dto_key = _extract_response_dto_key(slice_usages) or 'description' if found: return {code: {dto_key: STATUS_DESCRIPTIONS.get(code, 'Success')} for code in sorted(found)}