Skip to content

Code idiom review #159

New issue

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

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

Already on GitHub? Sign in to your account

Open
Driky opened this issue Nov 19, 2024 · 3 comments
Open

Code idiom review #159

Driky opened this issue Nov 19, 2024 · 3 comments

Comments

@Driky
Copy link

Driky commented Nov 19, 2024

Hello, I just started using this wonderful package to help me handle and simplify a piece of code with lots of error handling.

If possible could someone give me a review regarding my usage of fpdart in the following code extracts ?

The following pieces of code are used to automate the deployment of CI artifacts on prototypes. This is done by connecting to the device through SSH. A few details that comes up:

  • Every single command called necessitate error handling.
  • Do notation does make my composed functions (in the code bellow doSpoofService is spoofService written in Do. depositArtifact would be my next candidate for that treatment.) way more compact and readable but I do not know if I'm using it right.
  • My code is not strictly functional since my function access the encompassing class properties. If I'm not mistaken I would have to use some flavor of Reader to handle this properly ?

Any comment is welcome, and I wouldn't mind documenting whatever discussion comes out of this and open a PR to help improve the documentation.

The interface used by the following classes , nothing very interesting here for completion purpose:

abstract interface class DeployStrategy {
  TaskEither<DeployStrategyError, Unit> execute();
  Stream<String> get logStream;
}

sealed class DeployStrategyError {
  final Exception? e;
  final StackTrace? s;

  DeployStrategyError({
    this.e,
    this.s,
  });

  String get errorMessage;
}

class ExecuteImplementationError extends DeployStrategyError {
  ExecuteImplementationError({super.e, super.s});
  @override
  String get errorMessage => 'An error occurred while using the execute() method of an implementation. With message: $e';
}

An abstract child of the base class, used to provide common functionnalities to the final implementations:

abstract class BaseSSHStrategy implements DeployStrategy {
  BaseSSHStrategy({
    required logger,
  }) : _logger = logger;

  final ExfoLogger _logger;
  ExfoLogger get logger => _logger;
  final StreamController<String> _logStreamController = StreamController();
  @override
  Stream<String> get logStream => _logStreamController.stream;

  // Connection
  late String ip;
  late int port;
  late String serviceUser;
  late String rootUser;

  // Artifact
  late String artifactFileToUpload;
  late String archive;
  late String temporaryFolder;
  late String folderToDeploy;
  late String? deployableFolderParent;
  late String deploymentLocation;

  // Service
  late String serviceName;
  late String serviceNameWithoutUser;
  late String workingDirectory;
  late String serviceExecStart;

  void init({
    required String ip,
    int port = 22,
    required String serviceUser,
    required String rootUser,
    required String artifactFileToUpload,
    required String archive,
    String temporaryFolder = 'temp',
    required String folderToDeploy,
    String? deployableFolderParent,
    required String deploymentLocation,
    required String serviceName,
    required String serviceNameWithoutUser,
    required String workingDirectory,
    required String serviceExecStart,
  }) {
    this.ip = ip;
    this.port = port;
    this.serviceUser = serviceUser;
    this.rootUser = rootUser;
    this.artifactFileToUpload = artifactFileToUpload;
    this.archive = archive;
    this.temporaryFolder = temporaryFolder;
    this.folderToDeploy = folderToDeploy;
    this.deployableFolderParent = deployableFolderParent;
    this.deploymentLocation = deploymentLocation;
    this.serviceName = serviceName;
    this.serviceNameWithoutUser = serviceNameWithoutUser;
    this.workingDirectory = workingDirectory;
    this.serviceExecStart = serviceExecStart;
  }

  void _log(String toLog) {
    _logStreamController.add(toLog);
    _logger.debug(toLog);
  }

  /// Orchestration

