Repository: googleapis/google-cloud-rust
Pull request #1489: feat(generator/dart): generate toJson() and fromJson() methods for messages
Author: login: devoncarew
Created: 2025-03-13T18:33:28Z | Merged: 2025-03-14T21:01:50Z

Generate toJson() and fromJson() methods for messages:

- messages now get a `fromJson()` constructor and a `toJson()`  method to serialize to and from JSON; the bulk of the implementation is in the `createFromJsonLines()` and `createFromJsonLines()` functions
- add tests to `package:google_cloud:rpc` to round-trip serialize their types in order to test json encoding
- generate the `Any` type; this lets us analyze the generated code on the CI as well as run the `rpc` tests; `Any` does not yet have custom handling for encoding

I'm also happy to generate `Any` w/ some custom encoding in a separate PR, then merge that into this.



Diff:
diff --git a/.github/workflows/generator_dart.yaml b/.github/workflows/generator_dart.yaml
index bc1a9bf736..d3c4542925 100644
--- a/.github/workflows/generator_dart.yaml
+++ b/.github/workflows/generator_dart.yaml
@@ -54,20 +54,22 @@ jobs:
       - uses: actions/checkout@v4
       - uses: dart-lang/setup-dart@v1
 
+      # Run pub get.
       - run: dart pub get
         working-directory: generator/dart
 
-      - name: "analyze package:google_cloud_protobuf"
+      # Analyze code.
+      - name: "analyze package:google_cloud_common"
         run: dart analyze
-        working-directory: generator/dart/packages/generated/google_cloud_protobuf
-      - name: "test package:google_cloud_protobuf"
-        run: dart test
-        working-directory: generator/dart/packages/generated/google_cloud_protobuf
-
-      - name: "analyze package:google_cloud_type"
+        working-directory: generator/dart/packages/google_cloud_common
+      - name: "analyze generated packages"
         run: dart analyze
-        working-directory: generator/dart/packages/generated/google_cloud_type
+        working-directory: generator/dart/packages/generated
 
-      - name: "analyze package:google_cloud_common"
-        run: dart analyze
+      # Run tests.
+      - name: "test package:google_cloud_common"
+        run: dart test
         working-directory: generator/dart/packages/google_cloud_common
+      - name: "test package:google_cloud_protobuf"
+        run: dart test
+        working-directory: generator/dart/packages/generated/google_cloud_protobuf
diff --git a/generator/dart/packages/generated/google_cloud_protobuf/.sidekick.toml b/generator/dart/packages/generated/google_cloud_protobuf/.sidekick.toml
index 885d1a2f72..21e9ac9022 100644
--- a/generator/dart/packages/generated/google_cloud_protobuf/.sidekick.toml
+++ b/generator/dart/packages/generated/google_cloud_protobuf/.sidekick.toml
@@ -19,7 +19,9 @@ specification-source = 'google/protobuf'
 name-override = 'protobuf'
 title-override = 'Core Protobuf Types'
 description-override = 'Core Protobuf types used by most services.'
-include-list = "duration.proto,field_mask.proto"
+# TODO(#1034): Note that we generate 'Any' in order to get correct analysis but
+# do not yet have a custom implementation for it.
+include-list = "any.proto,duration.proto,field_mask.proto"
 
 [codec]
 copyright-year = '2025'
diff --git a/generator/dart/packages/generated/google_cloud_protobuf/lib/protobuf.dart b/generator/dart/packages/generated/google_cloud_protobuf/lib/protobuf.dart
index 17ad74d5b4..d82660c600 100755
--- a/generator/dart/packages/generated/google_cloud_protobuf/lib/protobuf.dart
+++ b/generator/dart/packages/generated/google_cloud_protobuf/lib/protobuf.dart
@@ -19,10 +19,162 @@
 /// Core Protobuf types used by most services.
 library;
 
+import 'dart:typed_data';
+
 import 'package:google_cloud_common/common.dart';
 
 part 'src/protobuf.p.dart';
 