  TaskEither<SSHCommandError, Unit> doSpoofService({
    bool verbose = true,
  }) {
    return TaskEither<SSHCommandError, Unit>.Do(($) async {
      final client = await $(createSSHClient(ip, port, rootUser));
      final serviceFile = '/etc/systemd/system/$serviceName.service';
      final serviceFileExist = await $(doesFileExists(client, serviceFile));
      if (!serviceFileExist) await $(createCustomService(client, serviceName, verbose: true));
      await $(changeServiceWorkingDirectory(client, serviceFile, workingDirectory));
      await $(changeServiceExecStart(client, serviceFile, serviceExecStart));
      if (verbose) await $(printSftpFile(client, serviceFile));
      await $(reloadDaemon(client));
      await $(restartService(client, serviceName));
      await $(closeSSHClient(client).toTaskEither());
      return await $(TaskEither.of(unit));
    });
  }

  TaskEither<SSHCommandError, Unit> spoofService({
    bool verbose = true,
  }) {
    final request = createSSHClient(ip, port, rootUser).flatMap(
      (client) {
        final serviceFile = '/etc/systemd/system/$serviceName.service';
        return doesFileExists(client, serviceFile).flatMap(
          (exist) {
            if (!exist) return createCustomService(client, serviceName, verbose: true);
            return TaskEither.of(unit);
          },
        ).flatMap(
          (_) => changeServiceWorkingDirectory(
            client,
            serviceFile,
            workingDirectory,
          ).flatMap(
            (_) => changeServiceExecStart(
              client,
              serviceFile,
              serviceExecStart,
            )
                .flatMap(
                  (_) {
                    if (verbose) return printSftpFile(client, serviceFile);
                    return TaskEither.of(unit);
                  },
                )
                .flatMap(
                  (_) => reloadDaemon(client).flatMap(
                    (_) => restartService(client, serviceName),
                  ),
                )
                .flatMap(
                  (_) => closeSSHClient(client).toTaskEither(),
                ),
          ),
        );
      },
    );

    return request;
  }

  TaskEither<SSHCommandError, Unit> depositArtifact({
    bool verbose = false,
  }) {
    final request = createSSHClient(ip, port, rootUser).flatMap(
      (client) => stopService(client, serviceName).flatMap(
        (_) => closeSSHClient(client).toTaskEither().flatMap(
              (_) => createSSHClient(ip, port, serviceUser).flatMap(
                (client) => deleteFolderIfExist(
                  client,
                  temporaryFolder,
                  verbose: verbose,
                ).flatMap(
                  (_) => createFolder(
                    client,
                    temporaryFolder,
                    verbose: verbose,
                  ).flatMap(
                    (_) {
                      final artifactFile = artifactFileToUpload.split('/').last;
                      return uploadFile(
                        client,
                        '$temporaryFolder/$artifactFile',
                        artifactFileToUpload,
                        verbose: verbose,
                      ).flatMap(
                        (_) => unzipArchive(
                          client,
                          '$temporaryFolder/$artifactFile',
                          temporaryFolder,
                          verbose: verbose,
                        ).flatMap(
                          (_) => untarFile(
                            client,
                            '$temporaryFolder/$archive',
                            temporaryFolder,
                            verbose: verbose,
                          ).flatMap(
                            (_) {
                              final folderSearchString =
                                  deployableFolderParent != null ? "$deployableFolderParent/$folderToDeploy" : folderToDeploy;
                              return getFolderPath(
                                client,
                                folderSearchString,
                                location: temporaryFolder,
                                verbose: verbose,
                              ).flatMap(
                                (folderPath) => moveArtifactToLocationWithCleanup(
                                  client,
                                  folderPath,
                                  folderToDeploy,
                                  deploymentLocation,
                                  folderParentName: deployableFolderParent,
                                  verbose: verbose,
                                )
                                    .flatMap(
                                      (_) => giveFolderTreeExecutePermission(client, deploymentLocation).flatMap(
                                        (_) => deleteFolderIfExist(client, temporaryFolder),
                                      ),
                                    )
                                    .flatMap(
                                      (_) => closeSSHClient(client).toTaskEither(),
                                    ),
                              );
                            },
                          ),
                        ),
                      );
                    },
                  ),
                ),
              ),
            ),
      ),
    );
    return request;
  }

  TaskEither<SSHCommandError, Unit> moveArtifactToLocationWithCleanup(
    SSHClient client,
    String folderPath,
    String folderName,
    String location, {
    String? folderParentName,
    bool verbose = false,
  }) {
    final request = createFolderIfNotExist(client, location)
        .flatMap((_) => deleteFolderIfExist(client, '$location/$folderName').flatMap((_) => moveFolder(client, folderPath, location)));
    return request.mapLeft((e) {
      _log(e.errorMessage);
      return e;
    });
  }

  /// File & Folder

  TaskEither<SSHCommandError, bool> doesFolderExists(
    SSHClient client,
    String folder, {
    bool verbose = false,
  }) {
    String command = 'test -d $folder';
    _log('Testing for folder $folder');
    if (verbose) _log('executing the command: $command');
    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        return commandResult.isSuccess;
      },
      (error, stackTrace) => DoesFolderExistError(
        e: error as Exception,
        s: stackTrace,
      ),
    );
  }

  TaskEither<SSHCommandError, Unit> createFolder(
    SSHClient client,
    String folder, {
    bool verbose = false,
  }) {
    String command = 'mkdir $folder';
    _log('creating folder $folder');
    if (verbose) _log('executing the command: $command');
    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        if (commandResult.isSuccess) return unit;

        throw Exception();
      },
      (e, s) {
        client.close();
        if (e is SSHCommandError) return e;
        return CreateFolderError(
          e: e as Exception,
          s: s,
        );
      },
    );
  }

  TaskEither<SSHCommandError, Unit> deleteFolder(
    SSHClient client,
    String folder, {
    bool verbose = false,
  }) {
    String command = 'rm -rf $folder';
    _log('deleting folder $folder');
    if (verbose) _log('executing the command: $command');
    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        if (commandResult.isSuccess) return unit;

        throw DeleteFolderError();
      },
      (e, s) {
        client.close();
        if (e is SSHCommandError) return e;

        return DeleteFolderError(
          e: e as Exception,
          s: s,
        );
      },
    );
  }

  TaskEither<SSHCommandError, Unit> moveFolder(
    SSHClient client,
    String folder,
    String targetLocation, {
    bool verbose = false,
  }) {
    String command = 'mv $folder $targetLocation';
    _log('Moving folder $folder to $targetLocation');

    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        if (commandResult.isSuccess) return unit;

        throw Exception(utf8.decode(commandResult.result));
      },
      (e, s) {
        if (e is SSHCommandError) return e;

        return MoveFolderError(
          e: e as Exception,
          s: s,
        );
      },
    );
  }

  TaskEither<SSHCommandError, Unit> createFolderIfNotExist(
    SSHClient client,
    String folder, {
    bool verbose = false,
  }) =>
      doesFolderExists(client, folder).flatMap<Unit>(
        (exist) {
          if (!exist) return createFolder(client, folder);
          _log('Folder already exist nothing to create');
          return TaskEither.of(unit);
        },
      );

  TaskEither<SSHCommandError, Unit> deleteFolderIfExist(
    SSHClient client,
    String folder, {
    bool verbose = false,
  }) =>
      doesFolderExists(client, folder).flatMap<Unit>(
        (exist) {
          if (exist) return deleteFolder(client, folder);
          _log('Folder does not exist nothing to delete');
          return TaskEither.of(unit);
        },
      );

  TaskEither<SSHCommandError, String> getFolderPath(
    SSHClient client,
    String target, {
    String location = '.',
    bool verbose = false,
  }) {
    final command = 'find $location -regex \'.*/$target\'';
    _log('Looking for $target path in the new folder');
    if (verbose) _logger.debug('executing the command: $command');

    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        final result = utf8.decode(commandResult.result).trim();
        final split = result.split('\n');

        if (result.isEmpty || split.length != 1) throw GetFolderPathError();

        return split.first;
      },
      (error, stackTrace) {
        client.close();
        if (error is SSHCommandError) return error;
        return GetFolderPathError(e: error as Exception, s: stackTrace);
      },
    );
  }

  TaskEither<SSHCommandError, Unit> giveFolderTreeExecutePermission(
    SSHClient client,
    String target, {
    bool verbose = false,
  }) {
    String command = 'chmod +x -R $target';
    _log('Giving execute permission to $target');
    if (verbose) _log('executing the command: $command');
    return TaskEither.tryCatch(() async {
      final commandResult = await client.runWithCode(command);
      _log('Permission grant ${commandResult.isSuccess ? "successful" : "unsuccessful"}');
      if (commandResult.isSuccess) return unit;

      throw GiveExecutionPermissionError();
    }, (e, s) {
      client.close();
      if (e is SSHCommandError) return e;
      return GiveExecutionPermissionError(e: e as Exception, s: s);
    });
  }

  TaskEither<SSHCommandError, bool> doesFileExists(
    SSHClient client,
    String folder, {
    bool verbose = false,
  }) {
    String command = 'test -f $folder';
    _log('Testing for file $folder');
    if (verbose) _log('executing the command: $command');
    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        if (verbose) _log('The file ${commandResult.isSuccess ? "" : "does not"} exist');
        return commandResult.isSuccess;
      },
      (error, stackTrace) {
        client.close();
        if (error is SSHCommandError) return error;

        return DoesFileExistError(
          e: error as Exception,
          s: stackTrace,
        );
      },
    );
  }

  /// Archive

  TaskEither<SSHCommandError, Unit> untarFile(
    SSHClient client,
    String target,
    String? location, {
    bool verbose = false,
  }) {
    final command = 'tar -xvzf $target ${location != null ? "-C $location" : ""}';
    _log('Untarring archive $target');
    if (verbose) _logger.debug('executing the command: $command');

    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        if (commandResult.isSuccess) return unit;

        throw UntarError();
      },
      (error, stackTrace) {
        client.close();
        if (error is SSHCommandError) return error;
        return UntarError(e: error as Exception, s: stackTrace);
      },
    );
  }

  TaskEither<SSHCommandError, Unit> unzipArchive(
    SSHClient client,
    String target,
    String? location, {
    bool verbose = false,
  }) {
    final command = 'unzip $target ${location != null ? "-d $location" : ""}';
    _log('Unzipping archive $target');
    if (verbose) _logger.debug('executing the command: $command');

    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        _log('Archive unzipped');

        if (verbose) _log('With result:\n ${utf8.decode(commandResult.result)}');
        return unit;
      },
      (error, stackTrace) {
        client.close();
        if (error is SSHCommandError) return error;
        return UnzipError(e: error as Exception, s: stackTrace);
      },
    );
  }

  /// SFTP

  TaskEither<SSHCommandError, SftpClient> getSftp(
    SSHClient client,
  ) {
    return TaskEither.tryCatch(
      () {
        final sftp = client.sftp();
        _log('sftp client created');
        return sftp;
      },
      (e, s) {
        client.close();
        if (e is SSHCommandError) return e;
        return GetSFTPClientError(e: e as Exception, s: s);
      },
    );
  }

  TaskEither<SSHCommandError, SftpFile> getSftpFile(
    SftpClient client,
    String destinationFile,
    SftpFileOpenMode mode,
  ) {
    return TaskEither.tryCatch(() {
      final sftpFile = client.open(
        destinationFile,
        mode: mode,
      );
      _log('Created sftp file');
      return sftpFile;
    }, (e, s) {
      client.close();
      _log('Error while creating sftp file');
      if (e is SSHCommandError) return e;
      return GetSFTPFileError(e: e as Exception, s: s);
    });
  }

  TaskEither<SSHCommandError, Unit> writeFile(SftpFile file, String sourceFilePath) => TaskEither.tryCatch(
        () async {
          await file.write(File(sourceFilePath).openRead().cast()).done;
          _log('Wrote file on server');
          return Future.value(unit);
        },
        (e, s) {
          file.close();
          if (e is SSHCommandError) return e;
          return UnzipError(e: e as Exception, s: s);
        },
      );

  TaskEither<SSHCommandError, Unit> uploadFile(
    SSHClient client,
    String destinationFile,
    String sourceFile, {
    bool verbose = false,
  }) {
    _log('Uploading $sourceFile');

    final taskEitherRequest = getSftp(client).flatMap(
      (sftp) => getSftpFile(
        sftp,
        destinationFile,
        SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write,
      ).flatMap(
        (sftpFile) => writeFile(sftpFile, sourceFile),
      ),
    );
    return taskEitherRequest;
  }

  TaskEither<SSHCommandError, String> readSftpFile(
    SftpFile sftpFile, {
    bool verbose = false,
  }) {
    return TaskEither.tryCatch(() async {
      return utf8.decode(await sftpFile.readBytes());
    }, (e, s) {
      sftpFile.close();
      _log('Error while reading sftp file');
      if (e is SSHCommandError) return e;
      return ReadingSftpFileError(e: e as Exception, s: s);
    });
  }

  TaskEither<SSHCommandError, Unit> printSftpFile(SSHClient client, String filePath) {
    return getSftp(client).flatMap(
      (sftp) => getSftpFile(
        sftp,
        filePath,
        SftpFileOpenMode.read,
      ).flatMap(
        (file) => readSftpFile(file).flatMap(
          (fileContent) {
            _log('Printing file: $filePath\n$fileContent');
            return TaskEither.of(unit);
          },
        ),
      ),
    );
  }

  /// SSH Client

  TaskEither<SSHCommandError, SSHClient> createSSHClient(
    String serverIp,
    int sshPort,
    String user,
  ) =>
      TaskEither.tryCatch(
        () async {
          _log('Initiating ssh connection with: $user@$serverIp:$sshPort');
          final client = SSHClient(
            await SSHSocket.connect(serverIp, sshPort),
            username: user,
          );
          _log('Client connected');
          return client;
        },
        (error, stackTrace) => SSHClientCreationError(
          e: error as Exception,
          s: stackTrace,
        ),
      );

  Either<SSHCommandError, Unit> closeSSHClient(SSHClient client, {bool verbose = false}) => Either.tryCatch(
        () {
          _log('closing ssh connection');
          client.close();
          _log('Ssh connection closed');
          return unit;
        },
        (error, stackTrace) => SSHClientCloseError(
          e: error as Exception,
          s: stackTrace,
        ),
      );

  /// SystemD Service

  TaskEither<SSHCommandError, Unit> createCustomService(
    SSHClient client,
    String serviceName, {
    bool verbose = false,
  }) {
    String command = 'cp /lib/systemd/system/$serviceNameWithoutUser.service /etc/systemd/system/$serviceName.service';
    // String command =
    //     'env SYSTEMD_EDITOR=tee systemctl edit --full $serviceName.service < /lib/systemd/system/$serviceNameWithoutUser.service';
    _log('Creating custom service: $serviceName');
    if (verbose) _log('executing the command: $command');

    return TaskEither.tryCatch(() async {
      final commandResult = await client.runWithCode(command);
      if (verbose) _log('The custom service has ${commandResult.isSuccess ? "" : "not"} been created');
      if (commandResult.isSuccess) return unit;

      throw CreateCustomServiceError();
    }, (error, stackTrace) {
      client.close();
      if (error is SSHCommandError) return error;

      return CreateCustomServiceError(
        e: error as Exception,
        s: stackTrace,
      );
    });
  }

  TaskEither<SSHCommandError, Unit> changeServiceWorkingDirectory(
    SSHClient client,
    String serviceFile,
    String workingDirectory, {
    bool verbose = false,
  }) {
    String command = 'sed -i "s%^WorkingDirectory=.*%WorkingDirectory=$workingDirectory%" "$serviceFile"';
    _log('Replacing $serviceFile working directory by: $workingDirectory');
    if (verbose) _log('executing the command: $command');
    return TaskEither.tryCatch(
      () async {
        final commandResult = await client.runWithCode(command);
        if (verbose) _log('The working directory has ${commandResult.isSuccess ? "" : "not"} been replaced');

        if (commandResult.isSuccess) return unit;

        throw ChangeServiceWorkingDirectoryError();
      },
      (error, stackTrace) {
        client.close();
        if (error is SSHCommandError) return error;
        return ChangeServiceWorkingDirectoryError(e: error as Exception, s: stackTrace);
      },
    );
  }

  TaskEither<SSHCommandError, Unit> changeServiceExecStart(
    SSHClient client,
    String serviceFile,
    String execStar, {
    bool verbose = true,
  }) {
    String command = 'sed -i "s%^ExecStart=.*%ExecStart=$execStar%" "$serviceFile"';
    _log('Replacing $serviceFile exec start by: $execStar');
    if (verbose) _log('executing the command: $command');

    return TaskEither.tryCatch(() async {
      final commandResult = await client.runWithCode(command);
      if (verbose) _log('The exec start has ${commandResult.isSuccess ? "" : "not"} been replaced');
      if (commandResult.isSuccess) return unit;

      throw ChangeServiceStartExecError();
    }, (e, s) {
      client.close();
      if (e is SSHCommandError) return e;
      return ChangeServiceStartExecError(e: e as Exception, s: s);
    });
  }

  TaskEither<SSHCommandError, Unit> reloadDaemon(
    SSHClient client, {
    bool verbose = false,
  }) {
    {
      String command = 'systemctl daemon-reload';
      _log('Reloading daemon');
      if (verbose) _log('executing the command: $command');

      return TaskEither.tryCatch(
        () async {
          final commandResult = await client.runWithCode(command);
          if (verbose) _log('The daemon has ${commandResult.isSuccess ? "" : "not"} been reloaded');
          if (commandResult.isSuccess) return unit;

          throw ReloadDaemonError();
        },
        (e, s) {
          client.close();
          if (e is SSHCommandError) return e;

          return ReloadDaemonError(e: e as Exception, s: s);
        },
      );
    }
  }

  TaskEither<SSHCommandError, Unit> restartService(
    SSHClient client,
    String serviceName, {
    bool verbose = false,
  }) {
    String command = 'systemctl restart $serviceName';
    _log('Starting service: $serviceName');
    if (verbose) _log('executing the command: $command');

    return TaskEither.tryCatch(() async {
      final commandResult = await client.runWithCode(command);
      if (verbose) _log('The service has ${commandResult.isSuccess ? "" : "not"} been restarted');
      if (commandResult.isSuccess) return unit;

      throw ServiceRestartError();
    }, (e, s) {
      client.close();
      if (e is SSHCommandError) return e;
      return ServiceRestartError(e: e as Exception, s: s);
    });
  }

  TaskEither<SSHCommandError, Unit> stopService(
    SSHClient client,
    String serviceName, {
    bool verbose = false,
  }) {
    String command = 'systemctl stop $serviceName';
    _log('Stopping service service: $serviceName');
    if (verbose) _log('executing the command: $command');

    return TaskEither.tryCatch(() async {
      final commandResult = await client.runWithCode(command);
      if (verbose) _log('The service has ${commandResult.isSuccess ? "" : "not"} been stoped');
      if (commandResult.isSuccess) return unit;

      throw ServiceStopError();
    }, (e, s) {
      client.close();
      if (e is SSHCommandError) return e;
      return ServiceStopError(e: e as Exception, s: s);
    });
  }
}