+/// `Any` contains an arbitrary serialized protocol buffer message along with a
+/// URL that describes the type of the serialized message.
+///
+/// Protobuf library provides support to pack/unpack Any values in the form
+/// of utility functions or additional generated methods of the Any type.
+///
+/// Example 1: Pack and unpack a message in C++.
+///
+///     Foo foo = ...;
+///     Any any;
+///     any.PackFrom(foo);
+///     ...
+///     if (any.UnpackTo(&foo)) {
+///       ...
+///     }
+///
+/// Example 2: Pack and unpack a message in Java.
+///
+///     Foo foo = ...;
+///     Any any = Any.pack(foo);
+///     ...
+///     if (any.is(Foo.class)) {
+///       foo = any.unpack(Foo.class);
+///     }
+///     // or ...
+///     if (any.isSameTypeAs(Foo.getDefaultInstance())) {
+///       foo = any.unpack(Foo.getDefaultInstance());
+///     }
+///
+///  Example 3: Pack and unpack a message in Python.
+///
+///     foo = Foo(...)
+///     any = Any()
+///     any.Pack(foo)
+///     ...
+///     if any.Is(Foo.DESCRIPTOR):
+///       any.Unpack(foo)
+///       ...
+///
+///  Example 4: Pack and unpack a message in Go
+///
+///      foo := &pb.Foo{...}
+///      any, err := anypb.New(foo)
+///      if err != nil {
+///        ...
+///      }
+///      ...
+///      foo := &pb.Foo{}
+///      if err := any.UnmarshalTo(foo); err != nil {
+///        ...
+///      }
+///
+/// The pack methods provided by protobuf library will by default use
+/// 'type.googleapis.com/full.type.name' as the type URL and the unpack
+/// methods only use the fully qualified type name after the last '/'
+/// in the type URL, for example "foo.bar.com/x/y.z" will yield type
+/// name "y.z".
+///
+/// JSON
+/// ====
+/// The JSON representation of an `Any` value uses the regular
+/// representation of the deserialized, embedded message, with an
+/// additional field `@type` which contains the type URL. Example:
+///
+///     package google.profile;
+///     message Person {
+///       string first_name = 1;
+///       string last_name = 2;
+///     }
+///
+///     {
+///       "@type": "type.googleapis.com/google.profile.Person",
+///       "firstName": <string>,
+///       "lastName": <string>
+///     }
+///
+/// If the embedded message type is well-known and has a custom JSON
+/// representation, that representation will be embedded adding a field
+/// `value` which holds the custom JSON in addition to the `@type`
+/// field. Example (for message [google.protobuf.Duration][]):
+///
+///     {
+///       "@type": "type.googleapis.com/google.protobuf.Duration",
+///       "value": "1.212s"
+///     }
+class Any extends Message {
+
+  /// A URL/resource name that uniquely identifies the type of the serialized
+  /// protocol buffer message. This string must contain at least
+  /// one "/" character. The last segment of the URL's path must represent
+  /// the fully qualified name of the type (as in
+  /// `path/google.protobuf.Duration`). The name should be in a canonical form
+  /// (e.g., leading "." is not accepted).
+  ///
+  /// In practice, teams usually precompile into the binary all types that they
+  /// expect it to use in the context of Any. However, for URLs which use the
+  /// scheme `http`, `https`, or no scheme, one can optionally set up a type
+  /// server that maps type URLs to message definitions as follows:
+  ///
+  /// * If no scheme is provided, `https` is assumed.
+  /// * An HTTP GET on the URL must yield a [google.protobuf.Type][]
+  ///   value in binary format, or produce an error.
+  /// * Applications are allowed to cache lookup results based on the
+  ///   URL, or have them precompiled into a binary to avoid any
+  ///   lookup. Therefore, binary compatibility needs to be preserved
+  ///   on changes to types. (Use versioned type names to manage
+  ///   breaking changes.)
+  ///
+  /// Note: this functionality is not currently available in the official
+  /// protobuf release, and it is not used for type URLs beginning with
+  /// type.googleapis.com. As of May 2023, there are no widely used type server
+  /// implementations and no plans to implement one.
+  ///
+  /// Schemes other than `http`, `https` (or the empty scheme) might be
+  /// used with implementation specific semantics.
+  final String? typeUrl;
+
+  /// Must be a valid serialized protocol buffer of the above specified type.
+  final Uint8List? value;
+
+  Any({
+    this.typeUrl,
+    this.value,
+  });
+
+  factory Any.fromJson(Map<String, dynamic> json) {
+    return Any(
+      typeUrl: json['typeUrl'],
+      value: json['value'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (typeUrl != null) 'typeUrl': typeUrl,
+      if (value != null) 'value': value,
+    };
+  }
+
+  @override
+  String toString() {
+    final contents = [
+      if (typeUrl != null) 'typeUrl=$typeUrl',
+      if (value != null) 'value=$value',
+    ].join(',');
+    return 'Any($contents)';
+  }
+}
+
 /// A Duration represents a signed, fixed-length span of time represented
 /// as a count of seconds and fractions of seconds at nanosecond
 /// resolution. It is independent of any calendar and concepts like "day"
@@ -81,7 +233,7 @@ part 'src/protobuf.p.dart';
 /// encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should
 /// be expressed in JSON format as "3.000000001s", and 3 seconds and 1
 /// microsecond should be expressed in JSON format as "3.000001s".
-class Duration extends CloudMessage {
+class Duration extends Message {
 
   /// Signed seconds of the span of time. Must be from -315,576,000,000
   /// to +315,576,000,000 inclusive. Note: these bounds are computed from:
@@ -103,6 +255,11 @@ class Duration extends CloudMessage {
     _validate();
   }
 
+  factory Duration.fromJson(String json) => DurationHelper.decode(json);
+
+  @override
+  Object toJson() => DurationHelper.encode(this);
+
   @override
   String toString() {
     final contents = [
@@ -312,7 +469,7 @@ class Duration extends CloudMessage {
 /// The implementation of any API method which has a FieldMask type field in the
 /// request should verify the included field paths, and return an
 /// `INVALID_ARGUMENT` error if any path is unmappable.
-class FieldMask extends CloudMessage {
+class FieldMask extends Message {
 
   /// The set of field mask paths.
   final List<String>? paths;
@@ -321,6 +478,11 @@ class FieldMask extends CloudMessage {
     this.paths,
   });
 
+  factory FieldMask.fromJson(String json) => FieldMaskHelper.decode(json);
+
+  @override
+  Object toJson() => FieldMaskHelper.encode(this);
+
   @override
   String toString() => 'FieldMask()';
 }
diff --git a/generator/dart/packages/generated/google_cloud_protobuf/lib/src/protobuf.p.dart b/generator/dart/packages/generated/google_cloud_protobuf/lib/src/protobuf.p.dart
index 5dde2b6ee4..127ead6b76 100644
--- a/generator/dart/packages/generated/google_cloud_protobuf/lib/src/protobuf.p.dart
+++ b/generator/dart/packages/generated/google_cloud_protobuf/lib/src/protobuf.p.dart
@@ -14,10 +14,10 @@
 
 part of '../protobuf.dart';
 
-extension FieldMaskExtension on FieldMask {
+class FieldMaskHelper {
   /// Encode the field mask as a single comma-separated string.
-  String encode() {
-    return paths?.join(',') ?? '';
+  static String encode(FieldMask fieldMask) {
+    return fieldMask.paths?.join(',') ?? '';
   }
 
   /// Decode the field mask from a single comma-separated string.
@@ -41,25 +41,28 @@ extension DurationExtension on Duration {
       throw ArgumentError('nanos out of range');
     }
   }
+}
 
+class DurationHelper {
   /// Encode into a decimal representation of the seconds and nanos, suffixed
   /// with 's'.
   ///
   /// E.g., 3 seconds with 0 nanoseconds would be '3s'; 3 seconds with 70
   /// nanosecond would be '3.00000007s'.
-  String encode() {
-    if (nanos == 0) {
-      return '${seconds}s';
+  static String encode(Duration duration) {
+    if (duration.nanos == 0) {
+      return '${duration.seconds}s';
     } else {
-      final rhs = nanos!.abs().toString().padLeft(9, '0');
+      final rhs = duration.nanos!.abs().toString().padLeft(9, '0');
 
-      var duration =
-          seconds == 0 ? '${nanos! < 0 ? '-' : ''}0.$rhs' : '${seconds}.$rhs';
-      while (duration.endsWith('0')) {
-        duration = duration.substring(0, duration.length - 1);
+      var result = duration.seconds == 0
+          ? '${duration.nanos! < 0 ? '-' : ''}0.$rhs'
+          : '${duration.seconds}.$rhs';
+      while (result.endsWith('0')) {
+        result = result.substring(0, result.length - 1);
       }
 
-      return '${duration}s';
+      return '${result}s';
     }
   }
 
diff --git a/generator/dart/packages/generated/google_cloud_protobuf/test/types_test.dart b/generator/dart/packages/generated/google_cloud_protobuf/test/types_test.dart
index b0e3242b37..35e19d7be1 100644
--- a/generator/dart/packages/generated/google_cloud_protobuf/test/types_test.dart
+++ b/generator/dart/packages/generated/google_cloud_protobuf/test/types_test.dart
@@ -19,33 +19,33 @@ void main() {
   group('FieldMask', () {
     test('encode empty', () {
       final fieldMask = FieldMask(paths: []);
-      expect(fieldMask.encode(), '');
+      expect(fieldMask.toJson(), '');
     });
 
     test('encode single', () {
       final fieldMask = FieldMask(paths: ['one']);
-      expect(fieldMask.encode(), 'one');
+      expect(fieldMask.toJson(), 'one');
     });
 
     test('encode multiple', () {
       final fieldMask = FieldMask(paths: ['one', 'two']);
-      expect(fieldMask.encode(), 'one,two');
+      expect(fieldMask.toJson(), 'one,two');
     });
 
     test('decode empty', () {
-      final fieldMask = FieldMaskExtension.decode('');
+      final fieldMask = FieldMask.fromJson('');
       final actual = fieldMask.paths!.join('|');
       expect(actual, '');
     });
 
     test('decode single', () {
-      final fieldMask = FieldMaskExtension.decode('one');
+      final fieldMask = FieldMask.fromJson('one');
       final actual = fieldMask.paths!.join('|');
       expect(actual, 'one');
     });
 
     test('decode multiple', () {
-      final fieldMask = FieldMaskExtension.decode('one,two');
+      final fieldMask = FieldMask.fromJson('one,two');
       final actual = fieldMask.paths!.join('|');
       expect(actual, 'one|two');
     });
@@ -59,10 +59,10 @@ void main() {
       test('test case $encoding', () {
         // test DurationExtension.encode()
         final duration = Duration(seconds: seconds, nanos: nanos);
-        expect(duration.encode(), encoding);
+        expect(duration.toJson(), encoding);
 
         // test DurationExtension.decode()
-        final copy = DurationExtension.decode(encoding);
+        final copy = Duration.fromJson(encoding);
         expect(copy.seconds, seconds);
         expect(copy.nanos, nanos);
       });
@@ -83,8 +83,8 @@ void main() {
     // Verify durations can roundtrip from String -> Duration -> String.
     void roundTrip(String name, String encoding) {
       test('round trip $name ($encoding)', () {
-        final duration = DurationExtension.decode(encoding);
-        expect(duration.encode(), encoding);
+        final duration = Duration.fromJson(encoding);
+        expect(duration.toJson(), encoding);
       });
     }
 
@@ -106,15 +106,15 @@ void main() {
 
     // bad format tests
     test('bad number', () {
-      expect(() => DurationExtension.decode('20u10s'), throwsFormatException);
+      expect(() => Duration.fromJson('20u10s'), throwsFormatException);
     });
 
     test('bad format', () {
-      expect(() => DurationExtension.decode('2.00001'), throwsFormatException);
+      expect(() => Duration.fromJson('2.00001'), throwsFormatException);
     });
 
     test('bad format - too many periods', () {
-      expect(() => DurationExtension.decode('1.2.3.4s'), throwsFormatException);
+      expect(() => Duration.fromJson('1.2.3.4s'), throwsFormatException);
     });
 
     test('too many positive nanoseconds', () {
diff --git a/generator/dart/packages/generated/google_cloud_rpc/lib/rpc.dart b/generator/dart/packages/generated/google_cloud_rpc/lib/rpc.dart
index 1dc197784b..183ac76fd6 100755
--- a/generator/dart/packages/generated/google_cloud_rpc/lib/rpc.dart
+++ b/generator/dart/packages/generated/google_cloud_rpc/lib/rpc.dart
@@ -48,7 +48,7 @@ import 'package:google_cloud_protobuf/protobuf.dart';
 ///         "availableRegions": "us-central1,us-east2"
 ///       }
 ///     }
-class ErrorInfo extends CloudMessage {
+class ErrorInfo extends Message {
 
   /// The reason of the error. This is a constant value that identifies the
   /// proximate cause of the error. Error reasons are unique within a particular
@@ -82,6 +82,23 @@ class ErrorInfo extends CloudMessage {
     this.metadata,
   });
 
+  factory ErrorInfo.fromJson(Map<String, dynamic> json) {
+    return ErrorInfo(
+      reason: json['reason'],
+      domain: json['domain'],
+      metadata: (json['metadata'] as Map?)?.cast(),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (reason != null) 'reason': reason,
+      if (domain != null) 'domain': domain,
+      if (metadata != null) 'metadata': metadata,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -105,7 +122,7 @@ class ErrorInfo extends CloudMessage {
 /// the delay between retries based on `retry_delay`, until either a maximum
 /// number of retries have been reached or a maximum retry delay cap has been
 /// reached.
-class RetryInfo extends CloudMessage {
+class RetryInfo extends Message {
 
   /// Clients should wait at least this long between retrying the same request.
   final Duration? retryDelay;
@@ -114,12 +131,25 @@ class RetryInfo extends CloudMessage {
     this.retryDelay,
   });
 
+  factory RetryInfo.fromJson(Map<String, dynamic> json) {
+    return RetryInfo(
+      retryDelay: $decode(json['retryDelay'], Duration.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (retryDelay != null) 'retryDelay': retryDelay!.toJson(),
+    };
+  }
+
   @override
   String toString() => 'RetryInfo()';
 }
 
 /// Describes additional debugging info.
-class DebugInfo extends CloudMessage {
+class DebugInfo extends Message {
 
   /// The stack trace entries indicating where the error occurred.
   final List<String>? stackEntries;
@@ -132,6 +162,21 @@ class DebugInfo extends CloudMessage {
     this.detail,
   });
 
+  factory DebugInfo.fromJson(Map<String, dynamic> json) {
+    return DebugInfo(
+      stackEntries: (json['stackEntries'] as List?)?.cast(),
+      detail: json['detail'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (stackEntries != null) 'stackEntries': stackEntries,
+      if (detail != null) 'detail': detail,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -152,7 +197,7 @@ class DebugInfo extends CloudMessage {
 ///
 /// Also see RetryInfo and Help types for other details about handling a
 /// quota failure.
-class QuotaFailure extends CloudMessage {
+class QuotaFailure extends Message {
 
   /// Describes all quota violations.
   final List<QuotaFailure$Violation>? violations;
@@ -161,13 +206,26 @@ class QuotaFailure extends CloudMessage {
     this.violations,
   });
 
+  factory QuotaFailure.fromJson(Map<String, dynamic> json) {
+    return QuotaFailure(
+      violations: $decodeList(json['violations'], QuotaFailure$Violation.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (violations != null) 'violations': $encodeList(violations),
+    };
+  }
+
   @override
   String toString() => 'QuotaFailure()';
 }
 
 /// A message type used to describe a single quota violation.  For example, a
 /// daily quota or a custom quota that was exceeded.
-class QuotaFailure$Violation extends CloudMessage {
+class QuotaFailure$Violation extends Message {
 
   /// The subject on which the quota check failed.
   /// For example, "clientip:<ip address of client>" or "project:<Google
@@ -188,6 +246,21 @@ class QuotaFailure$Violation extends CloudMessage {
     this.description,
   });
 
+  factory QuotaFailure$Violation.fromJson(Map<String, dynamic> json) {
+    return QuotaFailure$Violation(
+      subject: json['subject'],
+      description: json['description'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (subject != null) 'subject': subject,
+      if (description != null) 'description': description,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -203,7 +276,7 @@ class QuotaFailure$Violation extends CloudMessage {
 /// For example, if an RPC failed because it required the Terms of Service to be
 /// acknowledged, it could list the terms of service violation in the
 /// PreconditionFailure message.
-class PreconditionFailure extends CloudMessage {
+class PreconditionFailure extends Message {
 
   /// Describes all precondition violations.
   final List<PreconditionFailure$Violation>? violations;
@@ -212,12 +285,25 @@ class PreconditionFailure extends CloudMessage {
     this.violations,
   });
 
+  factory PreconditionFailure.fromJson(Map<String, dynamic> json) {
+    return PreconditionFailure(
+      violations: $decodeList(json['violations'], PreconditionFailure$Violation.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (violations != null) 'violations': $encodeList(violations),
+    };
+  }
+
   @override
   String toString() => 'PreconditionFailure()';
 }
 
 /// A message type used to describe a single precondition failure.
-class PreconditionFailure$Violation extends CloudMessage {
+class PreconditionFailure$Violation extends Message {
 
   /// The type of PreconditionFailure. We recommend using a service-specific
   /// enum type to define the supported precondition violation subjects. For
@@ -241,6 +327,23 @@ class PreconditionFailure$Violation extends CloudMessage {
     this.description,
   });
 
+  factory PreconditionFailure$Violation.fromJson(Map<String, dynamic> json) {
+    return PreconditionFailure$Violation(
+      type: json['type'],
+      subject: json['subject'],
+      description: json['description'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (type != null) 'type': type,
+      if (subject != null) 'subject': subject,
+      if (description != null) 'description': description,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -254,7 +357,7 @@ class PreconditionFailure$Violation extends CloudMessage {
 
 /// Describes violations in a client request. This error type focuses on the
 /// syntactic aspects of the request.
-class BadRequest extends CloudMessage {
+class BadRequest extends Message {
 
   /// Describes all violations in a client request.
   final List<BadRequest$FieldViolation>? fieldViolations;
@@ -263,12 +366,25 @@ class BadRequest extends CloudMessage {
     this.fieldViolations,
   });
 
+  factory BadRequest.fromJson(Map<String, dynamic> json) {
+    return BadRequest(
+      fieldViolations: $decodeList(json['fieldViolations'], BadRequest$FieldViolation.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (fieldViolations != null) 'fieldViolations': $encodeList(fieldViolations),
+    };
+  }
+
   @override
   String toString() => 'BadRequest()';
 }
 
 /// A message type used to describe a single bad request field.
-class BadRequest$FieldViolation extends CloudMessage {
+class BadRequest$FieldViolation extends Message {
 
   /// A path that leads to a field in the request body. The value will be a
   /// sequence of dot-separated identifiers that identify a protocol buffer
@@ -331,6 +447,25 @@ class BadRequest$FieldViolation extends CloudMessage {
     this.localizedMessage,
   });
 
+  factory BadRequest$FieldViolation.fromJson(Map<String, dynamic> json) {
+    return BadRequest$FieldViolation(
+      field: json['field'],
+      description: json['description'],
+      reason: json['reason'],
+      localizedMessage: $decode(json['localizedMessage'], LocalizedMessage.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (field != null) 'field': field,
+      if (description != null) 'description': description,
+      if (reason != null) 'reason': reason,
+      if (localizedMessage != null) 'localizedMessage': localizedMessage!.toJson(),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -344,7 +479,7 @@ class BadRequest$FieldViolation extends CloudMessage {
 
 /// Contains metadata about the request that clients can attach when filing a bug
 /// or providing other forms of feedback.
-class RequestInfo extends CloudMessage {
+class RequestInfo extends Message {
 
   /// An opaque string that should only be interpreted by the service generating
   /// it. For example, it can be used to identify requests in the service's logs.
@@ -359,6 +494,21 @@ class RequestInfo extends CloudMessage {
     this.servingData,
   });
 
+  factory RequestInfo.fromJson(Map<String, dynamic> json) {
+    return RequestInfo(
+      requestId: json['requestId'],
+      servingData: json['servingData'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (requestId != null) 'requestId': requestId,
+      if (servingData != null) 'servingData': servingData,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -370,7 +520,7 @@ class RequestInfo extends CloudMessage {
 }
 
 /// Describes the resource that is being accessed.
-class ResourceInfo extends CloudMessage {
+class ResourceInfo extends Message {
 
   /// A name for the type of resource being accessed, e.g. "sql table",
   /// "cloud storage bucket", "file", "Google calendar"; or the type URL
@@ -400,6 +550,25 @@ class ResourceInfo extends CloudMessage {
     this.description,
   });
 
+  factory ResourceInfo.fromJson(Map<String, dynamic> json) {
+    return ResourceInfo(
+      resourceType: json['resourceType'],
+      resourceName: json['resourceName'],
+      owner: json['owner'],
+      description: json['description'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (resourceType != null) 'resourceType': resourceType,
+      if (resourceName != null) 'resourceName': resourceName,
+      if (owner != null) 'owner': owner,
+      if (description != null) 'description': description,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -417,7 +586,7 @@ class ResourceInfo extends CloudMessage {
 /// For example, if a quota check failed with an error indicating the calling
 /// project hasn't enabled the accessed service, this can contain a URL pointing
 /// directly to the right place in the developer console to flip the bit.
-class Help extends CloudMessage {
+class Help extends Message {
 
   /// URL(s) pointing to additional information on handling the current error.
   final List<Help$Link>? links;
@@ -426,12 +595,25 @@ class Help extends CloudMessage {
     this.links,
   });
 
+  factory Help.fromJson(Map<String, dynamic> json) {
+    return Help(
+      links: $decodeList(json['links'], Help$Link.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (links != null) 'links': $encodeList(links),
+    };
+  }
+
   @override
   String toString() => 'Help()';
 }
 
 /// Describes a URL link.
-class Help$Link extends CloudMessage {
+class Help$Link extends Message {
 
   /// Describes what the link offers.
   final String? description;
@@ -444,6 +626,21 @@ class Help$Link extends CloudMessage {
     this.url,
   });
 
+  factory Help$Link.fromJson(Map<String, dynamic> json) {
+    return Help$Link(
+      description: json['description'],
+      url: json['url'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (description != null) 'description': description,
+      if (url != null) 'url': url,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -456,7 +653,7 @@ class Help$Link extends CloudMessage {
 
 /// Provides a localized error message that is safe to return to the user
 /// which can be attached to an RPC error.
-class LocalizedMessage extends CloudMessage {
+class LocalizedMessage extends Message {
 
   /// The locale used following the specification defined at
   /// https://www.rfc-editor.org/rfc/bcp/bcp47.txt.
@@ -471,6 +668,21 @@ class LocalizedMessage extends CloudMessage {
     this.message,
   });
 
+  factory LocalizedMessage.fromJson(Map<String, dynamic> json) {
+    return LocalizedMessage(
+      locale: json['locale'],
+      message: json['message'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (locale != null) 'locale': locale,
+      if (message != null) 'message': message,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -482,7 +694,7 @@ class LocalizedMessage extends CloudMessage {
 }
 
 /// Represents an HTTP request.
-class HttpRequest extends CloudMessage {
+class HttpRequest extends Message {
 
   /// The HTTP request method.
   final String? method;
@@ -504,6 +716,25 @@ class HttpRequest extends CloudMessage {
     this.body,
   });
 
+  factory HttpRequest.fromJson(Map<String, dynamic> json) {
+    return HttpRequest(
+      method: json['method'],
+      uri: json['uri'],
+      headers: $decodeList(json['headers'], HttpHeader.fromJson),
+      body: json['body'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (method != null) 'method': method,
+      if (uri != null) 'uri': uri,
+      if (headers != null) 'headers': $encodeList(headers),
+      if (body != null) 'body': body,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -516,7 +747,7 @@ class HttpRequest extends CloudMessage {
 }
 
 /// Represents an HTTP response.
-class HttpResponse extends CloudMessage {
+class HttpResponse extends Message {
 
   /// The HTTP status code, such as 200 or 404.
   final int? status;
@@ -538,6 +769,25 @@ class HttpResponse extends CloudMessage {
     this.body,
   });
 
+  factory HttpResponse.fromJson(Map<String, dynamic> json) {
+    return HttpResponse(
+      status: json['status'],
+      reason: json['reason'],
+      headers: $decodeList(json['headers'], HttpHeader.fromJson),
+      body: json['body'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (status != null) 'status': status,
+      if (reason != null) 'reason': reason,
+      if (headers != null) 'headers': $encodeList(headers),
+      if (body != null) 'body': body,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -550,7 +800,7 @@ class HttpResponse extends CloudMessage {
 }
 
 /// Represents an HTTP header.
-class HttpHeader extends CloudMessage {
+class HttpHeader extends Message {
 
   /// The HTTP header key. It is case insensitive.
   final String? key;
@@ -563,6 +813,21 @@ class HttpHeader extends CloudMessage {
     this.value,
   });
 
+  factory HttpHeader.fromJson(Map<String, dynamic> json) {
+    return HttpHeader(
+      key: json['key'],
+      value: json['value'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (key != null) 'key': key,
+      if (value != null) 'value': value,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -580,7 +845,7 @@ class HttpHeader extends CloudMessage {
 ///
 /// You can find out more about this error model and how to work with it in the
 /// [API Design Guide](https://cloud.google.com/apis/design/errors).
-class Status extends CloudMessage {
+class Status extends Message {
 
   /// The status code, which should be an enum value of
   /// [google.rpc.Code][google.rpc.Code].
@@ -602,6 +867,23 @@ class Status extends CloudMessage {
     this.details,
   });
 
+  factory Status.fromJson(Map<String, dynamic> json) {
+    return Status(
+      code: json['code'],
+      message: json['message'],
+      details: $decodeList(json['details'], Any.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (code != null) 'code': code,
+      if (message != null) 'message': message,
+      if (details != null) 'details': $encodeList(details),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -619,7 +901,7 @@ class Status extends CloudMessage {
 /// the most specific error code that applies.  For example, prefer
 /// `OUT_OF_RANGE` over `FAILED_PRECONDITION` if both codes apply.
 /// Similarly prefer `NOT_FOUND` or `ALREADY_EXISTS` over `FAILED_PRECONDITION`.
-class Code extends CloudEnum {
+class Code extends Enum {
   /// Not an error; returned on success.
   ///
   /// HTTP Mapping: 200 OK
diff --git a/generator/dart/packages/generated/google_cloud_type/lib/type.dart b/generator/dart/packages/generated/google_cloud_type/lib/type.dart
index fe4b0179d2..b2fb9e2261 100755
--- a/generator/dart/packages/generated/google_cloud_type/lib/type.dart
+++ b/generator/dart/packages/generated/google_cloud_type/lib/type.dart
@@ -52,7 +52,7 @@ import 'package:google_cloud_common/common.dart';
 /// The exact variables and functions that may be referenced within an expression
 /// are determined by the service that evaluates it. See the service
 /// documentation for additional information.
-class Expr extends CloudMessage {
+class Expr extends Message {
 
   /// Textual representation of an expression in Common Expression Language
   /// syntax.
@@ -78,6 +78,25 @@ class Expr extends CloudMessage {
     this.location,
   });
 
+  factory Expr.fromJson(Map<String, dynamic> json) {
+    return Expr(
+      expression: json['expression'],
+      title: json['title'],
+      description: json['description'],
+      location: json['location'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (expression != null) 'expression': expression,
+      if (title != null) 'title': title,
+      if (description != null) 'description': description,
+      if (location != null) 'location': location,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
diff --git a/generator/dart/packages/google_cloud_common/lib/common.dart b/generator/dart/packages/google_cloud_common/lib/common.dart
index d8c52ddc36..4d9bdb5d98 100644
--- a/generator/dart/packages/google_cloud_common/lib/common.dart
+++ b/generator/dart/packages/google_cloud_common/lib/common.dart
@@ -14,24 +14,30 @@
 
 import 'dart:convert';
 
+import 'package:google_cloud_rpc/rpc.dart';
 import 'package:http/http.dart';
 
-const _clientName = 'dart-test-client';
+const String _clientName = 'dart-test-client';
 
-abstract class Jsonable {
+/// An abstract class that can return a JSON encodable representation of itself.
+///
+/// Classes that implement [JsonEncodable] will often have a `fromJson()`
+/// constructor.
+abstract class JsonEncodable {
   Object toJson();
 }
 
-abstract class CloudMessage {}
+abstract class Message implements JsonEncodable {}
 
-abstract class CloudEnum {
+abstract class Enum implements JsonEncodable {
   final String value;
 
-  const CloudEnum(this.value);
+  const Enum(this.value);
 
   @override
   int get hashCode => value.hashCode;
 
+  @override
   Object toJson() => value;
 }
 
@@ -50,7 +56,7 @@ abstract class CloudService {
     return _processResponse(response);
   }
 
-  Future<Map<String, dynamic>> $post(Uri url, {Jsonable? body}) async {
+  Future<Map<String, dynamic>> $post(Uri url, {JsonEncodable? body}) async {
     final response = await client.post(
       url,
       body: body == null ? null : jsonEncode(body.toJson()),
@@ -62,7 +68,7 @@ abstract class CloudService {
     return _processResponse(response);
   }
 
-  Future<Map<String, dynamic>> $patch(Uri url, {Jsonable? body}) async {
+  Future<Map<String, dynamic>> $patch(Uri url, {JsonEncodable? body}) async {
     final response = await client.patch(
       url,
       body: body == null ? null : jsonEncode(body.toJson()),
@@ -91,7 +97,40 @@ abstract class CloudService {
       return body.isEmpty ? {} : jsonDecode(body);
     }
 
-    // TODO(#1454): This is a placeholder until we're able to parse 'Status'.
-    throw ClientException('${response.statusCode}: ${response.reasonPhrase}');
+    Status status;
+
+    try {
+      final json = jsonDecode(response.body);
+      status = Status.fromJson(json['error']);
+    } catch (_) {
+      // If we're not able to parse the Status error, return a general HTTP
+      // exception.
+      throw ClientException('${response.statusCode}: ${response.reasonPhrase}');
+    }
+
+    throw status;
   }
 }
+
+T? $decode<T, S>(dynamic json, T Function(S) decoder) {
+  return json == null ? null : decoder(json);
+}
+
+List<T>? $decodeList<T, S>(dynamic json, T Function(S) decoder) {
+  return (json as List?)?.map((item) => decoder(item)).toList().cast();
+}
+
+Map<String, T>? $decodeMap<T>(
+    dynamic json, T Function(Map<String, dynamic>) decoder) {
+  return (json as Map?)
+      ?.map((key, value) => MapEntry(key, decoder(value)))
+      .cast();
+}
+
+List? $encodeList(List<JsonEncodable>? items) {
+  return items?.map((item) => item.toJson()).toList();
+}
+
+Map? $encodeMap(Map<String, JsonEncodable>? items) {
+  return items?.map((key, value) => MapEntry(key, value.toJson()));
+}
diff --git a/generator/dart/packages/google_cloud_common/pubspec.yaml b/generator/dart/packages/google_cloud_common/pubspec.yaml
index 67efb4d341..4792a91344 100644
--- a/generator/dart/packages/google_cloud_common/pubspec.yaml
+++ b/generator/dart/packages/google_cloud_common/pubspec.yaml
@@ -22,4 +22,6 @@ environment:
 resolution: workspace
 
 dependencies:
+  google_cloud_rpc: ^0.1.0
   http: ^1.3.0
+  test: ^1.25.0
diff --git a/generator/dart/packages/google_cloud_common/test/rpc_types_test.dart b/generator/dart/packages/google_cloud_common/test/rpc_types_test.dart
new file mode 100644
index 0000000000..4016ad8178
--- /dev/null
+++ b/generator/dart/packages/google_cloud_common/test/rpc_types_test.dart
@@ -0,0 +1,148 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'dart:convert';
+
+import 'package:google_cloud_rpc/rpc.dart';
+import 'package:google_cloud_protobuf/protobuf.dart';
+import 'package:test/test.dart';
+
+// Tests to validate the generated JSON encoding code using types from
+// package:google_cloud_rpc.
+
+void main() {
+  // simple fields
+  test('LocalizedMessage', () {
+    final expected = LocalizedMessage(locale: 'en-US', message: 'Lorem ipsum.');
+    final actual = LocalizedMessage.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.locale, expected.locale);
+    expect(actual.message, expected.message);
+  });
+
+  // simple fields
+  test('HttpHeader', () {
+    final expected =
+        HttpHeader(key: 'Accept-Language', value: 'en-US,en;q=0.5');
+    final actual = HttpHeader.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.key, expected.key);
+    expect(actual.value, expected.value);
+  });
+
+  // enum
+  test('Code', () {
+    final expected = Code.unauthenticated;
+    final actual = Code.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual, expected);
+  });
+
+  // fields and primitive maps
+  test('ErrorInfo', () {
+    var expected = ErrorInfo(
+      reason: 'LOREM_IPSUM',
+      domain: 'cheese.com',
+    );
+    var actual = ErrorInfo.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.reason, expected.reason);
+    expect(actual.domain, expected.domain);
+    expect(actual.metadata, isNull);
+
+    expected = ErrorInfo(
+      reason: 'LOREM_IPSUM',
+      domain: 'cheese.com',
+      metadata: {'instanceLimitPerRequest': '100'},
+    );
+    actual = ErrorInfo.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.reason, expected.reason);
+    expect(actual.domain, expected.domain);
+    expect(actual.metadata, hasLength(1));
+    expect(actual.metadata!['instanceLimitPerRequest'], '100');
+  });
+
+  // fields using custom encoding
+  test('RetryInfo', () {
+    final expected = RetryInfo(retryDelay: Duration(seconds: 100, nanos: 1000));
+    final actual = RetryInfo.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.retryDelay, isNotNull);
+    final retry = actual.retryDelay!;
+    expect(retry.seconds, 100);
+    expect(retry.nanos, 1000);
+  });
+
+  // primitive lists
+  test('DebugInfo', () {
+    final expected = DebugInfo(
+        stackEntries: ['one', 'two', 'three'], detail: 'Lorem ipsum.');
+    final actual = DebugInfo.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.stackEntries, hasLength(3));
+    expect(actual.stackEntries![0], 'one');
+    expect(actual.stackEntries![1], 'two');
+    expect(actual.stackEntries![2], 'three');
+    expect(actual.detail, expected.detail);
+  });
+
+  // message lists
+  test('QuotaFailure', () {
+    final expected = QuotaFailure(
+      violations: [
+        QuotaFailure$Violation(
+          subject: 'project:foo',
+          description: 'Limit exceeded',
+        ),
+        QuotaFailure$Violation(
+          subject: 'clientip:1.2.3.4',
+          description: 'Service disabled',
+        ),
+      ],
+    );
+    final actual = QuotaFailure.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.violations, hasLength(2));
+    expect(
+      jsonEncode(actual.violations![0].toJson()),
+      jsonEncode(expected.violations![0].toJson()),
+    );
+    expect(
+      jsonEncode(actual.violations![1].toJson()),
+      jsonEncode(expected.violations![1].toJson()),
+    );
+  });
+
+  // Status
+  test('Status', () {
+    var expected = Status(code: 5, message: 'Not found');
+    var actual = Status.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.code, expected.code);
+    expect(actual.message, expected.message);
+
+    // For now, we're not testing round-tripping the 'Any' type.
+    expected = Status(code: 5, message: 'Not found', details: []);
+    actual = Status.fromJson(encodeDecode(expected.toJson()));
+
+    expect(actual.code, expected.code);
+    expect(actual.message, expected.message);
+    expect(actual.details, isEmpty);
+  });
+}
+
+/// Encode the given object to a JSON string, then return the results of JSON
+/// decoding that string.
+dynamic encodeDecode(Object json) => jsonDecode(jsonEncode(json));
diff --git a/generator/internal/dart/dart.go b/generator/internal/dart/dart.go
index f054ca8f04..385b30d30d 100644
--- a/generator/internal/dart/dart.go
+++ b/generator/internal/dart/dart.go
@@ -37,6 +37,11 @@ var needsCtorValidation = map[string]string{
 	".google.protobuf.Duration": ".google.protobuf.Duration",
 }
 
+var usesCustomEncoding = map[string]string{
+	".google.protobuf.Duration":  ".google.protobuf.Duration",
+	".google.protobuf.FieldMask": ".google.protobuf.FieldMask",
+}
+
 func Generate(model *api.API, outdir string, cfg *config.Config) error {
 	_, err := annotateModel(model, cfg.Codec)
 	if err != nil {
diff --git a/generator/internal/dart/darttemplate.go b/generator/internal/dart/darttemplate.go
index 46f1dc7069..fdfdea8e25 100644
--- a/generator/internal/dart/darttemplate.go
+++ b/generator/internal/dart/darttemplate.go
@@ -61,13 +61,13 @@ type serviceAnnotations struct {
 }
 
 type messageAnnotation struct {
-	Name             string
-	DocLines         []string
-	ConstructorBody  string // A custom body for the message's constructor.
-	BasicFields      []*api.Field
-	HasFields        bool
-	HasToStringLines bool
-	ToStringLines    []string
+	Name              string
+	DocLines          []string
+	ConstructorBody   string // A custom body for the message's constructor.
+	HasFields         bool
+	HasCustomEncoding bool
+	HasToStringLines  bool
+	ToStringLines     []string
 }
 
 type methodAnnotation struct {
@@ -78,15 +78,11 @@ type methodAnnotation struct {
 	DocLines     []string
 	PathParams   []*api.Field
 	QueryParams  []*api.Field
-	BodyAccessor string
 }
 
 type pathInfoAnnotation struct {
-	Method      string
-	PathFmt     string
-	PathArgs    []string
-	HasPathArgs bool
-	HasBody     bool
+	PathFmt  string
+	PathArgs []string
 }
 
 type oneOfAnnotation struct {
@@ -99,6 +95,9 @@ type fieldAnnotation struct {
 	Type     string
 	DocLines []string
 	Required bool
+	Nullable bool
+	FromJson string
+	ToJson   string
 }
 
 type enumAnnotation struct {
@@ -357,18 +356,107 @@ func annotateMessage(m *api.Message, state *api.APIState, packageMapping map[str
 		constructorBody = " {\n    _validate();\n  }"
 	}
 
+	_, hasCustomEncoding := usesCustomEncoding[m.ID]
 	toStringLines := createToStringLines(m)
 
 	m.Codec = &messageAnnotation{
-		Name:            messageName(m),
-		DocLines:        formatDocComments(m.Documentation, state),
-		ConstructorBody: constructorBody,
-		BasicFields: language.FilterSlice(m.Fields, func(s *api.Field) bool {
-			return !s.IsOneOf
-		}),
-		HasFields:        len(m.Fields) > 0,
-		HasToStringLines: len(toStringLines) > 0,
-		ToStringLines:    toStringLines,
+		Name:              messageName(m),
+		DocLines:          formatDocComments(m.Documentation, state),
+		ConstructorBody:   constructorBody,
+		HasFields:         len(m.Fields) > 0,
+		HasCustomEncoding: hasCustomEncoding,
+		HasToStringLines:  len(toStringLines) > 0,
+		ToStringLines:     toStringLines,
+	}
+}
+
+func createFromJsonLine(field *api.Field, state *api.APIState, required bool) string {
+	name := strcase.ToLowerCamel(field.Name)
+	message := state.MessageByID[field.TypezID]
+	typeName := ""
+
+	isList := field.Repeated
+	isMessage := field.Typez == api.MESSAGE_TYPE
+	isEnum := field.Typez == api.ENUM_TYPE
+	isMap := message != nil && message.IsMap
+	isMessageMap := isMap && message.Fields[1].Typez == api.MESSAGE_TYPE
+
+	if isMessage {
+		typeName = messageName(message)
+	} else if isEnum {
+		enum := state.EnumByID[field.TypezID]
+		typeName = enumName(enum)
+	}
+
+	data := "json['" + name + "']"
+	fn := typeName + ".fromJson"
+	opt := ""
+	bang := "!"
+	if !required {
+		opt = "?"
+		bang = ""
+	}
+
+	if isMap {
+		if isMessageMap {
+			// message maps: $decodeMap(json['name'], Status.fromJson)!,
+			return "$decodeMap(" + data + ", " + fn + ")" + bang
+		} else {
+			// primitive maps: (json['name'] as Map?)?.cast(),
+			return "(" + data + " as Map" + opt + ")" + opt + ".cast()"
+		}
+	} else if isList {
+		if isMessage {
+			// message lists, custom lists: $decodeList(json['name'], FieldMask.fromJson)!,
+			return "$decodeList(" + data + ", " + fn + ")" + bang
+		} else {
+			// primitive lists: (json['name'] as List?)?.cast(),
+			return "(" + data + " as List" + opt + ")" + opt + ".cast()"
+		}
+	} else if isMessage || isEnum {
+		// enum or message
+		if required {
+			// FieldMask.fromJson(json['name']),
+			return fn + "(" + data + ")"
+		} else {
+			// $decode(json['name'], FieldMask.fromJson),
+			return "$decode(" + data + ", " + fn + ")"
+		}
+	} else {
+		// json['name']
+		return data
+	}
+}
+
+func createToJsonLine(field *api.Field, state *api.APIState, required bool) string {
+	name := strcase.ToLowerCamel(field.Name)
+	message := state.MessageByID[field.TypezID]
+
+	isList := field.Repeated
+	isMessage := field.Typez == api.MESSAGE_TYPE
+	isEnum := field.Typez == api.ENUM_TYPE
+	isMap := message != nil && message.IsMap
+	isMessageMap := isMap && message.Fields[1].Typez == api.MESSAGE_TYPE
+	bang := "!"
+	if required {
+		bang = ""
+	}
+
+	if isMessageMap {
+		// message maps: $encodeMap(name)
+		return "$encodeMap(" + name + ")"
+	} else if isList && (isMessage || isEnum) {
+		// message lists, custom lists, and enum lists: $encodeList(name)
+		return "$encodeList(" + name + ")"
+	} else if isMap {
+		// primitive maps
+		return name
+	} else if isMessage || isEnum {
+		// message, enum, and custom: name!.toJson()
+		return name + bang + ".toJson()"
+	} else {
+		// primitive, primitive lists
+		return name
 	}
 }
 
@@ -436,13 +524,16 @@ func annotateOneOf(field *api.OneOf, state *api.APIState) {
 
 func annotateField(field *api.Field, state *api.APIState, packageMapping map[string]string,
 	imports map[string]string, requiredFields map[string]*api.Field) {
-	_, ok := requiredFields[field.ID]
+	_, required := requiredFields[field.ID]
 
 	field.Codec = &fieldAnnotation{
 		Name:     strcase.ToLowerCamel(field.Name),
 		Type:     fieldType(field, state, packageMapping, imports),
 		DocLines: formatDocComments(field.Documentation, state),
-		Required: ok,
+		Required: required,
+		Nullable: !required,
+		FromJson: createFromJsonLine(field, state, required),
+		ToJson:   createToJsonLine(field, state, required),
 	}
 }
 
diff --git a/generator/internal/dart/templates/lib/enum.mustache b/generator/internal/dart/templates/lib/enum.mustache
index e9c811b103..9280e81ea4 100644
--- a/generator/internal/dart/templates/lib/enum.mustache
+++ b/generator/internal/dart/templates/lib/enum.mustache
@@ -17,7 +17,7 @@ limitations under the License.
 {{#Codec.DocLines}}
 {{{.}}}
 {{/Codec.DocLines}}
-class {{Codec.Name}} extends CloudEnum {
+class {{Codec.Name}} extends Enum {
   {{#Values}}
   {{#Codec.DocLines}}
   {{{.}}}
diff --git a/generator/internal/dart/templates/lib/message.mustache b/generator/internal/dart/templates/lib/message.mustache
index 7149f7280b..da7f088a3e 100644
--- a/generator/internal/dart/templates/lib/message.mustache
+++ b/generator/internal/dart/templates/lib/message.mustache
@@ -18,7 +18,7 @@ limitations under the License.
 {{#Codec.DocLines}}
 {{{.}}}
 {{/Codec.DocLines}}
-class {{Codec.Name}} extends CloudMessage {
+class {{Codec.Name}} extends Message {
   {{#Fields}}
   {{> field}}
   {{/Fields}}
@@ -29,6 +29,33 @@ class {{Codec.Name}} extends CloudMessage {
   {{/Fields}}
   }{{/Codec.HasFields}}){{Codec.ConstructorBody}}
 
+  {{#Codec.HasCustomEncoding}}
+  factory {{Codec.Name}}.fromJson(String json) => {{Codec.Name}}Helper.decode(json);
+  {{/Codec.HasCustomEncoding}}
+  {{^Codec.HasCustomEncoding}}
+  factory {{Codec.Name}}.fromJson(Map<String, dynamic> json) {
+    return {{Codec.Name}}(
+      {{#Fields}}
+      {{{Codec.Name}}}: {{{Codec.FromJson}}},
+      {{/Fields}}
+    );
+  }
+  {{/Codec.HasCustomEncoding}}
+
+  @override
+  {{#Codec.HasCustomEncoding}}
+  Object toJson() => {{Codec.Name}}Helper.encode(this);
+  {{/Codec.HasCustomEncoding}}
+  {{^Codec.HasCustomEncoding}}
+  Object toJson() {
+    return {
+      {{#Fields}}
+      {{#Codec.Nullable}}if ({{{Codec.Name}}} != null) {{/Codec.Nullable}}'{{{Codec.Name}}}': {{{Codec.ToJson}}},
+      {{/Fields}}
+    };
+  }
+  {{/Codec.HasCustomEncoding}}
+
   @override
   {{#Codec.HasToStringLines}}
   String toString() {
diff --git a/generator/testdata/dart/protobuf/golden/secretmanager/lib/secretmanager.dart b/generator/testdata/dart/protobuf/golden/secretmanager/lib/secretmanager.dart
index 852dd98f62..7d41ff7446 100755
--- a/generator/testdata/dart/protobuf/golden/secretmanager/lib/secretmanager.dart
+++ b/generator/testdata/dart/protobuf/golden/secretmanager/lib/secretmanager.dart
@@ -168,7 +168,7 @@ class SecretManagerService extends CloudService {
 /// A [Secret][google.cloud.secretmanager.v1.Secret] is made up of zero or more
 /// [SecretVersions][google.cloud.secretmanager.v1.SecretVersion] that represent
 /// the secret data.
-class Secret extends CloudMessage {
+class Secret extends Message {
 
   /// Output only. The resource name of the
   /// [Secret][google.cloud.secretmanager.v1.Secret] in the format
@@ -282,6 +282,43 @@ class Secret extends CloudMessage {
     this.customerManagedEncryption,
   });
 
+  factory Secret.fromJson(Map<String, dynamic> json) {
+    return Secret(
+      name: json['name'],
+      replication: $decode(json['replication'], Replication.fromJson),
+      createTime: $decode(json['createTime'], Timestamp.fromJson),
+      labels: (json['labels'] as Map?)?.cast(),
+      topics: $decodeList(json['topics'], Topic.fromJson),
+      expireTime: $decode(json['expireTime'], Timestamp.fromJson),
+      ttl: $decode(json['ttl'], Duration.fromJson),
+      etag: json['etag'],
+      rotation: $decode(json['rotation'], Rotation.fromJson),
+      versionAliases: (json['versionAliases'] as Map?)?.cast(),
+      annotations: (json['annotations'] as Map?)?.cast(),
+      versionDestroyTtl: $decode(json['versionDestroyTtl'], Duration.fromJson),
+      customerManagedEncryption: $decode(json['customerManagedEncryption'], CustomerManagedEncryption.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (name != null) 'name': name,
+      if (replication != null) 'replication': replication!.toJson(),
+      if (createTime != null) 'createTime': createTime!.toJson(),
+      if (labels != null) 'labels': labels,
+      if (topics != null) 'topics': $encodeList(topics),
+      if (expireTime != null) 'expireTime': expireTime!.toJson(),
+      if (ttl != null) 'ttl': ttl!.toJson(),
+      if (etag != null) 'etag': etag,
+      if (rotation != null) 'rotation': rotation!.toJson(),
+      if (versionAliases != null) 'versionAliases': versionAliases,
+      if (annotations != null) 'annotations': annotations,
+      if (versionDestroyTtl != null) 'versionDestroyTtl': versionDestroyTtl!.toJson(),
+      if (customerManagedEncryption != null) 'customerManagedEncryption': customerManagedEncryption!.toJson(),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -293,7 +330,7 @@ class Secret extends CloudMessage {
 }
 
 /// A secret version resource in the Secret Manager API.
-class SecretVersion extends CloudMessage {
+class SecretVersion extends Message {
 
   /// Output only. The resource name of the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion] in the format
@@ -361,6 +398,35 @@ class SecretVersion extends CloudMessage {
     this.customerManagedEncryption,
   });
 
+  factory SecretVersion.fromJson(Map<String, dynamic> json) {
+    return SecretVersion(
+      name: json['name'],
+      createTime: $decode(json['createTime'], Timestamp.fromJson),
+      destroyTime: $decode(json['destroyTime'], Timestamp.fromJson),
+      state: $decode(json['state'], SecretVersion$State.fromJson),
+      replicationStatus: $decode(json['replicationStatus'], ReplicationStatus.fromJson),
+      etag: json['etag'],
+      clientSpecifiedPayloadChecksum: json['clientSpecifiedPayloadChecksum'],
+      scheduledDestroyTime: $decode(json['scheduledDestroyTime'], Timestamp.fromJson),
+      customerManagedEncryption: $decode(json['customerManagedEncryption'], CustomerManagedEncryptionStatus.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (name != null) 'name': name,
+      if (createTime != null) 'createTime': createTime!.toJson(),
+      if (destroyTime != null) 'destroyTime': destroyTime!.toJson(),
+      if (state != null) 'state': state!.toJson(),
+      if (replicationStatus != null) 'replicationStatus': replicationStatus!.toJson(),
+      if (etag != null) 'etag': etag,
+      if (clientSpecifiedPayloadChecksum != null) 'clientSpecifiedPayloadChecksum': clientSpecifiedPayloadChecksum,
+      if (scheduledDestroyTime != null) 'scheduledDestroyTime': scheduledDestroyTime!.toJson(),
+      if (customerManagedEncryption != null) 'customerManagedEncryption': customerManagedEncryption!.toJson(),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -376,7 +442,7 @@ class SecretVersion extends CloudMessage {
 /// The state of a
 /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion], indicating if
 /// it can be accessed.
-class SecretVersion$State extends CloudEnum {
+class SecretVersion$State extends Enum {
   /// Not specified. This value is unused and invalid.
   static const stateUnspecified = SecretVersion$State('STATE_UNSPECIFIED');
 
@@ -409,7 +475,7 @@ class SecretVersion$State extends CloudEnum {
 }
 
 /// A policy that defines the replication and encryption configuration of data.
-class Replication extends CloudMessage {
+class Replication extends Message {
 
   /// The [Secret][google.cloud.secretmanager.v1.Secret] will automatically be
   /// replicated without any restrictions.
@@ -424,6 +490,21 @@ class Replication extends CloudMessage {
     this.userManaged,
   });
 
+  factory Replication.fromJson(Map<String, dynamic> json) {
+    return Replication(
+      automatic: $decode(json['automatic'], Replication$Automatic.fromJson),
+      userManaged: $decode(json['userManaged'], Replication$UserManaged.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (automatic != null) 'automatic': automatic!.toJson(),
+      if (userManaged != null) 'userManaged': userManaged!.toJson(),
+    };
+  }
+
   @override
   String toString() => 'Replication()';
 }
@@ -431,7 +512,7 @@ class Replication extends CloudMessage {
 /// A replication policy that replicates the
 /// [Secret][google.cloud.secretmanager.v1.Secret] payload without any
 /// restrictions.
-class Replication$Automatic extends CloudMessage {
+class Replication$Automatic extends Message {
 
   /// Optional. The customer-managed encryption configuration of the
   /// [Secret][google.cloud.secretmanager.v1.Secret]. If no configuration is
@@ -448,6 +529,19 @@ class Replication$Automatic extends CloudMessage {
     this.customerManagedEncryption,
   });
 
+  factory Replication$Automatic.fromJson(Map<String, dynamic> json) {
+    return Replication$Automatic(
+      customerManagedEncryption: $decode(json['customerManagedEncryption'], CustomerManagedEncryption.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (customerManagedEncryption != null) 'customerManagedEncryption': customerManagedEncryption!.toJson(),
+    };
+  }
+
   @override
   String toString() => 'Automatic()';
 }
@@ -455,7 +549,7 @@ class Replication$Automatic extends CloudMessage {
 /// A replication policy that replicates the
 /// [Secret][google.cloud.secretmanager.v1.Secret] payload into the locations
 /// specified in [Secret.replication.user_managed.replicas][]
-class Replication$UserManaged extends CloudMessage {
+class Replication$UserManaged extends Message {
 
   /// Required. The list of Replicas for this
   /// [Secret][google.cloud.secretmanager.v1.Secret].
@@ -467,13 +561,26 @@ class Replication$UserManaged extends CloudMessage {
     this.replicas,
   });
 
+  factory Replication$UserManaged.fromJson(Map<String, dynamic> json) {
+    return Replication$UserManaged(
+      replicas: $decodeList(json['replicas'], Replication$UserManaged$Replica.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (replicas != null) 'replicas': $encodeList(replicas),
+    };
+  }
+
   @override
   String toString() => 'UserManaged()';
 }
 
 /// Represents a Replica for this
 /// [Secret][google.cloud.secretmanager.v1.Secret].
-class Replication$UserManaged$Replica extends CloudMessage {
+class Replication$UserManaged$Replica extends Message {
 
   /// The canonical IDs of the location to replicate data.
   /// For example: `"us-east1"`.
@@ -495,6 +602,21 @@ class Replication$UserManaged$Replica extends CloudMessage {
     this.customerManagedEncryption,
   });
 
+  factory Replication$UserManaged$Replica.fromJson(Map<String, dynamic> json) {
+    return Replication$UserManaged$Replica(
+      location: json['location'],
+      customerManagedEncryption: $decode(json['customerManagedEncryption'], CustomerManagedEncryption.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (location != null) 'location': location,
+      if (customerManagedEncryption != null) 'customerManagedEncryption': customerManagedEncryption!.toJson(),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -506,7 +628,7 @@ class Replication$UserManaged$Replica extends CloudMessage {
 
 /// Configuration for encrypting secret payloads using customer-managed
 /// encryption keys (CMEK).
-class CustomerManagedEncryption extends CloudMessage {
+class CustomerManagedEncryption extends Message {
 
   /// Required. The resource name of the Cloud KMS CryptoKey used to encrypt
   /// secret payloads.
@@ -527,6 +649,19 @@ class CustomerManagedEncryption extends CloudMessage {
     this.kmsKeyName,
   });
 
+  factory CustomerManagedEncryption.fromJson(Map<String, dynamic> json) {
+    return CustomerManagedEncryption(
+      kmsKeyName: json['kmsKeyName'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (kmsKeyName != null) 'kmsKeyName': kmsKeyName,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -538,7 +673,7 @@ class CustomerManagedEncryption extends CloudMessage {
 
 /// The replication status of a
 /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion].
-class ReplicationStatus extends CloudMessage {
+class ReplicationStatus extends Message {
 
   /// Describes the replication status of a
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion] with
@@ -563,6 +698,21 @@ class ReplicationStatus extends CloudMessage {
     this.userManaged,
   });
 
+  factory ReplicationStatus.fromJson(Map<String, dynamic> json) {
+    return ReplicationStatus(
+      automatic: $decode(json['automatic'], ReplicationStatus$AutomaticStatus.fromJson),
+      userManaged: $decode(json['userManaged'], ReplicationStatus$UserManagedStatus.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (automatic != null) 'automatic': automatic!.toJson(),
+      if (userManaged != null) 'userManaged': userManaged!.toJson(),
+    };
+  }
+
   @override
   String toString() => 'ReplicationStatus()';
 }
@@ -573,7 +723,7 @@ class ReplicationStatus extends CloudMessage {
 ///
 /// Only populated if the parent [Secret][google.cloud.secretmanager.v1.Secret]
 /// has an automatic replication policy.
-class ReplicationStatus$AutomaticStatus extends CloudMessage {
+class ReplicationStatus$AutomaticStatus extends Message {
 
   /// Output only. The customer-managed encryption status of the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion]. Only
@@ -584,6 +734,19 @@ class ReplicationStatus$AutomaticStatus extends CloudMessage {
     this.customerManagedEncryption,
   });
 
+  factory ReplicationStatus$AutomaticStatus.fromJson(Map<String, dynamic> json) {
+    return ReplicationStatus$AutomaticStatus(
+      customerManagedEncryption: $decode(json['customerManagedEncryption'], CustomerManagedEncryptionStatus.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (customerManagedEncryption != null) 'customerManagedEncryption': customerManagedEncryption!.toJson(),
+    };
+  }
+
   @override
   String toString() => 'AutomaticStatus()';
 }
@@ -594,7 +757,7 @@ class ReplicationStatus$AutomaticStatus extends CloudMessage {
 ///
 /// Only populated if the parent [Secret][google.cloud.secretmanager.v1.Secret]
 /// has a user-managed replication policy.
-class ReplicationStatus$UserManagedStatus extends CloudMessage {
+class ReplicationStatus$UserManagedStatus extends Message {
 
   /// Output only. The list of replica statuses for the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion].
@@ -604,13 +767,26 @@ class ReplicationStatus$UserManagedStatus extends CloudMessage {
     this.replicas,
   });
 
+  factory ReplicationStatus$UserManagedStatus.fromJson(Map<String, dynamic> json) {
+    return ReplicationStatus$UserManagedStatus(
+      replicas: $decodeList(json['replicas'], ReplicationStatus$UserManagedStatus$ReplicaStatus.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (replicas != null) 'replicas': $encodeList(replicas),
+    };
+  }
+
   @override
   String toString() => 'UserManagedStatus()';
 }
 
 /// Describes the status of a user-managed replica for the
 /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion].
-class ReplicationStatus$UserManagedStatus$ReplicaStatus extends CloudMessage {
+class ReplicationStatus$UserManagedStatus$ReplicaStatus extends Message {
 
   /// Output only. The canonical ID of the replica location.
   /// For example: `"us-east1"`.
@@ -626,6 +802,21 @@ class ReplicationStatus$UserManagedStatus$ReplicaStatus extends CloudMessage {
     this.customerManagedEncryption,
   });
 
+  factory ReplicationStatus$UserManagedStatus$ReplicaStatus.fromJson(Map<String, dynamic> json) {
+    return ReplicationStatus$UserManagedStatus$ReplicaStatus(
+      location: json['location'],
+      customerManagedEncryption: $decode(json['customerManagedEncryption'], CustomerManagedEncryptionStatus.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (location != null) 'location': location,
+      if (customerManagedEncryption != null) 'customerManagedEncryption': customerManagedEncryption!.toJson(),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -636,7 +827,7 @@ class ReplicationStatus$UserManagedStatus$ReplicaStatus extends CloudMessage {
 }
 
 /// Describes the status of customer-managed encryption.
-class CustomerManagedEncryptionStatus extends CloudMessage {
+class CustomerManagedEncryptionStatus extends Message {
 
   /// Required. The resource name of the Cloud KMS CryptoKeyVersion used to
   /// encrypt the secret payload, in the following format:
@@ -647,6 +838,19 @@ class CustomerManagedEncryptionStatus extends CloudMessage {
     this.kmsKeyVersionName,
   });
 
+  factory CustomerManagedEncryptionStatus.fromJson(Map<String, dynamic> json) {
+    return CustomerManagedEncryptionStatus(
+      kmsKeyVersionName: json['kmsKeyVersionName'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (kmsKeyVersionName != null) 'kmsKeyVersionName': kmsKeyVersionName,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -658,7 +862,7 @@ class CustomerManagedEncryptionStatus extends CloudMessage {
 
 /// A Pub/Sub topic which Secret Manager will publish to when control plane
 /// events occur on this secret.
-class Topic extends CloudMessage {
+class Topic extends Message {
 
   /// Required. The resource name of the Pub/Sub topic that will be published to,
   /// in the following format: `projects/*/topics/*`. For publication to succeed,
@@ -671,6 +875,19 @@ class Topic extends CloudMessage {
     this.name,
   });
 
+  factory Topic.fromJson(Map<String, dynamic> json) {
+    return Topic(
+      name: json['name'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (name != null) 'name': name,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -685,7 +902,7 @@ class Topic extends CloudMessage {
 /// Manager will send a Pub/Sub notification to the topics configured on the
 /// Secret. [Secret.topics][google.cloud.secretmanager.v1.Secret.topics] must be
 /// set to configure rotation.
-class Rotation extends CloudMessage {
+class Rotation extends Message {
 
   /// Optional. Timestamp in UTC at which the
   /// [Secret][google.cloud.secretmanager.v1.Secret] is scheduled to rotate.
@@ -716,6 +933,21 @@ class Rotation extends CloudMessage {
     this.rotationPeriod,
   });
 
+  factory Rotation.fromJson(Map<String, dynamic> json) {
+    return Rotation(
+      nextRotationTime: $decode(json['nextRotationTime'], Timestamp.fromJson),
+      rotationPeriod: $decode(json['rotationPeriod'], Duration.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (nextRotationTime != null) 'nextRotationTime': nextRotationTime!.toJson(),
+      if (rotationPeriod != null) 'rotationPeriod': rotationPeriod!.toJson(),
+    };
+  }
+
   @override
   String toString() => 'Rotation()';
 }
@@ -723,7 +955,7 @@ class Rotation extends CloudMessage {
 /// A secret payload resource in the Secret Manager API. This contains the
 /// sensitive secret payload that is associated with a
 /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion].
-class SecretPayload extends CloudMessage {
+class SecretPayload extends Message {
 
   /// The secret data. Must be no larger than 64KiB.
   final Uint8List? data;
@@ -751,6 +983,21 @@ class SecretPayload extends CloudMessage {
     this.dataCrc32C,
   });
 
+  factory SecretPayload.fromJson(Map<String, dynamic> json) {
+    return SecretPayload(
+      data: json['data'],
+      dataCrc32C: json['dataCrc32C'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (data != null) 'data': data,
+      if (dataCrc32C != null) 'dataCrc32C': dataCrc32C,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -763,7 +1010,7 @@ class SecretPayload extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.ListSecrets][google.cloud.secretmanager.v1.SecretManagerService.ListSecrets].
-class ListSecretsRequest extends CloudMessage {
+class ListSecretsRequest extends Message {
 
   /// Required. The resource name of the project associated with the
   /// [Secrets][google.cloud.secretmanager.v1.Secret], in the format `projects/*`
@@ -793,6 +1040,25 @@ class ListSecretsRequest extends CloudMessage {
     this.filter,
   });
 
+  factory ListSecretsRequest.fromJson(Map<String, dynamic> json) {
+    return ListSecretsRequest(
+      parent: json['parent'],
+      pageSize: json['pageSize'],
+      pageToken: json['pageToken'],
+      filter: json['filter'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'parent': parent,
+      if (pageSize != null) 'pageSize': pageSize,
+      if (pageToken != null) 'pageToken': pageToken,
+      if (filter != null) 'filter': filter,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -807,7 +1073,7 @@ class ListSecretsRequest extends CloudMessage {
 
 /// Response message for
 /// [SecretManagerService.ListSecrets][google.cloud.secretmanager.v1.SecretManagerService.ListSecrets].
-class ListSecretsResponse extends CloudMessage {
+class ListSecretsResponse extends Message {
 
   /// The list of [Secrets][google.cloud.secretmanager.v1.Secret] sorted in
   /// reverse by create_time (newest first).
@@ -830,6 +1096,23 @@ class ListSecretsResponse extends CloudMessage {
     this.totalSize,
   });
 
+  factory ListSecretsResponse.fromJson(Map<String, dynamic> json) {
+    return ListSecretsResponse(
+      secrets: $decodeList(json['secrets'], Secret.fromJson),
+      nextPageToken: json['nextPageToken'],
+      totalSize: json['totalSize'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (secrets != null) 'secrets': $encodeList(secrets),
+      if (nextPageToken != null) 'nextPageToken': nextPageToken,
+      if (totalSize != null) 'totalSize': totalSize,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -842,7 +1125,7 @@ class ListSecretsResponse extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.CreateSecret][google.cloud.secretmanager.v1.SecretManagerService.CreateSecret].
-class CreateSecretRequest extends CloudMessage {
+class CreateSecretRequest extends Message {
 
   /// Required. The resource name of the project to associate with the
   /// [Secret][google.cloud.secretmanager.v1.Secret], in the format `projects/*`
@@ -866,6 +1149,23 @@ class CreateSecretRequest extends CloudMessage {
     required this.secret,
   });
 
+  factory CreateSecretRequest.fromJson(Map<String, dynamic> json) {
+    return CreateSecretRequest(
+      parent: json['parent'],
+      secretId: json['secretId'],
+      secret: Secret.fromJson(json['secret']),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'parent': parent,
+      if (secretId != null) 'secretId': secretId,
+      'secret': secret.toJson(),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -878,7 +1178,7 @@ class CreateSecretRequest extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.AddSecretVersion][google.cloud.secretmanager.v1.SecretManagerService.AddSecretVersion].
-class AddSecretVersionRequest extends CloudMessage {
+class AddSecretVersionRequest extends Message {
 
   /// Required. The resource name of the
   /// [Secret][google.cloud.secretmanager.v1.Secret] to associate with the
@@ -895,6 +1195,21 @@ class AddSecretVersionRequest extends CloudMessage {
     this.payload,
   });
 
+  factory AddSecretVersionRequest.fromJson(Map<String, dynamic> json) {
+    return AddSecretVersionRequest(
+      parent: json['parent'],
+      payload: $decode(json['payload'], SecretPayload.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'parent': parent,
+      if (payload != null) 'payload': payload!.toJson(),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -906,7 +1221,7 @@ class AddSecretVersionRequest extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.GetSecret][google.cloud.secretmanager.v1.SecretManagerService.GetSecret].
-class GetSecretRequest extends CloudMessage {
+class GetSecretRequest extends Message {
 
   /// Required. The resource name of the
   /// [Secret][google.cloud.secretmanager.v1.Secret], in the format
@@ -917,6 +1232,19 @@ class GetSecretRequest extends CloudMessage {
     required this.name,
   });
 
+  factory GetSecretRequest.fromJson(Map<String, dynamic> json) {
+    return GetSecretRequest(
+      name: json['name'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'name': name,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -928,7 +1256,7 @@ class GetSecretRequest extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.ListSecretVersions][google.cloud.secretmanager.v1.SecretManagerService.ListSecretVersions].
-class ListSecretVersionsRequest extends CloudMessage {
+class ListSecretVersionsRequest extends Message {
 
   /// Required. The resource name of the
   /// [Secret][google.cloud.secretmanager.v1.Secret] associated with the
@@ -959,6 +1287,25 @@ class ListSecretVersionsRequest extends CloudMessage {
     this.filter,
   });
 
+  factory ListSecretVersionsRequest.fromJson(Map<String, dynamic> json) {
+    return ListSecretVersionsRequest(
+      parent: json['parent'],
+      pageSize: json['pageSize'],
+      pageToken: json['pageToken'],
+      filter: json['filter'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'parent': parent,
+      if (pageSize != null) 'pageSize': pageSize,
+      if (pageToken != null) 'pageToken': pageToken,
+      if (filter != null) 'filter': filter,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -973,7 +1320,7 @@ class ListSecretVersionsRequest extends CloudMessage {
 
 /// Response message for
 /// [SecretManagerService.ListSecretVersions][google.cloud.secretmanager.v1.SecretManagerService.ListSecretVersions].
-class ListSecretVersionsResponse extends CloudMessage {
+class ListSecretVersionsResponse extends Message {
 
   /// The list of [SecretVersions][google.cloud.secretmanager.v1.SecretVersion]
   /// sorted in reverse by create_time (newest first).
@@ -997,6 +1344,23 @@ class ListSecretVersionsResponse extends CloudMessage {
     this.totalSize,
   });
 
+  factory ListSecretVersionsResponse.fromJson(Map<String, dynamic> json) {
+    return ListSecretVersionsResponse(
+      versions: $decodeList(json['versions'], SecretVersion.fromJson),
+      nextPageToken: json['nextPageToken'],
+      totalSize: json['totalSize'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (versions != null) 'versions': $encodeList(versions),
+      if (nextPageToken != null) 'nextPageToken': nextPageToken,
+      if (totalSize != null) 'totalSize': totalSize,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -1009,7 +1373,7 @@ class ListSecretVersionsResponse extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.GetSecretVersion][google.cloud.secretmanager.v1.SecretManagerService.GetSecretVersion].
-class GetSecretVersionRequest extends CloudMessage {
+class GetSecretVersionRequest extends Message {
 
   /// Required. The resource name of the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion] in the format
@@ -1026,6 +1390,19 @@ class GetSecretVersionRequest extends CloudMessage {
     required this.name,
   });
 
+  factory GetSecretVersionRequest.fromJson(Map<String, dynamic> json) {
+    return GetSecretVersionRequest(
+      name: json['name'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'name': name,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -1037,7 +1414,7 @@ class GetSecretVersionRequest extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.UpdateSecret][google.cloud.secretmanager.v1.SecretManagerService.UpdateSecret].
-class UpdateSecretRequest extends CloudMessage {
+class UpdateSecretRequest extends Message {
 
   /// Required. [Secret][google.cloud.secretmanager.v1.Secret] with updated field
   /// values.
@@ -1051,13 +1428,28 @@ class UpdateSecretRequest extends CloudMessage {
     this.updateMask,
   });
 
+  factory UpdateSecretRequest.fromJson(Map<String, dynamic> json) {
+    return UpdateSecretRequest(
+      secret: Secret.fromJson(json['secret']),
+      updateMask: $decode(json['updateMask'], FieldMask.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'secret': secret.toJson(),
+      if (updateMask != null) 'updateMask': updateMask!.toJson(),
+    };
+  }
+
   @override
   String toString() => 'UpdateSecretRequest()';
 }
 
 /// Request message for
 /// [SecretManagerService.AccessSecretVersion][google.cloud.secretmanager.v1.SecretManagerService.AccessSecretVersion].
-class AccessSecretVersionRequest extends CloudMessage {
+class AccessSecretVersionRequest extends Message {
 
   /// Required. The resource name of the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion] in the format
@@ -1074,6 +1466,19 @@ class AccessSecretVersionRequest extends CloudMessage {
     required this.name,
   });
 
+  factory AccessSecretVersionRequest.fromJson(Map<String, dynamic> json) {
+    return AccessSecretVersionRequest(
+      name: json['name'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'name': name,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -1085,7 +1490,7 @@ class AccessSecretVersionRequest extends CloudMessage {
 
 /// Response message for
 /// [SecretManagerService.AccessSecretVersion][google.cloud.secretmanager.v1.SecretManagerService.AccessSecretVersion].
-class AccessSecretVersionResponse extends CloudMessage {
+class AccessSecretVersionResponse extends Message {
 
   /// The resource name of the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion] in the format
@@ -1101,6 +1506,21 @@ class AccessSecretVersionResponse extends CloudMessage {
     this.payload,
   });
 
+  factory AccessSecretVersionResponse.fromJson(Map<String, dynamic> json) {
+    return AccessSecretVersionResponse(
+      name: json['name'],
+      payload: $decode(json['payload'], SecretPayload.fromJson),
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      if (name != null) 'name': name,
+      if (payload != null) 'payload': payload!.toJson(),
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -1112,7 +1532,7 @@ class AccessSecretVersionResponse extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.DeleteSecret][google.cloud.secretmanager.v1.SecretManagerService.DeleteSecret].
-class DeleteSecretRequest extends CloudMessage {
+class DeleteSecretRequest extends Message {
 
   /// Required. The resource name of the
   /// [Secret][google.cloud.secretmanager.v1.Secret] to delete in the format
@@ -1129,6 +1549,21 @@ class DeleteSecretRequest extends CloudMessage {
     this.etag,
   });
 
+  factory DeleteSecretRequest.fromJson(Map<String, dynamic> json) {
+    return DeleteSecretRequest(
+      name: json['name'],
+      etag: json['etag'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'name': name,
+      if (etag != null) 'etag': etag,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -1141,7 +1576,7 @@ class DeleteSecretRequest extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.DisableSecretVersion][google.cloud.secretmanager.v1.SecretManagerService.DisableSecretVersion].
-class DisableSecretVersionRequest extends CloudMessage {
+class DisableSecretVersionRequest extends Message {
 
   /// Required. The resource name of the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion] to disable in
@@ -1160,6 +1595,21 @@ class DisableSecretVersionRequest extends CloudMessage {
     this.etag,
   });
 
+  factory DisableSecretVersionRequest.fromJson(Map<String, dynamic> json) {
+    return DisableSecretVersionRequest(
+      name: json['name'],
+      etag: json['etag'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'name': name,
+      if (etag != null) 'etag': etag,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -1172,7 +1622,7 @@ class DisableSecretVersionRequest extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.EnableSecretVersion][google.cloud.secretmanager.v1.SecretManagerService.EnableSecretVersion].
-class EnableSecretVersionRequest extends CloudMessage {
+class EnableSecretVersionRequest extends Message {
 
   /// Required. The resource name of the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion] to enable in
@@ -1191,6 +1641,21 @@ class EnableSecretVersionRequest extends CloudMessage {
     this.etag,
   });
 
+  factory EnableSecretVersionRequest.fromJson(Map<String, dynamic> json) {
+    return EnableSecretVersionRequest(
+      name: json['name'],
+      etag: json['etag'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'name': name,
+      if (etag != null) 'etag': etag,
+    };
+  }
+
   @override
   String toString() {
     final contents = [
@@ -1203,7 +1668,7 @@ class EnableSecretVersionRequest extends CloudMessage {
 
 /// Request message for
 /// [SecretManagerService.DestroySecretVersion][google.cloud.secretmanager.v1.SecretManagerService.DestroySecretVersion].
-class DestroySecretVersionRequest extends CloudMessage {
+class DestroySecretVersionRequest extends Message {
 
   /// Required. The resource name of the
   /// [SecretVersion][google.cloud.secretmanager.v1.SecretVersion] to destroy in
@@ -1222,6 +1687,21 @@ class DestroySecretVersionRequest extends CloudMessage {
     this.etag,
   });
 
+  factory DestroySecretVersionRequest.fromJson(Map<String, dynamic> json) {
+    return DestroySecretVersionRequest(
+      name: json['name'],
+      etag: json['etag'],
+    );
+  }
+
+  @override
+  Object toJson() {
+    return {
+      'name': name,
+      if (etag != null) 'etag': etag,
+    };
+  }
+
   @override
   String toString() {
     final contents = [


Reviews:
user: login: devoncarew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T18:34:58Z
user: login: coryan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T20:17:54Z
user: login: devoncarew
body: Thanks for the review! Some comments addressed; I'm going to do a bit more work to see if the code to generate the fromJson/toJson methods can be simplified.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T02:20:36Z
user: login: coryan
body: LGTM. Ping me if you need to make more changes, otherwise just merge.

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T19:00:35Z
user: login: devoncarew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T20:00:22Z
user: login: coryan
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T20:23:18Z

Review comments:
diff_hunk: @@ -61,13 +61,15 @@ type serviceAnnotations struct {
 }
 
 type messageAnnotation struct {
-	Name             string
-	DocLines         []string
-	ConstructorBody  string // A custom body for the message's constructor.
-	BasicFields      []*api.Field
-	HasFields        bool
-	HasToStringLines bool
-	ToStringLines    []string
+	Name              string
+	DocLines          []string
+	ConstructorBody   string // A custom body for the message's constructor.
+	HasFields         bool
+	HasCustomEncoding bool
+	FromJsonLines     []string
path: generator/internal/dart/darttemplate.go
user: login: devoncarew
body: The FromJson / ToJson info could also be annotated off of the fields of the message.
created_at: 2025-03-13T18:34:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'dart:convert';
+
+import 'package:google_cloud_rpc/rpc.dart';
+import 'package:google_cloud_protobuf/protobuf.dart';
+import 'package:test/test.dart';
+
+// Test round-tripping generated JSON encoding and decoding code.
path: generator/dart/packages/generated/google_cloud_rpc/test/json_test.dart
user: login: coryan
body: I assume this file is hand-crafted and not generated (despite the directory).  We prefer to

- keep generated code separate from hand-crafted code: fewer temptations to "patch" the generated code, augment things "just for this case", and also easier to review and build tooling
- for tests like this, I would have a fully hand-crafted integration test that depends on the generated code and verifies it works

created_at: 2025-03-13T20:07:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,16 +23,17 @@ abstract class Jsonable {
   Object toJson();
 }
 
-abstract class CloudMessage {}
+abstract class CloudMessage implements Jsonable {}
 
-abstract class CloudEnum {
+abstract class CloudEnum implements Jsonable {
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: coryan
body: Don't love the `Jsonable` name, not that I can suggest much better names:  `JsonSerializable` ? 
created_at: 2025-03-13T20:09:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,7 +93,49 @@ abstract class CloudService {
       return body.isEmpty ? {} : jsonDecode(body);
     }
 
-    // TODO(#1454): This is a placeholder until we're able to parse 'Status'.
-    throw ClientException('${response.statusCode}: ${response.reasonPhrase}');
+    Status status;
+
+    try {
+      final json = jsonDecode(response.body);
+      status = Status.fromJson(json['error']);
+    } catch (_) {
+      // If we're not able to parse the Status error, return a general HTTP
+      // exception.
+      throw ClientException('${response.statusCode}: ${response.reasonPhrase}');
+    }
+
+    throw status;
   }
 }
+
+T? $toMessage<T>(dynamic json, T Function(Map<String, dynamic>) decoder) {
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: coryan
body: For my education, what does the `$` mean here?
created_at: 2025-03-13T20:09:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,4 +22,5 @@ environment:
 resolution: workspace
 
 dependencies:
+  google_cloud_rpc: ^0.1.0
path: generator/dart/packages/google_cloud_common/pubspec.yaml
user: login: coryan
body: This suggests maybe moving the tests for `*_rpc` to this library?
created_at: 2025-03-13T20:10:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -357,19 +355,165 @@ func annotateMessage(m *api.Message, state *api.APIState, packageMapping map[str
 		constructorBody = " {\n    _validate();\n  }"
 	}
 
+	_, hasCustomEncoding := usesCustomEncoding[m.ID]
 	toStringLines := createToStringLines(m)
 
 	m.Codec = &messageAnnotation{
-		Name:            messageName(m),
-		DocLines:        formatDocComments(m.Documentation, state),
-		ConstructorBody: constructorBody,
-		BasicFields: language.FilterSlice(m.Fields, func(s *api.Field) bool {
-			return !s.IsOneOf
-		}),
-		HasFields:        len(m.Fields) > 0,
-		HasToStringLines: len(toStringLines) > 0,
-		ToStringLines:    toStringLines,
+		Name:              messageName(m),
+		DocLines:          formatDocComments(m.Documentation, state),
+		ConstructorBody:   constructorBody,
+		HasFields:         len(m.Fields) > 0,
+		HasCustomEncoding: hasCustomEncoding,
+		FromJsonLines:     createFromJsonLines(m, state),
+		ToJsonLines:       createToJsonLines(m, state),
+		HasToStringLines:  len(toStringLines) > 0,
+		ToStringLines:     toStringLines,
+	}
+}
+
+func createFromJsonLines(message *api.Message, state *api.APIState) []string {
+	_, hasCustomEncoding := usesCustomEncoding[message.ID]
+	if hasCustomEncoding {
+		return []string{}
+	}
+
+	lines := []string{}
+
+	for _, field := range message.Fields {
+		codec := field.Codec.(*fieldAnnotation)
+		name := codec.Name
+		message := state.MessageByID[field.TypezID]
+		typeName := ""
+
+		isCustom := false
+		isList := field.Repeated
+		isMessage := field.Typez == api.MESSAGE_TYPE
+		isEnum := field.Typez == api.ENUM_TYPE
+		isMap := message != nil && message.IsMap
+		isMessageMap := isMap && message.Fields[1].Typez == api.MESSAGE_TYPE
+		isRequired := codec.Required
+
+		if isMessage {
+			typeName = messageName(message)
+			_, isCustom = usesCustomEncoding[message.ID]
+		} else if isEnum {
+			enum := state.EnumByID[field.TypezID]
+			typeName = enumName(enum)
+		}
+
+		opt := ""
+		bang := "!"
+		if !isRequired {
+			opt = "?"
+			bang = ""
+		}
+
+		if isMessageMap {
+			// message maps
+			//   name: $toMap(json['name'], Status.fromJson)!,
+			//   name: $toMap(json['name'], Status.fromJson),
+			lines = append(lines, fmt.Sprintf("%s: $toMap(json['%s'], %s.fromJson)%s,", name, name, typeName, bang))
+		} else if isMap {
path: generator/internal/dart/darttemplate.go
user: login: coryan
body: I think I see what you are doing here: the treatment for the fields is very different depending on whether it is optional, repeated, a map, etc.  In Rust we did something different.  We partition the fields:

https://github.com/googleapis/google-cloud-rust/blob/f8d2b7e78da336b6ad1308060904092c335144bd/generator/internal/rust/annotate.go#L328-L334

and then use these partitions in the mustache template:

https://github.com/googleapis/google-cloud-rust/blob/f8d2b7e78da336b6ad1308060904092c335144bd/generator/internal/rust/templates/common/message.mustache#L70-L83

Do you think that may help here?
created_at: 2025-03-13T20:14:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,13 +61,15 @@ type serviceAnnotations struct {
 }
 
 type messageAnnotation struct {
-	Name             string
-	DocLines         []string
-	ConstructorBody  string // A custom body for the message's constructor.
-	BasicFields      []*api.Field
-	HasFields        bool
-	HasToStringLines bool
-	ToStringLines    []string
+	Name              string
+	DocLines          []string
+	ConstructorBody   string // A custom body for the message's constructor.
+	HasFields         bool
+	HasCustomEncoding bool
+	FromJsonLines     []string
path: generator/internal/dart/darttemplate.go
user: login: coryan
body: That would make the mustache templates less opaque.  I would like to give the partition idea a try though.

created_at: 2025-03-13T20:17:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'dart:convert';
+
+import 'package:google_cloud_rpc/rpc.dart';
+import 'package:google_cloud_protobuf/protobuf.dart';
+import 'package:test/test.dart';
+
+// Test round-tripping generated JSON encoding and decoding code.
path: generator/dart/packages/generated/google_cloud_rpc/test/json_test.dart
user: login: devoncarew
body: Yup, this is hand-written. Makes sense re: keeping generated and hand-written code separate for hygiene.

I was planning to add an example - in `generator/dart/packages/generated/google_cloud_language/example/` - in the next PR. But if we want to keep things separate, I can move this test and other hand-written pieces to someplace like `generator/dart/packages/google_cloud_common`.

That could swizzle around to something like generator/dart/examples, generator/dart/tests, ... later, but using package:google_cloud_common for now seems reasonable.
created_at: 2025-03-14T01:53:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,16 +23,17 @@ abstract class Jsonable {
   Object toJson();
 }
 
-abstract class CloudMessage {}
+abstract class CloudMessage implements Jsonable {}
 
-abstract class CloudEnum {
+abstract class CloudEnum implements Jsonable {
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: devoncarew
body: Perhaps `JsonEncodable`?
created_at: 2025-03-14T02:02:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,16 +23,17 @@ abstract class Jsonable {
   Object toJson();
 }
 
-abstract class CloudMessage {}
+abstract class CloudMessage implements Jsonable {}
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: devoncarew
body: Instead of `CloudMessage` (and `CloudEnum`), I could use `RpcMessage` and `RpcEnum` 🤷 
created_at: 2025-03-14T02:03:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,7 +93,49 @@ abstract class CloudService {
       return body.isEmpty ? {} : jsonDecode(body);
     }
 
-    // TODO(#1454): This is a placeholder until we're able to parse 'Status'.
-    throw ClientException('${response.statusCode}: ${response.reasonPhrase}');
+    Status status;
+
+    try {
+      final json = jsonDecode(response.body);
+      status = Status.fromJson(json['error']);
+    } catch (_) {
+      // If we're not able to parse the Status error, return a general HTTP
+      // exception.
+      throw ClientException('${response.statusCode}: ${response.reasonPhrase}');
+    }
+
+    throw status;
   }
 }
+
+T? $toMessage<T>(dynamic json, T Function(Map<String, dynamic>) decoder) {
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: devoncarew
body: It's just a regular identifier character. it's almost never used in hand-written code, but is sometimes used in generated code to create terse symbols that are unlikely to collide w/ other things in the library. For example:

```dart
import 'package:foo/bar.dart' as $0;

...

$0.FooBarClass.doSomething();
```

creates an import prefix that likely won't conflict w/ anything in that file.

created_at: 2025-03-14T02:11:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -357,19 +355,165 @@ func annotateMessage(m *api.Message, state *api.APIState, packageMapping map[str
 		constructorBody = " {\n    _validate();\n  }"
 	}
 
+	_, hasCustomEncoding := usesCustomEncoding[m.ID]
 	toStringLines := createToStringLines(m)
 
 	m.Codec = &messageAnnotation{
-		Name:            messageName(m),
-		DocLines:        formatDocComments(m.Documentation, state),
-		ConstructorBody: constructorBody,
-		BasicFields: language.FilterSlice(m.Fields, func(s *api.Field) bool {
-			return !s.IsOneOf
-		}),
-		HasFields:        len(m.Fields) > 0,
-		HasToStringLines: len(toStringLines) > 0,
-		ToStringLines:    toStringLines,
+		Name:              messageName(m),
+		DocLines:          formatDocComments(m.Documentation, state),
+		ConstructorBody:   constructorBody,
+		HasFields:         len(m.Fields) > 0,
+		HasCustomEncoding: hasCustomEncoding,
+		FromJsonLines:     createFromJsonLines(m, state),
+		ToJsonLines:       createToJsonLines(m, state),
+		HasToStringLines:  len(toStringLines) > 0,
+		ToStringLines:     toStringLines,
+	}
+}
+
+func createFromJsonLines(message *api.Message, state *api.APIState) []string {
+	_, hasCustomEncoding := usesCustomEncoding[message.ID]
+	if hasCustomEncoding {
+		return []string{}
+	}
+
+	lines := []string{}
+
+	for _, field := range message.Fields {
+		codec := field.Codec.(*fieldAnnotation)
+		name := codec.Name
+		message := state.MessageByID[field.TypezID]
+		typeName := ""
+
+		isCustom := false
+		isList := field.Repeated
+		isMessage := field.Typez == api.MESSAGE_TYPE
+		isEnum := field.Typez == api.ENUM_TYPE
+		isMap := message != nil && message.IsMap
+		isMessageMap := isMap && message.Fields[1].Typez == api.MESSAGE_TYPE
+		isRequired := codec.Required
+
+		if isMessage {
+			typeName = messageName(message)
+			_, isCustom = usesCustomEncoding[message.ID]
+		} else if isEnum {
+			enum := state.EnumByID[field.TypezID]
+			typeName = enumName(enum)
+		}
+
+		opt := ""
+		bang := "!"
+		if !isRequired {
+			opt = "?"
+			bang = ""
+		}
+
+		if isMessageMap {
+			// message maps
+			//   name: $toMap(json['name'], Status.fromJson)!,
+			//   name: $toMap(json['name'], Status.fromJson),
+			lines = append(lines, fmt.Sprintf("%s: $toMap(json['%s'], %s.fromJson)%s,", name, name, typeName, bang))
+		} else if isMap {
path: generator/internal/dart/darttemplate.go
user: login: devoncarew
body: Yeah, what we need to write out depends on whether something is singular, nullable, repeated, a map of primitives, a message w/ a custom encoding, a map of messages, ... This complex `if` statement was the simplest impl. I could come up with -

I'll give some thought to whether we could partition things up front in a way that would let us push some of the logic down to the templates.

created_at: 2025-03-14T02:18:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,16 +23,17 @@ abstract class Jsonable {
   Object toJson();
 }
 
-abstract class CloudMessage {}
+abstract class CloudMessage implements Jsonable {}
 
-abstract class CloudEnum {
+abstract class CloudEnum implements Jsonable {
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: coryan
body: I think that much better, thanks.
created_at: 2025-03-14T18:52:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,16 +23,17 @@ abstract class Jsonable {
   Object toJson();
 }
 
-abstract class CloudMessage {}
+abstract class CloudMessage implements Jsonable {}
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: coryan
body: We need a naming committee (not). Given that this are in the `google_cloud_common` package, would `Message` and `Enum` be too terrible?

created_at: 2025-03-14T18:54:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,7 +93,49 @@ abstract class CloudService {
       return body.isEmpty ? {} : jsonDecode(body);
     }
 
-    // TODO(#1454): This is a placeholder until we're able to parse 'Status'.
-    throw ClientException('${response.statusCode}: ${response.reasonPhrase}');
+    Status status;
+
+    try {
+      final json = jsonDecode(response.body);
+      status = Status.fromJson(json['error']);
+    } catch (_) {
+      // If we're not able to parse the Status error, return a general HTTP
+      // exception.
+      throw ClientException('${response.statusCode}: ${response.reasonPhrase}');
+    }
+
+    throw status;
   }
 }
+
+T? $toMessage<T>(dynamic json, T Function(Map<String, dynamic>) decoder) {
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: coryan
body: That is neat.
created_at: 2025-03-14T18:55:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -357,19 +355,165 @@ func annotateMessage(m *api.Message, state *api.APIState, packageMapping map[str
 		constructorBody = " {\n    _validate();\n  }"
 	}
 
+	_, hasCustomEncoding := usesCustomEncoding[m.ID]
 	toStringLines := createToStringLines(m)
 
 	m.Codec = &messageAnnotation{
-		Name:            messageName(m),
-		DocLines:        formatDocComments(m.Documentation, state),
-		ConstructorBody: constructorBody,
-		BasicFields: language.FilterSlice(m.Fields, func(s *api.Field) bool {
-			return !s.IsOneOf
-		}),
-		HasFields:        len(m.Fields) > 0,
-		HasToStringLines: len(toStringLines) > 0,
-		ToStringLines:    toStringLines,
+		Name:              messageName(m),
+		DocLines:          formatDocComments(m.Documentation, state),
+		ConstructorBody:   constructorBody,
+		HasFields:         len(m.Fields) > 0,
+		HasCustomEncoding: hasCustomEncoding,
+		FromJsonLines:     createFromJsonLines(m, state),
+		ToJsonLines:       createToJsonLines(m, state),
+		HasToStringLines:  len(toStringLines) > 0,
+		ToStringLines:     toStringLines,
+	}
+}
+
+func createFromJsonLines(message *api.Message, state *api.APIState) []string {
+	_, hasCustomEncoding := usesCustomEncoding[message.ID]
+	if hasCustomEncoding {
+		return []string{}
+	}
+
+	lines := []string{}
+
+	for _, field := range message.Fields {
+		codec := field.Codec.(*fieldAnnotation)
+		name := codec.Name
+		message := state.MessageByID[field.TypezID]
+		typeName := ""
+
+		isCustom := false
+		isList := field.Repeated
+		isMessage := field.Typez == api.MESSAGE_TYPE
+		isEnum := field.Typez == api.ENUM_TYPE
+		isMap := message != nil && message.IsMap
+		isMessageMap := isMap && message.Fields[1].Typez == api.MESSAGE_TYPE
+		isRequired := codec.Required
+
+		if isMessage {
+			typeName = messageName(message)
+			_, isCustom = usesCustomEncoding[message.ID]
+		} else if isEnum {
+			enum := state.EnumByID[field.TypezID]
+			typeName = enumName(enum)
+		}
+
+		opt := ""
+		bang := "!"
+		if !isRequired {
+			opt = "?"
+			bang = ""
+		}
+
+		if isMessageMap {
+			// message maps
+			//   name: $toMap(json['name'], Status.fromJson)!,
+			//   name: $toMap(json['name'], Status.fromJson),
+			lines = append(lines, fmt.Sprintf("%s: $toMap(json['%s'], %s.fromJson)%s,", name, name, typeName, bang))
+		} else if isMap {
path: generator/internal/dart/darttemplate.go
user: login: coryan
body: I think you found a good balance between the codec and the templates.  Thanks!

created_at: 2025-03-14T18:56:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,16 +23,17 @@ abstract class Jsonable {
   Object toJson();
 }
 
-abstract class CloudMessage {}
+abstract class CloudMessage implements Jsonable {}
path: generator/dart/packages/google_cloud_common/lib/common.dart
user: login: devoncarew
body: They're worth trying! The identifiers we definitely can't use are https://dart.dev/language/keywords. We can try Message and Enum for now and pivot if they become problematic.
created_at: 2025-03-14T20:00:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-13T18:43:41Z
body: ## [Codecov](https://app.codecov.io/gh/googleapis/google-cloud-rust/pull/1489?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=googleapis) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 95.36%. Comparing base [(`6424d8c`)](https://app.codecov.io/gh/googleapis/google-cloud-rust/commit/6424d8cbae72744ee9a64505a9a4a1b9ef1368c5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=googleapis) to head [(`0b56eaf`)](https://app.codecov.io/gh/googleapis/google-cloud-rust/commit/0b56eaf06a9b12a5757412e31f22be37bea03342?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=googleapis).
> Report is 22 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1489      +/-   ##
==========================================
- Coverage   95.87%   95.36%   -0.51%     
==========================================
  Files          38       38              
  Lines        1526     1576      +50     
==========================================
+ Hits         1463     1503      +40     
- Misses         63       73      +10     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/googleapis/google-cloud-rust/pull/1489?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=googleapis).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=googleapis).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: devoncarew
created_at: 2025-03-14T20:03:40Z
body: I updated this PR w/ one last change - renaming `CloudEnum` to `Enum` and `CloudMessage` to `Message`.
author_association: CONTRIBUTOR