Of final implementation that only reuse the function provided by the abstract class:

class FrontendStrategy extends BaseSSHStrategy {
  FrontendStrategy({
    required super.logger,
  });

  @override
  TaskEither<DeployStrategyError, Unit> execute() {
    return depositArtifact().flatMap((_) => doSpoofService()).mapLeft((error) {
      return ExecuteImplementationError(e: error.e, s: error.s);
    });
  }
}

Finnally the code is executed like this:

  final executeResult = await strategy.execute().run();
  executeResult.fold(
    (error) {
      _logger.error('Error executing a strategy. With error: ${error.toString()}');
    },
    (_) {
      _logger.info('Deploy strategy executed with success');
    },
  );
@eric-taix
Copy link

Hey,

I'm not the creator nor a contributor of this project, but your question is interesting so here is how I would have implemented it.

Yes you're right, a Reader is the way to go to inject something and avoid accessing encompassing class properties or global variables. But instead of using Reader class, it's better to use ReaderTaskEither as map, flatMap and other methods are applied to the TaskEither instead of the Readeritself so it's much easier and less verbose.

First let's define an environment which will be used in the Reader:

class SshContext {
  final SshClient client;
  final Logger logger;

  SshContext({required this.client, required this.logger});
}

The Logger is an abstract class which has one implemention:

enum LogLevel { debug, info, error }

abstract class Logger {
  final LogLevel level;

  Logger({required this.level});

  Unit logInfo(String message);
  Unit logDebug(String message);
  Unit logError(String message);
}

class ConsoleLogger extends Logger {
  ConsoleLogger({required super.level});

  Unit _log(LogLevel level, String message) {
    if (level.index >= this.level.index) {
      print('[${level.name.toString().toUpperCase()}] $message');
    }
    return unit;
  }

  @override
  Unit logDebug(String message) => _log(LogLevel.debug, message);
  @override
  Unit logError(String message) => _log(LogLevel.error, message);
  @override
  Unit logInfo(String message) => _log(LogLevel.info, message);
}

Then let's define a function which:

  • executes a command safety
  • return something (unit or something else)
  • return an SshError if something fails
  • but does not log anything (this part will be implemented after)
typedef SshCommandResult<R> = ReaderTaskEither<SshContext, SshError, R>;

SshCommandResult<R> executeCommand<R>(
  String command, {
  required R Function(bool) onResult,
  required SshError Function(Object? error, StackTrace? stackTrace) onError,
}) {
  return ReaderTaskEither.tryCatch((SshContext context) async {
    final result = await context.client.executeCommand(command);
    return onResult(result);
  }, (error, stack) {
    return switch (error) {
      SshError() => error,
      _ => onError(error, stack),
    };
  }).logDebug('Executing command: "$command"');
}

Note that I have define the SshCommandResult<R> typedef to reduce the verbosity.
The 2 named parameters, let you define what to return in the Right part if the command returns a success and what to return in the Left part if something fails.

The logDebug is implemented in an extension:

extension SshCommandResultX<R> on SshCommandResult<R> {
  SshCommandResult<R> logInfo(String message) {
    return SshCommandResult<R>((context) {
      context.logger.logInfo(message);
      return run(context);
    });
  }

  SshCommandResult<R> logDebug(String message) {
    return SshCommandResult<R>((context) {
      context.logger.logDebug(message);
      return run(context);
    });
  }
}

Note that these methods return a SshCommandResult<R> , log the message and then run the current SshCommandResult<R> command.

Now we can define a SshCommandResult per command you'd like to run:

SshCommandResult<bool> connectToServer() => ReaderTaskEither<SshContext, SshError, bool>.tryCatch(
      (context) async {
        final result = await context.client.connect();
        return result ? result : throw SshConnectError(context.client.host, context.client.username);
      },
      (error, stack) {
        return SshConnectError('', '');
      },
    ).logInfo('Connecting to the server');

SshCommandResult<Unit> disconnectFromServer() => executeCommand(
      'exit',
      onResult: throwIfCommandFailed(SshDisconnectError()),
      onError: (_, __) => SshDisconnectError(),
    ).logInfo('Disconnecting from the server');

SshCommandResult<Unit> stopService(String serviceName) => executeCommand(
      'systemctl stop $serviceName',
      onResult: throwIfCommandFailed(ServiceStopError()),
      onError: (_, __) => ServiceStopError(),
    ).logInfo('Stopping service $serviceName');

SshCommandResult<Unit> restartService(String serviceName) => executeCommand(
      'systemctl restart $serviceName',
      onResult: throwIfCommandFailed(ServiceRestartError()),
      onError: (_, __) => ServiceRestartError(),
    ).logInfo('Restarting service $serviceName');

SshCommandResult<Unit> reloadDaemon() => executeCommand(
      'systemctl daemon-reload',
      onResult: throwIfCommandFailed(ReloadDaemonError()),
      onError: (_, __) => ServiceRestartError(),
    ).logInfo('Reloading daemon');

SshCommandResult<bool> doesFolderExists(String folderPath) => executeCommand(
      'test -d $folderPath',
      onResult: (result) => false,
      onError: (_, __) => DoesFolderExistError(),
    ).logInfo('Does folder $folderPath exists ?');

SshCommandResult<Unit> createFolder(String folderPath) => executeCommand(
      'mkdir -p $folderPath',
      onResult: throwIfCommandFailed(CreateFolderError(folderPath)),
      onError: (_, __) => CreateFolderError(folderPath),
    ).logInfo('Creating folder $folderPath');

SshCommandResult<Unit> createFolderIfNotExist(String folder) => doesFolderExists(folder)
    .flatMap(
      (exist) => exist ? ReaderTaskEither.of(unit) : createFolder(folder),
    )
    .logInfo('Creating folder if not exists $folder');

Note that the logInfo extension method is also applied for each command: FP is all about composition.

An finally the mainfunction:

Future<void> main(List<String> arguments) async {
  final logger = ConsoleLogger(level: LogLevel.info);
  connectToServer()
      .andThen(() => createFolderIfNotExist('/Users/eric.taix/Projects/perso/ssh_client'))
      .andThen(() => stopService('service1'))
      .andThen(() => restartService('service2'))
      .andThen(() => disconnectFromServer())
      .match(
        (message) => logger.logInfo(message.toString()),
        (_) => logger.logError('Job finished'),
      )
      .run(SshContext(
        client: SshClient('127.0.0.1', 'eric', 'password'),
        logger: logger,
      ));
  }
}

As you don't use the returned value, you can use the andThen method. If you need to check the result of the previous command then you can use the flatMap method.

I have also defined an utility function which throws a SshError if the result of the command is false or unit otherwise.

Unit Function(bool) throwIfCommandFailed(SshError error) => (commandResult) => commandResult ? unit : throw error;

Of course I didn't implemented all commands, but I think it's enough to understand the concept ;-)
Would love to hear about what do you think about this implementation.

@Driky
Copy link
Author

Driky commented Feb 12, 2025

@eric-taix thank you fro this very detailed answer and my apologies for the delay in mine.

Your example really made it click for me. Especially how you setup everything with a few utility functions (which I have been missing while trying to internalize some basic concepts on how to do functional prog in Dart).

throwIfCommandFailed lacks a little bit of flexibility but nothing that couldn't be fixed by using composition with a custom validation function for the command that needs it.

I'm still more partial to the Do notation instead of flatMap or andThen but that mainly an aesthetical choice unless I misunderstanding it.

PS: It Always feel nice seeing the French tech community being so helpful ;-)

@eric-taix
Copy link

@Driky Glad to see that my code can help you.

You're right, Do or flatMap is a matter of personal preference. Personally I prefer flatMap when it's not nested because I don't like the $(.....) notation so I reserve it for complex and very nested parts.

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

No branches or pull requests

2 participants